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