New action: Set as default #2

Closed
opened 2026-11-12 19:39:42 +01:00 by lucas · 0 comments
Owner

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-default or a GUI mixer.

Add an action that sets a given sink or source as the system default:

  • A button press makes the action's target node 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.
  • Whether the target is a sink or a source is derived from the node's own media.class, so no extra config and no way to misconfigure it. Only Audio/Sink and Audio/Source are accepted; a Video/Source target is rejected with a warning, since this daemon is an audio bridge.
  • The LED (on LED-capable controls) is lit while the target is the active default — what audio actually uses right now, not what is merely configured — and follows changes made anywhere else (wpctl, pavucontrol, desktop settings).
  • The action never changes the default on its own. The default sink is global, user-visible state that other tools write too; claiming it on daemon start or on device hotplug would silently move the user's audio.

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) and AudioService::UnregisterLinkListener (src/AudioService.cpp:257-265) both loop

for (auto Itr = val.rbegin(); Itr != val.rbegin(); ++Itr)

The 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 dangling IActionBase* entries behind any future teardown.

  1. Replace both loop bodies with std::erase(val, listener) over the same std::views::values range. std::erase for std::vector lives in <vector>, already included at src/AudioService.cpp:7. The parameter of UnregisterNodeListener is const IActionBase* listener and of UnregisterLinkListener const IActionBase* listener; std::erase compares IActionBase* elements against a const IActionBase* — comparison between T* and const T* is valid, no cast needed.

Resulting shape for each:

void AudioService::UnregisterNodeListener(const IActionBase* listener) {
    for (auto& val : NodeListeners | std::views::values) {
        std::erase(val, listener);
    }
}

Commit this on its own.

Commit 2 — the set-default action

Step 1 — hold the default-nodes-api plugin in AudioService

In includes/AudioService.h, next to MixerApi (line 44), add:

std::unique_ptr<WpPlugin, WpPluginDeleter> DefaultNodesApi{nullptr};

and next to the other listener maps (lines 47-48):

std::vector<IActionBase*> DefaultNodeListeners;

Add the static callback declaration beside on_module_loaded (line 39):

static void on_default_nodes_changed(WpPlugin* Plugin, gpointer UserData);

In src/AudioService.cpp, Init (lines 40-52) currently finds or loads mixer-api. Loading default-nodes-api uses the identical shape — wp_core_load_component with type "module" both loads and enables the plugin, no wp_object_activate call is needed (this is exactly what wpctl does for the same module). Extend Init with:

    DefaultNodesApi = std::unique_ptr<WpPlugin, WpPluginDeleter>{wp_plugin_find(core, "default-nodes-api")};
    if (DefaultNodesApi == nullptr) {
        wp_core_load_component(
            core,
            "libwireplumber-module-default-nodes-api",
            "module",
            nullptr,
            "default-nodes-api",
            nullptr,
            reinterpret_cast<GAsyncReadyCallback>(&AudioService::on_module_loaded),
            this
        );
    } else {
        g_signal_connect(DefaultNodesApi.get(), "changed", G_CALLBACK(AudioService::on_default_nodes_changed), this);
    }

on_module_loaded (lines 431-450) is currently hardcoded to mixer-api. It is invoked for both loads and wp_core_load_component_finish carries no component identity, so make it resolve both plugins defensively: after the existing wp_core_load_component_finish error check, replace the mixer-api-only tail with

    if (!Service->MixerApi) {
        if (WpPlugin* Plugin = wp_plugin_find(core, "mixer-api")) {
            Service->MixerApi = std::unique_ptr<WpPlugin, WpPluginDeleter>{Plugin};
        }
    }
    if (!Service->DefaultNodesApi) {
        if (WpPlugin* Plugin = wp_plugin_find(core, "default-nodes-api")) {
            Service->DefaultNodesApi = std::unique_ptr<WpPlugin, WpPluginDeleter>{Plugin};
            g_signal_connect(Plugin, "changed", G_CALLBACK(AudioService::on_default_nodes_changed), Service);
            AudioService::on_default_nodes_changed(Plugin, Service);
        }
    }

The direct call after connecting delivers the initial state: the plugin only emits changed on 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 to MixerApi.reset():

    DefaultNodesApi.reset();

Do not clear DefaultNodeListeners there — Reset deliberately keeps listener registrations across a reconnect (it clears only WirePlumber-owned state), and Init is called again on reconnect, which re-finds the plugin and re-delivers state through the on_default_nodes_changed call above.

Step 2 — default-node query/set API on AudioService

Declare in includes/AudioService.h beside the other public node operations (lines 19-24):

    bool SetDefaultNode(const std::string& nodeName);
    bool IsDefaultNode(const std::string& nodeName);

and beside the register/unregister group (lines 26-29):

    void RegisterDefaultNodeListener(IActionBase* listener);
    void UnregisterDefaultNodeListener(const IActionBase* listener);

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 like Audio/Sink or Audio/Sink/Virtual:

namespace {
    // The default-nodes API keys its metadata by media class. Node media.class
    // values can carry a suffix (e.g. "Audio/Sink/Virtual"), so match by prefix,
    // the way wpctl set-default does. "*/Internal" nodes are monitor/internal
    // endpoints that cannot be defaults.
    const char* ResolveDefaultMediaClass(WpNode* Node) {
        const char* MediaClass = wp_pipewire_object_get_property(WP_PIPEWIRE_OBJECT(Node), PW_KEY_MEDIA_CLASS);
        if (!MediaClass || g_str_has_suffix(MediaClass, "/Internal")) return nullptr;
        if (g_str_has_prefix(MediaClass, "Audio/Sink"))   return "Audio/Sink";
        if (g_str_has_prefix(MediaClass, "Audio/Source")) return "Audio/Source";
        return nullptr;
    }
}

PW_KEY_MEDIA_CLASS comes from pipewire-0.3/pipewire/keys.h, already included at src/AudioService.cpp:12.

SetDefaultNode — returns true only when WirePlumber accepted the change; the action logs on false:

bool AudioService::SetDefaultNode(const std::string& nodeName) {
    const auto Itr = Nodes.find(nodeName);
    if (Itr == Nodes.end() || !DefaultNodesApi) return false;

    const char* MediaClass = ResolveDefaultMediaClass(Itr->second.get());
    if (!MediaClass) return false;

    gboolean Success = FALSE;
    g_signal_emit_by_name(DefaultNodesApi.get(), "set-default-configured-node-name", MediaClass, nodeName.c_str(), &Success);
    return Success;
}

The set-default-configured-node-name action signal has signature gboolean (const gchar* media_class, const gchar* name); passing nullptr as 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:

bool AudioService::IsDefaultNode(const std::string& nodeName) {
    const auto Itr = Nodes.find(nodeName);
    if (Itr == Nodes.end() || !DefaultNodesApi) return false;

    const char* MediaClass = ResolveDefaultMediaClass(Itr->second.get());
    if (!MediaClass) return false;

    guint32 DefaultId = SPA_ID_INVALID;
    g_signal_emit_by_name(DefaultNodesApi.get(), "get-default-node", MediaClass, &DefaultId);
    return DefaultId != SPA_ID_INVALID && DefaultId == wp_proxy_get_bound_id(WP_PROXY(Itr->second.get()));
}

get-default-node returns SPA_ID_INVALID when there is no default for that class. SPA_ID_INVALID needs #include <spa/utils/defs.h> added to the include block at the top of src/AudioService.cpp; the libspa-0.2 headers are already on the include path via the wireplumber-0.5 pkg-config module (CI installs libspa-0.2-dev, .github/workflows/build.yml:37). If that include fails to resolve during execution, compare against G_MAXUINT32 instead, which is the same value and needs no new include.

Register/unregister mirror the (now fixed) node-listener helpers:

void AudioService::RegisterDefaultNodeListener(IActionBase* listener) {
    for (auto* Listener : DefaultNodeListeners) {
        if (Listener == listener) return;
    }
    DefaultNodeListeners.push_back(listener);
}

void AudioService::UnregisterDefaultNodeListener(const IActionBase* listener) {
    std::erase(DefaultNodeListeners, listener);
}

The changed callback fans out to every registered action; the plugin's changed signal takes no arguments beyond the instance, so each action re-queries its own state:

