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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String>) -> Self {
|
||||
let (tx_engine, rx_engine) = flume::bounded(16);
|
||||
let (tx_player, messages): (Sender<PlayerMessage>, Receiver<PlayerMessage>) =
|
||||
flume::bounded(16);
|
||||
|
|
@ -36,7 +46,8 @@ 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) {
|
||||
let engine =
|
||||
match PlayerEngine::init(tx_callbacks, tx_player, runtime, engine_tap, device) {
|
||||
Err(e) => {
|
||||
error!("Could not initialize player: {}", e);
|
||||
return;
|
||||
|
|
@ -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<SpectrumTap> {
|
||||
self.spectrum.clone()
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
pub fn init(
|
||||
tx_engine: Sender<PlayerEngineCommand>,
|
||||
tx_player: Sender<PlayerMessage>,
|
||||
runtime: Option<tokio::runtime::Handle>,
|
||||
spectrum: Arc<SpectrumTap>,
|
||||
device: Option<String>,
|
||||
) -> Result<Self> {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -102,6 +102,51 @@ pub async fn guard(args: GuardArgs) -> Result<(), Box<dyn Error>> {
|
|||
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.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct ScanSummary {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,12 +40,13 @@ impl Playback {
|
|||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||
provider_tx: flume::Sender<ProviderMessage>,
|
||||
store: Option<Arc<CrabidyStore>>,
|
||||
audio_device: Option<String>,
|
||||
) -> 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<Arc<CrabidyStore>>) -> 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) {
|
||||
|
|
|
|||
|
|
@ -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<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`].
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue