Musik im Terminal: cava, roon-tui und ncmpcpp — deklarativ, monochrom, vim-navigiert

Drei Musik-Tools, ein Flake: cava visualisiert den Sound in einem 8-stufigen Orange-Gradienten mit gepatchten ▄-Blöcken, roon-tui steuert den Roon Core mit vim-Keybindings und einem monochromen Theme, und ncmpcpp ist der MPD-Fallback mit derselben Navigation. Dazu ein Overlay, das roon-tuis hartkodierte Farben und Keybindings per sed/substituteInPlace umschreibt, ein RoonBridge- und squeezelite-Setup, das sowohl bit-perfektes ALSA als auch PipeWire für cava bedient, und ein Musik-Profil, das zur Build-Zeit zwischen Roon und MPD wählt. Alles in einer tmux-Session, die cava im oberen Split und den Player darunter startet.
Table of contents

Musik auf der Workstation ist für mich kein Nebenbei — sie läuft den ganzen Tag, und das Setup soll sich so anfühlen wie der Rest des Desktops: deklarativ, monochrom mit orangen Akzenten, vim-navigiert. Drei Tools teilen sich die Bühne: cava visualisiert den Sound, roon-tui steuert den Roon Core, und ncmpcpp ist der MPD-Fallback, wenn Roon nicht läuft. Alle drei leben in fr0st , alle drei sind per Overlay auf dasselbe Theme und dieselben Keybindings gepatcht, und alle drei starten in einer tmux-Session , die cava im oberen Split und den Player darunter aufbaut.

cava (oben, ▄-Blöcke im Orange-Gradient) und roon-tui (unten, monochrom mit orangen Akzenten und ▮-Progressbar) in einer tmux-Session auf Workspace 9.

Die Besetzung#

Drei Tools, drei Rollen:

ToolRolle
cavaAudio-Visualizer — liest den PulseAudio/PipeWire-Monitor und malt Balken ins Terminal
roon-tuiTUI-Client für die Roon-API — steuert Playback, Queue, Zonen, Browse
ncmpcppMPD-Client — der Fallback, wenn kein Roon Core erreichbar ist

Dahinter stehen zwei Audio-Pfade: RoonBridge gibt bit-perfektes ALSA auf die eingebaute Soundkarte (HDMI, Onboard), und squeezelite-pulse streamt dasselbe Roon-Signal über PipeWire auf den Default-Sink — das ist der Pfad, den cava visualisieren kann, und der auch Bluetooth-Kopfhörer erreicht. MPD hängt direkt an PipeWire und schreibt parallel in eine FIFO (/tmp/mpd.fifo), die ncmpccps eingebauten Visualizer speist.

cava: der Visualizer mit dem ▄-Block#

cava ist in NixOS schnell installiert — programs.cava.enable = true, eine Config dazu, fertig. Das Interessante ist das Overlay, das ich drübergelegt habe.

Das Overlay: alle Bar-Characters auf ▄#

cava rendert standardmäßig einen Gradienten aus acht verschiedenen Block-Zeichen — von (U+2581, lower one eighth) bis (U+2588, full block). Das sieht in den meisten Setups gut aus, aber in meinem monochromen Terminal mit bitmap-Font wirkt der gemischte Zeichensatz unruhig. Das Overlay patcht output/terminal_noncurses.c und ersetzt alle Bar-Characters — sowohl die ASCII-Varianten (H, AG) als auch die Unicode-Blöcke () — durch ein einziges Zeichen: (U+2584, lower half block):

 1# overlays/cava/default.nix
 2cava = prev.cava.overrideAttrs (old: {
 3  pname = "cava";
 4  version = "${old.version}-block";
 5
 6  postPatch = (old.postPatch or "") + ''
 7    substituteInPlace output/terminal_noncurses.c \
 8      --replace-fail 'strcat(ttybarstring[0], "H");' 'strcat(ttybarstring[0], "\xe2\x96\x84");' \
 9      --replace-fail 'strcat(ttybarstring[1], "A");' 'strcat(ttybarstring[1], "\xe2\x96\x84");' \
10      # ... alle 8 ASCII-Bars ...
11      --replace-fail 'wcscat(barstring[0], L"\u2588");' 'wcscat(barstring[0], L"\u2584");' \
12      --replace-fail 'wcscat(barstring[1], L"\u2581");' 'wcscat(barstring[1], L"\u2584");' \
13      # ... alle 8 Unicode-Blöcke ...
14  '';
15});

Das Ergebnis ist ein uniformer Block-Visualizer: Jeder Balken ist ein , die Höhe kommt nur noch aus der Farbe. Kein visuelles Rauschen durch wechselnde Zeichenformen — die Amplitude wird allein durch den Farbverlauf lesbar.

Die Config: 8-stufiger Orange-Gradient#

Die cava-Config setzt einen 8-stufigen Gradienten von dunklem Magenta über Orange bis Creme:

 1color = {
 2  gradient = 1;
 3  gradient_count = 8;
 4  gradient_color_1 = "'#6b1040'";  # dunkles Magenta
 5  gradient_color_2 = "'#a02060'";
 6  gradient_color_3 = "'#d04070'";
 7  gradient_color_4 = "'#e06040'";
 8  gradient_color_5 = "'#fe8019'";  # fr0st-Akzent
 9  gradient_color_6 = "'#ffa040'";
10  gradient_color_7 = "'#ffc080'";
11  gradient_color_8 = "'#ffce96'";  # Creme
12};

60 fps, noncurses-Output (roh auf stdout, kein ncurses-Fenster), Stereo, 32 Pixel Balkenhöhe, kein Gap zwischen den Balken. Der Input kommt von PulseAudio/PipeWire (method = "pulse"), sodass cava alles visualisiert, was über den Default-Sink läuft — Roon via squeezelite, MPD, Browser-Audio, alles.

roon-tui: der Roon-Client mit vim-Keybindings#

roon-tui ist ein Rust-TUI-Client für die Roon-API. Er hat kein Theme-System — Farben und Keybindings sind in src/app/ui.rs und src/app/mod.rs hartkodiert. Also patcht ein Overlay sie.

Das Overlay: Farben, Progressbar, vim-Navigation#

Das Overlay macht vier Dinge:

1. Farben auf fr0st-Theme umschreiben. substituteInPlace tauscht die hartkodierten RGB-Werte:

1--replace-fail "Color::Rgb(0x75, 0x75, 0xf3)" "Color::Rgb(0xfe, 0x80, 0x19)"  # Brand → Akzent
2--replace-fail "Color::Rgb(0x80, 0x80, 0x80)" "Color::Rgb(0x78, 0x78, 0x78)"  # Grau
3--replace-fail "Color::Rgb(0x30, 0x30, 0x30)" "Color::Rgb(0x1a, 0x1a, 0x1a)"  # Gauge-Hintergrund

Selektierte Zeilen bekommen schwarzen Text auf orangem Hintergrund (.bg(ROON_BRAND_COLOR).fg(Color::Black)) — sonst wäre grauer Text auf Orange unlesbar.

2. Progressbar von Gauge auf LineGauge mit . Die Default-Progressbar ist ein solider Gauge-Block. Das Overlay tauscht ihn gegen eine LineGauge mit -Segmenten — dasselbe Zeichen, das ncmpccps Progressbar nutzt:

1--replace-fail "Clear, Gauge," "Clear, Gauge, LineGauge,"
2--replace-fail "Gauge::default()" "LineGauge::default()"
3--replace-fail ".percent(progress as u16)" \
4  ".line_set(ratatui::symbols::line::Set { horizontal: \"\", .. }).style(...).ratio(progress as f64 / 100.0)"

3. Vim-Keybindings in allen Views. j/k für down/up in Browse, Queue, Zones und Grouping; h/l für prev/next Track im NowPlaying. Das sind sed- und substituteInPlace-Patches, die die bestehenden Up/Down- und Char('s')-Matches erweitern:

 1# j/k in Browse (sed fügt vor dem Char-catch-all ein)
 2sed -i 's/KeyCode::Char(key) => self.select_by_input(key),/KeyCode::Char('"'"'j'"'"') => self.browse.next(),\n                    KeyCode::Char('"'"'k'"'"') => self.browse.prev(),\n                    &/' src/app/mod.rs
 3
 4# j/k in Queue, Zones, Grouping (erweitert bestehende Up/Down-Matches)
 5--replace-fail "KeyCode::Up => self.queue.prev()," \
 6  "KeyCode::Char('k') | KeyCode::Up => self.queue.prev(),"
 7
 8# h/l für prev/next im NowPlaying
 9--replace-fail "KeyCode::Char('s') => self.to_roon.send(IoEvent::Shuffle)..." \
10  "KeyCode::Char('s') => ...\n            KeyCode::Char('h') => ...\n            KeyCode::Char('l') => ..."

4. Wrapper-Skript für XDG-Pfade. roon-tui schreibt sein Config-File (Core-Pairing-Token) standardmäßig als config.json ins CWD — also $HOME, wenn man es aus dem Terminal startet. Ein writeShellApplication-Wrapper lenkt das nach $XDG_DATA_HOME/roon-tui/ um und leitet Logs nach /dev/null:

1roonTui = pkgs.writeShellApplication {
2  name = "roon-tui";
3  text = ''
4    mkdir -p "${configDir}"
5    exec ${lib.getExe pkgs.roon-tui} -c "${configDir}/config.json" -l /dev/null \
6      ${lib.optionalString (cfg.coreAddress != "") "-i ${cfg.coreAddress}"} "$@"
7  '';
8};

