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 cleanup_removes_only_recording_artifacts() {
235        let directory = tempfile::tempdir().expect("tempdir must be creatable");
236        plant(directory.path(), "aria-recording-999999-abc.wav");
237        plant(directory.path(), "aria-recording-notapid.wav");
238        plant(directory.path(), "keep-me.txt");
239
240        remove_all_spools(directory.path());
241
242        assert!(directory.path().join("keep-me.txt").exists());
243        assert!(
244            !directory
245                .path()
246                .join("aria-recording-999999-abc.wav")
247                .exists()
248        );
249        assert!(!directory.path().join("aria-recording-notapid.wav").exists());
250    }
251
252    #[test]
253    fn cleanup_continues_past_an_unremovable_entry() {
254        let directory = tempfile::tempdir().expect("tempdir must be creatable");
255        std::fs::create_dir(directory.path().join("aria-recording-666-trap.wav"))
256            .expect("trap must be creatable");
257        plant(directory.path(), "aria-recording-1-orphan.wav");
258
259        remove_all_spools(directory.path());
260
261        assert!(
262            !directory
263                .path()
264                .join("aria-recording-1-orphan.wav")
265                .exists()
266        );
267        assert!(
268            directory
269                .path()
270                .join("aria-recording-666-trap.wav")
271                .exists()
272        );
273    }
274}