Skip to content

feat(audio): allow routing individual app output to a specific device - #3865

Draft
oldirtty wants to merge 3 commits into
noctalia-dev:mainfrom
oldirtty:feat/audio-per-app-output-routing
Draft

feat(audio): allow routing individual app output to a specific device#3865
oldirtty wants to merge 3 commits into
noctalia-dev:mainfrom
oldirtty:feat/audio-per-app-output-routing

Conversation

@oldirtty

@oldirtty oldirtty commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds per-application audio output routing to the Audio tab's "Application Volumes" section. Each row now has a routing button next to the mute button that opens a menu listing the system default plus all available output devices, letting a single app's stream be pinned to a specific sink instead of always following the system default.

PipeWireService::moveProgramOutput(programStreamId, targetSinkId) writes target.object (as Spa:Id) on the "default" WirePlumber metadata, the same mechanism pavucontrol and pwvucontrol use to move streams. AudioNode::targetObject was added so the UI can reflect the current routing (checkmark in the menu, outlined routing button when an app is pinned to a non-default device).

Motivation

Lets a single app play through a different output than the rest of the system (e.g. notifications on the monitor speakers while music stays on headphones) without a separate app like pavucontrol/pwvucontrol.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring
  • Build / packaging

Related Issue

Closes #3553

Testing

  • just format
  • just build
  • just test, everything ok
  • Manual testing described below (no automated test coverage for PipeWire routing).

Manual Coverage

  • Tested on Niri
  • Tested on Hyprland
  • Tested on Sway
  • Tested on another compositor:
  • Tested with different bar positions and density settings
  • Tested at different interface scaling values
  • Tested with multiple monitors

Manually verified with pw-metadata -m that target.object is written correctly (Spa:Id, numeric sink id) and confirmed via wpctl status that the stream actually moves. Tested with multiple simultaneous output devices (wired + Bluetooth), switching a single app between them while other apps stayed on the default device.

Screenshots / Videos

screenshot_20260810_220516-region

Checklist

  • This PR is ready for review, or it is marked as Draft.
  • I read and followed the relevant guidance in CONTRIBUTING.md.
  • I ran just format with clang-format v22+ installed, or this PR has no code changes.
  • I ran the relevant build or test commands, or explained why they were not run.
  • I self-reviewed the changes.
  • I checked for new warnings or errors.
  • I will update end-user documentation after merge, or this PR does not change user-facing configuration or behavior.
  • I added or updated assets/translations/en.json, or this PR adds no new user-facing strings.
  • I did not edit non-English translation files unless this PR is explicitly for translation tooling, an import/export sync, or a maintainer-requested locale change.
  • I used the existing canonical names for config keys, IPC names, paths, and identifiers.

Additional Notes

Known limitation: when a stream is routed through an intermediate virtual sink, such as EasyEffects sink, WirePlumber's stream-restore mechanism stores and reapplies EasyEffects' own target for that app, so routing the app directly gets silently reverted back to the EasyEffects sink. To change the final output in that setup, the "EasyEffects Sink" node itself needs to be routed instead of the individual app. Out of scope for this PR.

@oldirtty
oldirtty force-pushed the feat/audio-per-app-output-routing branch from 0bbd40e to 98835ac Compare August 15, 2026 14:25
@ItsLemmy

Copy link
Copy Markdown
Collaborator

[P1] target.object is written with the wrong PipeWire identifier

src/pipewire/pipewire_service.cpp:2078-2081
targetSinkId is the registry/global object.id, but PipeWire defines target.object as a node.name or object.serial. The deprecated node.target accepts object.id; target.object does not. See the PipeWire property
contract (https://docs.pipewire.org/page_man_pipewire-props_7.html) and pwvucontrol’s implementation
(https://github.com/saivert/pwvucontrol/blob/da095e6ca49181bc58c81b3afa0e8ea7bbfb7d11/src/backend/pwnodeobject.rs#L509-L524), which writes the target node’s serial.

This is not theoretical: the active PipeWire graph here contains sinks with id=94, object.serial=744, id=83, object.serial=225, and id=77, object.serial=226. Writing 94 as target.object therefore identifies the
wrong namespace and may fail to move the stream or resolve another object. It only appears correct while ID and serial happen to coincide.
Store object.serial for nodes and write that as Spa:Id, or write the canonical node.name using the corresponding string type.

[P2] Routing state is not read from the metadata being modified

src/pipewire/pipewire_service.cpp:169-183, 1613
src/shell/control_center/tabs/audio_tab.cpp:1565-1593
onMetadataProperty() discards the subject and handles only default sink/source keys. It ignores target.object, including deletion events because value == nullptr returns immediately. Meanwhile,
AudioNode::targetObject comes from the stream node’s registry properties plus the optimistic local assignment in moveProgramOutput().

Consequently, the routing button/checkmark is stale or wrong when:

  • pavucontrol/pwvucontrol moves the stream;
  • WirePlumber restores routing metadata;
  • Noctalia starts while an existing stream is already routed;
  • another client clears the route.

Track target.object metadata by stream subject and resolve its serial/name to the current sink. The metadata event should be the authoritative state; the stream’s creation-time target.object property is a
separate input.

[P2] Failed metadata writes are still presented as successful

src/pipewire/pipewire_service.cpp:2065-2088
Both branches log ret < 0 but still mutate programIt->second->targetObject and rebuild state. If the metadata proxy rejects the write, the route does not change while the button outline and menu checkmark claim
that it did.

Return without changing local state on a negative result. Prefer updating displayed state from the metadata callback described above rather than maintaining a parallel optimistic copy

@ItsLemmy
ItsLemmy marked this pull request as draft August 17, 2026 02:54
@oldirtty
oldirtty force-pushed the feat/audio-per-app-output-routing branch from 98835ac to 1500843 Compare August 17, 2026 04:47
@oldirtty

Copy link
Copy Markdown
Contributor Author

P1: now writes the sink's object.serial as Spa:Id, falling back to node.name if serial is unavailable.

P2 (stale state): added onTargetObjectMetadata, subscribed to target.object on the default metadata object, this is now the single source of truth for routing state.

P2 (failed writes): moveProgramOutput no longer mutates local state on write, only the metadata callback does.

Tested manually with pwvucontrol (move + reset to default), state stays in sync now.

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.

[FEATURE] Add ability to route individual audio sources to outputs in control panel

2 participants