use std::io::{Seek, SeekFrom};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::Duration;
use atome::output::Scheduler;
use crate::engine::SoundSource;
use crate::error::{Error, Result};
pub const LEAD: Duration = Duration::from_millis(750);
const BLOCK_FRAMES: usize = 4096;
pub(crate) struct Soundtrack {
voice: u64,
scheduler: Scheduler<f32>,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl Soundtrack {
pub(crate) fn start(
source: &SoundSource,
scheduler: Scheduler<f32>,
at: Duration,
period: Option<Duration>,
) -> Result<Self> {
open(source)?;
let voice = scheduler.new_voice();
let stop = Arc::new(AtomicBool::new(false));
let thread = {
let (source, scheduler, stop) = (source.clone(), scheduler.clone(), Arc::clone(&stop));
std::thread::Builder::new()
.name("vtome-soundtrack".to_string())
.spawn(move || feed(&source, &scheduler, voice, at, period, &stop))
.map_err(|error| Error::unsupported(format!("no thread for a soundtrack: {error}")))?
};
Ok(Soundtrack {
voice,
scheduler,
stop,
thread: Some(thread),
})
}
}
impl Drop for Soundtrack {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
self.scheduler.cancel(self.voice);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn open(source: &SoundSource) -> Result<Box<dyn atome::import::AudioStream<f32>>> {
match source {
SoundSource::Path(path) => {
let name = path.display().to_string();
let encoding = atome::import::find_type(path).map_err(|error| no_audio(&name, error))?;
atome::import::stream_as::<f32>(path, encoding).map_err(|error| no_audio(&name, error))
}
SoundSource::Reader(reader, name) => {
let mut reader = reader.clone();
reader.seek(SeekFrom::Start(0)).map_err(|error| no_audio(name, error))?;
atome::import::stream_reader::<f32>(Box::new(reader), name).map_err(|error| no_audio(name, error))
}
}
}
fn no_audio(name: &str, error: impl std::fmt::Display) -> Error {
Error::unsupported(format!("{name}: atome cannot play its audio: {error}"))
}
fn feed(
source: &SoundSource,
scheduler: &Scheduler<f32>,
voice: u64,
at: Duration,
period: Option<Duration>,
stop: &AtomicBool,
) {
let channels = usize::from(scheduler.channels().max(1));
let rate = f64::from(scheduler.sample_rate());
let index_at = |time: Duration| (time.as_secs_f64() * rate).round() as usize * channels;
let lead = index_at(LEAD);
let mut pass = 0_u32;
loop {
let start = at + period.map_or(Duration::ZERO, |period| period * pass);
let mut index = index_at(start);
let Ok(mut stream) = open(source) else {
return;
};
let source_channels = usize::from(stream.channels().max(1));
let source_rate = stream.sample_rate();
let mut block = vec![0.0_f32; BLOCK_FRAMES * source_channels];
loop {
if stop.load(Ordering::Relaxed) {
return;
}
let playing = scheduler.clock().frames() as usize * channels;
if index > playing + lead {
std::thread::sleep(Duration::from_millis(10));
continue;
}
let read = match stream.read(&mut block) {
Ok(0) | Err(_) => break,
Ok(read) => read - read % source_channels,
};
let samples = scheduler.align(&block[..read], source_rate, stream.channels());
loop {
if stop.load(Ordering::Relaxed) {
return;
}
match scheduler.schedule(&samples, index, Some(voice)) {
Ok(next) => {
index = next;
break;
}
Err(_) => std::thread::sleep(Duration::from_millis(2)),
}
}
}
match period {
Some(period) if !period.is_zero() => pass += 1,
_ => return,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use atome::output::{OutputClass, OutputType, SampleRate};
const SILENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/bars_h264.mp4");
fn output() -> Option<OutputClass<f32>> {
let device = atome::output::default_device()?;
Some(OutputClass::new(Some(device), OutputType::CoreAudio, 2, SampleRate::Hz48k, Some(512)))
}
#[test]
fn a_film_without_sound_is_refused_up_front() {
let Some(output) = output() else {
eprintln!("skipping: no output device");
return;
};
let result = Soundtrack::start(&SoundSource::Path(SILENT.into()), output.scheduler(), Duration::ZERO, None);
assert!(matches!(result, Err(Error::Unsupported { .. })), "the fixture has no audio");
}
#[test]
fn a_soundtrack_feeds_ahead_and_stops_when_dropped() {
let Some(output) = output() else {
eprintln!("skipping: no output device");
return;
};
let directory = tempfile::TempDir::new().unwrap();
let path = directory.path().join("tone.wav");
let made = std::process::Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi"])
.args(["-i", "sine=frequency=440:sample_rate=44100:d=5"])
.arg(&path)
.status();
if !made.is_ok_and(|status| status.success()) {
eprintln!("skipping: ffmpeg is needed to make a sound file");
return;
}
let soundtrack =
Soundtrack::start(&SoundSource::Path(path.clone()), output.scheduler(), Duration::from_millis(100), None).unwrap();
std::thread::sleep(Duration::from_millis(50));
let stopping = std::time::Instant::now();
drop(soundtrack);
assert!(stopping.elapsed() < Duration::from_millis(500));
}
}