Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ type Config struct {
Compact bool // compact mode: cap frame width at 80 columns
PaddingH int // horizontal padding for the UI frame (default 3)
PaddingV int // vertical padding for the UI frame (default 1)
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
Expand Down Expand Up @@ -354,6 +355,8 @@ func Load() (Config, error) {
}
case "compact":
cfg.Compact = val == "true"
case "audio_device":
cfg.AudioDevice = strings.Trim(val, `"'`)
case "padding_horizontal":
if v, err := strconv.Atoi(val); err == nil {
cfg.PaddingH = v
Expand Down
27 changes: 26 additions & 1 deletion config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -68,6 +69,9 @@ func (o Overrides) Apply(cfg *Config) {
if o.Play != nil {
cfg.AutoPlay = *o.Play
}
if o.AudioDevice != nil {
cfg.AudioDevice = *o.AudioDevice
}
cfg.clamp()
}

Expand All @@ -76,7 +80,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)
}

// Subcommand: cliamp plugins [list|install|remove] [args...]
if len(args) > 0 && args[0] == "plugins" {
if len(args) == 1 {
Expand Down Expand Up @@ -189,6 +205,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)
Expand Down
25 changes: 25 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,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.
Expand Down Expand Up @@ -338,6 +344,7 @@ Audio engine:
--buffer-ms <ms> Speaker buffer in milliseconds (50–500)
--resample-quality <n> Resample quality factor (1–4)
--bit-depth <n> PCM bit depth: 16 (default) or 32 (lossless)
--audio-device <name> Audio output device (use --audio-device=list to show available devices)

Provider:
--provider <name> Default provider: radio, navidrome, plex, jellyfin, spotify, yt, youtube, ytmusic (default: radio)
Expand Down Expand Up @@ -429,6 +436,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
case "plugins":
fmt.Println(pluginsHelpText)
return
Expand Down
9 changes: 9 additions & 0 deletions player/audio_device.go
Original file line number Diff line number Diff line change
@@ -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
}
102 changes: 102 additions & 0 deletions player/audio_device_linux.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading