From 4c0c5f14019da01453f59bc296f262b55723a2ad Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Jul 2026 20:13:23 +0200 Subject: [PATCH] Audio: select the output device (fixes silent output on Raspberry Pi) The player always opened the system default output device. On a Raspberry Pi that default is often HDMI, so playback ran but nothing came out of the headphone jack or a USB/DAC -- "it plays but I hear no sound". Add an [audio] device option to crabidy-server.toml: a case-insensitive substring of the output device name (a memorable fragment is enough). The player engine opens the first matching device and falls back to the system default with a warning if none matches. Absent config keeps the system default, so existing setups are unchanged. To discover the names, a new `crabidy-server audio-devices` subcommand (also on `cbd`) lists the output devices and marks the one the current config selects, using the same match the server applies at startup. Plumbing: audio_player::output_device_names() enumerates via cpal; Player::new(Option) replaces the device-less construction (Default = new(None)); Playback::new takes the device and serve() reads it from settings. cpal's name() is deprecated in favor of description(), but name() returns the ALSA-stable string users see in `aplay -l` and match against, so it is kept behind a documented #[allow(deprecated)]. README documents the [audio] device option under the Pi/config section. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 26 ++++++++++++ audio-player/src/lib.rs | 2 +- audio-player/src/player.rs | 27 ++++++++----- audio-player/src/player_engine.rs | 67 ++++++++++++++++++++++++++++++- cbd-cli/src/lib.rs | 4 ++ cbd/src/main.rs | 1 + crabidy-server/src/cli.rs | 45 +++++++++++++++++++++ crabidy-server/src/lib.rs | 1 + crabidy-server/src/main.rs | 1 + crabidy-server/src/playback.rs | 5 ++- crabidy-server/src/settings.rs | 25 ++++++++++++ 11 files changed, 190 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6f6f0b5..d2cbcf3 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,32 @@ startup rather than silently running open. Note that the transport is plain HTTP/2: fine on a trusted home network, but anything exposed further needs TLS termination (reverse proxy, VPN) in front. +#### Audio output device + +By default the server plays to the system default output device. On a +Raspberry Pi that is often HDMI, so playback runs but you hear nothing on +the headphone jack or a USB/DAC. List the devices the server can see: + +```console +$ crabidy-server audio-devices +Audio output devices (* = selected by the current config): + hdmi:CARD=vc4hdmi,DEV=0 + sysdefault:CARD=Headphones + ... +``` + +Then pin one in `crabidy-server.toml` — the value is matched +case-insensitively as a substring of the name, so a memorable fragment is +enough — and restart the server: + +```toml +[audio] +device = "Headphones" +``` + +If the name matches nothing, the server logs a warning and falls back to +the system default. + ## Command line Every binary is a clap CLI: run it with `--help` (and any subcommand diff --git a/audio-player/src/lib.rs b/audio-player/src/lib.rs index 0b86753..3b0ed52 100644 --- a/audio-player/src/lib.rs +++ b/audio-player/src/lib.rs @@ -4,5 +4,5 @@ mod spectrum_tap; pub mod windowed_http; pub use player::{Player, PlayerError}; -pub use player_engine::{MediaInfo, PlayerMessage}; +pub use player_engine::{output_device_names, MediaInfo, PlayerMessage}; pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW}; diff --git a/audio-player/src/player.rs b/audio-player/src/player.rs index 46acbef..6d9e550 100644 --- a/audio-player/src/player.rs +++ b/audio-player/src/player.rs @@ -22,6 +22,16 @@ pub struct Player { impl Default for Player { fn default() -> Self { + Self::new(None) + } +} + +impl Player { + /// Spawns the player engine on its own thread, sending audio to the + /// output device whose name contains `device` (case-insensitive), or the + /// system default when `device` is `None`. See + /// [`crate::output_device_names`] to discover names. + pub fn new(device: Option) -> Self { let (tx_engine, rx_engine) = flume::bounded(16); let (tx_player, messages): (Sender, Receiver) = flume::bounded(16); @@ -36,13 +46,14 @@ impl Default for Player { let spectrum = SpectrumTap::new(); let engine_tap = spectrum.clone(); thread::spawn(move || { - let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap) { - Err(e) => { - error!("Could not initialize player: {}", e); - return; - } - Ok(engine) => engine, - }; + let engine = + match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap, device) { + Err(e) => { + error!("Could not initialize player: {}", e); + return; + } + Ok(engine) => engine, + }; engine.run(rx_engine); }); @@ -52,9 +63,7 @@ impl Default for Player { spectrum, } } -} -impl Player { /// The spectrum tap the played audio is mirrored into. pub fn spectrum_tap(&self) -> Arc { self.spectrum.clone() diff --git a/audio-player/src/player_engine.rs b/audio-player/src/player_engine.rs index 929660f..84ebe41 100644 --- a/audio-player/src/player_engine.rs +++ b/audio-player/src/player_engine.rs @@ -107,15 +107,78 @@ pub struct PlayerEngine { pre_mute_volume: f32, } +/// The names of the available audio output devices, best-effort (an empty +/// list if the host cannot be queried). Shown by `crabidy-server +/// audio-devices` so a user can pick one for the `[audio] device` config. +// +// `DeviceTrait::name` is deprecated in cpal 0.17 in favor of `description()`, +// but on the ALSA backend (the Raspberry Pi target) `name()` returns the +// stable device string users already see in `aplay -l` and match against; +// `description().name()` is newer and less proven there. Keep `name()` so the +// listing and the server's device matching use the same reliable string. +#[allow(deprecated)] +pub fn output_device_names() -> Vec { + use rodio::cpal::traits::{DeviceTrait, HostTrait}; + match rodio::cpal::default_host().output_devices() { + Ok(devices) => devices.filter_map(|device| device.name().ok()).collect(), + Err(err) => { + warn!("could not enumerate audio output devices: {err}"); + Vec::new() + } + } +} + +/// Opens the audio output. With `preferred` set, picks the first output +/// device whose name contains that string (case-insensitive) — so a user can +/// name a memorable fragment ("Headphones", "USB") instead of the full ALSA +/// string — and falls back to the system default with a warning if none +/// matches (the Pi's default is often HDMI, which is exactly the silent-output +/// case this selection exists to fix). With `preferred` unset, uses the +/// system default. +// `name()` deprecation: see the note on `output_device_names`. +#[allow(deprecated)] +fn open_output_device(preferred: Option<&str>) -> Result { + use rodio::cpal::traits::{DeviceTrait, HostTrait}; + if let Some(wanted) = preferred { + let needle = wanted.to_lowercase(); + let matched = rodio::cpal::default_host() + .output_devices() + .ok() + .and_then(|mut devices| { + devices.find(|device| { + device + .name() + .map(|name| name.to_lowercase().contains(&needle)) + .unwrap_or(false) + }) + }); + match matched { + Some(device) => { + let name = device.name().unwrap_or_else(|_| "?".to_string()); + info!(device = %name, "opening selected audio output device"); + return DeviceSinkBuilder::from_device(device) + .with_context(|| format!("failed to open audio device {name}"))? + .open_stream() + .with_context(|| format!("failed to open a stream on audio device {name}")); + } + None => warn!( + requested = wanted, + "no audio output device name matched; using the system default" + ), + } + } + DeviceSinkBuilder::open_default_sink().context("failed to open audio output device") +} + impl PlayerEngine { pub fn init( tx_engine: Sender, tx_player: Sender, runtime: Option, spectrum: Arc, + device: Option, ) -> Result { - let stream = - DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?; + let stream = open_output_device(device.as_deref())?; let sink = rodio::Player::connect_new(stream.mixer()); let (runtime, owned_runtime) = match runtime { Some(handle) => (handle, None), diff --git a/cbd-cli/src/lib.rs b/cbd-cli/src/lib.rs index baa6c43..9bdb510 100644 --- a/cbd-cli/src/lib.rs +++ b/cbd-cli/src/lib.rs @@ -195,6 +195,8 @@ pub enum ServerCommand { /// Playback / global operations against a running server. #[command(subcommand)] Global(GlobalCmd), + /// List the audio output devices (to pick `[audio] device`). + AudioDevices, /// Print a shell completion script. Completions(CompletionsArgs), } @@ -249,6 +251,8 @@ pub enum CbdCommand { Queue(QueueCmd), #[command(subcommand)] Global(GlobalCmd), + /// List the audio output devices (to pick `[audio] device`). + AudioDevices, /// Print a shell completion script. Completions(CompletionsArgs), } diff --git a/cbd/src/main.rs b/cbd/src/main.rs index 607e888..f3ef302 100644 --- a/cbd/src/main.rs +++ b/cbd/src/main.rs @@ -92,6 +92,7 @@ async fn run_command( match command { CbdCommand::Guard(args) => server_cli::guard(args).await, CbdCommand::Scan(args) => server_cli::scan(args).await, + CbdCommand::AudioDevices => server_cli::audio_devices(), CbdCommand::Auth(args) => { let password = args .password diff --git a/crabidy-server/src/cli.rs b/crabidy-server/src/cli.rs index 1881d4d..59ca454 100644 --- a/crabidy-server/src/cli.rs +++ b/crabidy-server/src/cli.rs @@ -102,6 +102,51 @@ pub async fn guard(args: GuardArgs) -> Result<(), Box> { Ok(()) } +/// `audio-devices`: list the available audio output devices so the user can +/// choose one for `[audio] device` in `crabidy-server.toml`. Marks the device +/// the current config selects, using the same case-insensitive substring match +/// the server applies at startup. Fixes the common Raspberry Pi case where the +/// default device is HDMI and audio plays but is silent on the jack/DAC. +pub fn audio_devices() -> Result<(), Box> { + let dir = config_dir().ok(); + let configured = dir + .as_deref() + .and_then(|dir| ServerSettings::load(dir).ok()) + .and_then(|settings| settings.audio.device); + + let names = audio_player::output_device_names(); + if names.is_empty() { + println!("No audio output devices found."); + return Ok(()); + } + + let needle = configured.as_ref().map(|device| device.to_lowercase()); + println!("Audio output devices (* = selected by the current config):"); + for name in &names { + let selected = needle + .as_ref() + .is_some_and(|needle| name.to_lowercase().contains(needle)); + println!(" {}{name}", if selected { "* " } else { " " }); + } + + match configured { + Some(device) => println!("\n[audio] device = \"{device}\""), + None => { + let path = dir + .map(|dir| { + dir.join(crate::settings::SETTINGS_FILE) + .display() + .to_string() + }) + .unwrap_or_else(|| crate::settings::SETTINGS_FILE.to_string()); + println!( + "\nNo [audio] device set (using the system default). To pin one, add to {path}:\n\n[audio]\ndevice = \"Headphones\" # a name or fragment from the list above" + ); + } + } + Ok(()) +} + /// The outcome of a `scan` walk, for a concise summary and for tests. #[derive(Debug, Default, PartialEq, Eq)] pub struct ScanSummary { diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index bd6d70f..a058486 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -75,6 +75,7 @@ pub async fn serve( update_tx.clone(), orchestrator.provider_tx.clone(), crabidy_store, + server_settings.audio.device.clone(), ); // Reload the persisted current queue before anything can observe or // mutate state; never starts playback. diff --git a/crabidy-server/src/main.rs b/crabidy-server/src/main.rs index 6223b41..d49574d 100644 --- a/crabidy-server/src/main.rs +++ b/crabidy-server/src/main.rs @@ -41,6 +41,7 @@ async fn run_command( match command { ServerCommand::Guard(args) => cli::guard(args).await, ServerCommand::Scan(args) => cli::scan(args).await, + ServerCommand::AudioDevices => cli::audio_devices(), ServerCommand::Library(cmd) => { cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await } diff --git a/crabidy-server/src/playback.rs b/crabidy-server/src/playback.rs index 3391d93..6be80cb 100644 --- a/crabidy-server/src/playback.rs +++ b/crabidy-server/src/playback.rs @@ -40,12 +40,13 @@ impl Playback { update_tx: tokio::sync::broadcast::Sender, provider_tx: flume::Sender, store: Option>, + audio_device: Option, ) -> Self { let (playback_tx, playback_rx) = flume::bounded(64); let queue = Mutex::new(QueueManager::new()); let state = Mutex::new(PlayState::Stopped); let (persist_tx, _) = tokio::sync::watch::channel(None); - let player = Player::default(); + let player = Player::new(audio_device); Self { update_tx, provider_tx, @@ -828,7 +829,7 @@ mod tests { fn playback_with(store: Option>) -> Playback { let (update_tx, _) = tokio::sync::broadcast::channel(64); let (provider_tx, _provider_rx) = flume::bounded(16); - Playback::new(update_tx, provider_tx, store) + Playback::new(update_tx, provider_tx, store, None) } fn fill_queue(playback: &Playback, n: usize) { diff --git a/crabidy-server/src/settings.rs b/crabidy-server/src/settings.rs index b3bfa54..d636f0c 100644 --- a/crabidy-server/src/settings.rs +++ b/crabidy-server/src/settings.rs @@ -34,6 +34,30 @@ pub struct ServerSettings { /// Role credentials; absent (or empty) means the server runs open. #[serde(default, skip_serializing_if = "AuthSettings::is_default")] pub auth: AuthSettings, + /// Audio output selection; absent means the system default device. + #[serde(default, skip_serializing_if = "AudioSettings::is_default")] + pub audio: AudioSettings, +} + +/// Audio output configuration. +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AudioSettings { + /// A substring of the output device's name (case-insensitive) to send + /// audio to. Absent uses the system default device — which on a Raspberry + /// Pi is often HDMI, so audio plays but is silent on the jack/DAC. List + /// the available names with `crabidy-server audio-devices`, then set e.g. + /// `device = "Headphones"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub device: Option, +} + +impl AudioSettings { + /// Whether this is the empty default (no device pinned), so the + /// auto-written config omits the `[audio]` table entirely. + fn is_default(&self) -> bool { + self.device.is_none() + } } /// Which providers to mount, resolved from [`ServerSettings::providers`]. @@ -181,6 +205,7 @@ impl ServerSettings { let settings = ServerSettings { providers: Some(ALL_PROVIDERS.iter().map(|s| s.to_string()).collect()), auth: AuthSettings::default(), + audio: AudioSettings::default(), }; settings.store(config_dir) }