--[[ This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ]]-- local mp = require "mp" local ATTR_A = "user.alcan.loopa" local ATTR_B = "user.alcan.loopb" local MAX_SAFE_INTEGER = 9007199254740991 local staged_loop = nil local loop_checked = false local function strip_trailing_whitespace(value) return (value or ""):gsub("%s+$", "") end local function read_attr(path, attr) local result = mp.command_native({ name = "subprocess", playback_only = false, capture_stdout = true, capture_stderr = true, capture_size = 4096, args = {"getfattr", "--only-values", "-n", attr, "--", path}, }) if result.status ~= 0 then return nil, strip_trailing_whitespace(result.stderr or result.error_string or "getfattr failed") end return strip_trailing_whitespace(result.stdout) end local function parse_samples(value) if type(value) ~= "string" or not value:match("^%d+$") then return nil end local parsed = tonumber(value) if not parsed or parsed < 0 or parsed > MAX_SAFE_INTEGER or parsed ~= math.floor(parsed) then return nil end return parsed end local function is_local_path(path) return path and path ~= "" and not path:match("^%a[%w+.-]*://") end local function checked_add(a, b) if not a or not b or b <= 0 or a > MAX_SAFE_INTEGER - b then return nil end return a + b end local function metadata_by_lowercase_key() local normalized = {} local metadata = mp.get_property_native("metadata") or {} for key, value in pairs(metadata) do normalized[string.lower(tostring(key))] = tostring(value) end return normalized end local function read_xattr_loop(path) if not is_local_path(path) then return nil end local raw_a, err_a = read_attr(path, ATTR_A) local raw_b, err_b = read_attr(path, ATTR_B) if not raw_a or not raw_b then mp.msg.debug("missing Alcan loop xattrs: " .. (err_a or err_b or "unknown")) return nil end local loopa = parse_samples(raw_a) local loopb = parse_samples(raw_b) if not loopa or not loopb or loopb <= loopa then mp.msg.warn(string.format( "invalid sample-based Alcan loop xattrs on %s: loopa=%q loopb=%q", path, raw_a, raw_b )) return nil end return {a = loopa, b = loopb, source = "Alcan xattrs"} end local function read_mp3_loop(metadata) local comment = metadata.comment if not comment then return nil end local normalized_comment = string.lower(comment) local raw_start, raw_length = normalized_comment:match("repeat%s+start=(%d+)%s+len=(%d+)") local loopa = parse_samples(raw_start) local loop_length = parse_samples(raw_length) local loopb = checked_add(loopa, loop_length) if not loopb then if normalized_comment:match("repeat%s+start=") then mp.msg.warn("invalid MP3 repeat loop comment: " .. comment) end return nil end return {a = loopa, b = loopb, source = "MP3 repeat comment"} end local function read_vorbis_loop(metadata) local has_loop_metadata = metadata.loopstart ~= nil or metadata.looplength ~= nil or metadata.loopend ~= nil local loopa = parse_samples(metadata.loopstart) if not loopa then if has_loop_metadata then mp.msg.warn(string.format( "invalid loop tags: LOOPSTART=%q LOOPLENGTH=%q LOOPEND=%q", tostring(metadata.loopstart), tostring(metadata.looplength), tostring(metadata.loopend) )) end return nil end if metadata.looplength ~= nil then local loop_length = parse_samples(metadata.looplength) local loopb = checked_add(loopa, loop_length) if not loopb then mp.msg.warn(string.format( "invalid LOOPSTART/LOOPLENGTH tags: %q/%q", metadata.loopstart, metadata.looplength )) return nil end return {a = loopa, b = loopb, source = "LOOPSTART/LOOPLENGTH tags"} end local inclusive_end = parse_samples(metadata.loopend) local loopb = checked_add(inclusive_end, 1) if not loopb or loopb <= loopa then if metadata.loopend ~= nil then mp.msg.warn(string.format( "invalid LOOPSTART/LOOPEND tags: %q/%q", metadata.loopstart, metadata.loopend )) end return nil end return {a = loopa, b = loopb, source = "LOOPSTART/LOOPEND tags"} end local function read_embedded_loop() local format = string.lower(mp.get_property("file-format") or "") local metadata = metadata_by_lowercase_key() if format == "mp3" then return read_mp3_loop(metadata) elseif format == "flac" or format == "ogg" then return read_vorbis_loop(metadata) end return nil end local function reset_alcan_loop() staged_loop = nil loop_checked = false mp.set_property("ab-loop-a", "no") mp.set_property("ab-loop-b", "no") end local function enable_alcan_loop() if not staged_loop then mp.osd_message("No Alcan loop points loaded") return end mp.set_property_number("ab-loop-a", staged_loop.a_seconds) mp.set_property_number("ab-loop-b", staged_loop.b_seconds) mp.set_property("ab-loop-count", "inf") mp.osd_message(string.format( "Alcan loop enabled: %d -> %d samples", staged_loop.a_samples, staged_loop.b_samples )) mp.msg.info(string.format( "enabled %s loop: %d -> %d samples", staged_loop.source, staged_loop.a_samples, staged_loop.b_samples )) end local function apply_alcan_loop() if staged_loop or loop_checked then return end local path = mp.get_property("path") if not path or path == "" then return end local sample_rate = mp.get_property_number("audio-params/samplerate") if not sample_rate or sample_rate <= 0 then mp.msg.debug("waiting for the audio sample rate before loading loop points") return end loop_checked = true local loop = read_xattr_loop(path) or read_embedded_loop() if not loop then mp.msg.debug("no valid loop metadata found") return end staged_loop = { a_samples = loop.a, b_samples = loop.b, a_seconds = loop.a / sample_rate, b_seconds = loop.b / sample_rate, path = path, sample_rate = sample_rate, source = loop.source, } mp.msg.info(string.format( "loaded %s loop points: %d -> %d samples at %d Hz", loop.source, loop.a, loop.b, sample_rate )) if mp.get_property("loop-file") == "inf" then mp.set_property("file-local-options/loop-file", "no") enable_alcan_loop() end end local function disable_alcan_loop() mp.set_property("ab-loop-a", "no") mp.set_property("ab-loop-b", "no") mp.osd_message("Alcan loop disabled") mp.msg.info("disabled Alcan loop") end local function is_alcan_loop_active() if not staged_loop then return false end local loopa = mp.get_property_number("ab-loop-a") local loopb = mp.get_property_number("ab-loop-b") if not loopa or not loopb then return false end local tolerance = 0.5 / staged_loop.sample_rate return math.abs(loopa - staged_loop.a_seconds) < tolerance and math.abs(loopb - staged_loop.b_seconds) < tolerance end local function handle_l_key() if staged_loop then if is_alcan_loop_active() then disable_alcan_loop() else enable_alcan_loop() end else mp.command("ab-loop") end end mp.add_key_binding("l", "enable-loop", handle_l_key) mp.register_event("start-file", reset_alcan_loop) mp.register_event("audio-reconfig", apply_alcan_loop) mp.register_event("file-loaded", apply_alcan_loop)