From 72c72874455df95ebdeb18e7080b27653225783c Mon Sep 17 00:00:00 2001 From: Colin Date: Mon, 8 Apr 2024 06:16:27 +0000 Subject: [PATCH] mpv: sane-sysvol: monitor pipewire for changes and relay that to uosc --- TODO.md | 1 + hosts/common/programs/mpv/default.nix | 5 +- hosts/common/programs/mpv/input.conf | 8 +- .../common/programs/mpv/sane-sysvol-main.lua | 37 --- .../common/programs/mpv/sane-sysvol/main.lua | 240 ++++++++++++++++++ .../mpv/sane-sysvol/non_blocking_popen.lua | 80 ++++++ 6 files changed, 328 insertions(+), 43 deletions(-) delete mode 100644 hosts/common/programs/mpv/sane-sysvol-main.lua create mode 100644 hosts/common/programs/mpv/sane-sysvol/main.lua create mode 100644 hosts/common/programs/mpv/sane-sysvol/non_blocking_popen.lua diff --git a/TODO.md b/TODO.md index 0ceebbeb..59e98607 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,7 @@ - could at least direct the cache to `http://desko-hn:5001` ## REFACTORING: +- REMOVE DEPRECATED `crypt` from sftpgo_auth_hook - consolidate ~/dev and ~/ref - ~/dev becomes a link to ~/ref/cat/mine - fold hosts/common/home/ssh.nix -> hosts/common/users/colin.nix diff --git a/hosts/common/programs/mpv/default.nix b/hosts/common/programs/mpv/default.nix index a6936303..bd5d1cd5 100644 --- a/hosts/common/programs/mpv/default.nix +++ b/hosts/common/programs/mpv/default.nix @@ -122,7 +122,7 @@ let in { sane.programs.mpv = { - packageUnwrapped = pkgs.wrapMpv mpv-unwrapped { + packageUnwrapped = pkgs.wrapMpv (mpv-unwrapped.override { lua = pkgs.luajit; }) { scripts = [ pkgs.mpvScripts.mpris pkgs.mpvScripts.mpv-playlistmanager @@ -191,7 +191,8 @@ in ".local/state/mpv" ]; fs.".config/mpv/scripts/sane-cast/main.lua".symlink.target = ./sane-cast-main.lua; - fs.".config/mpv/scripts/sane-sysvol/main.lua".symlink.target = ./sane-sysvol-main.lua; + fs.".config/mpv/scripts/sane-sysvol/main.lua".symlink.target = ./sane-sysvol/main.lua; + fs.".config/mpv/scripts/sane-sysvol/non_blocking_popen.lua".symlink.target = ./sane-sysvol/non_blocking_popen.lua; fs.".config/mpv/input.conf".symlink.target = ./input.conf; fs.".config/mpv/mpv.conf".symlink.target = ./mpv.conf; fs.".config/mpv/script-opts/osc.conf".symlink.target = ./osc.conf; diff --git a/hosts/common/programs/mpv/input.conf b/hosts/common/programs/mpv/input.conf index ec9c1b30..c8023890 100644 --- a/hosts/common/programs/mpv/input.conf +++ b/hosts/common/programs/mpv/input.conf @@ -31,7 +31,7 @@ ctrl+s async screenshot #! Utils > Screenshot alt+i script-binding uosc/keybinds #! Utils > Key bindings O script-binding uosc/show-in-directory #! Utils > Show in directory # script-binding uosc/open-config-directory #! Utils > Open config directory -ctrl+r script-binding sane/blast #! Audiocast -ctrl+t script-binding sane/go2tv-video #! Cast -# script-binding sane/go2tv-stream #! Cast (...) > Stream -# script-binding sane/go2tv-gui #! Cast (...) > GUI +ctrl+r script-binding sane-cast/blast #! Audiocast +ctrl+t script-binding sane-cast/go2tv-video #! Cast +# script-binding sane-cast/go2tv-stream #! Cast (...) > Stream +# script-binding sane-cast/go2tv-gui #! Cast (...) > GUI diff --git a/hosts/common/programs/mpv/sane-sysvol-main.lua b/hosts/common/programs/mpv/sane-sysvol-main.lua deleted file mode 100644 index ee60a963..00000000 --- a/hosts/common/programs/mpv/sane-sysvol-main.lua +++ /dev/null @@ -1,37 +0,0 @@ -msg = require('mp.msg') -msg.trace('sane-sysvol: load: begin') - -function subprocess(args) - mp.command_native({ - name = "subprocess", - args = args, - -- these arguments below probably don't matter: copied from sane-cast - detach = false, - capture_stdout = false, - capture_stderr = false, - passthrough_stdin = false, - playback_only = false, - }) -end - -function translate_out(vol) - -- called when mpv wants to set the system-wide volume - local volstr = tostring(vol) .. "%" - msg.trace("setting system-wide volume:", volstr) - if vol == nil then - return - end - subprocess({ - "wpctl", - "set-volume", - "@DEFAULT_AUDIO_SINK@", - volstr - }) -end - -mp.set_property_native('user-data/sane-sysvol/volume', 0) -mp.observe_property('user-data/sane-sysvol/volume', 'native', function(_, val) - translate_out(val) -end) - -msg.trace('sane-sysvol: load: complete') diff --git a/hosts/common/programs/mpv/sane-sysvol/main.lua b/hosts/common/programs/mpv/sane-sysvol/main.lua new file mode 100644 index 00000000..5ee9ac43 --- /dev/null +++ b/hosts/common/programs/mpv/sane-sysvol/main.lua @@ -0,0 +1,240 @@ +msg = require('mp.msg') +msg.trace('sane-sysvol: load: begin') + +non_blocking_popen = require("non_blocking_popen") + +RD_SIZE = 4096 + +function startswith(superstring, substring) + return superstring:sub(1, substring:len()) == substring +end +function strip_prefix(superstring, substring) + return superstring:sub(substring:len()) +end + +function ltrim(s) + -- remove all leading whitespace from `s` + local i = 1 + while s:sub(i, i) == " " or s:sub(i, i) == "\t" do + i = i + 1 + end + return s:sub(i) +end + +function subprocess(args) + mp.command_native({ + name = "subprocess", + args = args, + -- these arguments below probably don't matter: copied from sane-cast + detach = false, + capture_stdout = false, + capture_stderr = false, + passthrough_stdin = false, + playback_only = false, + }) +end + +function sysvol_new() + return { + -- sysvol is pipewire-native volume + -- it's the cube of the equivalent 0-100% value represented inside mpv + sysvol = nil, + change_sysvol = function(self, mpv_vol) + -- called when mpv wants to set the system-wide volume + if mpv_vol == nil then + return + end + + local old_mpv_vol = nil + if self.sysvol ~= nil then + old_mpv_vol = 100 * self.sysvol^(1/3) + end + if old_mpv_vol ~= nil and math.floor(old_mpv_vol) == math.floor(mpv_vol) then + return + end + + local volstr = tostring(mpv_vol) .. "%" + msg.debug("setting system-wide volume:", volstr) + self.sysvol = (0.01*mpv_vol)^3 + subprocess({ + "wpctl", + "set-volume", + "@DEFAULT_AUDIO_SINK@", + volstr + }) + end, + on_sysvol_change = function(self, sysvol) + if sysvol == nil then + return + end + -- called when the pipewire system volume is changed (either by us, or an external application) + local new_mpv_vol = 100 * sysvol^(1/3) + local old_mpv_vol = nil + if self.sysvol ~= nil then + old_mpv_vol = 100 * self.sysvol^(1/3) + end + + if old_mpv_vol ~= nil and math.abs(new_mpv_vol - old_mpv_vol) < 1.0 then + msg.debug("NOT announcing volume change to mpv (because it was what triggered the change):", old_mpv_vol, new_mpv_vol) + return + end + + self.sysvol = sysvol + msg.debug("announcing volume change to mpv:", old_mpv_vol, new_mpv_vol) + mp.set_property_native("user-data/sane-sysvol/volume", new_mpv_vol) + end + } +end + +function pwmon_parser_new() + return { + -- volume: pipewire-native volume. usually 0.0 - 1.0, but can go higher (e.g. 3.25) + -- `wpctl get-volume` and this volume are related, in that the volume reported by + -- wpctl is the cube-root of this one. + volume = {}, -- object-id (number) -> volume (number) + mute = {}, -- object-id (number) -> mute (bool) + last_audio_device_id = nil, -- TODO: might not actually be necessary + + -- parser state: + in_changed = false, + changed_id = nil, + in_device = false, + in_direction = false, + in_output = false, + in_vol = false, + in_mute = false, + + feed_line = function(self, line) + line = ltrim(line) + if startswith(line, "changed:") then + self.in_changed = true + self.changed_id = nil + self.in_device = false + self.in_direction = false + self.in_output = false + self.in_vol = false + self.in_mute = false + self.in_properties = false + elseif startswith(line, "added:") or startswith(line, "removed:") then + self.in_changed = false + self.changed_id = nil + self.in_device = false + self.in_direction = false + self.in_output = false + self.in_vol = false + self.in_mute = false + self.in_properties = false + elseif startswith(line, "id: ") and self.in_changed then + if self.changed_id == nil then + self.changed_id = tonumber(strip_prefix(line, "id: ")) + msg.debug("changed_id:", self.changed_id) + end + elseif startswith(line, "type: ") and self.in_changed then + self.in_device = startswith(line, "type: PipeWire:Interface:Device") + msg.trace("parsed type:", line, self.in_device) + elseif startswith(line, "Prop: ") and self.in_changed and self.in_device then + self.in_direction = startswith(line, "Prop: key Spa:Pod:Object:Param:Route:direction") + if self.in_direction then + self.in_output = false + end + -- which of the *Volumes params we read is unclear. + -- alternative to this is to just detect the change, and then cal wpctl get-volume @DEFAULT_AUDIO_SINK@ + self.in_vol = startswith(line, "Prop: key Spa:Pod:Object:Param:Props:channelVolumes") + self.in_mute = startswith(line, "Prop: key Spa:Pod:Object:Param:Props:softMute") + msg.trace("parsed `Prop:`", line, self.in_vol) + elseif line:find("Spa:Enum:Direction:Output", 1, true) and self.in_direction then + self.in_output = true + elseif startswith(line, "Float ") and self.in_changed and self.in_device and self.in_output and self.in_vol then + value = tonumber(strip_prefix(line, "Float ")) + self:feed_volume(value) + elseif startswith(line, "Bool ") and self.in_changed and self.in_device and self.in_output and self.in_mute then + value = tonumber(strip_prefix(line, "Bool ")) == "true" + self:feed_mute(value) + elseif startswith(line, "properties:") and self.in_changed and self.in_device then + self.in_properties = true + elseif line == 'media.class = "Audio/Device"' and self.in_changed and self.in_device and self.in_properties then + self.last_audio_device_id = self.changed_id + msg.debug("last_audio_device_id:", self.changed_id) + end + end, + + feed_volume = function(self, vol) + msg.debug("volume:", self.changed_id, vol) + self.volume[self.changed_id] = vol + end, + feed_mute = function(self, mute) + msg.debug("mute:", self.changed_id, mute) + self.mute[self.changed_id] = mute + end, + get_effective_volume = function(self, id) + if id == nil then + id = self.last_audio_device_id + end + + if self.mute[id] then + return 0 + else + return self.volume[id] + end + end + } +end + +function pwmon_new() + return { + -- non_blocking_popen handle for the pw-mon process + -- which can be periodically read and parsed to detect volume changes + handle = non_blocking_popen.non_blocking_popen("pw-mon", RD_SIZE), + stdout_unparsed = "", + pwmon_parser = pwmon_parser_new(), + service = function(self) + -- do a single non-blocking read, and parse the result + -- in the *rare* case in which more than RD_SIZE data is ready, we service that remaining data on the next call + local buf, res = self.handle:read(RD_SIZE) + if res == "closed" then + msg.debug("pw-mon unexpectedly closed!") + end + if buf ~= nil then + self.stdout_unparsed = self.stdout_unparsed .. buf + self:consume_stdout() + end + end, + consume_stdout = function(self) + local idx_newline, next_newline = 0, 0 + while next_newline ~= nil do + next_newline = self.stdout_unparsed:find("\n", idx_newline + 1, true) + if next_newline ~= nil then + self:ingest_line(self.stdout_unparsed:sub(idx_newline + 1, next_newline - 1)) + idx_newline = next_newline + end + end + self.stdout_unparsed = self.stdout_unparsed:sub(idx_newline + 1) + end, + ingest_line = function(self, line) + msg.trace("pw-mon:", line) + local old_vol = self.pwmon_parser:get_effective_volume() + self.pwmon_parser:feed_line(line) + local new_vol = self.pwmon_parser:get_effective_volume() + if new_vol ~= old_vol then + msg.debug("pipewire volume change:", old_vol, new_vol) + mp.set_property_native("user-data/sane-sysvol/pw-mon-volume", new_vol) + end + end + } +end + + +mp.set_property_native("user-data/sane-sysvol/volume", 0) + +local sysvol = sysvol_new() +mp.observe_property("user-data/sane-sysvol/volume", "native", function(_, val) + sysvol:change_sysvol(val) +end) +mp.observe_property("user-data/sane-sysvol/pw-mon-volume", "native", function(_, val) + sysvol:on_sysvol_change(val) +end) + +local pwmon = pwmon_new() +mp.register_event('tick', function() pwmon:service() end) + +msg.trace("sane-sysvol: load: complete") diff --git a/hosts/common/programs/mpv/sane-sysvol/non_blocking_popen.lua b/hosts/common/programs/mpv/sane-sysvol/non_blocking_popen.lua new file mode 100644 index 00000000..03409911 --- /dev/null +++ b/hosts/common/programs/mpv/sane-sysvol/non_blocking_popen.lua @@ -0,0 +1,80 @@ +-- source: +-- requires: luajit +-- +-- Implements a basic binding for popen that allows non-blocking reads +-- returned "file" table only supports :read(with an optional size argument, no mode etc.) and :close +local function non_blocking_popen(cmd, read_buffer_size) + local ffi = require("ffi") + + -- C functions that we need + ffi.cdef([[ + void* popen(const char* cmd, const char* mode); + int pclose(void* stream); + int fileno(void* stream); + int fcntl(int fd, int cmd, int arg); + int *__errno_location (); + ssize_t read(int fd, void* buf, size_t count); + ]]) + + -- you can compile a simple C programm to find these values(Or look in the headers) + local F_SETFL = 4 + local O_NONBLOCK = 2048 + local EAGAIN = 11 + + -- this "array" holds the errno variable + local _errno = ffi.C.__errno_location() + + -- the buffer for reading from the process + local read_buffer_size = tonumber(read_buffer_size) or 2048 + local read_buffer = ffi.new('uint8_t[?]',read_buffer_size) + + -- get a FILE* for our command + local file = assert(ffi.C.popen(cmd, "r")) + + -- turn the FILE* to a fd(int) for fcntl + local fd = ffi.C.fileno(file) + + -- set non-blocking mode for read + assert(ffi.C.fcntl(fd, F_SETFL, O_NONBLOCK)==0, "fcntl failed") + + -- close the process, prevent reading, allow garbage colletion + function file_close(self) + ffi.C.pclose(file) + self.read_buffer = nil + read_buffer = nil + self.read = function() return nil, "closed"end + end + + -- read up to size bytes from the process. Returns data(string) and number of bytes read if successfull, + -- nil, "EAGAIN" if there is no data aviable, and + -- nil, "closed" if the process has ended + local read = ffi.C.read + function file_read(self, size) + local _size = math.min(read_buffer_size, size) + while true do + local nbytes = read(fd,read_buffer,_size) + if nbytes > 0 then + local data = ffi.string(read_buffer, nbytes) + return data, nbytes + elseif (nbytes == -1) and (_errno[0] == EAGAIN) then + return nil, "EAGAIN" + else + file_close(self) + return nil, "closed" + end + end + end + + return { + _fd = fd, + _file = file, + _read_buffer = read_buffer, + _read_buffer_size = read_buffer_size, + read = file_read, + close = file_close + } +end + +return { + non_blocking_popen = non_blocking_popen +}