Skip to main content

aria_kit/audio/
spool.rs

1//! The temporary WAV that capture writes, and cleanup of ones left behind.
2
3use std::ffi::OsStr;
4use std::fs::{self, File};
5use std::io::BufWriter;
6use std::path::Path;
7use std::sync::Arc;
8use std::time::Duration;
9
10use tempfile::{Builder, TempPath};
11
12use super::recording::{RecordedAudio, Recording, RecordingWriter};
13use super::wav::wav_spec;
14use super::{CANONICAL_SAMPLE_RATE, MAX_RECORDING_DURATION, canonical_samples};
15
16const FILE_PREFIX: &str = "aria-recording-";
17const FILE_SUFFIX: &str = ".wav";
18const MAX_CLEANUP_ENTRIES: usize = 256;
19const MIN_RECORDING_DURATION: Duration =
20    Duration::from_nanos(1_000_000_000 / CANONICAL_SAMPLE_RATE as u64);
21
22type WavWriter = hound::WavWriter<BufWriter<File>>;
23type Result<T> = std::result::Result<T, SpoolError>;
24
25/// An unfinished, bounded 16 kHz mono PCM WAV.
26///
27/// Every sample reaches the [`Recording`] in the order the WAV gets it, so a
28/// reader following from the start and a reader opening the finished file
29/// see the same audio.
30pub(super) struct WavSpool {
31    writer: WavWriter,
32    recording: Arc<Recording>,
33    path: TempPath,
34    samples_written: u64,
35    maximum_samples: u64,
36}
37
38impl WavSpool {
39    /// Creates a private temporary WAV inside `directory`.
40    pub(super) fn create(
41        directory: impl AsRef<Path>,
42        maximum_duration: Duration,
43        writer: &RecordingWriter,
44    ) -> Result<Self> {
45        let maximum_samples = maximum_samples(maximum_duration)?;
46        let directory = directory.as_ref();
47        let prefix = format!("{FILE_PREFIX}{}-", std::process::id());
48        let temporary = Builder::new()
49            .prefix(&prefix)
50            .suffix(FILE_SUFFIX)
51            .tempfile_in(directory)
52            .map_err(|source| SpoolError::CreateTemporary {
53                directory: directory.to_path_buf(),
54                source,
55            })?;
56        let (file, path) = temporary.into_parts();
57        let wav = hound::WavWriter::new(BufWriter::new(file), wav_spec())
58            .map_err(|source| SpoolError::Initialize(Box::new(source)))?;
59
60        Ok(Self {
61            writer: wav,
62            recording: writer.recording(),
63            path,
64            samples_written: 0,
65            maximum_samples,
66        })
67    }
68
69    /// Appends samples to the WAV and the recording. Fails once the maximum
70    /// length would be exceeded.
71    pub(super) fn write_samples(&mut self, samples: &[i16]) -> Result<()> {
72        let sample_count =
73            u64::try_from(samples.len()).map_err(|_error| SpoolError::SampleCountOverflow)?;
74        let attempted_samples = self
75            .samples_written
76            .checked_add(sample_count)
77            .ok_or(SpoolError::SampleCountOverflow)?;
78        if attempted_samples > self.maximum_samples {
79            return Err(SpoolError::LimitExceeded {
80                attempted_samples,
81                maximum_samples: self.maximum_samples,
82            });
83        }
84
85        for &sample in samples {
86            self.writer
87                .write_sample(sample)
88                .map_err(|source| SpoolError::Write(Box::new(source)))?;
89        }
90        self.samples_written = attempted_samples;
91        self.recording.append(samples);
92        Ok(())
93    }
94
95    /// Finalizes the WAV header and transfers deletion ownership to the result.
96    pub(super) fn finish(self) -> Result<RecordedAudio> {
97        if self.samples_written == 0 {
98            return Err(SpoolError::Empty);
99        }
100
101        let Self {
102            writer,
103            path,
104            samples_written,
105            ..
106        } = self;
107        writer
108            .finalize()
109            .map_err(|source| SpoolError::Finalize(Box::new(source)))?;
110
111        Ok(RecordedAudio::owned(path, samples_written))
112    }
113}
114
115// Best-effort: failures are logged, never returned, so cleanup cannot block
116// startup. The caller must hold the daemon's exclusive lock, because every
117// matching WAV in `directory` is deleted.
118pub fn remove_all_spools(directory: &Path) {
119    let entries = match fs::read_dir(directory) {
120        Ok(entries) => entries,
121        Err(error) => {
122            tracing::warn!(
123                directory = %directory.display(),
124                %error,
125                "cannot list temporary recordings"
126            );
127            return;
128        }
129    };
130
131    let mut removed = 0;
132    for (scanned, entry) in entries.enumerate() {
133        if scanned == MAX_CLEANUP_ENTRIES {
134            tracing::warn!(
135                scanned,
136                "temporary recording cleanup reached its scan limit"
137            );
138            break;
139        }
140        let entry = match entry {
141            Ok(entry) => entry,
142            Err(error) => {
143                tracing::warn!(%error, "cannot read a temporary recording entry");
144                continue;
145            }
146        };
147        if !is_recording_artifact(&entry.file_name()) {
148            continue;
149        }
150        match fs::remove_file(entry.path()) {
151            Ok(()) => removed += 1,
152            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
153            Err(error) => tracing::warn!(
154                path = %entry.path().display(),
155                %error,
156                "cannot remove an abandoned temporary recording"
157            ),
158        }
159    }
160
161    if removed > 0 {
162        tracing::info!(removed, "removed abandoned temporary recordings");
163    }
164}
165
166// The spool directory is user-configurable, so cleanup removes only files
167// named like this crate's spools.
168fn is_recording_artifact(name: &OsStr) -> bool {
169    name.to_str()
170        .is_some_and(|name| name.starts_with(FILE_PREFIX) && name.ends_with(FILE_SUFFIX))
171}
172
173pub(in crate::audio) fn maximum_samples(duration: Duration) -> Result<u64> {
174    if !(MIN_RECORDING_DURATION..=MAX_RECORDING_DURATION).contains(&duration) {
175        return Err(SpoolError::InvalidDuration {
176            actual: duration,
177            minimum: MIN_RECORDING_DURATION,
178            maximum: MAX_RECORDING_DURATION,
179        });
180    }
181
182    Ok(canonical_samples(duration))
183}
184
185#[derive(Debug, thiserror::Error)]
186pub enum SpoolError {
187    #[error("recording duration must be between {minimum:?} and {maximum:?}; got {actual:?}")]
188    InvalidDuration {
189        actual: Duration,
190        minimum: Duration,
191        maximum: Duration,
192    },
193
194    #[error("failed to create a temporary recording in {}", directory.display())]
195    CreateTemporary {
196        directory: std::path::PathBuf,
197        #[source]
198        source: std::io::Error,
199    },
200
201    #[error("failed to initialize the temporary WAV recording")]
202    Initialize(#[source] Box<dyn std::error::Error + Send + Sync>),
203
204    #[error(
205        "recording would contain {attempted_samples} samples; maximum is {maximum_samples} samples"
206    )]
207    LimitExceeded {
208        attempted_samples: u64,
209        maximum_samples: u64,
210    },
211
212    #[error("recording sample count overflowed")]
213    SampleCountOverflow,
214
215    #[error("failed to write PCM samples to the temporary recording")]
216    Write(#[source] Box<dyn std::error::Error + Send + Sync>),
217
218    #[error("cannot finalize an empty recording")]
219    Empty,
220
221    #[error("failed to finalize the temporary recording")]
222    Finalize(#[source] Box<dyn std::error::Error + Send + Sync>),
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn plant(directory: &Path, name: &str) {
230        std::fs::write(directory.join(name), []).expect("fixture must be writable");
231    }
232
233    #[test]
234    fn a_spool_past_its_limit_is_refused_and_leaves_no_file() {
235        let directory = tempfile::tempdir().expect("tempdir must be creatable");
236        let (_recording, writer) = super::super::recording::ends();
237        let mut spool = WavSpool::create(directory.path(), Duration::from_secs(1), &writer)
238            .expect("the spool is creatable");
239
240        let error = spool
241            .write_samples(&vec![0; CANONICAL_SAMPLE_RATE as usize + 1])
242            .expect_err("an oversized recording is refused");
243        drop(spool);
244
245        assert!(matches!(error, SpoolError::LimitExceeded { .. }));
246        let leftovers = std::fs::read_dir(directory.path()).expect("the directory lists");
247        assert_eq!(leftovers.count(), 0);
248    }
249
250    #[test]
251    fn a_duration_past_the_wav_format_is_refused() {
252        let past_the_format = MAX_RECORDING_DURATION + Duration::from_secs(1);
253
254        assert!(matches!(
255            maximum_samples(past_the_format),
256            Err(SpoolError::InvalidDuration { .. })
257        ));
258        assert_eq!(
259            maximum_samples(MAX_RECORDING_DURATION).expect("the bound itself is allowed"),
260            crate::audio::MAX_RECORDING_SAMPLES
261        );
262    }
263
264    #[test]
265    fn cleanup_removes_only_recording_artifacts() {
266        let directory = tempfile::tempdir().expect("tempdir must be creatable");
267        plant(directory.path(), "aria-recording-999999-abc.wav");
268        plant(directory.path(), "aria-recording-notapid.wav");
269        plant(directory.path(), "keep-me.txt");
270
271        remove_all_spools(directory.path());
272
273        assert!(directory.path().join("keep-me.txt").exists());
274        assert!(
275            !directory
276                .path()
277                .join("aria-recording-999999-abc.wav")
278                .exists()
279        );
280        assert!(!directory.path().join("aria-recording-notapid.wav").exists());
281    }
282
283    #[test]
284    fn cleanup_continues_past_an_unremovable_entry() {
285        let directory = tempfile::tempdir().expect("tempdir must be creatable");
286        std::fs::create_dir(directory.path().join("aria-recording-666-trap.wav"))
287            .expect("trap must be creatable");
288        plant(directory.path(), "aria-recording-1-orphan.wav");
289
290        remove_all_spools(directory.path());
291
292        assert!(
293            !directory
294                .path()
295                .join("aria-recording-1-orphan.wav")
296                .exists()
297        );
298        assert!(
299            directory
300                .path()
301                .join("aria-recording-666-trap.wav")
302                .exists()
303        );
304    }
305}