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.

Die Besetzung#
Drei Tools, drei Rollen:
| Tool | Rolle |
|---|---|
| cava | Audio-Visualizer — liest den PulseAudio/PipeWire-Monitor und malt Balken ins Terminal |
| roon-tui | TUI-Client für die Roon-API — steuert Playback, Queue, Zonen, Browse |
| ncmpcpp | MPD-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, A–G) 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+s → MUSIC_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.