void AudioService::on_default_nodes_changed(WpPlugin*, gpointer UserData) {
    if (!UserData) return;
    auto* Service = static_cast<AudioService*>(UserData);
    for (IActionBase* Listener : Service->DefaultNodeListeners) {
        Listener->on_default_nodes_changed();
    }
}

Step 3 — the listener hook on IActionBase

In includes/Actions/IAction.h, add a defaulted virtual next to the other hooks (lines 33-43):

    // Called when the PipeWire default sink/source changed; actions should
    // re-query their own default state rather than trust any cached value.
    virtual void on_default_nodes_changed() {}

In src/Actions/IAction.cpp, the destructor (lines 4-7) must also drop the new registration, otherwise AudioService keeps a dangling pointer:

    m_audioService->UnregisterDefaultNodeListener(this);

Step 4 — Action_SetDefault

New files, following the Action_Mute pair exactly. Registration is automatic through the IAction<T, Name> template; no edit to main.cpp, ActionFactoryRegistry, or CMakeLists.txt is needed (file(GLOB_RECURSE SOURCES "src/*.cpp"), CMakeLists.txt:23). m_Node is already filled from params["target"] by IActionBase::Init, and main.cpp:144-148 resolves FriendlyNames aliases in every param value before construction, so target accepts an alias for free.

includes/Actions/Action_SetDefault.h:

#pragma once

#include "IAction.h"

class Action_SetDefault final : public IAction<Action_SetDefault, "set-default"> {
public:
    void on_midi(int value) override;
    void on_node_available(WpNode* node, const std::string& name) override;
    void on_node_removed(WpNode* node, const std::string& name) override;
    void on_default_nodes_changed() override;

protected:
    void Init_Internal(const std::unordered_map<std::string, std::string>& params) override;

private:
    void Refresh_LED_State();

    bool m_bIsDefault{false};
    bool m_bWaitingForLow{false};
};

src/Actions/Action_SetDefault.cpp:

  • Init_Internal: same guard as Action_Mute::Init_Internal (src/Actions/Action_Mute.cpp:8-14) — if m_Node.empty(), print Warning: Set-default action has no "target" node specified – action will be inactive. to std::cerr and return. Otherwise m_audioService->RegisterNodeListener(m_Node, this); and m_audioService->RegisterDefaultNodeListener(this);.

  • on_midi(int value): the press/release latch from Action_Mute::on_midi — on value == 127, return early if m_bWaitingForLow, else set m_bWaitingForLow = true and act; on value == 0, clear m_bWaitingForLow. Acting means:

    if (!m_audioService->SetDefaultNode(m_Node)) {
        g_printerr("Failed to set \"%s\" as default node – it may be absent or not an audio sink/source.\n", m_Node.c_str());
    }
    

    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 a Video/Source or */Internal target (all return false from SetDefaultNode).

  • on_node_available / on_node_removed: call Refresh_LED_State(). The node appearing or disappearing changes whether the target can be the active default, and neither event is accompanied by a default-nodes changed signal in general.

  • on_default_nodes_changed: call Refresh_LED_State().

  • Refresh_LED_State(): mirrors Action_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:

    void Action_SetDefault::Refresh_LED_State() {
        const bool bOldValue = m_bIsDefault;
        m_bIsDefault = m_audioService->IsDefaultNode(m_Node);
        if (bOldValue != m_bIsDefault && LedManager::IsLedCapable(m_Control)) {
            m_ledManager->Set(static_cast<NanoKontrol2::LED>(m_Control), m_bIsDefault);
        }
    }
    

    Because LedManager::ResetState() runs at startup (src/main.cpp:133), every LED is known-off before any action exists, so the initial false → false no-write is correct.

Critical files & anchors

  • src/AudioService.cpp — Init (lines 17-53) and on_module_loaded (lines 431-450): the mixer-api load is the pattern to copy, and on_module_loaded is shared between both module loads.
  • src/AudioService.cpp — UnregisterNodeListener (220-228) and UnregisterLinkListener (257-265): the broken loops fixed in commit 1.
  • src/Actions/Action_Mute.cpp — Init_Internal (8-14) and on_midi (16-33): the missing-target warning and the press/release latch to replicate.
  • includes/Actions/IAction.h — hooks at lines 33-43 and the IAction<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:

cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j$(nproc)

This needs the dependencies listed in the README (CMake ≥ 4.2, a C++26 compiler, wireplumber-0.5 headers, 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 status lists them):

  1. Write a config mapping two LED-capable controls to two different sinks, e.g.

    {
      "actions": {
        "Mute1": { "type": "set-default", "params": { "target": "<sink-a-node-name>" } },
        "Mute2": { "type": "set-default", "params": { "target": "<sink-b-node-name>" } }
      }
    }
    
  2. 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.

  3. Press Mute2. Expected: wpctl status now marks sink B as the default sink, Mute2's LED lights and Mute1's goes dark.

  4. With the daemon still running, run wpctl set-default <sink-a-id> in a terminal. Expected: without any button press, Mute1's LED lights and Mute2's goes dark — this exercises the changed signal path.

  5. Press Mute1 twice in a row. Expected: no error output, default stays sink A, LED stays lit (set-only, second press is a no-op).

  6. Map a third control to a target that does not exist in the graph and press it. Expected: Failed to set "…" as default node … on stderr, no crash, LED stays dark.

  7. Map a control to a microphone (Audio/Source) node and press it. Expected: wpctl status shows it as the default source; the sink default is untouched.

Assumptions & contingencies

  • The default-nodes-api module is assumed present in the user's WirePlumber installation; it ships with WirePlumber 0.5 and wpctl loads it the same way. If wp_core_load_component fails for it, on_module_loaded already prints the error and DefaultNodesApi stays null, which makes every press a logged no-op rather than a crash — no additional handling.
  • The changed signal is assumed to fire for both default.audio.sink and default.audio.source metadata changes. If during execution the LED fails to track an externally-made change, fall back to also calling Refresh_LED_State() from on_node_params_changed for the target node; do not add polling.
