IT
December 13, 2024

Wireplumber's lua scripting sucks; here's how to use it

The problem

Recently steam rolled out a beta preview of their game recording feature. It's amazing! But, since it's a beta, one small problem: on linux, at the time of writing, you can not choose which audio source it's recording from, it captures entire system audio. So I wanted to write a relatively simple script to unlink all sinks from steam and link wine64-preloader apps to it instead. Sounds like a job for pipewire!

Gotchas

Wireplumber doesn't have any (good) overview documentation

Here's how it works in Pipewire world:

  • You have nodes that produce/consume sound
  • You have ports which regulate settings of how said nodes produce/consume sound
  • You have links that connect the ports

Wireplumber doesn't have any (good) scripting documentation

Oh, you want to know all possible types for `Interest` object? Too bad.

The type of an interest must be a valid GType, written as a string without the “Wp” prefix and with the first letter optionally being lowercase. The type may match any wireplumber object or interface.

No comprehensive list, go read the possible values from C library.

Oh, you somehow found that wireplumber has eventhooks? Too bad, there's only one mention of it in "Wireplumber's design" page and no examples whatsoever. Go read source code to find example usage.

Oh, you want to read examples on github that documentation explicitly mentions? Too bad, they're outdated and use legacy api.

Hell, it doesn't even describe all the objects there are! The only circulating snippet around the internet for linking ports has this:

local link = Link("link-factory", link_args)
link:activate(1)

Link object is not mentioned ANYWHERE in lua scripting documentation. It looks to be a wrapper against wp_link_new_from_factory, but why the hell do I have to guess?

Awesome, guys.

Wireplumber doesn't run "real" lua, it's sandboxed context

Okay, you found ObjectManager, managed to give it interest and connect it to callback. Great! It works!

local node_om = ObjectManager {
  type = "node",
  Constraint { "media.class", "equals", "Stream/Output/Audio", type = "pw" }
}

node_om:connect("object-added", function(om, object)
  print("Object exists!")
end)

You run this script via wpexec, and it prints a bunch of stuff! Cool! Now you launch a VLC or something to check that it will print once again and.... it doesn't?

Saving you some headache, it's because of local. As far as I understand, once it's done with first run, wireplumber switches context from script back to the main app and garbage collects all locals (you'll also see some "proxy destroyed" errors along the way).

Do not use locals for ANY ObjectManager objects, global or not.

Quick bits

  • Wireplumber's sandbox doesn't expose much if at all from standard lua features. Here's comprehensive list from the doc. Don't expect to be able to make pid tree here:
_VERSION  assert       error    ipairs    next       pairs   tonumber
pcall     select       print    tostring  type       xpcall  require
table     string       math     package   utf8       debug   coroutine
os.clock  os.difftime  os.time  os.date   os.getenv
  • It doesn't print logs by default (neither wpexec nor wireplumber itself), enable it:
    WIREPLUMBER_DEBUG=W,<my-logger-name>:T
  • You are not guaranteed to have anything, which results on having lots of ObjectManagers and callback to wait for stuff to appear; i.e. do NOT assume that the node that just spawned has ports already, especially if it's something like game that takes long time to load
  • Use Debug.dump_table(t) to dump properties

Okay, how do I actually use it?

⚠️ Use qpwgraph app to visually see what's happening

I'm sorry, I can't give you a solution. Your usecase is probably just as non trivial as mine, if you are here.

If you're still reading, this is your last chance to change your mind and make it in bash via pactl subscribe.

Okay, less words, more examples. Here's how I disconnect my steam nodes from every sink input:

steam_om = ObjectManager { 
  Interest {
	type = "node",
	Constraint { "application.process.binary", "matches", "steam",              type = "pw" },
	Constraint { "media.class",                "matches", "Stream/Input/Audio", type = "pw" },
  },
}

-- On any steam node, ...
steam_om:connect("object-added", function(_, steam_node)
  -- ... We look for links with this node as one it is inputting into,
  steam_link_om = ObjectManager {
    Interest {
      type = "link",
      Constraint { "link.input.node", "equals", steam_node.properties["object.id"] },
    }
  }

  -- ... Then we look at the Audio/Sink nodes that are sources of this link,
  steam_link_om:connect("object-added", function(_, steam_link)
  
    steam_source_om = ObjectManager {
      Interest {
        type = "node",
        Constraint { "object.id",   "equals", steam_link.properties["link.output.node"] },
        Constraint { "media.class", "matches", "Audio/Sink*" },
      }
    }

    -- ... And then we destroy them.
    steam_source_om:connect("object-added", function(_, source_node)
    
      local _, err = pcall(function() steam_link:request_destroy() end)
      if err then log:debug("Destroying error: " .. tostring(err)) end
    
    end)

    steam_source_om:activate()
  end)

  steam_link_om:activate()
end)

And, arguably more hard one, here's how to connect every wine64-preload node to steam:

-- For every wine app, ...
wine_om:connect("object-added", function(_, wine_node)
  wine_port_om = ObjectManager {
    Interest {
      type = "port",
      Constraint { "node.id",        "equals", wine_node.properties["object.id"] },
      Constraint { "port.direction", "equals", "out" },
    }
  }

  -- ... We're waiting for it to have ports (which can very
  -- much be not instant, look at spotify and FFXIV), ...
  wine_port_om:connect("object-added", function(_, wine_port)
    -- ..., Comparing them to steam's ports, ...
    for steam_node in steam_om:iterate() do
      for steam_port in steam_node:iterate_ports(input_ports) do
      
        -- ..., Checking if those ports match channels, ...
        if steam_port.properties["audio.channel"] == wine_port.properties["audio.channel"] then
          -- ... And connect them.
          link_ports(steam_port, wine_port)
        end
      
      end
    end
  end)

  wine_port_om:activate()
end)

And here's a function to link two given ports (stolen from here):

function link_ports(input_port, output_port)
  -- Yes, sometimes I have gotten nil ports, don't remove this check
  if not input_port or not output_port then
    log:warning("nil values, not linking")
    return
  end

  local link_args = {
    ["link.input.node"]  = input_port.properties["node.id"],
    ["link.input.port"]  = input_port.properties["object.id"],
    ["link.output.node"] = output_port.properties["node.id"],
    ["link.output.port"] = output_port.properties["object.id"],
    ["object.id"]        = nil,
    ["object.linger"]    = true,
    ["node.description"] = "Link created by steam-wire",
  }

  local link = Link("link-factory", link_args)
  link:activate(1)
end

And here is link to full code in case you need it: https://github.com/Widowan/steam-wire

Put the script into ~/.local/share/wireplumber/scripts/ (create if not exists) and make ~/.config/wireplumber/wireplumber.conf.d/90-steam-wire.conf that looks like this:

wireplumber.components = [
  {
    name = steam-wire.lua,
    type = script/lua,
    provides = custom.steam-wire
  }
]

wireplumber.profiles = {
  main = {
    custom.steam-wire = required
  }
}

Really useful resources!