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:
parent
33fd3b227c
commit
4c0c5f1401
26
README.md
26
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue