feat(audio-switcher): support grouped outputs (#163)
This commit is contained in:
@@ -14,6 +14,7 @@ local decoded = {
|
||||
INFO = {
|
||||
default_sink_name = "sink.main",
|
||||
default_source_name = "source.main",
|
||||
server_name = "PulseAudio (on PipeWire 1.6.8)",
|
||||
},
|
||||
SINKS_UNMUTED = {
|
||||
{
|
||||
@@ -106,6 +107,7 @@ assert(load(serviceSource, "@service.luau"))()
|
||||
assert(type(streamCallback) == "function", "pactl subscription was not started")
|
||||
|
||||
complete("'info'", "INFO")
|
||||
complete("'list' 'short' 'modules'", "")
|
||||
complete("'list' 'sinks'", "SINKS_UNMUTED")
|
||||
complete("'list' 'sources'", "SOURCES_UNMUTED")
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
local function clone(value)
|
||||
if type(value) ~= "table" then return value end
|
||||
local result = {}
|
||||
for key, item in pairs(value) do result[clone(key)] = clone(item) end
|
||||
return result
|
||||
end
|
||||
|
||||
local values = {}
|
||||
local watchers = {}
|
||||
local pending = {}
|
||||
|
||||
local decoded = {
|
||||
INFO_PHYSICAL = {
|
||||
default_sink_name = "sink.a",
|
||||
default_source_name = "source.main",
|
||||
server_name = "PulseAudio (on PipeWire 1.6.8)",
|
||||
},
|
||||
INFO_GROUPED = {
|
||||
default_sink_name = "noctalia_group_123_1",
|
||||
default_source_name = "source.main",
|
||||
server_name = "PulseAudio (on PipeWire 1.6.8)",
|
||||
},
|
||||
SINKS_PHYSICAL = {
|
||||
{
|
||||
name = "sink.a",
|
||||
description = "Speakers",
|
||||
mute = false,
|
||||
volume = { front_left = { value_percent = "40%" } },
|
||||
properties = {},
|
||||
},
|
||||
{
|
||||
name = "sink.b",
|
||||
description = "Headphones",
|
||||
mute = false,
|
||||
volume = { front_left = { value_percent = "40%" } },
|
||||
properties = {},
|
||||
},
|
||||
},
|
||||
SINKS_GROUPED = {
|
||||
{
|
||||
name = "sink.a",
|
||||
description = "Speakers",
|
||||
mute = false,
|
||||
volume = { front_left = { value_percent = "40%" } },
|
||||
properties = {},
|
||||
},
|
||||
{
|
||||
name = "sink.b",
|
||||
description = "Headphones",
|
||||
mute = false,
|
||||
volume = { front_left = { value_percent = "40%" } },
|
||||
properties = {},
|
||||
},
|
||||
{
|
||||
name = "noctalia_group_123_1",
|
||||
description = "Noctalia Output Group",
|
||||
mute = false,
|
||||
volume = { front_left = { value_percent = "40%" } },
|
||||
properties = {},
|
||||
},
|
||||
},
|
||||
SOURCES = {
|
||||
{
|
||||
name = "source.main",
|
||||
description = "Microphone",
|
||||
mute = false,
|
||||
volume = { front_left = { value_percent = "30%" } },
|
||||
properties = {},
|
||||
},
|
||||
},
|
||||
STREAMS_EMPTY = {},
|
||||
}
|
||||
|
||||
local fakeTime = 123
|
||||
|
||||
noctalia = {
|
||||
pluginDataDir = function() return nil end,
|
||||
getConfig = function() return nil end,
|
||||
commandExists = function(command) return command == "pactl" end,
|
||||
readFile = function() return nil end,
|
||||
writeFile = function() return true end,
|
||||
setUpdateInterval = function() end,
|
||||
runAsync = function(command, callback)
|
||||
pending[#pending + 1] = { command = command, callback = callback }
|
||||
return true
|
||||
end,
|
||||
runStream = function() return true end,
|
||||
json = {
|
||||
decode = function(value) return clone(decoded[value]) end,
|
||||
encode = function() return "PREFERENCES" end,
|
||||
},
|
||||
string = {
|
||||
trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") or "" end,
|
||||
},
|
||||
state = {
|
||||
get = function(key) return clone(values[key]) end,
|
||||
set = function(key, value)
|
||||
values[key] = clone(value)
|
||||
if watchers[key] ~= nil then watchers[key](clone(value)) end
|
||||
end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
tr = function(key) return key end,
|
||||
log = function() end,
|
||||
notify = function() end,
|
||||
notifyError = function() end,
|
||||
}
|
||||
|
||||
local function complete(expected, stdout, exitCode)
|
||||
local call = table.remove(pending, 1)
|
||||
assert(call ~= nil, "expected pending command containing " .. expected)
|
||||
assert(call.command:find(expected, 1, true) ~= nil, "unexpected command: " .. call.command)
|
||||
call.callback({
|
||||
exitCode = exitCode or 0,
|
||||
stdout = stdout or "",
|
||||
stderr = "",
|
||||
timedOut = false,
|
||||
stdoutTruncated = false,
|
||||
stderrTruncated = false,
|
||||
})
|
||||
end
|
||||
|
||||
local function completeRefresh(info, modules, sinks)
|
||||
complete("'info'", info)
|
||||
complete("'list' 'short' 'modules'", modules)
|
||||
complete("'list' 'sinks'", sinks)
|
||||
complete("'list' 'sources'", "SOURCES")
|
||||
end
|
||||
|
||||
local originalTime = os.time
|
||||
os.time = function() return fakeTime end
|
||||
|
||||
local serviceFile = assert(io.open("service.luau", "r"))
|
||||
local serviceSource = serviceFile:read("*a")
|
||||
serviceFile:close()
|
||||
serviceSource = serviceSource:gsub("([%w_%.]+)%s*%+=%s*([^\n]+)", "%1 = %1 + %2")
|
||||
serviceSource = serviceSource:gsub("([%w_%.]+)%s*%-=%s*([^\n]+)", "%1 = %1 - %2")
|
||||
assert(load(serviceSource, "@service.luau"))()
|
||||
|
||||
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
|
||||
|
||||
noctalia.state.set("audio_switcher_command", {
|
||||
requestId = "create-group",
|
||||
action = "create_output_group",
|
||||
ids = { "sink.a", "sink.b" },
|
||||
})
|
||||
assert(pending[1].command:find("'sinks=sink.a,sink.b'", 1, true), "PipeWire must use the sinks module option")
|
||||
complete("'load-module' 'module-combine-sink'", "77\n")
|
||||
complete("'set-default-sink' 'noctalia_group_123_1'")
|
||||
complete("'list' 'sink-inputs'", "STREAMS_EMPTY")
|
||||
completeRefresh(
|
||||
"INFO_GROUPED",
|
||||
"77\tmodule-combine-sink\tsink_name=noctalia_group_123_1 sinks=sink.a,sink.b sink_properties=device.description=Noctalia_Output_Group\t\n",
|
||||
"SINKS_GROUPED"
|
||||
)
|
||||
|
||||
local snapshot = values["audio_switcher_snapshot"]
|
||||
local group = nil
|
||||
for _, output in ipairs(snapshot.outputs) do
|
||||
if output.group then group = output end
|
||||
end
|
||||
assert(group ~= nil, "plugin-created combine sink was not detected")
|
||||
assert(group.active == true, "new output group is not active")
|
||||
assert(group.groupModuleId == 77, "module index was not retained")
|
||||
assert(#group.groupMembers == 2, "group members were not parsed")
|
||||
|
||||
noctalia.state.set("audio_switcher_command", {
|
||||
requestId = "remove-group",
|
||||
action = "remove_output_group",
|
||||
id = group.id,
|
||||
})
|
||||
complete("'set-default-sink' 'sink.a'")
|
||||
complete("'list' 'sink-inputs'", "STREAMS_EMPTY")
|
||||
complete("'unload-module' '77'")
|
||||
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
|
||||
|
||||
snapshot = values["audio_switcher_snapshot"]
|
||||
for _, output in ipairs(snapshot.outputs) do
|
||||
assert(output.group ~= true, "removed group remained in the snapshot")
|
||||
end
|
||||
|
||||
decoded.INFO_PHYSICAL.server_name = "pulseaudio"
|
||||
noctalia.state.set("audio_switcher_command", {
|
||||
requestId = "refresh-pulseaudio",
|
||||
action = "refresh",
|
||||
})
|
||||
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
|
||||
noctalia.state.set("audio_switcher_command", {
|
||||
requestId = "create-pulseaudio-group",
|
||||
action = "create_output_group",
|
||||
ids = { "sink.a", "sink.b" },
|
||||
})
|
||||
assert(pending[1].command:find("'slaves=sink.a,sink.b'", 1, true), "PulseAudio must use the slaves module option")
|
||||
complete("'load-module' 'module-combine-sink'", "", 1)
|
||||
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
|
||||
|
||||
assert(#pending == 0, "unexpected commands remain pending")
|
||||
|
||||
os.time = originalTime
|
||||
print("audio-switcher output group tests: ok")
|
||||
Reference in New Issue
Block a user