Users browsing this thread: 2 Guest(s)
venam
Administrators
Let's update this thread with my new setup as I recently dived bit more into PipeWire.

I was able to reproduce my previous setup with PipeWire since my last issue with the restoration stream was fixed.
I even tested if the target-node, which is what PipeWire called the node a stream remembers to attach to when seen, keeps the right target in edge-cases like when it fallsback to another node when the current node the stream is attached to disappears. It still keeps the right expected target node in that case, which is what we want.


My setup consists of 3 nodes that are virtual path connected to my headset by default:

- Multimedia Node
- Voip Node
- Notification Node

[Image: qjackctl.png]

The idea is that I rely on the restoration mechanism to have streams automatically remember which type of path they should attach. The use-case is that if I am in a Voip call, I can listen to it through the Voip Node path, and meanwhile, I can redirect the Multimedia Node audio towards the call if I need to without the person hearing a loopback of their own voice.

Additionally, as a bonus, I should be able to plug and unplug my headset without loosing the streams that are currently being played as they are attached to these virtual nodes. I've had issues in the past with this on PulseAudio with this specific setup that I mentioned above.

Now to achieve this there are two steps that need to be done:

1. Create the virtual nodes
2. Have a mechanism to attach them automatically to my headset

The first step is pretty simple, you can rely on the `context.objects` section of one of the service you're starting, be it pipewire, pipewire-pulse, or pipewire-media-session, they all allow this. It doesn't make much difference which one, as I noticed, so I just added them in my ~/.config/pipewire/pipewire.conf
It looks like this:

Code:
{   factory = adapter
        args = {
            factory.name     = support.null-audio-sink
            node.name        = "Multimedia"
            node.description = "Multimedia Node"
            media.class      = "Audio/Duplex"
            audio.position   = "FL,FR"
            monitor.channel-volumes = true
        }
    }

    {   factory = adapter
        args = {
            factory.name     = support.null-audio-sink
            node.name        = "Notification"
            node.description = "Notification Node"
            media.class      = "Audio/Duplex"
            audio.position   = "FL,FR"
            monitor.channel-volumes = true
        }
    }

    {   factory = adapter
        args = {
            factory.name     = support.null-audio-sink
            node.name        = "Voip"
            node.description = "Voip Node"
            media.class      = "Audio/Duplex"
            audio.position   = "FL,FR"
            monitor.channel-volumes = true
        }
    }

The node.description is what appears in pulseaudio tools, media.class dictates how ports are created, adding "Virtual" to it hides it from most tools, Duplex makes the output port name be called "capture_*" instead of "monitor_*". Monitor would the equivalent of the automated capture streams created in PulseAudio language.
The part that was the hardest to discover was the monitor.channel-volumes, which if set to false would mean that the audio is pass-through, so if you change the volume in one of the virtual stream it wouldn't affect it. That's not what I want so I opted to set this to true.

The second step is the one that gave me issues.
I initially wanted to rely on WirePlumber implementation of the concept of Endpoints, which is similar to what I want to implement, but I wasn't able to rely on the restoration mechanism and nor did the Endpoint appear in PulseAudio tools, so it wasn't really what I wanted.

I couldn't also create the links between the virtual nodes and my headset directly from the config file, even when reproducing some configs I've seen online.
They set either "target.node" or "node.target", which I'm not sure what is the difference (it seems the first one is in the metadata and the other in the node properties), and that's suppose to give a hint to the session manager, similar to what is in the restoration db, to connect the nodes. Yet, it's not respected.
Maybe it's related to the fact that if I issue the following I don't see my nodes but only my headset:
Code:
pw-cat -p --list-targets

Available targets ("*" denotes default):
*    48: description="LifeChat LX-3000 Headset Digital Stereo (IEC958)" prio=880

I then tried to find another way to do this natively and stumbled upon this:
Code:
pw_metadata <id> target.node <target-id> Spa:Id
Which sets the target.node metadata on the node, so that it triggers the session manager to connect it to the specified target. However, it connects both ends of the node (capture/playback) to it, and it doesn't seem to keep respecting this value on different events. This wasn't a solid solution.

The best thing I've found when it comes to this was to rely on jack toolkits. I personally used something called jack-plumbing, but I've heard of similar tools such as jack_connect, and possibly others like aj-snapshot when running in daemon mode.
What these tools do is listen for events in the graph and decide how to connect nodes according to specific rules. I know that's supposed to be the role of the session manager, but in this case neither pipewire-media-session nor WirePlumber offers an easy way to do that.

