#![cfg(feature = "audio")]
use std::sync::{Arc, Mutex};
use mlxrs::audio::playback::{
AudioOutputStream, ChannelLayout, PlaybackConfig, SampleFormat,
player::{WRITE_CHUNK_MAX, sanitize_volume},
};
struct RecordingSink {
buffer: Arc<Mutex<Vec<f32>>>,
capacity: usize,
running: bool,
}
impl RecordingSink {
fn new(capacity: usize) -> Self {
Self {
buffer: Arc::new(Mutex::new(Vec::new())),
capacity,
running: true,
}
}
}
impl AudioOutputStream for RecordingSink {
fn write_samples(&mut self, samples: &[f32]) -> mlxrs::error::Result<usize> {
if !self.running {
return Err(mlxrs::error::Error::InvariantViolation(
mlxrs::error::InvariantViolationPayload::new(
"RecordingSink::write_samples",
"stream stopped",
),
));
}
let mut buf = self.buffer.lock().unwrap();
let projected = buf.len() + samples.len();
if projected > self.capacity {
return Err(mlxrs::error::Error::CapExceeded(
mlxrs::error::CapExceededPayload::new(
"RecordingSink::write_samples",
"capacity",
self.capacity as u64,
projected as u64,
),
));
}
buf.extend_from_slice(samples);
Ok(samples.len())
}
fn flush(&mut self) -> mlxrs::error::Result<()> {
self.buffer.lock().unwrap().clear();
Ok(())
}
fn stop(&mut self) -> mlxrs::error::Result<()> {
self.running = false;
self.buffer.lock().unwrap().clear();
Ok(())
}
fn is_running(&self) -> bool {
self.running
}
}
#[test]
fn playback_config_default_sample_rate_matches_swift_default() {
let cfg = PlaybackConfig::default();
assert_eq!(cfg.sample_rate(), 24_000);
assert_eq!(cfg.channels(), ChannelLayout::Mono);
assert_eq!(cfg.sample_format(), SampleFormat::F32);
assert_eq!(cfg.buffer_size_frames(), None);
assert_eq!(cfg.queue_capacity_frames(), 96_000);
}
#[test]
fn playback_config_mono_constructor() {
let cfg = PlaybackConfig::mono(48_000);
assert_eq!(cfg.sample_rate(), 48_000);
assert_eq!(cfg.channels(), ChannelLayout::Mono);
assert_eq!(cfg.channels().count(), 1);
assert_eq!(cfg.queue_capacity_frames(), 48_000 * 4);
}
#[test]
fn playback_config_stereo_constructor() {
let cfg = PlaybackConfig::stereo(44_100);
assert_eq!(cfg.channels(), ChannelLayout::Stereo);
assert_eq!(cfg.channels().count(), 2);
assert_eq!(cfg.queue_capacity_frames(), 44_100 * 4);
}
#[test]
fn playback_config_stereo_queue_capacity_is_frames_not_samples() {
let cfg = PlaybackConfig::stereo(48_000);
assert_eq!(cfg.queue_capacity_frames(), 192_000);
let samples = cfg.queue_capacity_frames() * usize::from(cfg.channels().count());
assert_eq!(samples, 384_000);
}
#[test]
fn playback_config_mono_queue_capacity_is_frames_not_samples() {
let cfg = PlaybackConfig::mono(48_000);
assert_eq!(cfg.queue_capacity_frames(), 192_000);
let samples = cfg.queue_capacity_frames() * usize::from(cfg.channels().count());
assert_eq!(samples, 192_000);
}
#[test]
fn channel_layout_count_arbitrary() {
assert_eq!(ChannelLayout::Mono.count(), 1);
assert_eq!(ChannelLayout::Stereo.count(), 2);
assert_eq!(ChannelLayout::Channels(6).count(), 6);
}
#[test]
fn playback_config_cpal_config_rejects_zero_channels() {
let cfg = PlaybackConfig::new(16_000, ChannelLayout::Channels(0), SampleFormat::F32)
.with_queue_capacity_frames(1024);
let err = cfg.cpal_config().unwrap_err();
assert!(
matches!(err, mlxrs::error::Error::InvariantViolation(_)),
"expected InvariantViolation, got {err:?}"
);
assert!(
format!("{err}").contains("channel count"),
"message names channel count: {err}"
);
}
#[test]
fn playback_config_cpal_config_passes_buffer_hint() {
let with_hint = PlaybackConfig::new(16_000, ChannelLayout::Mono, SampleFormat::F32)
.with_buffer_size_frames(256)
.with_queue_capacity_frames(1024);
let cpal_cfg = with_hint.cpal_config().unwrap();
assert_eq!(cpal_cfg.channels, 1);
assert_eq!(cpal_cfg.sample_rate, 16_000);
assert!(matches!(cpal_cfg.buffer_size, cpal::BufferSize::Fixed(256)));
let without_hint = PlaybackConfig::mono(16_000);
let cpal_cfg = without_hint.cpal_config().unwrap();
assert!(matches!(cpal_cfg.buffer_size, cpal::BufferSize::Default));
}
#[test]
fn audio_output_stream_writes_samples_returns_count() {
let mut sink = RecordingSink::new(4096);
let samples = vec![0.5_f32; 1024];
let written = sink.write_samples(&samples).unwrap();
assert_eq!(written, 1024);
assert!(sink.is_running());
}
#[test]
fn audio_output_stream_flush_drains_buffer() {
let mut sink = RecordingSink::new(4096);
sink.write_samples(&[0.1_f32; 256]).unwrap();
assert_eq!(sink.buffer.lock().unwrap().len(), 256);
sink.flush().unwrap();
assert_eq!(sink.buffer.lock().unwrap().len(), 0);
}
#[test]
fn audio_output_stream_stop_marks_not_running_and_rejects_writes() {
let mut sink = RecordingSink::new(4096);
assert!(sink.is_running());
sink.stop().unwrap();
assert!(!sink.is_running());
let err = sink.write_samples(&[0.0_f32; 32]).unwrap_err();
match err {
mlxrs::error::Error::InvariantViolation(p) => {
assert!(
p.requirement().contains("stopped"),
"InvariantViolation requirement must mention stopped state, got: {}",
p.requirement()
);
}
other => panic!("expected InvariantViolation, got {other:?}"),
}
}
#[test]
fn audio_output_stream_overflow_returns_err() {
let mut sink = RecordingSink::new(1024);
sink.write_samples(&[0.0_f32; 512]).unwrap();
sink.write_samples(&[0.0_f32; 512]).unwrap();
let err = sink.write_samples(&[0.0_f32; 1]).unwrap_err();
match err {
mlxrs::error::Error::CapExceeded(p) => {
assert_eq!(p.cap_name(), "capacity", "cap_name must be \"capacity\"");
}
other => panic!("expected CapExceeded, got {other:?}"),
}
}
#[test]
fn audio_player_write_chunk_max_splits_large_writes() {
assert_eq!(
WRITE_CHUNK_MAX, 4096,
"WRITE_CHUNK_MAX is the documented contract value; bumping it changes the audio-callback \
contention envelope (see player.rs ## Concurrency)"
);
let payload = vec![0.0_f32; 10_000];
let chunks: Vec<usize> = payload.chunks(WRITE_CHUNK_MAX).map(<[f32]>::len).collect();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0], 4096);
assert_eq!(chunks[1], 4096);
assert_eq!(chunks[2], 1808);
assert_eq!(chunks.iter().sum::<usize>(), 10_000);
assert!(chunks.iter().all(|&n| n <= WRITE_CHUNK_MAX));
assert_eq!(vec![0.0_f32; 100].chunks(WRITE_CHUNK_MAX).count(), 1);
assert_eq!(
vec![0.0_f32; WRITE_CHUNK_MAX]
.chunks(WRITE_CHUNK_MAX)
.count(),
1
);
assert_eq!(
vec![0.0_f32; WRITE_CHUNK_MAX + 1]
.chunks(WRITE_CHUNK_MAX)
.count(),
2
);
}
#[test]
fn audio_player_rejects_non_f32_sample_format_pre_device() {
let cfg = PlaybackConfig::new(16_000, ChannelLayout::Mono, SampleFormat::I16)
.with_queue_capacity_frames(1024);
assert_eq!(cfg.sample_format(), SampleFormat::I16);
}
#[test]
fn audio_player_queue_capacity_frames_multiplied_by_channels() {
let stereo = PlaybackConfig::new(16_000, ChannelLayout::Stereo, SampleFormat::F32)
.with_queue_capacity_frames(1024);
assert_eq!(
stereo.queue_capacity_frames() * usize::from(stereo.channels().count()),
2048
);
let mono = PlaybackConfig::new(16_000, ChannelLayout::Mono, SampleFormat::F32)
.with_queue_capacity_frames(1024);
assert_eq!(
mono.queue_capacity_frames() * usize::from(mono.channels().count()),
1024
);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_constructs_without_starting_stream() {
use mlxrs::audio::playback::AudioPlayer;
let mut player = AudioPlayer::new(PlaybackConfig::mono(24_000)).unwrap();
assert!(!player.is_running());
assert!(!player.is_paused());
assert_eq!(player.buffer_depth(), 0);
assert_eq!(player.config().sample_rate(), 24_000);
assert!((player.volume() - 1.0).abs() < 1e-6);
let _ = player.stop();
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_starts_and_stops_on_default_device() {
use std::{thread, time::Duration};
use mlxrs::audio::playback::AudioPlayer;
let mut player = AudioPlayer::new(PlaybackConfig::mono(24_000)).unwrap();
player.start().unwrap();
assert!(player.is_running());
let samples = vec![0.0_f32; 24_000 / 4];
player.write_samples(&samples).unwrap();
player.flush().unwrap();
assert_eq!(player.buffer_depth(), 0);
player.pause().unwrap();
assert!(player.is_paused());
assert!(!player.is_running());
player.resume().unwrap();
assert!(player.is_running());
player.stop().unwrap();
assert!(!player.is_running());
thread::sleep(Duration::from_millis(50));
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_buffer_overflow_returns_err() {
use mlxrs::audio::playback::AudioPlayer;
let cfg = PlaybackConfig::new(16_000, ChannelLayout::Mono, SampleFormat::F32)
.with_queue_capacity_frames(1024);
let mut player = AudioPlayer::new(cfg).unwrap();
player.start().unwrap();
player.pause().unwrap();
player.write_samples(&[0.0_f32; 1024]).unwrap();
let err = player.write_samples(&[0.0_f32; 1]).unwrap_err();
match err {
mlxrs::error::Error::CapExceeded(p) => {
assert_eq!(
p.cap_name(),
"queue_capacity_samples",
"cap_name must be queue_capacity_samples"
);
}
other => panic!("expected CapExceeded, got {other:?}"),
}
let _ = player.stop();
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_underrun_emits_silence_no_panic() {
use std::{thread, time::Duration};
use mlxrs::audio::playback::AudioPlayer;
let mut player = AudioPlayer::new(PlaybackConfig::mono(24_000)).unwrap();
player.start().unwrap();
assert!(player.is_running());
thread::sleep(Duration::from_millis(100));
assert!(player.is_running(), "underrun must not stop the player");
player.stop().unwrap();
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_store_volume_clamps_and_persists() {
use mlxrs::audio::playback::AudioPlayer;
let player = AudioPlayer::new(PlaybackConfig::mono(16_000)).unwrap();
assert!((player.volume() - 1.0).abs() < 1e-6);
player.store_volume(0.5);
assert!((player.volume() - 0.5).abs() < 1e-6);
player.store_volume(1.5);
assert!((player.volume() - 1.0).abs() < 1e-6);
player.store_volume(-0.1);
assert!((player.volume() - 0.0).abs() < 1e-6);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_output_stream_rejects_writes_after_stop() {
use mlxrs::audio::playback::AudioPlayer;
let mut player = AudioPlayer::new(PlaybackConfig::mono(16_000)).unwrap();
player.start().unwrap();
assert_eq!(player.write_samples(&[0.0_f32; 128]).unwrap(), 128);
player.stop().unwrap();
assert!(!player.is_running());
let err = player.write_samples(&[0.0_f32; 32]).unwrap_err();
match err {
e @ mlxrs::error::Error::InvariantViolation(_) => {
assert!(
format!("{e}").contains("after stop()"),
"expected `after stop()` in error message, got: {e}"
);
}
other => panic!("expected InvariantViolation error, got {other:?}"),
}
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_start_after_stop_returns_terminated_err() {
use mlxrs::audio::playback::AudioPlayer;
let mut player = AudioPlayer::new(PlaybackConfig::mono(16_000)).unwrap();
player.start().unwrap();
player.stop().unwrap();
let err = player.start().unwrap_err();
match err {
e @ mlxrs::error::Error::InvariantViolation(_) => {
assert!(
format!("{e}").contains("terminated"),
"expected `terminated` in start()-after-stop() error, got: {e}"
);
}
other => panic!("expected InvariantViolation error, got {other:?}"),
}
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_write_samples_after_restart_attempt_still_rejected() {
use mlxrs::audio::playback::AudioPlayer;
let mut player = AudioPlayer::new(PlaybackConfig::mono(16_000)).unwrap();
player.start().unwrap();
player.stop().unwrap();
let _ = player.start();
let err = player.write_samples(&[0.5_f32; 64]).unwrap_err();
match err {
e @ mlxrs::error::Error::InvariantViolation(_) => {
assert!(
format!("{e}").contains("terminated"),
"expected `terminated` in write_samples()-after-restart-attempt error, got: {e}"
);
}
other => panic!("expected InvariantViolation error, got {other:?}"),
}
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_pause_after_stop_returns_terminated_err() {
use mlxrs::audio::playback::AudioPlayer;
let mut player = AudioPlayer::new(PlaybackConfig::mono(16_000)).unwrap();
player.start().unwrap();
player.stop().unwrap();
let err = player.pause().unwrap_err();
match err {
e @ mlxrs::error::Error::InvariantViolation(_) => {
assert!(
format!("{e}").contains("terminated"),
"expected `terminated` in pause()-after-stop() error, got: {e}"
);
}
other => panic!("expected InvariantViolation error, got {other:?}"),
}
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires real default audio output device"]
fn audio_player_resume_after_stop_returns_terminated_err() {
use mlxrs::audio::playback::AudioPlayer;
let mut player = AudioPlayer::new(PlaybackConfig::mono(16_000)).unwrap();
player.start().unwrap();
player.stop().unwrap();
let err = player.resume().unwrap_err();
match err {
e @ mlxrs::error::Error::InvariantViolation(_) => {
assert!(
format!("{e}").contains("terminated"),
"expected `terminated` in resume()-after-stop() error, got: {e}"
);
}
other => panic!("expected InvariantViolation error, got {other:?}"),
}
}
#[test]
fn audio_player_store_volume_sanitizes_nan_to_zero() {
let stored = sanitize_volume(f32::NAN);
assert!(
!stored.is_nan(),
"sanitize_volume(NaN) must NOT return NaN (would produce NaN PCM via sample * volume); \
got {stored}"
);
assert_eq!(stored, 0.0, "NaN volume must sanitize to 0.0");
}
#[test]
fn audio_player_store_volume_sanitizes_infinity_to_zero() {
assert_eq!(
sanitize_volume(f32::INFINITY),
0.0,
"+infinity volume must sanitize to 0.0 (non-finite policy)"
);
assert_eq!(
sanitize_volume(f32::NEG_INFINITY),
0.0,
"-infinity volume must sanitize to 0.0 (non-finite policy)"
);
}
#[test]
fn audio_player_store_volume_clamps_negative_to_zero() {
assert_eq!(sanitize_volume(-0.5), 0.0);
assert_eq!(sanitize_volume(-1.0), 0.0);
assert_eq!(sanitize_volume(-1000.0), 0.0);
}
#[test]
fn audio_player_store_volume_passes_through_in_range() {
assert_eq!(sanitize_volume(0.0), 0.0);
assert_eq!(sanitize_volume(0.5), 0.5);
assert_eq!(sanitize_volume(1.0), 1.0);
}
#[test]
fn audio_player_store_volume_clamps_above_unity() {
assert_eq!(sanitize_volume(1.5), 1.0);
assert_eq!(sanitize_volume(100.0), 1.0);
}