use std::any::Any;
use std::sync::atomic::{AtomicU64, Ordering};
use std::{error::Error, fmt, sync::Arc};
use crate::config;
use crate::playsync::PlaybackSync;
use crate::songs::Song;
use std::collections::HashMap;
pub mod click_analysis;
pub mod confirmation;
pub mod context;
pub mod cpal;
pub mod crossfade;
pub mod format;
pub mod midi_tempo;
pub mod mixer;
pub mod mock;
pub mod sample_source;
pub mod tempo_guess;
pub use context::PlaybackContext;
pub use cpal::AudioDeviceInfo;
pub use format::{SampleFormat, TargetFormat};
static SOURCE_ID_COUNTER: AtomicU64 = AtomicU64::new(1);
pub fn next_source_id() -> u64 {
SOURCE_ID_COUNTER.fetch_add(1, Ordering::Relaxed)
}
pub type SourceSender = crossbeam_channel::Sender<mixer::ActiveSource>;
#[derive(Debug, thiserror::Error)]
pub enum AudioError {
#[error("Audio device not found: {0}")]
DeviceNotFound(String),
#[error("Audio format mismatch: {0}")]
FormatMismatch(String),
#[error("Audio playback error: {0}")]
Playback(String),
#[error("Audio stream error: {0}")]
Stream(String),
#[error(transparent)]
Other(Box<dyn Error + Send + Sync>),
}
impl From<Box<dyn Error + Send + Sync>> for AudioError {
fn from(e: Box<dyn Error + Send + Sync>) -> Self {
AudioError::Other(e)
}
}
pub trait Device: Any + fmt::Display + std::marker::Send + std::marker::Sync {
fn play_from(
&self,
song: Arc<Song>,
mappings: &HashMap<String, Vec<u16>>,
sync: PlaybackSync,
) -> Result<(), AudioError>;
fn mixer(&self) -> Option<Arc<mixer::AudioMixer>> {
None
}
fn source_sender(&self) -> Option<SourceSender> {
None
}
fn sample_counter(&self) -> Option<Arc<AtomicU64>> {
self.mixer().map(|m| m.sample_counter())
}
fn sample_rate(&self) -> Option<u32> {
self.mixer().map(|m| m.sample_rate())
}
#[cfg(test)]
fn to_mock(&self) -> Result<Arc<mock::Device>, AudioError>;
}
pub(crate) fn find_input_device(name: &str) -> Result<::cpal::Device, AudioError> {
use ::cpal::traits::{DeviceTrait, HostTrait};
for host_id in ::cpal::available_hosts() {
let host =
::cpal::host_from_id(host_id).map_err(|e| AudioError::Playback(e.to_string()))?;
let devices = match host.input_devices() {
Ok(d) => d,
Err(e) => {
tracing::warn!(
host = host_id.name(),
error = %e,
"Failed to list input devices for host"
);
continue;
}
};
for device in devices {
let device_id = match device.id() {
Ok(id) => id.to_string(),
Err(_) => continue,
};
if device_id.trim() == name.trim() {
return Ok(device);
}
}
}
Err(AudioError::DeviceNotFound(name.to_string()))
}
pub fn list_device_info() -> Result<Vec<AudioDeviceInfo>, AudioError> {
cpal::list_device_info().map_err(|e| AudioError::Playback(e.to_string()))
}
pub fn list_devices() -> Result<Vec<Box<dyn Device>>, AudioError> {
cpal::Device::list().map_err(|e| AudioError::Playback(e.to_string()))
}
pub fn get_device(config: Option<config::Audio>) -> Result<Arc<dyn Device>, AudioError> {
let config = match config {
Some(config) => config,
None => {
return Err(AudioError::DeviceNotFound(
"there must be an audio device specified".to_string(),
))
}
};
let device = config.device();
if device.starts_with("mock") {
return Ok(Arc::new(mock::Device::get(device)));
};
Ok(Arc::new(
cpal::Device::get(config).map_err(|e| AudioError::Playback(e.to_string()))?,
))
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn get_device_none_returns_error() {
let result = get_device(None);
match result {
Err(AudioError::DeviceNotFound(msg)) => {
assert!(msg.contains("audio device specified"))
}
Err(e) => panic!("expected DeviceNotFound, got: {}", e),
Ok(_) => panic!("expected error for None config"),
}
}
#[test]
fn get_device_mock_returns_ok() {
let config = config::Audio::new("mock-device");
let result = get_device(Some(config));
assert!(result.is_ok());
}
#[test]
fn default_mixer_returns_none() {
struct Dummy;
impl fmt::Display for Dummy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "dummy")
}
}
impl Device for Dummy {
fn play_from(
&self,
_song: Arc<Song>,
_mappings: &HashMap<String, Vec<u16>>,
_sync: PlaybackSync,
) -> Result<(), AudioError> {
Ok(())
}
fn to_mock(&self) -> Result<Arc<mock::Device>, AudioError> {
Err(AudioError::Other("not a mock".into()))
}
}
let d = Dummy;
assert!(d.mixer().is_none());
assert!(d.source_sender().is_none());
assert!(d.sample_counter().is_none());
assert!(d.sample_rate().is_none());
}
#[test]
fn next_source_id_increments() {
let id1 = next_source_id();
let id2 = next_source_id();
assert!(id2 > id1);
}
#[test]
fn get_device_mock_prefix_variants() {
for name in &["mock", "mock-test", "mock_custom", "mockDevice"] {
let config = config::Audio::new(name);
let result = get_device(Some(config));
assert!(result.is_ok(), "mock device '{}' should succeed", name);
let device = result.unwrap();
let display = format!("{}", device);
assert!(
display.contains("Mock"),
"device '{}' display should contain Mock: {}",
name,
display
);
}
}
#[test]
fn get_device_display_shows_name() {
let config = config::Audio::new("mock-hello");
let device = get_device(Some(config)).unwrap();
let display = format!("{}", device);
assert!(display.contains("mock-hello"));
}
#[test]
fn mock_device_clock_methods_return_none() {
let device = mock::Device::get("mock-test");
let d: &dyn Device = &device;
assert!(d.sample_counter().is_none());
assert!(d.sample_rate().is_none());
}
#[test]
fn mock_device_to_mock() {
let config = config::Audio::new("mock-test");
let device = get_device(Some(config)).unwrap();
let mock = device
.to_mock()
.expect("to_mock should work on mock devices");
assert_eq!(format!("{}", mock), "mock-test (Mock)");
}
#[test]
fn source_id_is_unique_across_calls() {
let ids: Vec<u64> = (0..100).map(|_| next_source_id()).collect();
for i in 1..ids.len() {
assert!(
ids[i] > ids[i - 1],
"IDs should be monotonically increasing"
);
}
}
#[test]
fn audio_error_device_not_found() {
let e = AudioError::DeviceNotFound("test-device".to_string());
assert!(e.to_string().contains("test-device"));
}
#[test]
fn audio_error_from_boxed() {
let boxed: Box<dyn Error + Send + Sync> = "something failed".into();
let e: AudioError = boxed.into();
assert!(e.to_string().contains("something failed"));
}
}