Here are the rules I've set in my ~/.jack-plumbing:
Code:
(connect "Voip Node Monitor:capture_FL" "LifeChat LX-3000 Headset Digital Stereo \(IEC958\):playback_FL")
(connect "Voip Node Monitor:capture_FR" "LifeChat LX-3000 Headset Digital Stereo \(IEC958\):playback_FR")
(connect "Multimedia Node Monitor:capture_FL" "LifeChat LX-3000 Headset Digital Stereo \(IEC958\):playback_FL")
(connect "Multimedia Node Monitor:capture_FR" "LifeChat LX-3000 Headset Digital Stereo \(IEC958\):playback_FR")
(connect "Notification Node Monitor:capture_FL" "LifeChat LX-3000 Headset Digital Stereo \(IEC958\):playback_FL")
(connect "Notification Node Monitor:capture_FR" "LifeChat LX-3000 Headset Digital Stereo \(IEC958\):playback_FR")

What's nice with jack-plumbing (which you should run as pw-jack jack-plumbing), is that on any event, even when links change for a reason or another, it will go through the rules and re-create the links if needed. That's useful if the headset gets disconnected and reconnected.

The next issue is to run pipewire, pipewire-media-session, pipewire-pulse, and jack-plumbing as systemd services.
Fortunately, Arch comes with service and socket units for all of them, so I just had to enable them… except for my custom jack-plumbing.
Another thing to beware, is that socket units should be the first units started in the chain.

This is what I ended up having as a jack-plumbing.service in my ~/.config/systemd/user/jack-plumbing.service

Code:
[Unit]
Description=Jack Plumbing
Wants=pipewire-media-session.service
After=pipewire-media-session.service
PartOf=pipewire-media-session.service

[Service]
ExecStart=/usr/bin/pw-jack /home/vnm/docu/bin/jack-plumbing

[Install]
WantedBy=default.target

When testing you can stop everything by doing
systemctl --user stop pipewire.socket
systemctl --user stop pipewire-pulse.socket

Then restart everything in the following order

systemctl --user start pipewire.socket
systemctl --user start pipewire-pulse.socket
systemctl --user start pipewire
systemctl --user start pipewire-pulse
systemctl --user start jack-plumbing.service


The next thing I'll try to add, which I've tested but haven't really seen the benefits of yet, is having rnnoise ldaspa module filter for echo cancelation on the microphone.
I had to install a package called "noise-suppression-for-voice" on ArchLinux to get the ladspa library in /usr/lib/ladspa/librnnoise_ladspa.so, there wasn't so much documentation on that.

It could also be used to add another level of indirection, avoiding issues when real devices disconnect.

In the pipewire configuration you can then create a node that will use that filter for you.
Another way to test this is to create a separate pipewire configuration file, with the bare minimum in it and put only this module.

Code:
{   name = libpipewire-module-filter-chain
    args = {
        node.name = "effect_input.rnnoise"
        node.description = "Noise Canceling source"
        media.name = "Noise Canceling source"
        filter.graph = {
            nodes = [
                {
                    type = ladspa
                    name = rnnoise
                    plugin = librnnoise_ladspa
                    label = noise_suppressor_stereo
                    control = {
                        "VAD Threshold (%)" 50.0
                    }
                }
            ]
        }
        capture.props = {
            node.description = "Noise Canceling MicInput"
            node.passive = true
            node.pause-on-idle = true
        }
        playback.props = {
            node.description = "Noise Canceling Output"
            media.class = Audio/Source
        }
    }
}


Now as for the utilities that can be used to interact with PipeWire stuff, it's not missing, you can rely on PulseAudio, on Jack, or on native tools.
I personally use the most pulsemixer for volume and qjackctl to move streams.

I was curious to see all the ways I coul set the volume for the default node.

The easiest way is to rely on the ALSA pcm that PipeWire creates:
Code:
amixer -D pipewire sset Master 5%- # decrease
amixer -D pipewire sset Master 5%+ # increase
However, I still use pulse as the default pcm with alsa (alsactl dump-cfg and search for ctl.default and pcm.default), so I would rely on:
Code:
amixer -D pulse sset Master 5%+
amixer -D pulse sset Master 5%-

We can rely on pactl
Code:
pactl set-sink-volume @DEFAULT_SINK@ +5%
pactl set-sink-volume @DEFAULT_SINK@ -5%