## 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-default` or a GUI mixer. Add an action that sets a given sink or source as the system default: - A button press makes the action's `target` node 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. - Whether the target is a sink or a source is derived from the node's own `media.class`, so no extra config and no way to misconfigure it. Only `Audio/Sink` and `Audio/Source` are accepted; a `Video/Source` target is rejected with a warning, since this daemon is an audio bridge. - The LED (on LED-capable controls) is lit while the target is the *active* default — what audio actually uses right now, not what is merely configured — and follows changes made anywhere else (`wpctl`, pavucontrol, desktop settings). - The action never changes the default on its own. The default sink is global, user-visible state that other tools write too; claiming it on daemon start or on device hotplug would silently move the user's audio. ## 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`) and `AudioService::UnregisterLinkListener` (`src/AudioService.cpp:257-265`) both loop ```cpp for (auto Itr = val.rbegin(); Itr != val.rbegin(); ++Itr) ``` The 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 dangling `IActionBase*` entries behind any future teardown. 1. Replace both loop bodies with `std::erase(val, listener)` over the same `std::views::values` range. `std::erase` for `std::vector` lives in `<vector>`, already included at `src/AudioService.cpp:7`. The parameter of `UnregisterNodeListener` is `const IActionBase* listener` and of `UnregisterLinkListener` `const IActionBase* listener`; `std::erase` compares `IActionBase*` elements against a `const IActionBase*` — comparison between `T*` and `const T*` is valid, no cast needed. Resulting shape for each: ```cpp void AudioService::UnregisterNodeListener(const IActionBase* listener) { for (auto& val : NodeListeners | std::views::values) { std::erase(val, listener); } } ``` Commit this on its own. ### Commit 2 — the `set-default` action #### Step 1 — hold the `default-nodes-api` plugin in `AudioService` In `includes/AudioService.h`, next to `MixerApi` (line 44), add: ```cpp std::unique_ptr<WpPlugin, WpPluginDeleter> DefaultNodesApi{nullptr}; ``` and next to the other listener maps (lines 47-48): ```cpp std::vector<IActionBase*> DefaultNodeListeners; ``` Add the static callback declaration beside `on_module_loaded` (line 39): ```cpp static void on_default_nodes_changed(WpPlugin* Plugin, gpointer UserData); ``` In `src/AudioService.cpp`, `Init` (lines 40-52) currently finds or loads `mixer-api`. Loading `default-nodes-api` uses the identical shape — `wp_core_load_component` with type `"module"` both loads and enables the plugin, no `wp_object_activate` call is needed (this is exactly what `wpctl` does for the same module). Extend `Init` with: ```cpp DefaultNodesApi = std::unique_ptr<WpPlugin, WpPluginDeleter>{wp_plugin_find(core, "default-nodes-api")}; if (DefaultNodesApi == nullptr) { wp_core_load_component( core, "libwireplumber-module-default-nodes-api", "module", nullptr, "default-nodes-api", nullptr, reinterpret_cast<GAsyncReadyCallback>(&AudioService::on_module_loaded), this ); } else { g_signal_connect(DefaultNodesApi.get(), "changed", G_CALLBACK(AudioService::on_default_nodes_changed), this); } ``` `on_module_loaded` (lines 431-450) is currently hardcoded to `mixer-api`. It is invoked for both loads and `wp_core_load_component_finish` carries no component identity, so make it resolve both plugins defensively: after the existing `wp_core_load_component_finish` error check, replace the `mixer-api`-only tail with ```cpp if (!Service->MixerApi) { if (WpPlugin* Plugin = wp_plugin_find(core, "mixer-api")) { Service->MixerApi = std::unique_ptr<WpPlugin, WpPluginDeleter>{Plugin}; } } if (!Service->DefaultNodesApi) { if (WpPlugin* Plugin = wp_plugin_find(core, "default-nodes-api")) { Service->DefaultNodesApi = std::unique_ptr<WpPlugin, WpPluginDeleter>{Plugin}; g_signal_connect(Plugin, "changed", G_CALLBACK(AudioService::on_default_nodes_changed), Service); AudioService::on_default_nodes_changed(Plugin, Service); } } ``` The direct call after connecting delivers the initial state: the plugin only emits `changed` on 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 to `MixerApi.reset()`: ```cpp DefaultNodesApi.reset(); ``` Do **not** clear `DefaultNodeListeners` there — `Reset` deliberately keeps listener registrations across a reconnect (it clears only WirePlumber-owned state), and `Init` is called again on reconnect, which re-finds the plugin and re-delivers state through the `on_default_nodes_changed` call above. #### Step 2 — default-node query/set API on `AudioService` Declare in `includes/AudioService.h` beside the other public node operations (lines 19-24): ```cpp bool SetDefaultNode(const std::string& nodeName); bool IsDefaultNode(const std::string& nodeName); ``` and beside the register/unregister group (lines 26-29): ```cpp void RegisterDefaultNodeListener(IActionBase* listener); void UnregisterDefaultNodeListener(const IActionBase* listener); ``` 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 like `Audio/Sink` or `Audio/Sink/Virtual`: ```cpp namespace { // The default-nodes API keys its metadata by media class. Node media.class // values can carry a suffix (e.g. "Audio/Sink/Virtual"), so match by prefix, // the way wpctl set-default does. "*/Internal" nodes are monitor/internal // endpoints that cannot be defaults. const char* ResolveDefaultMediaClass(WpNode* Node) { const char* MediaClass = wp_pipewire_object_get_property(WP_PIPEWIRE_OBJECT(Node), PW_KEY_MEDIA_CLASS); if (!MediaClass || g_str_has_suffix(MediaClass, "/Internal")) return nullptr; if (g_str_has_prefix(MediaClass, "Audio/Sink")) return "Audio/Sink"; if (g_str_has_prefix(MediaClass, "Audio/Source")) return "Audio/Source"; return nullptr; } } ``` `PW_KEY_MEDIA_CLASS` comes from `pipewire-0.3/pipewire/keys.h`, already included at `src/AudioService.cpp:12`. `SetDefaultNode` — returns `true` only when WirePlumber accepted the change; the action logs on `false`: ```cpp bool AudioService::SetDefaultNode(const std::string& nodeName) { const auto Itr = Nodes.find(nodeName); if (Itr == Nodes.end() || !DefaultNodesApi) return false; const char* MediaClass = ResolveDefaultMediaClass(Itr->second.get()); if (!MediaClass) return false; gboolean Success = FALSE; g_signal_emit_by_name(DefaultNodesApi.get(), "set-default-configured-node-name", MediaClass, nodeName.c_str(), &Success); return Success; } ``` The `set-default-configured-node-name` action signal has signature `gboolean (const gchar* media_class, const gchar* name)`; passing `nullptr` as 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: ```cpp bool AudioService::IsDefaultNode(const std::string& nodeName) { const auto Itr = Nodes.find(nodeName); if (Itr == Nodes.end() || !DefaultNodesApi) return false; const char* MediaClass = ResolveDefaultMediaClass(Itr->second.get()); if (!MediaClass) return false; guint32 DefaultId = SPA_ID_INVALID; g_signal_emit_by_name(DefaultNodesApi.get(), "get-default-node", MediaClass, &DefaultId); return DefaultId != SPA_ID_INVALID && DefaultId == wp_proxy_get_bound_id(WP_PROXY(Itr->second.get())); } ``` `get-default-node` returns `SPA_ID_INVALID` when there is no default for that class. `SPA_ID_INVALID` needs `#include <spa/utils/defs.h>` added to the include block at the top of `src/AudioService.cpp`; the `libspa-0.2` headers are already on the include path via the `wireplumber-0.5` pkg-config module (CI installs `libspa-0.2-dev`, `.github/workflows/build.yml:37`). If that include fails to resolve during execution, compare against `G_MAXUINT32` instead, which is the same value and needs no new include. Register/unregister mirror the (now fixed) node-listener helpers: ```cpp void AudioService::RegisterDefaultNodeListener(IActionBase* listener) { for (auto* Listener : DefaultNodeListeners) { if (Listener == listener) return; } DefaultNodeListeners.push_back(listener); } void AudioService::UnregisterDefaultNodeListener(const IActionBase* listener) { std::erase(DefaultNodeListeners, listener); } ``` The `changed` callback fans out to every registered action; the plugin's `changed` signal takes no arguments beyond the instance, so each action re-queries its own state: ```cpp void AudioService::on_default_nodes_changed(WpPlugin*, gpointer UserData) { if (!UserData) return; auto* Service = static_cast<AudioService*>(UserData); for (IActionBase* Listener : Service->DefaultNodeListeners) { Listener->on_default_nodes_changed(); } } ``` #### Step 3 — the listener hook on `IActionBase` In `includes/Actions/IAction.h`, add a defaulted virtual next to the other hooks (lines 33-43): ```cpp // Called when the PipeWire default sink/source changed; actions should // re-query their own default state rather than trust any cached value. virtual void on_default_nodes_changed() {} ``` In `src/Actions/IAction.cpp`, the destructor (lines 4-7) must also drop the new registration, otherwise `AudioService` keeps a dangling pointer: ```cpp m_audioService->UnregisterDefaultNodeListener(this); ``` #### Step 4 — `Action_SetDefault` New files, following the `Action_Mute` pair exactly. Registration is automatic through the `IAction<T, Name>` template; no edit to `main.cpp`, `ActionFactoryRegistry`, or `CMakeLists.txt` is needed (`file(GLOB_RECURSE SOURCES "src/*.cpp")`, `CMakeLists.txt:23`). `m_Node` is already filled from `params["target"]` by `IActionBase::Init`, and `main.cpp:144-148` resolves `FriendlyNames` aliases in every param value before construction, so `target` accepts an alias for free. `includes/Actions/Action_SetDefault.h`: ```cpp #pragma once #include "IAction.h" class Action_SetDefault final : public IAction<Action_SetDefault, "set-default"> { public: void on_midi(int value) override; void on_node_available(WpNode* node, const std::string& name) override; void on_node_removed(WpNode* node, const std::string& name) override; void on_default_nodes_changed() override; protected: void Init_Internal(const std::unordered_map<std::string, std::string>& params) override; private: void Refresh_LED_State(); bool m_bIsDefault{false}; bool m_bWaitingForLow{false}; }; ``` `src/Actions/Action_SetDefault.cpp`: - `Init_Internal`: same guard as `Action_Mute::Init_Internal` (`src/Actions/Action_Mute.cpp:8-14`) — if `m_Node.empty()`, print `Warning: Set-default action has no "target" node specified – action will be inactive.` to `std::cerr` and return. Otherwise `m_audioService->RegisterNodeListener(m_Node, this);` and `m_audioService->RegisterDefaultNodeListener(this);`. - `on_midi(int value)`: the press/release latch from `Action_Mute::on_midi` — on `value == 127`, return early if `m_bWaitingForLow`, else set `m_bWaitingForLow = true` and act; on `value == 0`, clear `m_bWaitingForLow`. Acting means: ```cpp if (!m_audioService->SetDefaultNode(m_Node)) { g_printerr("Failed to set \"%s\" as default node – it may be absent or not an audio sink/source.\n", m_Node.c_str()); } ``` 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 a `Video/Source` or `*/Internal` target (all return `false` from `SetDefaultNode`). - `on_node_available` / `on_node_removed`: call `Refresh_LED_State()`. The node appearing or disappearing changes whether the target can be the active default, and neither event is accompanied by a default-nodes `changed` signal in general. - `on_default_nodes_changed`: call `Refresh_LED_State()`. - `Refresh_LED_State()`: mirrors `Action_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: ```cpp void Action_SetDefault::Refresh_LED_State() { const bool bOldValue = m_bIsDefault; m_bIsDefault = m_audioService->IsDefaultNode(m_Node); if (bOldValue != m_bIsDefault && LedManager::IsLedCapable(m_Control)) { m_ledManager->Set(static_cast<NanoKontrol2::LED>(m_Control), m_bIsDefault); } } ``` Because `LedManager::ResetState()` runs at startup (`src/main.cpp:133`), every LED is known-off before any action exists, so the initial `false → false` no-write is correct. ## Critical files & anchors - `src/AudioService.cpp` — `Init` (lines 17-53) and `on_module_loaded` (lines 431-450): the `mixer-api` load is the pattern to copy, and `on_module_loaded` is shared between both module loads. - `src/AudioService.cpp` — `UnregisterNodeListener` (220-228) and `UnregisterLinkListener` (257-265): the broken loops fixed in commit 1. - `src/Actions/Action_Mute.cpp` — `Init_Internal` (8-14) and `on_midi` (16-33): the missing-`target` warning and the press/release latch to replicate. - `includes/Actions/IAction.h` — hooks at lines 33-43 and the `IAction<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: ```bash cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j$(nproc) ``` This needs the dependencies listed in the README (CMake ≥ 4.2, a C++26 compiler, `wireplumber-0.5` headers, 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 status` lists them): 1. Write a config mapping two LED-capable controls to two different sinks, e.g. ```json { "actions": { "Mute1": { "type": "set-default", "params": { "target": "<sink-a-node-name>" } }, "Mute2": { "type": "set-default", "params": { "target": "<sink-b-node-name>" } } } } ``` 2. 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. 3. Press `Mute2`. Expected: `wpctl status` now marks sink B as the default sink, `Mute2`'s LED lights and `Mute1`'s goes dark. 4. With the daemon still running, run `wpctl set-default <sink-a-id>` in a terminal. Expected: without any button press, `Mute1`'s LED lights and `Mute2`'s goes dark — this exercises the `changed` signal path. 5. Press `Mute1` twice in a row. Expected: no error output, default stays sink A, LED stays lit (set-only, second press is a no-op). 6. Map a third control to a `target` that does not exist in the graph and press it. Expected: `Failed to set "…" as default node …` on stderr, no crash, LED stays dark. 7. Map a control to a microphone (`Audio/Source`) node and press it. Expected: `wpctl status` shows it as the default source; the sink default is untouched. ## Assumptions & contingencies - The `default-nodes-api` module is assumed present in the user's WirePlumber installation; it ships with WirePlumber 0.5 and `wpctl` loads it the same way. If `wp_core_load_component` fails for it, `on_module_loaded` already prints the error and `DefaultNodesApi` stays null, which makes every press a logged no-op rather than a crash — no additional handling. - The `changed` signal is assumed to fire for both `default.audio.sink` and `default.audio.source` metadata changes. If during execution the LED fails to track an externally-made change, fall back to also calling `Refresh_LED_State()` from `on_node_params_changed` for the target node; do not add polling.
lucas closed this issue 2026-11-12 22:22:18 +01:00
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/nanoKontroller2#2
No description provided.