Skip to main content

aria_kit/audio/capture/
wav_replay.rs

1//! WAV-replay capture for unattended benchmarking and testing.
2//!
3//! Replays one fixed recording instead of opening the microphone. `new`
4//! validates the file and reads it into memory, so every recording replays
5//! the same audio and a bad fixture fails before the first one.
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::thread;
10use std::time::{Duration, Instant};
11
12use crate::audio::StopSignal;
13use crate::audio::recording;
14use crate::audio::spool::{WavSpool, maximum_samples};
15use crate::audio::wav::{
16    WavFileReader, check_declared_length, collect_samples, open_canonical_wav,
17};
18use crate::audio::{RecordedAudio, RecordingReader, canonical_samples};
19use crate::primitives::file_inspection::{InspectedFile, InspectionError};
20
21use super::RecordingMode;
22use super::{AudioCapture, CaptureError};
23
24type Result<T> = std::result::Result<T, WavReplayError>;
25
26/// Short enough that a reader is never far behind the fixture's clock.
27const PACED_CHUNK: Duration = Duration::from_millis(100);
28const CHUNK_SAMPLES: usize = canonical_samples(PACED_CHUNK) as usize;
29
30/// How a WAV replay delivers its samples. `Immediate` writes the whole file
31/// before `start` returns. `Realtime` writes it at recorded speed, so
32/// transcription overlaps capture as on a real press.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ReplayPace {
35    Immediate,
36    Realtime,
37}
38
39#[derive(Debug)]
40pub struct WavReplayCapture {
41    spool_dir: PathBuf,
42    maximum_duration: Duration,
43    pace: ReplayPace,
44    samples: Arc<Vec<i16>>,
45}
46
47impl WavReplayCapture {
48    pub fn new(
49        spool_dir: impl Into<PathBuf>,
50        maximum_duration: Duration,
51        wav_path: &Path,
52        pace: ReplayPace,
53    ) -> std::result::Result<Self, CaptureError> {
54        let samples = read_replay_samples(wav_path, maximum_duration)?;
55        Ok(Self {
56            spool_dir: spool_dir.into(),
57            maximum_duration,
58            pace,
59            samples: Arc::new(samples),
60        })
61    }
62}
63
64impl AudioCapture for WavReplayCapture {
65    /// Replays the WAV into a new recording. A `Realtime` replay ends early
66    /// when `stop` fires. `mode` is ignored: a fixed file has no silence to
67    /// stop on.
68    fn start(
69        &self,
70        _mode: RecordingMode,
71        stop: StopSignal,
72    ) -> std::result::Result<RecordingReader, CaptureError> {
73        let mut recording = match self.pace {
74            // A failure to spool the fixture fails `start`. It is not handed
75            // to readers as a recording that ended in error.
76            ReplayPace::Immediate => {
77                RecordingReader::from_pcm16_in(&self.spool_dir, &self.samples)?
78            }
79            ReplayPace::Realtime => {
80                let (recording, writer) = recording::ends();
81                let spool = WavSpool::create(&self.spool_dir, self.maximum_duration, &writer)?;
82                let samples = Arc::clone(&self.samples);
83                writer.begin();
84                thread::Builder::new()
85                    .name("aria-wav-replay".to_owned())
86                    .spawn(move || {
87                        let result = replay_paced(spool, &samples, &stop);
88                        // Drop `stop` before closing the writer, so state it
89                        // owns is released before a reader sees capture end.
90                        drop(stop);
91                        writer.close(result);
92                    })
93                    .map_err(WavReplayError::SpawnReplay)?;
94                recording
95            }
96        };
97        recording.wait_for_capture()?;
98        Ok(recording)
99    }
100}
101
102impl WavReplayCapture {
103    /// Runs the checks `new` runs on a fixture, without reading its samples.
104    /// Returns the validated path.
105    pub fn check(
106        wav_path: &Path,
107        maximum_duration: Duration,
108    ) -> std::result::Result<PathBuf, CaptureError> {
109        checked_reader(wav_path, maximum_duration).map(|(path, _unread)| path)
110    }
111}
112
113/// Writes the samples to `spool` at recorded speed until they run out or
114/// `stop` fires. Each chunk is due at a fixed offset from the start, so one
115/// slow write does not delay every later chunk.
116fn replay_paced(
117    mut spool: WavSpool,
118    samples: &[i16],
119    stop: &StopSignal,
120) -> std::result::Result<RecordedAudio, CaptureError> {
121    let started = Instant::now();
122    for (index, batch) in samples.chunks(CHUNK_SAMPLES).enumerate() {
123        let due = started + PACED_CHUNK * index as u32;
124        if let Some(wait) = due.checked_duration_since(Instant::now()) {
125            thread::sleep(wait);
126        }
127        spool.write_samples(batch)?;
128        if stop.should_stop() {
129            break;
130        }
131    }
132    Ok(spool.finish()?)
133}
134
135#[derive(Debug, thiserror::Error)]
136pub enum WavReplayError {
137    #[error("the capture WAV file cannot be used")]
138    UnusableFile(#[source] InspectionError),
139
140    #[error("failed to start the paced replay thread")]
141    SpawnReplay(#[source] std::io::Error),
142}
143
144/// Rejects fixtures another user could have planted or tampered with — the
145/// same trust policy model files get.
146fn validate_replay_file(path: &Path) -> Result<PathBuf> {
147    InspectedFile::inspect(path.to_path_buf())
148        .and_then(InspectedFile::require_regular_file)
149        .and_then(InspectedFile::require_trusted)
150        .map_err(WavReplayError::UnusableFile)
151}
152
153fn read_replay_samples(
154    path: &Path,
155    maximum_duration: Duration,
156) -> std::result::Result<Vec<i16>, CaptureError> {
157    let (path, reader) = checked_reader(path, maximum_duration)?;
158    Ok(collect_samples(reader, &path)?)
159}
160
161/// Validates the fixture and returns the reader the checks ran on. Samples
162/// are read from this same reader, so a file swapped in after the length
163/// check is never read.
164fn checked_reader(
165    path: &Path,
166    maximum_duration: Duration,
167) -> std::result::Result<(PathBuf, WavFileReader), CaptureError> {
168    maximum_samples(maximum_duration)?;
169    let path = validate_replay_file(path)?;
170    let reader = open_canonical_wav(&path)?;
171    check_declared_length(&reader, &path, maximum_duration)?;
172    Ok((path, reader))
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::audio::WavError;
179    use crate::audio::testing::{FIXTURE_SAMPLES, canonical_fixture, write_fixture};
180    use crate::audio::wav::wav_spec;
181
182    const MAXIMUM_DURATION: Duration = Duration::from_secs(10);
183    const PACE: ReplayPace = ReplayPace::Immediate;
184
185    #[test]
186    fn replays_the_fixture_as_recorded_audio() {
187        let directory = tempfile::tempdir().expect("tempdir must be creatable");
188        let fixture = canonical_fixture(directory.path());
189        let capture = WavReplayCapture::new(directory.path(), MAXIMUM_DURATION, &fixture, PACE)
190            .expect("fixture must load");
191        let mut recording = capture
192            .start(RecordingMode::UntilStopped, StopSignal::never())
193            .expect("replay must succeed");
194        let audio = recording
195            .finished_audio()
196            .expect("the replay already ended");
197        assert_eq!(audio.duration(), Duration::from_secs(1));
198    }
199
200    /// The point of a paced replay: a reader sees the opening while the rest
201    /// is still arriving.
202    #[test]
203    fn a_paced_replay_arrives_while_the_reader_waits() {
204        let directory = tempfile::tempdir().expect("tempdir must be creatable");
205        let fixture = canonical_fixture(directory.path());
206        let capture = WavReplayCapture::new(
207            directory.path(),
208            MAXIMUM_DURATION,
209            &fixture,
210            ReplayPace::Realtime,
211        )
212        .expect("fixture must load");
213
214        let mut recording = capture
215            .start(RecordingMode::UntilStopped, StopSignal::never())
216            .expect("replay must start");
217        recording
218            .fill_through(CHUNK_SAMPLES as u64)
219            .expect("the first chunk arrives");
220        let arriving = recording.reached_end();
221        recording.fill().expect("the rest arrives");
222
223        assert!(!arriving, "the replay must not be over after one chunk");
224        assert_eq!(recording.samples().len(), FIXTURE_SAMPLES);
225    }
226
227    #[test]
228    fn rejects_a_non_canonical_sample_rate() {
229        let directory = tempfile::tempdir().expect("tempdir must be creatable");
230        let mut spec = wav_spec();
231        spec.sample_rate = 44_100;
232        let fixture = write_fixture(directory.path(), spec, FIXTURE_SAMPLES);
233        let error = WavReplayCapture::new(directory.path(), MAXIMUM_DURATION, &fixture, PACE)
234            .expect_err("a 44.1 kHz fixture must be rejected");
235        assert!(matches!(
236            error,
237            CaptureError::Wav(WavError::UnsupportedFormat { .. })
238        ));
239    }
240
241    #[test]
242    fn rejects_a_fixture_writable_by_other_users() {
243        use std::os::unix::fs::PermissionsExt;
244
245        let directory = tempfile::tempdir().expect("tempdir must be creatable");
246        let fixture = canonical_fixture(directory.path());
247        std::fs::set_permissions(&fixture, std::fs::Permissions::from_mode(0o666))
248            .expect("fixture permissions must be settable");
249        let error = WavReplayCapture::new(directory.path(), MAXIMUM_DURATION, &fixture, PACE)
250            .expect_err("a group/other-writable fixture must be rejected");
251        assert!(matches!(
252            error,
253            CaptureError::WavReplay(WavReplayError::UnusableFile(
254                InspectionError::WritableByOthers { .. }
255            ))
256        ));
257    }
258
259    #[test]
260    fn rejects_a_relative_path() {
261        let error = WavReplayCapture::new(
262            "recordings",
263            MAXIMUM_DURATION,
264            Path::new("fixture.wav"),
265            PACE,
266        )
267        .expect_err("a relative path must be rejected");
268        assert!(matches!(
269            error,
270            CaptureError::WavReplay(WavReplayError::UnusableFile(
271                InspectionError::NotAbsolute { .. }
272            ))
273        ));
274    }
275
276    #[test]
277    fn rejects_a_fixture_longer_than_the_recording_timeout() {
278        let directory = tempfile::tempdir().expect("tempdir must be creatable");
279        let fixture = write_fixture(directory.path(), wav_spec(), 2 * FIXTURE_SAMPLES);
280        let error = WavReplayCapture::new(directory.path(), Duration::from_secs(1), &fixture, PACE)
281            .expect_err("an over-long fixture must be rejected");
282        assert!(matches!(error, CaptureError::Wav(WavError::TooLong { .. })));
283    }
284}