1use 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
25pub(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 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 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 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
115pub 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
166fn 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}