use std::io::Cursor;
use std::num::NonZero;
use anyhow::{Context, Result};
use rodio::{DeviceSinkBuilder, MixerDeviceSink, Player};
use super::AudioClip;
pub struct Playback {
_sink: MixerDeviceSink,
player: Player,
}
impl Playback {
pub fn open() -> Result<Self> {
let mut sink =
DeviceSinkBuilder::open_default_sink().context("failed to open the audio device")?;
sink.log_on_drop(false);
let player = Player::connect_new(sink.mixer());
Ok(Self {
_sink: sink,
player,
})
}
pub fn enqueue(&self, clip: AudioClip) -> Result<()> {
match clip {
AudioClip::Pcm {
sample_rate,
channels,
bytes,
} => {
let samples = pcm_s16le_to_f32(&bytes);
if samples.is_empty() {
return Ok(());
}
let rate = NonZero::new(sample_rate).context("zero sample rate")?;
let ch = NonZero::new(channels).context("zero channel count")?;
self.player
.append(rodio::buffer::SamplesBuffer::new(ch, rate, samples));
}
AudioClip::Encoded(bytes) => {
if bytes.is_empty() {
return Ok(());
}
let decoder = rodio::Decoder::new(Cursor::new(bytes))
.context("failed to decode audio from the TTS server")?;
self.player.append(decoder);
}
}
Ok(())
}
pub fn queued(&self) -> usize {
self.player.len()
}
pub fn is_drained(&self) -> bool {
self.player.empty()
}
pub fn stop(&self) {
self.player.clear();
}
pub fn pause(&self) {
self.player.pause();
}
pub fn resume(&self) {
self.player.play();
}
}
fn pcm_s16le_to_f32(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(2)
.map(|b| i16::from_le_bytes([b[0], b[1]]) as f32 / 32768.0)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pcm_conversion_scales_and_drops_odd_tail() {
let bytes = [0x00, 0x00, 0x00, 0x80, 0x00, 0x40, 0x7f];
let samples = pcm_s16le_to_f32(&bytes);
assert_eq!(samples, vec![0.0, -1.0, 0.5]);
assert!(pcm_s16le_to_f32(&[]).is_empty());
assert!(pcm_s16le_to_f32(&[0x01]).is_empty());
}
#[test]
#[ignore = "requires a sound card (a short tone is audible)"]
fn plays_generated_tone_live() {
let rate = 24_000u32;
let secs = 0.4f32;
let samples = (rate as f32 * secs) as usize;
let mut bytes = Vec::with_capacity(samples * 2);
for i in 0..samples {
let t = i as f32 / rate as f32;
let amp = (t * 440.0 * std::f32::consts::TAU).sin() * 0.25;
bytes.extend_from_slice(&((amp * i16::MAX as f32) as i16).to_le_bytes());
}
let Ok(playback) = Playback::open() else {
eprintln!("skip: no audio device available");
return;
};
let started = std::time::Instant::now();
playback
.enqueue(AudioClip::Pcm {
sample_rate: rate,
channels: 1,
bytes,
})
.expect("PCM should be accepted by the queue");
assert_eq!(playback.queued(), 1, "the clip is queued");
while !playback.is_drained() && started.elapsed() < std::time::Duration::from_secs(5) {
std::thread::sleep(std::time::Duration::from_millis(20));
}
let elapsed = started.elapsed();
assert!(playback.is_drained(), "the queue should drain: {elapsed:?}");
assert!(
elapsed >= std::time::Duration::from_millis(300),
"the clip played too fast ({elapsed:?}) — it likely went nowhere"
);
eprintln!("440 Hz tone played in {elapsed:?} (expected ~{secs}s)");
}
#[test]
fn open_degrades_gracefully_without_audio_device() {
match Playback::open() {
Ok(p) => {
assert!(p.is_drained(), "a fresh queue is empty");
assert_eq!(p.queued(), 0);
p.enqueue(AudioClip::Encoded(Vec::new())).unwrap();
p.stop();
}
Err(err) => {
let msg = err.to_string();
assert!(!msg.is_empty(), "the error should be explainable");
}
}
}
}