Skip to content

fix(linux): use pw-link for PipeWire isolation (resilient to WirePlumber) - #49

Draft
m96-chan wants to merge 1 commit into
mainfrom
fix/linux-pw-link
Draft

fix(linux): use pw-link for PipeWire isolation (resilient to WirePlumber)#49
m96-chan wants to merge 1 commit into
mainfrom
fix/linux-pw-link

Conversation

@m96-chan

Copy link
Copy Markdown
Owner

Closes #48.

Summary

  • Replace the sink_input_move + name-targeted pw-record isolation in PipeWireStrategy with explicit pw-link connections resolved by global port id from a pw-dump snapshot
  • The target process's original audio route to the speakers is left intact — we add the tap as an extra subscriber rather than seizing the stream — so user-created explicit port links are exempt from WirePlumber's session policy and stay put
  • pw-record is spawned with --target=0 and a per-PID node.name, then its input ports are wired to the tap null-sink's monitor by global port id, so the session manager cannot redirect it to the default sink's monitor

Root cause (per #48)

On PipeWire + WirePlumber (Ubuntu 24.04 / VRChat repro), the previous strategy silently failed:

  1. pulse.sink_input_move() succeeds, but WirePlumber's stream-restore / follow-default-target policies immediately revert the move. The API reports success while isolation is broken.
  2. pw-record --target=<name> is redirected by the session manager to the default sink's monitor in multi-sink environments, so the recorder picks up the full system mix.

Fix

  1. Load module-null-sink (proctap_pw_isolated_<pid>) as the dedicated capture target.
  2. Spawn pw-record --target=0 -P node.name=proctap_pw_rec_<pid> --format=s16 ... so the recorder's input ports stay unconnected until we link them explicitly.
  3. Take a single pw-dump snapshot and resolve:
    • the target PID's Stream/Output/Audio node(s) (every one, since some apps register multiple streams),
    • the tap null-sink node,
    • the pw-record node,
    • and the global port ids on each side (matched by audio.channel).
  4. pw-link two hops by global port id (deterministic, collision-free under shared VRChat.exe node names):
    • producer node out-ports → tap in-ports (original speaker route preserved),
    • tap monitor out-ports → recorder in-ports.
  5. Cleanup unloads the null-sink; PipeWire garbage-collects every attached link, so no explicit pw-link teardown is needed.

What's NOT touched

  • PulseAudioStrategy — pure PulseAudio environments are not subject to WirePlumber's session policy.
  • PipeWireNativeStrategy — uses the native C API directly, no pw-record subprocess.

Tests

  • tests/test_linux_pw_link_helpers.py (32 tests, no PipeWire daemon required) covers the pure helpers: pw-dump JSON parsing, node lookup by name / by PID, port resolution by direction + channel, idempotent-link detection (File exists), and the channel-matching loop in _pw_link_nodes.
  • mypy clean (--python-version 3.10 --ignore-missing-imports).

Test plan (manual / reviewer)

  • Ubuntu 24.04 + PipeWire 1.0.x + WirePlumber: run two audio-producing apps concurrently (e.g. two browser tabs, or VRChat + system sounds)
  • proctap --pid <one_pid> --stdout | ffplay -f s16le -ar 48000 -ac 2 -i pipe:0 — verify only the targeted app is audible, the other is absent
  • During capture, pw-link -l shows the target node's outputs linked to both the original sink and proctap_pw_isolated_<pid> playback ports
  • pw-link -l also shows proctap_pw_isolated_<pid> monitor → proctap_pw_rec_<pid> input
  • After capture stops, pw-cli ls Node shows neither proctap_pw_isolated_<pid> nor proctap_pw_rec_<pid>, and the target app's playback route is unchanged
  • PulseAudio-only environment (no PipeWire) still works (no regression in PulseAudioStrategy)

Notes

🤖 Generated with Claude Code

…move

On Linux with PipeWire + WirePlumber, per-PID capture was leaking audio
from other apps. Two root causes (Issue #48):

1. `pulse.sink_input_move()` moves the target's sink-input to our
   null-sink, but WirePlumber's `stream-restore` / `follow-default-target`
   policies immediately move it back. The API reports success while
   isolation silently fails.
2. `pw-record --target=<name>` is redirected by the session manager to
   the default sink's monitor in multi-sink environments, so the
   recorder picks up the system mix.

Replace the PipeWireStrategy isolation path with explicit `pw-link`
connections by global port id:

- Spawn `pw-record --target=0 -P node.name=proctap_pw_rec_<pid>` so the
  recorder's input ports stay unconnected until we link them.
- Resolve the target app's `Stream/Output/Audio` node(s), the tap
  null-sink, and the recorder node from a single `pw-dump` snapshot.
- Producer-side: link target node out-ports onto the tap's playback
  ports (original speaker route is left intact — user-created explicit
  port links are exempt from WirePlumber's session policy).
- Capture-side: link tap monitor ports onto the recorder input ports.
- Cleanup unloads the null-sink; PipeWire garbage-collects every
  attached link, so no explicit pw-link teardown is needed.

PulseAudioStrategy and PipeWireNativeStrategy are unchanged.

Tests cover the pure pw-dump / pw-link helpers (no daemon required);
end-to-end verification requires Ubuntu + PipeWire + WirePlumber.

Refs: #48

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@m96-chan

m96-chan commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

Recently testing this module...now.

@m96-chan m96-chan self-assigned this Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Linux (PipeWire + WirePlumber) でアプリ音声が他アプリ音声が混入する

1 participant