A Linux daemon that bridges the Korg nanoKONTROL2 MIDI controller with PipeWire/WirePlumber, letting you map physical faders, knobs, and buttons directly to audio node actions (muting, volume control, and toggling PipeWire link connections).
  • C++ 97.4%
  • CMake 2.3%
  • C 0.2%
  • Shell 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Lucas Schwiderski 0859597bba
All checks were successful
Build / build (push) Successful in 50s
Merge pull request 'ci(actions): keep push runs on main from being cancelled' (#28) from agent/issue-26-push-run-concurrency into main
Reviewed-on: #28
Reviewed-by: Lucas Schwiderski <lucas@noreply.git.sclu1034.dev>
2026-11-13 15:04:38 +01:00
.forgejo/workflows docs(actions): cut the concurrency comment down to its reasons 2026-11-13 14:01:47 +00:00
.omp chore: Improve agent instructions 2026-11-13 09:43:16 +01:00
includes feat(actions): add media action controlling MPRIS players 2026-11-13 13:18:37 +00:00
packaging feat(actions): add media action controlling MPRIS players 2026-11-13 13:18:37 +00:00
src feat(actions): add media action controlling MPRIS players 2026-11-13 13:18:37 +00:00
tests feat(mpris): add player registry with deterministic selection 2026-11-13 13:10:58 +00:00
.gitignore feat: First implementation 2026-06-03 21:27:13 +01:00
AGENTS.md chore: Add agent documentation 2026-11-13 13:22:00 +01:00
CMakeLists.txt feat(actions): add media action controlling MPRIS players 2026-11-13 13:18:37 +00:00
config-example.json feat: match nodes by pattern or list instead of one exact name 2026-11-12 21:32:52 +00:00
LICENSE Initial commit 2026-06-03 19:48:19 +01:00
mise.toml chore(mise): provision API docs for the project's libraries 2026-11-13 10:59:05 +00:00
README.md feat(actions): add media action controlling MPRIS players 2026-11-13 13:18:37 +00:00

nanoKontroller2

A Linux daemon that bridges the Korg nanoKONTROL2 MIDI controller with PipeWire/WirePlumber, letting you map physical faders, knobs, and buttons directly to audio node actions (muting, volume control, switching the default sink/source, and toggling PipeWire link connections) and to media players over MPRIS. This is a rewrite in c++ of the original nanoKontroller python script to have new features that I needed and to interact with wireplumberr directly instead of going through pulse.

Features

  • Maps every nanoKONTROL2 control (faders, knobs, Mute/Solo/Rec buttons, transport keys) to configurable actions.
  • Integrates with PipeWire through WirePlumber – reacts to nodes appearing/disappearing in real time.
  • Controls media players (play/pause, stop, track skip, seek) over MPRIS on the D-Bus session bus.
  • LED feedback: button LEDs reflect the current state of the action (e.g. a Mute button lights up when the target is muted).
  • Extensible action system – new action types can be added by implementing IAction<T, "name">.
  • Lightweight producer/consumer MIDI queue keeps memory allocations minimal.

Requirements

Dependency Notes
CMake ≥ 4.2 Build system
C++26 compiler GCC 14+ or Clang 18+ recommended
WirePlumber (wireplumber-0.5) PipeWire session manager library
GLib 2 / GObject 2 / GIO 2 Pulled in transitively with WirePlumber; GIO provides the D-Bus client
ALSA Required by RtMidi on Linux
RtMidi MIDI I/O
glaze JSON config parsing
cxxopts Command-line argument parsing
concurrentqueue Lock-free MIDI event queue
magic_enum Enum ↔ string conversion for control names

On a typical Arch / Fedora / Ubuntu system the system libraries can be installed with the package manager; the header-only/CMake libraries (glaze, cxxopts, concurrentqueue, magic_enum) are easiest to obtain via vcpkg or Conan.

Building

# 1. Clone the repository
git clone https://github.com/your-username/nanoKontroller2.git
cd nanoKontroller2

# 2. Configure
cmake -B build -DCMAKE_BUILD_TYPE=Release

# 3. Compile
cmake --build build -j$(nproc)

# 4. (Optional) install
sudo cmake --install build

The resulting binary is build/nanoKontroller2.

Unit tests are off by default. To build and run them:

cmake -B build -DCMAKE_BUILD_TYPE=Debug -DNANOKONTROLLER2_BUILD_TESTS=ON
cmake --build build -j$(nproc)
ctest --test-dir build --output-on-failure

nanoKONTROL2 Device Setup

The nanoKONTROL2 must be configured so that its LEDs are driven externally (not by the device itself):

  1. Open the Korg KONTROL Editor (available from Korg's website).
  2. Set the LED Mode for every button you want feedback on to "External".
  3. Write the scene to the device.

Without this step the LED feedback will not work correctly.

Usage

nanoKontroller2 [options]

Options:
  -h, --help          Print help and exit
  -c, --config PATH   Path to the JSON config file (default: config.json)

Example:

./nanoKontroller2 --config /etc/nanokontroller2/config.json

The daemon connects to PipeWire, opens the nanoKONTROL2 MIDI port, and starts processing events. Send SIGINT (Ctrl+C) or SIGTERM to stop it cleanly.

Configuration File

The configuration is a JSON file. Copy config-example.json as a starting point:

cp config-example.json config.json

Top-level structure

{
  // Optional: human-friendly aliases for PipeWire node names
  "FriendlyNames": {
    "alias": "actual.pipewire.node.name"
  },

  // Required: map nanoKONTROL2 control names to actions
  "actions": {
    "<ControlName>": {
      "type": "<action-type>",
      "params": {
        // action-specific parameters
      }
    }
  }
}

Note: The JSON key for actions is "actions" (lowercase). The key for aliases is "FriendlyNames" (mixed case).

Control names

Use the exact name from the table below as the key inside "actions":

Category Names Has LED
Faders Fader1 … Fader8 No
Knobs Knob1 … Knob8 No
Mute buttons Mute1 … Mute8 ✅
Solo buttons Solo1 … Solo8 ✅
Rec/Arm buttons Rec1 … Rec8 ✅
Transport Play, Stop, Record, Cycle, Rewind, FastForward ✅
Marker / Track PreviousTrack, NextTrack, Set, PreviousMarker, NextMarker No

Controls marked ✅ support LED feedback – their LED state is automatically managed by the assigned action.


Specifying nodes

Wherever a node is named – target, left, right, and the values of FriendlyNames – the JSON value may be a single string or a list of strings:

"target": "music-audio-sink"
"target": ["music-audio-sink", "alsa_output\\..*", "players"]

Each entry is one of:

  • a plain node name, which matches only that node;
  • a regular expression (ECMAScript grammar, case-sensitive), matched against the whole candidate – it is anchored for you, so firefox|chromium matches exactly those two names and nothing containing them. Note that JSON requires backslashes to be escaped: write alsa_output\\..* to match a literal dot;
  • a FriendlyNames alias, replaced by the alias's entries before matching.

What an entry is matched against

Every entry is matched against up to two candidate strings per node:

  1. the node's node.name;
  2. node.name: media.name – the node name, a colon and a space, then the node's media.name – when the node has a media.name.

media.name is what a desktop mixer shows on its second label line: for application streams it is usually the stream or tab title. This is how several streams of the same application are told apart, since they all share one node.name:

"target": "Firefox"
"target": "Firefox: .*YouTube.*"

The first targets every Firefox stream, the second only those whose title contains YouTube. A pattern without the separator never sees a media.name, so existing configurations keep matching exactly what they matched before – but a pattern broad enough to match arbitrary text (.*, or a negative-lookahead "everything else" pattern) now also matches through the second candidate, so an exclusion list must account for the node.name: media.name form as well.

media.name changes while a node lives (a tab retitles itself). Matching is re-evaluated when it does: a node that starts matching is brought to the action's sticky state right away, and a node that stops matching is simply no longer written to – whatever state it was left in stays.

Only nodes whose media.class contains Audio are ever considered, so a broad pattern such as .* cannot reach cameras, screen-share nodes or the nanoKONTROL2's own MIDI node.

An entry that is not a valid regular expression is reported on stderr and ignored; the other entries of the same list keep working. A pattern that currently matches no node is not an error – the node may appear later.

Many matches: writes fan out, the LED follows one node

When several nodes match, a button press applies to every match (for connection, to every left×right pair). Nodes are identified by their PipeWire id, so several streams sharing one node.name – the usual case for a browser with multiple tabs playing – are several matches, not one. The state an action reads back – and therefore what its LED shows – comes from a single representative node: the first configured entry that currently has a live match, and within that entry the node with the lowest PipeWire id. Put a more specific entry first to choose which node drives the LED.

If nothing matches there is no representative: the LED is off and a press does nothing.

Consequence by design: if the representative is muted by another tool while the other matches stay unmuted, the LED lights, and the next press (which toggles away from what the LED shows) unmutes everything.

Sticky state

Once an action has commanded a state, a node that starts matching later is brought to that state as it appears: Mute after the first press, volume after the first fader move, connection from startup when default-state is present.


Available action types

Mute

Toggles the mute state of a PipeWire node. If the assigned control has an LED, it lights up when the target node is muted and turns off when it is unmuted.

A press mutes or unmutes every matched node; the LED follows the representative (see Specifying nodes).

Parameter Required Description
target ✅ Node specification (name, pattern, alias, or a list of them) to mute/unmute

Example – mute a sink with Mute1:

"Mute1": {
  "type": "Mute",
  "params": {
    "target": "music-audio-sink"
  }
}

volume

Maps a continuous control (fader or knob, range 0–127) to the volume of a PipeWire node. The MIDI value is converted to a linear volume using a power curve: volume = (midi / 127) ^ pow.

Parameter Required Default Description
target ✅ — Node specification (name, pattern, alias, or a list of them) to control
pow No 4.0 Exponent for the power curve. Higher values make the lower end of the fader more granular. Use 1.0 for a linear response.

Example – control a sink volume with Fader1:

"Fader1": {
  "type": "volume",
  "params": {
    "target": "music-audio-sink",
    "pow": "3.0"
  }
}

connection

Toggles PipeWire links between two sets of nodes. Pressing the assigned button connects or disconnects every left×right combination that currently matches. If the assigned control has an LED, it lights up when the representative pair (first-ranked left with first-ranked right) is linked.

default-state is the action's initial desired state. It is enforced on every pair when a matching node appears or gains ports, so a node that shows up later is brought in line – including after a press, which replaces the desired state. Links are never torn down in reaction to link events, so a link you create by hand with pw-link survives and is reflected in the LED.

Parameter Required Description
left ✅ Source (output) node specification (name, pattern, alias, or a list of them)
right ✅ Sink (input) node specification (name, pattern, alias, or a list of them)
default-state ✅ State to enforce while the action runs, until the first press: "connected" or "disconnected"

Example – toggle a link between two nodes with Solo1:

"Solo1": {
  "type": "connection",
  "params": {
    "left":          "my-source-node",
    "right":         "my-sink-node",
    "default-state": "disconnected"
  }
}

set-default

Makes the target node the PipeWire default sink or source. Whether it is a sink or a source is derived from the node's own media.class, so no extra parameter is needed; targets that are neither an audio sink nor an audio source are rejected with a message on stderr.

This is not a toggle: pressing the button sets the default, and pressing it again is a harmless no-op. The daemon never changes the default on its own — not on start and not when a node appears. If the assigned control has an LED, it lights up while the target is the active default and follows changes made by other tools (wpctl, pavucontrol, desktop settings).

There is only one default per media class, so this action does not fan out: it acts on the representative node, which is also the one the LED reads.

Parameter Required Description
target ✅ Node specification (name, pattern, alias, or a list of them) to make the default sink/source

Example – switch the default sink with Rec1:

"Rec1": {
  "type": "set-default",
  "params": {
    "target": "headphones-audio-sink"
  }
}

media

Controls a media player over MPRIS on the D-Bus session bus – the same interface the desktop's own media keys use. One press acts on exactly one player: the top-ranked one among those that match (see below), not on every match.

Parameter Required Default Description
command ✅ — play-pause, stop, next, previous, seek-backward or seek-forward
target No every player Player specification (name, pattern, alias, or a list of them); see Choosing the player
seconds No 5 Seek step of seek-backward / seek-forward, in seconds; ignored by the other commands

Choosing the player. Unlike every other action type, target here does not name a PipeWire node. It is matched – with the same anchored-regex rules – against the player's D-Bus bus name and against its MPRIS Identity (what the player calls itself, e.g. Mozilla Firefox). The bus name is matched at every dot boundary, and the per-process instance_<n> suffix is ignored, so org.mpris.MediaPlayer2.firefox.instance_1234 is matched by firefox, by MediaPlayer2\\.firefox and by the full name, but not by refox. Without target every player is a candidate.

Among the candidates the daemon picks, in order: the one that is playing; then the one that played most recently; then the one named by the earliest target entry; then the one that appeared first. So a single Play button follows whatever is actually making sound.

LED feedback. play-pause lights its LED while the selected player is playing, blinks it while paused, and turns it off while stopped or when no player matches. stop lights its LED while the selected player is stopped. next and previous never touch their LED. seek-backward and seek-forward light their LED while the button is held – that is press feedback, not player state.

Seeking. A tap seeks one step. Holding the button keeps seeking: one more step after 400 ms, then a step every 200 ms until it is released. A player that reports it cannot seek is left alone.

If there is no session bus, the daemon says so once on startup and every media action stays inert; the rest of the configuration is unaffected.

Example – transport controls:

"Play": {
  "type": "media",
  "params": { "command": "play-pause" }
},
"Stop": {
  "type": "media",
  "params": { "command": "stop" }
},
"NextTrack": {
  "type": "media",
  "params": { "command": "next", "target": "spotify" }
},
"FastForward": {
  "type": "media",
  "params": { "command": "seek-forward", "seconds": "10" }
}

FriendlyNames

FriendlyNames lets you give a short alias to a long or unstable PipeWire node name, or to a whole group of nodes. Wherever you use the alias as a target, left, or right, the daemon substitutes the alias's entries – a list alias is spliced into the surrounding list, keeping the order you wrote.

{
  "FriendlyNames": {
    "spotify": "Spotify - Music and Podcasts",
    "mic":     "alsa_input.usb-my_microphone.mono-fallback",
    "players": ["Spotify - Music and Podcasts", "Firefox", "mpv"]
  }
}

Expansion is single-level: an alias value that happens to be another alias's name is left as literal text, so aliases cannot chain or cycle.

Tip: Run wpctl status or pw-cli list-objects to find the exact PipeWire node names on your system.


Full example

The following config demonstrates all four action types, aliases and patterns:

{
  "FriendlyNames": {
    "music": "music-output-sink",
    "mic":   "alsa_input.usb-my_microphone.mono-fallback",
    "players": ["Spotify - Music and Podcasts", "Firefox", "mpv"],
    "loopback-src": "my-loopback-source",
    "loopback-dst": "my-loopback-sink"
  },
  "actions": {
    "Fader1": {
      "type": "volume",
      "params": {
        "target": "music",
        "pow": "4.0"
      }
    },
    "Fader2": {
      "type": "volume",
      "params": {
        "target": "mic",
        "pow": "1.0"
      }
    },
    "Mute1": {
      "type": "Mute",
      "params": { "target": "music" }
    },
    "Mute2": {
      "type": "Mute",
      "params": { "target": ["players", "chromium.*"] }
    },
    "Rec1": {
      "type": "set-default",
      "params": { "target": "headphones-audio-sink" }
    },
    "Solo1": {
      "type": "connection",
      "params": {
        "left":          "loopback-src",
        "right":         "loopback-dst",
        "default-state": "disconnected"
      }
    }
  }
}

Extending with new actions

  1. Create a header in includes/Actions/ and a source file in src/Actions/.
  2. Derive from IAction<YourClass, "your-type-name">:
// includes/Actions/Action_MyAction.h
#pragma once
#include "IAction.h"

class Action_MyAction final : public IAction<Action_MyAction, "my-action"> {
public:
    void on_midi(int value) override;
protected:
    void Init_Internal(const ActionParams& params) override;
};
  1. Implement the virtual methods. Optionally override on_node_available, on_node_removed, on_node_params_changed, on_link_created, on_link_removed, on_default_nodes_changed, and on_audio_system_ready for event-driven behaviour.
  2. The action is automatically registered with the factory via the IAction template – no further wiring is needed.
  3. In the config use "type": "my-action".

Project structure

includes/
  AppSettings.h              – Config structs (AppSettings, ActionConfig)
  AudioService.h             – WirePlumber/PipeWire audio node interface
  LedManager.h               – Sends LED on/off messages to the device
  MidiManager.h              – RtMidi wrapper (open port, send/receive CC)
  MidiTypes.h                – MidiEvent struct
  NanoKontrol2.hpp           – Control & LED enum definitions
  NodeMatcher.h              – Anchored-regex matching of node names
  Actions/
    IAction.h                – IActionBase + self-registering IAction<T, Name> template
    ActionFactoryRegistry.h  – Singleton factory: maps type strings → constructors
    Action_Mute.h            – "Mute" action declaration
    Action_Volume.h          – "volume" action declaration
    Action_Connection.h      – "connection" action declaration
    Action_SetDefault.h      – "set-default" action declaration
src/
  main.cpp                   – Entry point, GLib main loop, wiring
  AppSettings.cpp            – JSON config loading via glaze
  AudioService.cpp           – WirePlumber integration (nodes, links, params)
  NodeMatcher.cpp            – Pattern compilation and matching
  LedManager.cpp             – LED CC message dispatch
  MidiManager.cpp            – RtMidi port management and CC I/O
  Actions/
    IAction.cpp              – IActionBase destructor & Init()
    ActionFactoryRegistry.cpp – Factory registration storage
    Action_Mute.cpp          – Toggle mute on a PipeWire node
    Action_Volume.cpp        – Set volume on a PipeWire node (power curve)
    Action_Connection.cpp    – Toggle a PipeWire link between two nodes
    Action_SetDefault.cpp    – Make a PipeWire node the default sink/source
tests/
  NodeMatcher_tests.cpp      – Unit tests for NodeMatcher and config handling
CMakeLists.txt               – Build definition
config-example.json          – Starter configuration