New action: Set as default #2
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/nanoKontroller2#2
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
There is no way to change the PipeWire default sink or source from the nanoKONTROL2. The daemon can mute, set volume, and toggle links, but switching which sink audio goes to still requires
wpctl set-defaultor a GUI mixer.Add an action that sets a given sink or source as the system default:
targetnode the default. It is not a toggle — there is no meaningful "un-default", and clearing the configured default would hand the choice back to WirePlumber's priority logic with an unpredictable result. A second press is a harmless no-op.media.class, so no extra config and no way to misconfigure it. OnlyAudio/SinkandAudio/Sourceare accepted; aVideo/Sourcetarget is rejected with a warning, since this daemon is an audio bridge.wpctl, pavucontrol, desktop settings).Plan
Two commits, in this order. Commit 1 is a standalone bug fix with no dependency on the feature; commit 2 builds on it.
Commit 1 — fix the no-op listener unregister loops
AudioService::UnregisterNodeListener(src/AudioService.cpp:220-228) andAudioService::UnregisterLinkListener(src/AudioService.cpp:257-265) both loopThe condition compares
rbegin()with itself, so the body never executes and a destroyed action is never removed from the listener vectors. It is currently latent (actions live for the whole process lifetime) but leaves danglingIActionBase*entries behind any future teardown.std::erase(val, listener)over the samestd::views::valuesrange.std::eraseforstd::vectorlives in<vector>, already included atsrc/AudioService.cpp:7. The parameter ofUnregisterNodeListenerisconst IActionBase* listenerand ofUnregisterLinkListenerconst IActionBase* listener;std::erasecomparesIActionBase*elements against aconst IActionBase*— comparison betweenT*andconst T*is valid, no cast needed.Resulting shape for each:
Commit this on its own.
Commit 2 — the
set-defaultactionStep 1 — hold the
default-nodes-apiplugin inAudioServiceIn
includes/AudioService.h, next toMixerApi(line 44), add:and next to the other listener maps (lines 47-48):
Add the static callback declaration beside
on_module_loaded(line 39):In
src/AudioService.cpp,Init(lines 40-52) currently finds or loadsmixer-api. Loadingdefault-nodes-apiuses the identical shape —wp_core_load_componentwith type"module"both loads and enables the plugin, nowp_object_activatecall is needed (this is exactly whatwpctldoes for the same module). ExtendInitwith:on_module_loaded(lines 431-450) is currently hardcoded tomixer-api. It is invoked for both loads andwp_core_load_component_finishcarries no component identity, so make it resolve both plugins defensively: after the existingwp_core_load_component_finisherror check, replace themixer-api-only tail withThe direct call after connecting delivers the initial state: the plugin only emits
changedon later metadata changes, so without it an action registered before the plugin finished loading would keep a stale LED until someone else moved the default.Reset()(lines 55-62) must drop the new plugin ref on WirePlumber disconnect, next toMixerApi.reset():Do not clear
DefaultNodeListenersthere —Resetdeliberately keeps listener registrations across a reconnect (it clears only WirePlumber-owned state), andInitis called again on reconnect, which re-finds the plugin and re-delivers state through theon_default_nodes_changedcall above.Step 2 — default-node query/set API on
AudioServiceDeclare in
includes/AudioService.hbeside the other public node operations (lines 19-24):and beside the register/unregister group (lines 26-29):
Implement in
src/AudioService.cpp. A file-local helper resolves the media class from a cached node; both public functions need it, and the two accepted classes are matched by prefix because real nodes carry values likeAudio/SinkorAudio/Sink/Virtual:PW_KEY_MEDIA_CLASScomes frompipewire-0.3/pipewire/keys.h, already included atsrc/AudioService.cpp:12.SetDefaultNode— returnstrueonly when WirePlumber accepted the change; the action logs onfalse:The
set-default-configured-node-nameaction signal has signaturegboolean (const gchar* media_class, const gchar* name); passingnullptras name would clear the default, which this feature never does.IsDefaultNode— compares the active default's bound id with the target's, so it reports what audio actually uses rather than what is configured:get-default-nodereturnsSPA_ID_INVALIDwhen there is no default for that class.SPA_ID_INVALIDneeds#include <spa/utils/defs.h>added to the include block at the top ofsrc/AudioService.cpp; thelibspa-0.2headers are already on the include path via thewireplumber-0.5pkg-config module (CI installslibspa-0.2-dev,.github/workflows/build.yml:37). If that include fails to resolve during execution, compare againstG_MAXUINT32instead, which is the same value and needs no new include.Register/unregister mirror the (now fixed) node-listener helpers:
The
changedcallback fans out to every registered action; the plugin'schangedsignal takes no arguments beyond the instance, so each action re-queries its own state:Step 3 — the listener hook on
IActionBaseIn
includes/Actions/IAction.h, add a defaulted virtual next to the other hooks (lines 33-43):In
src/Actions/IAction.cpp, the destructor (lines 4-7) must also drop the new registration, otherwiseAudioServicekeeps a dangling pointer:Step 4 —
Action_SetDefaultNew files, following the
Action_Mutepair exactly. Registration is automatic through theIAction<T, Name>template; no edit tomain.cpp,ActionFactoryRegistry, orCMakeLists.txtis needed (file(GLOB_RECURSE SOURCES "src/*.cpp"),CMakeLists.txt:23).m_Nodeis already filled fromparams["target"]byIActionBase::Init, andmain.cpp:144-148resolvesFriendlyNamesaliases in every param value before construction, sotargetaccepts an alias for free.includes/Actions/Action_SetDefault.h:src/Actions/Action_SetDefault.cpp:Init_Internal: same guard asAction_Mute::Init_Internal(src/Actions/Action_Mute.cpp:8-14) — ifm_Node.empty(), printWarning: Set-default action has no "target" node specified – action will be inactive.tostd::cerrand return. Otherwisem_audioService->RegisterNodeListener(m_Node, this);andm_audioService->RegisterDefaultNodeListener(this);.on_midi(int value): the press/release latch fromAction_Mute::on_midi— onvalue == 127, return early ifm_bWaitingForLow, else setm_bWaitingForLow = trueand act; onvalue == 0, clearm_bWaitingForLow. Acting means:No optimistic LED write: the LED is refreshed only from
on_default_nodes_changed, so it never shows a change WirePlumber did not make. This covers every no-op path decided for this feature — node absent from the graph, plugin not yet loaded, and aVideo/Sourceor*/Internaltarget (all returnfalsefromSetDefaultNode).on_node_available/on_node_removed: callRefresh_LED_State(). The node appearing or disappearing changes whether the target can be the active default, and neither event is accompanied by a default-nodeschangedsignal in general.on_default_nodes_changed: callRefresh_LED_State().Refresh_LED_State(): mirrorsAction_Connection::Refresh_LED_State(src/Actions/Action_Connection.cpp:23-27) but re-queries first, and only writes the LED on change to avoid redundant MIDI traffic:Because
LedManager::ResetState()runs at startup (src/main.cpp:133), every LED is known-off before any action exists, so the initialfalse → falseno-write is correct.Critical files & anchors
src/AudioService.cpp—Init(lines 17-53) andon_module_loaded(lines 431-450): themixer-apiload is the pattern to copy, andon_module_loadedis shared between both module loads.src/AudioService.cpp—UnregisterNodeListener(220-228) andUnregisterLinkListener(257-265): the broken loops fixed in commit 1.src/Actions/Action_Mute.cpp—Init_Internal(8-14) andon_midi(16-33): the missing-targetwarning and the press/release latch to replicate.includes/Actions/IAction.h— hooks at lines 33-43 and theIAction<T, Name>registrator at 69-90: where the new virtual goes and why no factory wiring is needed.Verification
Build first, from the repository root:
This needs the dependencies listed in the README (CMake ≥ 4.2, a C++26 compiler,
wireplumber-0.5headers, GLib, ALSA, RtMidi, glaze, cxxopts, concurrentqueue, magic_enum). If they are not available locally, push the branch instead and read the result of the repository's build workflow, which compiles against WirePlumber 0.5 — that is a compile check only and does not replace the behavioural steps below.Behavioural check, requiring a running PipeWire/WirePlumber session, a connected nanoKONTROL2 with LED Mode set to External, and at least two audio sinks (
wpctl statuslists them):Write a config mapping two LED-capable controls to two different sinks, e.g.
Run
./build/nanoKontroller2 --config <that file>. Expected: the LED of whichever control targets the current default (wpctl status, the sink marked*) is lit, the other is dark.Press
Mute2. Expected:wpctl statusnow marks sink B as the default sink,Mute2's LED lights andMute1's goes dark.With the daemon still running, run
wpctl set-default <sink-a-id>in a terminal. Expected: without any button press,Mute1's LED lights andMute2's goes dark — this exercises thechangedsignal path.Press
Mute1twice in a row. Expected: no error output, default stays sink A, LED stays lit (set-only, second press is a no-op).Map a third control to a
targetthat does not exist in the graph and press it. Expected:Failed to set "…" as default node …on stderr, no crash, LED stays dark.Map a control to a microphone (
Audio/Source) node and press it. Expected:wpctl statusshows it as the default source; the sink default is untouched.Assumptions & contingencies
default-nodes-apimodule is assumed present in the user's WirePlumber installation; it ships with WirePlumber 0.5 andwpctlloads it the same way. Ifwp_core_load_componentfails for it,on_module_loadedalready prints the error andDefaultNodesApistays null, which makes every press a logged no-op rather than a crash — no additional handling.changedsignal is assumed to fire for bothdefault.audio.sinkanddefault.audio.sourcemetadata changes. If during execution the LED fails to track an externally-made change, fall back to also callingRefresh_LED_State()fromon_node_params_changedfor the target node; do not add polling.