- C++ 97.4%
- CMake 2.3%
- C 0.2%
- Shell 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Build / build (push) Successful in 50s
Reviewed-on: #28 Reviewed-by: Lucas Schwiderski <lucas@noreply.git.sclu1034.dev> |
||
| .forgejo/workflows | ||
| .omp | ||
| includes | ||
| packaging | ||
| src | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| CMakeLists.txt | ||
| config-example.json | ||
| LICENSE | ||
| mise.toml | ||
| README.md | ||
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):
- Open the Korg KONTROL Editor (available from Korg's website).
- Set the LED Mode for every button you want feedback on to "External".
- 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|chromiummatches exactly those two names and nothing containing them. Note that JSON requires backslashes to be escaped: writealsa_output\\..*to match a literal dot; - a
FriendlyNamesalias, 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:
- the node's
node.name; node.name: media.name– the node name, a colon and a space, then the node'smedia.name– when the node has amedia.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 statusorpw-cli list-objectsto 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
- Create a header in
includes/Actions/and a source file insrc/Actions/. - 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;
};
- 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, andon_audio_system_readyfor event-driven behaviour. - The action is automatically registered with the factory via the
IActiontemplate – no further wiring is needed. - 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