So far, so good, now the hardest way to set the volume is to do it natively, there's no direct tool to do that right now. So here's as a bonus how to do it.

1. Find the current default sink
2. Find the current volume
3. Set the volume

To find the current default sink, we have to rely on pw-metadata, as its an information that's only useful for the session manager.
Here's what I got (NB: I rely on the jq tool to parse json):

Code:
# the metadata only contains the name of the default sink
default_sink_name=$(pw-metadata 0 'default.audio.sink' | grep 'value' | sed "s/.* value:'//;s/' type:.*$//;" | jq .name)
default_sink_id=$(pw-dump Node | jq '.[].info.props|select(."node.name" == '" $default_sink_name "') | ."object.id"')

To find the current volume isn't easy either, you have to interrogate the params of the node and parse them, converting the volume using cube root function, here's what I got:

Code:
current_volume=$(pw-cli enum-params $default_sink_id 'Props' | grep -A 2 'Spa:Pod:Object:Param:Props:channelVolumes' | awk '/Float / {gsub(/.*Float\s/," "); print $1^(1/3) }')

Now the final step is to set the volume, we can do that using pw-cli, but we also need to calculate how much change we want.
Here we simulate an increment of 0.1.

Code:
change=0.1
new_volume=$(echo "$current_volume $change" | awk '{printf "%f", $1 + $2}')
# we need to reconvert to cubic root
new_volume_cube=$(echo $new_volume | awk '{ print $1^3 }')
pw-cli s $default_sink_id Props "{ mute: false, channelVolumes: [ $new_volume_cube , $new_volume_cube ] }"

This works fine, yet, the PulseAudio tools don't seem to show the volume as updated when you do this maneuver.
Here's the final script:

Code:
# the metadata only contains the name of the default sink
default_sink_name=$(pw-metadata 0 'default.audio.sink' | grep 'value' | sed "s/.* value:'//;s/' type:.*$//;" | jq .name)
default_sink_id=$(pw-dump Node | jq '.[].info.props|select(."node.name" == '" $default_sink_name "') | ."object.id"')
current_volume=$(pw-cli enum-params $default_sink_id 'Props' | grep -A 2 'Spa:Pod:Object:Param:Props:channelVolumes' | awk '/Float / {gsub(/.*Float\s/," "); print $1^(1/3) }')
change="${1:-0.1}" # defaults to increment of 0.1
new_volume=$(echo "$current_volume $change" | awk '{printf "%f", $1 + $2}')
# we need to reconvert to cubic root
new_volume_cube=$(echo $new_volume | awk '{ print $1^3 }')
pw-cli s $default_sink_id Props "{ mute: false, channelVolumes: [ $new_volume_cube , $new_volume_cube ] }"
# or use wpctl instead
gist

Another way to set the volume would be to rely on wpctl from WirePlumber tools (but doesn't actually need WirePlumber running), it also doesn't need all the juggling with cube transformation and updates PulseAudio tools:
Code:
wpctl set-volume $default_sink_id $new_volume

So to conclude, PipeWire is sort of the Perl of media, or the shell pipeline of media, mechanism not policy, somehwat the Tim Toady There's more than one way to do it.

I've had issues in the past with the PulseAudio setup I mentioned above but it seems to work fine on PipeWire. The native tooling is still missing things but you get around. Same for the stuff which I had to rely on jack-plumbing. But it's nice to have everything backward compatible.

NB: After further usage, I'm still hitting edge-cases and bugs, so be sure to know it's still not the most stable software yet.

Update: I'm testing with the following setup so that the media stream is automatically connected and merge through an intermediary node called "Record Node", that is the default source node for recording.
Here's what it looks like when the headset is in (using helvum instead of qjackctl):
[Image: helvum_headset_in.png]
And when I remove my headset:
[Image: helvum_headset_out.png]

I also forgot to set the rtkit permission for pipewire, I did that in polkit, it's helping it being more responsive.


Messages In This Thread
Your Audio Setup - by venam - 08-02-2021, 03:37 AM
RE: Your Audio Setup - by movq - 09-02-2021, 01:06 PM
RE: Your Audio Setup - by venam - 09-02-2021, 01:30 PM
RE: Your Audio Setup - by movq - 10-02-2021, 11:33 AM
RE: Your Audio Setup - by venam - 10-02-2021, 11:58 AM
RE: Your Audio Setup - by venam - 05-07-2021, 03:29 PM
RE: Your Audio Setup - by VMS - 28-02-2022, 12:21 PM