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<String>) 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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-23 20:13:23 +02:00
parent 33fd3b227c
commit 4c0c5f1401
11 changed files with 190 additions and 14 deletions

View File

@ -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 plain HTTP/2: fine on a trusted home network, but anything exposed
further needs TLS termination (reverse proxy, VPN) in front. 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 ## Command line
Every binary is a clap CLI: run it with `--help` (and any subcommand Every binary is a clap CLI: run it with `--help` (and any subcommand

View File

@ -4,5 +4,5 @@ mod spectrum_tap;
pub mod windowed_http; pub mod windowed_http;
pub use player::{Player, PlayerError}; 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}; pub use spectrum_tap::{SpectrumTap, SPECTRUM_WINDOW};

View File

@ -22,6 +22,16 @@ pub struct Player {
impl Default for Player { impl Default for Player {
fn default() -> Self { 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<String>) -> Self {
let (tx_engine, rx_engine) = flume::bounded(16); let (tx_engine, rx_engine) = flume::bounded(16);
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) = let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
flume::bounded(16); flume::bounded(16);
@ -36,13 +46,14 @@ impl Default for Player {
let spectrum = SpectrumTap::new(); let spectrum = SpectrumTap::new();
let engine_tap = spectrum.clone(); let engine_tap = spectrum.clone();
thread::spawn(move || { thread::spawn(move || {
let engine = match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap) { let engine =
Err(e) => { match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap, device) {
error!("Could not initialize player: {}", e); Err(e) => {
return; error!("Could not initialize player: {}", e);
} return;
Ok(engine) => engine, }
}; Ok(engine) => engine,
};
engine.run(rx_engine); engine.run(rx_engine);
}); });
@ -52,9 +63,7 @@ impl Default for Player {
spectrum, spectrum,
} }
} }
}
impl Player {
/// The spectrum tap the played audio is mirrored into. /// The spectrum tap the played audio is mirrored into.
pub fn spectrum_tap(&self) -> Arc<SpectrumTap> { pub fn spectrum_tap(&self) -> Arc<SpectrumTap> {
self.spectrum.clone() self.spectrum.clone()

View File

@ -107,15 +107,78 @@ pub struct PlayerEngine {
pre_mute_volume: f32, 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<String> {
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<MixerDeviceSink> {
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 { impl PlayerEngine {
pub fn init( pub fn init(
tx_engine: Sender<PlayerEngineCommand>, tx_engine: Sender<PlayerEngineCommand>,
tx_player: Sender<PlayerMessage>, tx_player: Sender<PlayerMessage>,
runtime: Option<tokio::runtime::Handle>, runtime: Option<tokio::runtime::Handle>,
spectrum: Arc<SpectrumTap>, spectrum: Arc<SpectrumTap>,
device: Option<String>,
) -> Result<Self> { ) -> Result<Self> {
let stream = let stream = open_output_device(device.as_deref())?;
DeviceSinkBuilder::open_default_sink().context("failed to open audio output device")?;
let sink = rodio::Player::connect_new(stream.mixer()); let sink = rodio::Player::connect_new(stream.mixer());
let (runtime, owned_runtime) = match runtime { let (runtime, owned_runtime) = match runtime {
Some(handle) => (handle, None), Some(handle) => (handle, None),

View File

@ -195,6 +195,8 @@ pub enum ServerCommand {
/// Playback / global operations against a running server. /// Playback / global operations against a running server.
#[command(subcommand)] #[command(subcommand)]
Global(GlobalCmd), Global(GlobalCmd),
/// List the audio output devices (to pick `[audio] device`).
AudioDevices,
/// Print a shell completion script. /// Print a shell completion script.
Completions(CompletionsArgs), Completions(CompletionsArgs),
} }
@ -249,6 +251,8 @@ pub enum CbdCommand {
Queue(QueueCmd), Queue(QueueCmd),
#[command(subcommand)] #[command(subcommand)]
Global(GlobalCmd), Global(GlobalCmd),
/// List the audio output devices (to pick `[audio] device`).
AudioDevices,
/// Print a shell completion script. /// Print a shell completion script.
Completions(CompletionsArgs), Completions(CompletionsArgs),
} }

View File

@ -92,6 +92,7 @@ async fn run_command(
match command { match command {
CbdCommand::Guard(args) => server_cli::guard(args).await, CbdCommand::Guard(args) => server_cli::guard(args).await,
CbdCommand::Scan(args) => server_cli::scan(args).await, CbdCommand::Scan(args) => server_cli::scan(args).await,
CbdCommand::AudioDevices => server_cli::audio_devices(),
CbdCommand::Auth(args) => { CbdCommand::Auth(args) => {
let password = args let password = args
.password .password

View File

@ -102,6 +102,51 @@ pub async fn guard(args: GuardArgs) -> Result<(), Box<dyn Error>> {
Ok(()) 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<dyn Error>> {
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. /// The outcome of a `scan` walk, for a concise summary and for tests.
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
pub struct ScanSummary { pub struct ScanSummary {

View File

@ -75,6 +75,7 @@ pub async fn serve(
update_tx.clone(), update_tx.clone(),
orchestrator.provider_tx.clone(), orchestrator.provider_tx.clone(),
crabidy_store, crabidy_store,
server_settings.audio.device.clone(),
); );
// Reload the persisted current queue before anything can observe or // Reload the persisted current queue before anything can observe or
// mutate state; never starts playback. // mutate state; never starts playback.

View File

@ -41,6 +41,7 @@ async fn run_command(
match command { match command {
ServerCommand::Guard(args) => cli::guard(args).await, ServerCommand::Guard(args) => cli::guard(args).await,
ServerCommand::Scan(args) => cli::scan(args).await, ServerCommand::Scan(args) => cli::scan(args).await,
ServerCommand::AudioDevices => cli::audio_devices(),
ServerCommand::Library(cmd) => { ServerCommand::Library(cmd) => {
cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await cbd_cli::run_remote(&cli::connection(remote), RemoteCmd::Library(cmd)).await
} }

View File

@ -40,12 +40,13 @@ impl Playback {
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>, update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
provider_tx: flume::Sender<ProviderMessage>, provider_tx: flume::Sender<ProviderMessage>,
store: Option<Arc<CrabidyStore>>, store: Option<Arc<CrabidyStore>>,
audio_device: Option<String>,
) -> Self { ) -> Self {
let (playback_tx, playback_rx) = flume::bounded(64); let (playback_tx, playback_rx) = flume::bounded(64);
let queue = Mutex::new(QueueManager::new()); let queue = Mutex::new(QueueManager::new());
let state = Mutex::new(PlayState::Stopped); let state = Mutex::new(PlayState::Stopped);
let (persist_tx, _) = tokio::sync::watch::channel(None); let (persist_tx, _) = tokio::sync::watch::channel(None);
let player = Player::default(); let player = Player::new(audio_device);
Self { Self {
update_tx, update_tx,
provider_tx, provider_tx,
@ -828,7 +829,7 @@ mod tests {
fn playback_with(store: Option<Arc<CrabidyStore>>) -> Playback { fn playback_with(store: Option<Arc<CrabidyStore>>) -> Playback {
let (update_tx, _) = tokio::sync::broadcast::channel(64); let (update_tx, _) = tokio::sync::broadcast::channel(64);
let (provider_tx, _provider_rx) = flume::bounded(16); 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) { fn fill_queue(playback: &Playback, n: usize) {

View File

@ -34,6 +34,30 @@ pub struct ServerSettings {
/// Role credentials; absent (or empty) means the server runs open. /// Role credentials; absent (or empty) means the server runs open.
#[serde(default, skip_serializing_if = "AuthSettings::is_default")] #[serde(default, skip_serializing_if = "AuthSettings::is_default")]
pub auth: AuthSettings, 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<String>,
}
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`]. /// Which providers to mount, resolved from [`ServerSettings::providers`].
@ -181,6 +205,7 @@ impl ServerSettings {
let settings = ServerSettings { let settings = ServerSettings {
providers: Some(ALL_PROVIDERS.iter().map(|s| s.to_string()).collect()), providers: Some(ALL_PROVIDERS.iter().map(|s| s.to_string()).collect()),
auth: AuthSettings::default(), auth: AuthSettings::default(),
audio: AudioSettings::default(),
}; };
settings.store(config_dir) settings.store(config_dir)
} }