From c95945c56c0304d9fcc32cdbf7287ae99f460a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Sat, 14 Mar 2026 18:12:10 +0100 Subject: [PATCH 1/2] Add --audio-device flag and TUI device picker (d) with cross-platform backends for Linux/macOS/Windows --- config/config.go | 3 + config/flags.go | 27 ++++- main.go | 25 +++++ player/audio_device.go | 9 ++ player/audio_device_linux.go | 102 +++++++++++++++++++ player/audio_device_macos.go | 175 +++++++++++++++++++++++++++++++++ player/audio_device_stub.go | 20 ++++ player/audio_device_windows.go | 46 +++++++++ ui/commands.go | 26 +++++ ui/keymap.go | 1 + ui/keys.go | 36 +++++++ ui/model.go | 27 ++++- ui/state.go | 9 ++ ui/view.go | 4 + ui/view_overlays.go | 46 +++++++++ 15 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 player/audio_device.go create mode 100644 player/audio_device_linux.go create mode 100644 player/audio_device_macos.go create mode 100644 player/audio_device_stub.go create mode 100644 player/audio_device_windows.go diff --git a/config/config.go b/config/config.go index bea7a9c1..986e8664 100644 --- a/config/config.go +++ b/config/config.go @@ -123,6 +123,7 @@ type Config struct { ResampleQuality int // beep resample quality factor (1–4) BitDepth int // PCM bit depth for FFmpeg output: 16 or 32 Compact bool // compact mode: cap frame width at 80 columns + AudioDevice string // preferred audio output device name (empty = system default) Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials Spotify SpotifyConfig // optional Spotify provider (requires Premium) YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider @@ -271,6 +272,8 @@ func Load() (Config, error) { } case "compact": cfg.Compact = val == "true" + case "audio_device": + cfg.AudioDevice = strings.Trim(val, `"'`) } } } diff --git a/config/flags.go b/config/flags.go index aabdf720..c3c1837a 100644 --- a/config/flags.go +++ b/config/flags.go @@ -22,6 +22,7 @@ type Overrides struct { BitDepth *int Play *bool Compact *bool + AudioDevice *string } // Apply merges non-nil overrides into cfg and clamps the result. @@ -65,6 +66,9 @@ func (o Overrides) Apply(cfg *Config) { if o.Compact != nil { cfg.Compact = *o.Compact } + if o.AudioDevice != nil { + cfg.AudioDevice = *o.AudioDevice + } cfg.clamp() } @@ -73,7 +77,19 @@ func (o Overrides) Apply(cfg *Config) { // and correctly treats negative numbers as flag values rather than flags. // // Returned action is one of "help", "version", "upgrade", or "" (run). -func ParseFlags(args []string) (action string, ov Overrides, positional []string, err error) { +func ParseFlags(rawArgs []string) (action string, ov Overrides, positional []string, err error) { + // Normalize --flag=value into --flag value so the parser handles both forms. + var args []string + for _, a := range rawArgs { + if strings.HasPrefix(a, "--") { + if eqIdx := strings.IndexByte(a, '='); eqIdx > 0 { + args = append(args, a[:eqIdx], a[eqIdx+1:]) + continue + } + } + args = append(args, a) + } + i := 0 for i < len(args) { arg := args[i] @@ -179,6 +195,15 @@ func ParseFlags(args []string) (action string, ov Overrides, positional []string return "", ov, nil, e } ov.BitDepth = &v + case "--audio-device": + v, e := requireNextString(args, &i, arg) + if e != nil { + return "", ov, nil, e + } + if strings.ToLower(v) == "list" { + return "list-audio-devices", ov, nil, nil + } + ov.AudioDevice = &v default: return "", ov, nil, fmt.Errorf("unknown flag: %s", arg) diff --git a/main.go b/main.go index 1d09e867..1d4184e1 100644 --- a/main.go +++ b/main.go @@ -143,6 +143,12 @@ func run(overrides config.Overrides, positional []string) error { pl := playlist.New() pl.Add(resolved.Tracks...) + // Configure audio output device before speaker init. + if cfg.AudioDevice != "" { + cleanup := player.PrepareAudioDevice(cfg.AudioDevice) + defer cleanup() + } + // Resolve sample rate: 0 means auto-detect from the system's default // output audio device (e.g. 48 kHz for USB-C headphones). Falls back // to 44100 Hz if detection is unavailable or returns an unusable value. @@ -248,6 +254,7 @@ Audio engine: --buffer-ms Speaker buffer in milliseconds (50–500) --resample-quality Resample quality factor (1–4) --bit-depth PCM bit depth: 16 (default) or 32 (lossless) + --audio-device Audio output device (use --audio-device=list to show available devices) Provider: --provider Default provider: radio, navidrome, spotify, yt, youtube, ytmusic (default: radio) @@ -309,6 +316,24 @@ func main() { os.Exit(1) } return + case "list-audio-devices": + devices, err := player.ListAudioDevices() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(devices) == 0 { + fmt.Println("No audio output devices found.") + } else { + for _, d := range devices { + marker := " " + if d.Active { + marker = "* " + } + fmt.Printf("%s%-50s %s\n", marker, d.Description, d.Name) + } + } + return } telemetry.Ping(version) diff --git a/player/audio_device.go b/player/audio_device.go new file mode 100644 index 00000000..86ee8a34 --- /dev/null +++ b/player/audio_device.go @@ -0,0 +1,9 @@ +package player + +// AudioDevice represents an available audio output device (sink/endpoint). +type AudioDevice struct { + Index int + Name string // internal identifier (sink name, UID, or device ID) + Description string // human-readable label + Active bool // true when this is the current default +} diff --git a/player/audio_device_linux.go b/player/audio_device_linux.go new file mode 100644 index 00000000..73fb96e8 --- /dev/null +++ b/player/audio_device_linux.go @@ -0,0 +1,102 @@ +//go:build linux + +package player + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// ListAudioDevices returns available output sinks via pactl. +// Works on PulseAudio and PipeWire (via pipewire-pulse). +func ListAudioDevices() ([]AudioDevice, error) { + defaultSink := "" + if out, err := exec.Command("pactl", "get-default-sink").Output(); err == nil { + defaultSink = strings.TrimSpace(string(out)) + } + + out, err := exec.Command("pactl", "list", "sinks").Output() + if err != nil { + return nil, fmt.Errorf("pactl: %w (is PulseAudio/PipeWire running?)", err) + } + + var devices []AudioDevice + var cur *AudioDevice + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Sink #") { + idx, _ := strconv.Atoi(strings.TrimPrefix(line, "Sink #")) + devices = append(devices, AudioDevice{Index: idx}) + cur = &devices[len(devices)-1] + } else if cur != nil { + switch { + case strings.HasPrefix(line, "Name: "): + cur.Name = strings.TrimPrefix(line, "Name: ") + cur.Active = cur.Name == defaultSink + case strings.HasPrefix(line, "Description: "): + cur.Description = strings.TrimPrefix(line, "Description: ") + } + } + } + + return devices, nil +} + +// PrepareAudioDevice sets PIPEWIRE_NODE so the PipeWire ALSA plugin +// routes this process's audio to the named device. +// Must be called before player.New(). Returns a no-op cleanup. +func PrepareAudioDevice(device string) func() { + os.Setenv("PIPEWIRE_NODE", device) + return func() {} +} + +// SwitchAudioDevice moves this process's active audio stream to a +// different output at runtime via pactl move-sink-input. +func SwitchAudioDevice(deviceName string) error { + pid := os.Getpid() + + out, err := exec.Command("pactl", "list", "sink-inputs").Output() + if err != nil { + return fmt.Errorf("pactl: %w", err) + } + + sinkInputIdx := -1 + currentIdx := 0 + inEntry := false + + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Sink Input #") { + idx, _ := strconv.Atoi(strings.TrimPrefix(line, "Sink Input #")) + currentIdx = idx + inEntry = true + } + if inEntry && strings.Contains(line, "application.process.id") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + pidStr := strings.Trim(strings.TrimSpace(parts[1]), `"`) + if pidStr == strconv.Itoa(pid) { + sinkInputIdx = currentIdx + break + } + } + } + } + + if sinkInputIdx < 0 { + return fmt.Errorf("no active audio stream found for PID %d", pid) + } + + cmd := exec.Command("pactl", "move-sink-input", + strconv.Itoa(sinkInputIdx), deviceName) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("move-sink-input: %s (%w)", + strings.TrimSpace(string(out)), err) + } + + return nil +} diff --git a/player/audio_device_macos.go b/player/audio_device_macos.go new file mode 100644 index 00000000..6d6d9079 --- /dev/null +++ b/player/audio_device_macos.go @@ -0,0 +1,175 @@ +// player/audio_device_macos.go — macOS Core Audio output device enumeration & selection. + +//go:build darwin && !ios + +package player + +/* +#cgo LDFLAGS: -framework CoreAudio -framework CoreFoundation +#include +#include +#include + +static AudioDeviceID caDefaultOutput() { + AudioObjectPropertyAddress addr = { + kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, + }; + AudioDeviceID id = kAudioObjectUnknown; + UInt32 sz = sizeof(id); + AudioObjectGetPropertyData(kAudioObjectSystemObject, &addr, 0, NULL, &sz, &id); + return id; +} + +static int caSetDefaultOutput(AudioDeviceID id) { + AudioObjectPropertyAddress addr = { + kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain, + }; + return AudioObjectSetPropertyData(kAudioObjectSystemObject, &addr, 0, NULL, sizeof(id), &id) == noErr ? 0 : -1; +} + +static int caOutputChannels(AudioDeviceID id) { + AudioObjectPropertyAddress addr = { + kAudioDevicePropertyStreamConfiguration, + kAudioObjectPropertyScopeOutput, + kAudioObjectPropertyElementMain, + }; + UInt32 sz = 0; + if (AudioObjectGetPropertyDataSize(id, &addr, 0, NULL, &sz) != noErr || sz == 0) return 0; + AudioBufferList *bl = (AudioBufferList *)malloc(sz); + if (AudioObjectGetPropertyData(id, &addr, 0, NULL, &sz, bl) != noErr) { free(bl); return 0; } + int ch = 0; + for (UInt32 i = 0; i < bl->mNumberBuffers; i++) ch += bl->mBuffers[i].mNumberChannels; + free(bl); + return ch; +} + +static char* caStr(CFStringRef s) { + if (s == NULL) return NULL; + CFIndex len = CFStringGetMaximumSizeForEncoding(CFStringGetLength(s), kCFStringEncodingUTF8)+1; + char *buf = (char*)malloc(len); + CFStringGetCString(s, buf, len, kCFStringEncodingUTF8); + CFRelease(s); + return buf; +} + +static char* caDevName(AudioDeviceID id) { + AudioObjectPropertyAddress a = {kAudioObjectPropertyName, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + CFStringRef s = NULL; UInt32 sz = sizeof(s); + if (AudioObjectGetPropertyData(id, &a, 0, NULL, &sz, &s) != noErr) return NULL; + return caStr(s); +} + +static char* caDevUID(AudioDeviceID id) { + AudioObjectPropertyAddress a = {kAudioDevicePropertyDeviceUID, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + CFStringRef s = NULL; UInt32 sz = sizeof(s); + if (AudioObjectGetPropertyData(id, &a, 0, NULL, &sz, &s) != noErr) return NULL; + return caStr(s); +} + +typedef struct { AudioDeviceID id; char *name; char *uid; } CADev; + +static int caListOutputs(CADev **out, int *count) { + AudioObjectPropertyAddress a = {kAudioHardwarePropertyDevices, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain}; + UInt32 sz = 0; + if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &a, 0, NULL, &sz) != noErr) { *count=0; return -1; } + int n = sz / sizeof(AudioDeviceID); + AudioDeviceID *ids = (AudioDeviceID*)malloc(sz); + if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &a, 0, NULL, &sz, ids) != noErr) { free(ids); *count=0; return -1; } + int oc = 0; + for (int i=0; i0) oc++; } + CADev *devs = (CADev*)calloc(oc, sizeof(CADev)); + int j = 0; + for (int i=0; i 0 { + m.devicePicker.cursor-- + } + case "down", "j": + if m.devicePicker.cursor < len(m.devicePicker.devices)-1 { + m.devicePicker.cursor++ + } + case "enter": + if len(m.devicePicker.devices) > 0 && m.devicePicker.cursor < len(m.devicePicker.devices) { + dev := m.devicePicker.devices[m.devicePicker.cursor] + m.devicePicker.visible = false + return switchDeviceCmd(dev.Name) + } + case "esc", "d": + m.devicePicker.visible = false + } + return nil +} diff --git a/ui/model.go b/ui/model.go index 8fec4985..66d2b070 100644 --- a/ui/model.go +++ b/ui/model.go @@ -185,6 +185,9 @@ type Model struct { // Full-screen visualizer mode (Shift+V) fullVis bool + // Audio device picker overlay + devicePicker devicePickerState + autoPlay bool // start playing immediately on launch compact bool // compact mode: cap frame width at 80 columns @@ -312,7 +315,7 @@ func (m Model) ThemeName() string { // the main player view. When true, the visualizer is not visible and we can // use the slower tick rate. func (m *Model) isOverlayActive() bool { - return m.keymap.visible || m.themePicker.visible || + return m.keymap.visible || m.themePicker.visible || m.devicePicker.visible || m.fileBrowser.visible || m.navBrowser.visible || m.plManager.visible || m.queue.visible || m.showInfo || m.search.active || m.netSearch.active || m.jumping || m.urlInputting @@ -1042,6 +1045,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.provLoading = true return m, fetchPlaylistsCmd(m.provider) + case devicesListedMsg: + m.devicePicker.loading = false + if msg.err != nil { + m.status.text = fmt.Sprintf("Device list failed: %s", msg.err) + m.status.ttl = 60 + m.devicePicker.visible = false + } else { + m.devicePicker.devices = msg.devices + } + return m, nil + + case deviceSwitchedMsg: + if msg.err != nil { + m.status.text = fmt.Sprintf("Switch failed: %s", msg.err) + } else { + m.status.text = fmt.Sprintf("Audio output: %s", msg.name) + // Persist the selection to config. + _ = config.Save("audio_device", fmt.Sprintf("%q", msg.name)) + } + m.status.ttl = 80 + return m, nil + case mpris.InitMsg: m.mpris = msg.Svc return m, nil diff --git a/ui/state.go b/ui/state.go index 24537da3..a3c9a697 100644 --- a/ui/state.go +++ b/ui/state.go @@ -8,6 +8,7 @@ import ( "cliamp/external/navidrome" "cliamp/lyrics" + "cliamp/player" "cliamp/playlist" ) @@ -131,6 +132,14 @@ type reconnectState struct { at time.Time } +// devicePickerState holds state for the audio device picker overlay. +type devicePickerState struct { + visible bool + devices []player.AudioDevice + cursor int + loading bool +} + // statusMsg holds a temporary status message shown at the bottom of the UI. type statusMsg struct { text string diff --git a/ui/view.go b/ui/view.go index 819a9936..2949495a 100644 --- a/ui/view.go +++ b/ui/view.go @@ -37,6 +37,10 @@ func (m Model) View() string { return m.renderThemePicker() } + if m.devicePicker.visible { + return m.renderDeviceOverlay() + } + if m.fileBrowser.visible { return m.renderFileBrowser() } diff --git a/ui/view_overlays.go b/ui/view_overlays.go index 2acddb52..cba8bd20 100644 --- a/ui/view_overlays.go +++ b/ui/view_overlays.go @@ -9,6 +9,52 @@ import ( "cliamp/theme" ) +func (m Model) renderDeviceOverlay() string { + lines := []string{ + titleStyle.Render("A U D I O D E V I C E S"), + "", + } + + if m.devicePicker.loading { + lines = append(lines, dimStyle.Render(" Loading devices...")) + lines = append(lines, "", helpKey("Esc", "Cancel")) + return m.centerOverlay(strings.Join(lines, "\n")) + } + + if len(m.devicePicker.devices) == 0 { + lines = append(lines, dimStyle.Render(" No audio output devices found.")) + lines = append(lines, "", helpKey("Esc", "Close")) + return m.centerOverlay(strings.Join(lines, "\n")) + } + + maxVisible := 12 + scroll := scrollStart(m.devicePicker.cursor, maxVisible) + + for i := scroll; i < len(m.devicePicker.devices) && i < scroll+maxVisible; i++ { + d := m.devicePicker.devices[i] + label := d.Description + if label == "" { + label = d.Name + } + suffix := "" + if d.Active { + suffix = " " + activeToggle.Render("●") + } + if i == m.devicePicker.cursor { + lines = append(lines, playlistSelectedStyle.Render("> "+label)+suffix) + } else { + lines = append(lines, dimStyle.Render(" "+label)+suffix) + } + } + + if len(m.devicePicker.devices) > maxVisible { + lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %d/%d devices", m.devicePicker.cursor+1, len(m.devicePicker.devices)))) + } + + lines = append(lines, "", helpKey("↑↓", "Navigate ")+helpKey("Enter", "Select ")+helpKey("Esc", "Cancel")) + return m.centerOverlay(strings.Join(lines, "\n")) +} + func (m Model) renderKeymapOverlay() string { lines := []string{ titleStyle.Render("K E Y M A P"), From 76f124bc76c09501ee382d578a292800ba80d2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Mon, 30 Mar 2026 18:22:42 +0200 Subject: [PATCH 2/2] Fix device picker cursor wrapping and overlay height consistency --- ui/model/keys.go | 4 ++++ ui/model/view_overlays.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/ui/model/keys.go b/ui/model/keys.go index db648e21..fa1a4c4a 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -1267,10 +1267,14 @@ func (m *Model) handleDeviceKey(msg tea.KeyMsg) tea.Cmd { case "up", "k": if m.devicePicker.cursor > 0 { m.devicePicker.cursor-- + } else if len(m.devicePicker.devices) > 0 { + m.devicePicker.cursor = len(m.devicePicker.devices) - 1 } case "down", "j": if m.devicePicker.cursor < len(m.devicePicker.devices)-1 { m.devicePicker.cursor++ + } else { + m.devicePicker.cursor = 0 } case "enter": if len(m.devicePicker.devices) > 0 && m.devicePicker.cursor < len(m.devicePicker.devices) { diff --git a/ui/model/view_overlays.go b/ui/model/view_overlays.go index 4340f676..9dbb7c41 100644 --- a/ui/model/view_overlays.go +++ b/ui/model/view_overlays.go @@ -30,6 +30,7 @@ func (m Model) renderDeviceOverlay() string { maxVisible := 12 scroll := scrollStart(m.devicePicker.cursor, maxVisible) + rendered := 0 for i := scroll; i < len(m.devicePicker.devices) && i < scroll+maxVisible; i++ { d := m.devicePicker.devices[i] @@ -46,8 +47,11 @@ func (m Model) renderDeviceOverlay() string { } else { lines = append(lines, dimStyle.Render(" "+label)+suffix) } + rendered++ } + lines = padLines(lines, maxVisible, rendered) + if len(m.devicePicker.devices) > maxVisible { lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %d/%d devices", m.devicePicker.cursor+1, len(m.devicePicker.devices)))) }