Eine Home-Manager-Activation migriert ein eventuell vorhandenes ~/config.json (mit "roonstate"-Marker) nach XDG, sodass die bestehende Core-Pairing erhalten bleibt.

RoonBridge + squeezelite: zwei Audio-Pfade#

Das Roon-Modul startet zwei systemd-User-Services:

  • RoonBridge — ein Roon-Ready-Endpoint, der rohes ALSA auf die eingebaute Hardware ausgibt. Bit-perfekt, kein PipeWire dazwischen. cava kann diesen Pfad nicht sehen.
  • squeezelite-pulse — ein zweiter Roon-Endpoint, der über PipeWire auf den Default-Sink geht. Das ist der Pfad für cava, Bluetooth-Kopfhörer und alles, was nicht die eingebaute Soundkarte ist.

Beide erscheinen in der Roon-App als separate Zonen. Der Operator wählt, welche Zone gerade spielt — mean-six (PipeWire) für den Alltag mit Visualizer, die Hardware-Zone für kritisches Hören.

ncmpcpp: der MPD-Fallback mit denselben Keybindings#

Wenn kein Roon Core läuft (oder auf Maschinen ohne Roon), springt MPD + ncmpcpp ein. ncmpcpp bekommt dieselben vim-Keybindings wie roon-tui und tmux:

 1bindings = [
 2  { key = "j"; command = "scroll_down"; }
 3  { key = "k"; command = "scroll_up"; }
 4  { key = "h"; command = "previous_column"; }
 5  { key = "l"; command = "next_column"; }
 6  { key = "g"; command = "move_home"; }
 7  { key = "G"; command = "move_end"; }
 8  { key = "ctrl-u"; command = "page_up"; }
 9  { key = "ctrl-d"; command = "page_down"; }
10];

Der eingebaute Visualizer (visualizerSupport = true) liest aus /tmp/mpd.fifo — MPD schreibt dorthin parallel zum Audio-Output. Der Look ist wave_filled mit ▊▊-Zeichen, die Progressbar nutzt ▮▮▮ — dieselbe Zeichensprache wie roon-tui.

Das Musik-Profil: Roon oder MPD, Nix entscheidet#

Das music-Profil ist der Schalter, der zur Build-Zeit zwischen den beiden Welten wählt:

1# profiles/home/music.nix
2roon = config.fr0st.apps.roon.enable;
3config = mkIf (cfg.enable && isHome config) {
4  fr0st.apps = {
5    cava = enabled;           # immer
6    mpd.enable = !roon;       # nur wenn Roon aus
7    ncmpcpp.enable = !roon;   # nur wenn Roon aus
8  };
9};

cava läuft immer. MPD und ncmpcpp nur, wenn Roon nicht aktiviert ist. Die Entscheidung fällt einmal im Host-Konfigurationsblock — mean-six hat fr0st.apps.roon.enable = true und fr0st.profiles.home.music = enabled, also Roon. Ein Laptop ohne Roon-Core-Zugang bekommt dasselbe Profil, aber ohne roon.enable — dann läuft MPD.

Die tmux-Session: cava oben, Player unten#

Die MUSIC_PLAYER-Session ist ein sesh -Template, das ein Fenster mit vertikalem Split aufbaut:

1MUSIC = {
2  name = "MUSIC";
3  windows = [
4    {
5      name = "music";
6      startup_script = "tmux split-window -v -b -p 66 cava && ${musicPlayer}";
7    }
8  ];
9};

split-window -v -b -p 66 cava teilt das Fenster vertikal, legt cava vor (-b) das aktuelle Pane und gibt ihm 66% der Höhe. ${musicPlayer} ist roon-tui, wenn Roon aktiviert ist, sonst ncmpcpp — Nix setzt die Variable zur Build-Zeit. Das Ergebnis: cava im oberen Split, der Player darunter, ein SUPER+sMUSIC_PLAYER und die Session steht.

Hyprland routet das Fenster per Window-Rule auf Workspace 9 (music- und cava-Klasse), MOD+SHIFT+r lädt Hyprland und tmux-Config neu. Der ganze Stack — vom Overlay-Patch über die systemd-Services bis zum Session-Layout — ist eine Handvoll Nix-Dateien, die dasselbe Theme, dieselben Keybindings und dieselbe deklarative Philosophie teilen wie der Rest des Desktops.


Das Repo liegt offen auf meinem Radicle -Seed — rad:zVi9VheaDwbEgCUQUQ9sLwpHuaMo. Die cava-, roon-tui- und ncmpcpp-Module plus Overlays sind unter modules/home/apps/ und overlays/ zu finden.