Skip to main content

unlit3dx/
record.rs

1//! Recording the mix an audio world fills into its output buffer.
2//!
3//! A recording is an [`OnAudioFill`] source ordered after every other one, so it
4//! sees the samples the whole world contributed. The samples it keeps are the
5//! ones the output stream would receive: the world's [`GlobalAudioGain`] scales the
6//! sum and it clamps to the unit range, exactly as [`dispatch_audio_fill`] finishes a
7//! fill.
8//!
9//! [`dispatch_audio_fill`]: crate::audio::dispatch_audio_fill
10//!
11//! The recorder learns the format from the first fill it sees rather than being
12//! told one: an audio world publishes its rate only once a device is open, and
13//! a world that opens none fills at the rate its sources were built for. A
14//! recording stops at `max_seconds` of audio and keeps what it captured, so an
15//! unattended recording cannot grow without bound. A finished recording is
16//! written in the [`RecordingFormat`] its caller names: a RIFF/WAVE file of
17//! the samples themselves, or the same samples in a lossless FLAC stream.
18
19use std::sync::{Arc, Mutex};
20
21use unlit_ecs::sync;
22use unlit_ecs::{Entity, World, bundle};
23
24use crate::audio::{AudioChunk, AudioFillOrder, AudioFormat, GlobalAudioGain, OnAudioFill};
25
26/// Why a recording, or the format it is written in, was refused.
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum RecordingError {
30    /// The name names a format the recorder cannot write.
31    #[error("cannot write a {0:?} recording; use .flac or .wav")]
32    UnsupportedFormat(String),
33    /// The file name carries no extension to pick a format from.
34    #[error("the file name {0:?} has no extension")]
35    MissingExtension(String),
36    /// The encoder's configuration failed verification.
37    #[error("the encoder configuration is invalid: {0}")]
38    Verify(#[from] flacenc::error::VerifyError),
39    /// The samples could not be encoded into a FLAC stream.
40    #[error("the samples could not be encoded: {0}")]
41    Encode(#[from] flacenc::error::EncodeError),
42    /// The FLAC stream could not be written into its byte sink.
43    #[error("the FLAC stream could not be written: {0}")]
44    Write(#[from] flacenc::error::OutputError<flacenc::bitsink::ByteSink>),
45}
46
47/// A recording in progress on a world.
48///
49/// The handle owns the shared buffer the [`OnAudioFill`] source writes into. Keep it
50/// around — as a component of the world, for instance — and hand it back to
51/// [`RecordingHandle::stop`] or [`RecordingHandle::cancel`] to finish.
52#[derive(Clone, Debug)]
53pub struct RecordingHandle {
54    entity: Entity,
55    state: Arc<Mutex<RecordingState>>,
56}
57
58/// What a recording has captured so far.
59#[derive(Debug)]
60struct RecordingState {
61    /// The format of the first fill the recording saw, which every later fill
62    /// is taken to share.
63    format: Option<AudioFormat>,
64    samples: Vec<f32>,
65    /// How many samples the recording keeps before it stops capturing.
66    max_samples: usize,
67    /// How many seconds of audio the recording keeps at most.
68    max_seconds: f32,
69}
70
71/// The samples a finished recording captured.
72#[derive(Clone, Debug, PartialEq)]
73pub struct RecordedAudio {
74    /// The format the recording captured at.
75    pub format: AudioFormat,
76    /// The interleaved samples, in the format's channel order.
77    pub samples: Vec<f32>,
78}
79
80impl RecordedAudio {
81    /// How many frames were captured.
82    #[must_use]
83    pub fn frame_count(&self) -> usize {
84        self.samples.len() / usize::from(self.format.channels).max(1)
85    }
86
87    /// How many seconds of audio were captured.
88    #[must_use]
89    pub fn seconds(&self) -> f64 {
90        self.frame_count() as f64 / f64::from(self.format.sample_rate.max(1))
91    }
92}
93
94/// Start recording everything `world` fills, for at most `max_seconds`.
95///
96/// The recorder runs after every other [`OnAudioFill`] source and keeps the mix after
97/// the world's [`GlobalAudioGain`] and the unit-range clamp, so the result is what
98/// the output stream receives. Because a source is handed the buffer before
99/// the dispatcher scales it, the recorder applies the gain itself; that is the
100/// one step it repeats rather than observes. No call fills the world: whichever
101/// driver usually does (the audio thread, [`dispatch_audio_fill`] directly, a
102/// stepped world) keeps doing it, and the recording grows as it goes.
103///
104/// [`dispatch_audio_fill`]: crate::audio::dispatch_audio_fill
105pub fn start_recording(world: &mut World, max_seconds: f32) -> RecordingHandle {
106    let state = Arc::new(Mutex::new(RecordingState {
107        format: None,
108        samples: Vec::new(),
109        // Nothing is kept until a fill names the format the limit is measured
110        // in; the first fill replaces this with the real bound.
111        max_samples: usize::MAX,
112        max_seconds: max_seconds.max(0.0),
113    }));
114    let recorder = Arc::clone(&state);
115    let entity = world.root_scope_mut().spawn(bundle![OnAudioFill::new(
116        AudioFillOrder(i32::MAX),
117        move |world: &World, _entity: Entity, chunk: &mut AudioChunk| {
118            // The gain is read before the lock is taken. A query is the
119            // world's to arbitrate and panics on a conflicting borrow, and a
120            // panic is only recoverable because the lock sees none of it: the
121            // recorder's guard covers the samples and nothing else.
122            let gain = {
123                let mut rows = world.root_scope().query::<&GlobalAudioGain>();
124                rows.iter().next().map(|audio_gain| audio_gain.0)
125            }
126            .unwrap_or(1.0);
127            let mut state = sync::lock(&recorder);
128            if state.format.is_none() {
129                let format = AudioFormat {
130                    sample_rate: chunk.sample_rate,
131                    channels: chunk.channels,
132                };
133                let frames =
134                    (f64::from(format.sample_rate) * f64::from(state.max_seconds)) as usize;
135                state.max_samples = frames.saturating_mul(usize::from(format.channels).max(1));
136                state.format = Some(format);
137            }
138            let room = state.max_samples.saturating_sub(state.samples.len());
139            state.samples.extend(
140                chunk
141                    .out
142                    .iter()
143                    .take(room)
144                    .map(|sample| (sample * gain).clamp(-1.0, 1.0)),
145            );
146        },
147    ),]);
148    RecordingHandle { entity, state }
149}
150
151/// How many bits one quantized sample takes, the resolution both encoders
152/// declare for the samples they hold.
153const BITS_PER_SAMPLE: usize = i16::BITS as usize;
154
155/// Quantize a sample into the 16-bit range both encoders write.
156fn quantize(sample: f32) -> i16 {
157    (sample.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16
158}
159
160/// Write interleaved samples as a 16-bit PCM RIFF/WAVE file.
161///
162/// Each sample clamps to the unit range and then scales to `i16`, the same
163/// conversion the soundfont pipeline uses for its own files.
164pub(crate) fn write_wav(format: AudioFormat, samples: &[f32]) -> Vec<u8> {
165    let channels = format.channels.max(1);
166    let sample_rate = format.sample_rate.max(1);
167    let block_align = channels * 2;
168    let data_len = (samples.len() * 2) as u32;
169    let mut bytes = Vec::with_capacity(44 + data_len as usize);
170    bytes.extend_from_slice(b"RIFF");
171    bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
172    bytes.extend_from_slice(b"WAVE");
173    bytes.extend_from_slice(b"fmt ");
174    bytes.extend_from_slice(&16u32.to_le_bytes());
175    bytes.extend_from_slice(&1u16.to_le_bytes());
176    bytes.extend_from_slice(&channels.to_le_bytes());
177    bytes.extend_from_slice(&sample_rate.to_le_bytes());
178    bytes.extend_from_slice(&(sample_rate * u32::from(block_align)).to_le_bytes());
179    bytes.extend_from_slice(&block_align.to_le_bytes());
180    bytes.extend_from_slice(&16u16.to_le_bytes());
181    bytes.extend_from_slice(b"data");
182    bytes.extend_from_slice(&data_len.to_le_bytes());
183    for &sample in samples {
184        bytes.extend_from_slice(&quantize(sample).to_le_bytes());
185    }
186    bytes
187}
188
189/// Write interleaved samples as a lossless FLAC stream.
190///
191/// The samples quantize to 16 bits first, exactly as [`write_wav`] does, so
192/// the stream decodes back to the same samples the WAV would hold. It is the
193/// smaller of the two answers — a mix takes a third of the WAV's bytes —
194/// which is why it is the format a caller gets when it names none.
195pub(crate) fn write_flac(format: AudioFormat, samples: &[f32]) -> Result<Vec<u8>, RecordingError> {
196    use flacenc::component::BitRepr;
197    use flacenc::error::Verify;
198
199    let channels = usize::from(format.channels.max(1));
200    let quantized: Vec<i32> = samples
201        .iter()
202        .map(|sample| i32::from(quantize(*sample)))
203        .collect();
204    let config = flacenc::config::Encoder::default();
205    let block_size = config.block_size;
206    let verified = config
207        .into_verified()
208        .map_err(|(_, error)| RecordingError::Verify(error))?;
209    let source = flacenc::source::MemSource::from_samples(
210        &quantized,
211        channels,
212        BITS_PER_SAMPLE,
213        format.sample_rate.max(1) as usize,
214    );
215    let mut stream = flacenc::encode_with_fixed_block_size(&verified, source, block_size)?;
216    // The trailing frame holds whatever samples the mix's length leaves over,
217    // and the encoder lets that drag the stream's declared minimum block size
218    // down. The reference encoder declares one block size for the whole
219    // stream, and a decoder that trusts the declaration to seek cannot tell
220    // the two apart, so say the same thing here.
221    stream
222        .stream_info_mut()
223        .set_block_sizes(block_size, block_size)?;
224    let mut sink = flacenc::bitsink::ByteSink::new();
225    stream.write(&mut sink)?;
226    Ok(sink.into_inner())
227}
228
229/// The file format a recording is written in.
230///
231/// Both hold the same quantized samples: a WAV is a short header in front of
232/// them, a FLAC stream is them compressed losslessly. A caller picks one the
233/// same way it picks an image format — by the extension of the file it names,
234/// or by the format argument of the tool that answers inline.
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub enum RecordingFormat {
237    /// A 16-bit PCM RIFF/WAVE file.
238    Wav,
239    /// A lossless FLAC stream.
240    Flac,
241}
242
243impl RecordingFormat {
244    /// The format a recording is written in when its caller names none.
245    ///
246    /// FLAC is it because it is the smaller answer to carry across a
247    /// transport and the one the stored baselines are compared in.
248    pub const DEFAULT: Self = Self::Flac;
249
250    /// The format `name` names, which is an extension or a tool argument and
251    /// so is matched without regard to case.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error when `name` names a format the recorder cannot write.
256    pub fn from_name(name: &str) -> Result<Self, RecordingError> {
257        match name.to_ascii_lowercase().as_str() {
258            "wav" | "wave" => Ok(Self::Wav),
259            "flac" => Ok(Self::Flac),
260            other => Err(RecordingError::UnsupportedFormat(other.to_owned())),
261        }
262    }
263
264    /// The format the extension of `file_name` names.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error when `file_name` has no extension, or when the
269    /// extension names a format the recorder cannot write.
270    pub fn of(file_name: &str) -> Result<Self, RecordingError> {
271        unlit_ecs::naming::extension_of(file_name).map_or_else(
272            || Err(RecordingError::MissingExtension(file_name.to_owned())),
273            Self::from_name,
274        )
275    }
276
277    /// The format as a MIME type, for the audio content block a tool that
278    /// answers inline sends it in.
279    #[must_use]
280    pub fn mime_type(self) -> &'static str {
281        match self {
282            Self::Wav => "audio/wav",
283            Self::Flac => "audio/flac",
284        }
285    }
286
287    /// Encode interleaved samples in this format.
288    ///
289    /// # Errors
290    ///
291    /// Returns an error when the FLAC encoder rejects the samples.
292    pub fn encode(self, format: AudioFormat, samples: &[f32]) -> Result<Vec<u8>, RecordingError> {
293        match self {
294            Self::Wav => Ok(write_wav(format, samples)),
295            Self::Flac => write_flac(format, samples),
296        }
297    }
298}
299
300impl RecordingHandle {
301    /// The format the recording captured at, once it has seen a fill.
302    ///
303    /// Only the MCP tools ask for it, and their module is compiled on the host
304    /// under its feature alone, so a build without them carries it unread.
305    /// The unit tests below read it too, so a test build never does.
306    #[cfg_attr(
307        all(not(test), any(not(feature = "mcp"), target_arch = "wasm32")),
308        expect(dead_code, reason = "only the MCP tools ask for it")
309    )]
310    #[must_use]
311    pub(crate) fn format(&self) -> Option<AudioFormat> {
312        sync::lock(&self.state).format
313    }
314
315    /// How many frames the recording has captured.
316    #[must_use]
317    pub fn frame_count(&self) -> usize {
318        let state = sync::lock(&self.state);
319        let channels = state
320            .format
321            .map_or(1, |format| usize::from(format.channels).max(1));
322        state.samples.len() / channels
323    }
324
325    /// How many seconds of audio the recording has captured.
326    #[must_use]
327    pub fn seconds(&self) -> f64 {
328        let state = sync::lock(&self.state);
329        match state.format {
330            Some(format) => {
331                let channels = usize::from(format.channels).max(1);
332                (state.samples.len() / channels) as f64 / f64::from(format.sample_rate.max(1))
333            }
334            None => 0.0,
335        }
336    }
337
338    /// Whether the recording reached its limit and stopped capturing.
339    ///
340    /// A recording that has seen no fill yet is never full: its limit is not
341    /// known until the format is.
342    #[must_use]
343    pub fn is_full(&self) -> bool {
344        let state = sync::lock(&self.state);
345        state.format.is_some() && state.samples.len() >= state.max_samples
346    }
347
348    /// Stop recording, despawn the source and take the captured samples.
349    ///
350    /// A recording that saw no fill has no format to write, so there is
351    /// nothing to hand back.
352    #[must_use]
353    pub fn stop(self, world: &mut World) -> Option<RecordedAudio> {
354        world.root_scope_mut().despawn(self.entity).unwrap();
355        let mut state = sync::lock(&self.state);
356        let format = state.format?;
357        Some(RecordedAudio {
358            format,
359            samples: std::mem::take(&mut state.samples),
360        })
361    }
362
363    /// Stop recording, despawn the source and discard the captured samples.
364    pub fn cancel(self, world: &mut World) {
365        world.root_scope_mut().despawn(self.entity).unwrap();
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::audio::dispatch_audio_fill;
373
374    fn constant_source(world: &mut World, level: f32) -> Entity {
375        world.root_scope_mut().spawn(bundle![OnAudioFill::new(
376            AudioFillOrder(0),
377            move |_world, _entity, chunk: &mut AudioChunk| {
378                chunk.out.fill(level);
379            },
380        ),])
381    }
382
383    #[test]
384    fn a_recording_captures_the_mixed_samples() {
385        let mut world = World::new();
386        constant_source(&mut world, 0.75);
387        world.root_scope_mut().spawn(bundle![GlobalAudioGain(2.0)]);
388        let format = AudioFormat {
389            sample_rate: 8,
390            channels: 1,
391        };
392        let handle = start_recording(&mut world, 1.0);
393        assert_eq!(handle.format(), None, "nothing fills it yet");
394        let mut out = [0.0f32; 4];
395        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
396        assert_eq!(
397            out, [1.0f32; 4],
398            "the dispatcher scales the mix once, after every source ran"
399        );
400        assert_eq!(handle.frame_count(), 4);
401        assert!(!handle.is_full());
402        assert_eq!(handle.format(), Some(format));
403        let recorded = handle.stop(&mut world).expect("the recording saw a fill");
404        assert_eq!(recorded.samples, vec![1.0; 4]);
405        assert_eq!(recorded.frame_count(), 4);
406        assert_eq!(recorded.seconds(), 0.5);
407    }
408
409    #[test]
410    fn a_recording_keeps_what_the_output_receives() {
411        // The recorder runs as a source, before the dispatcher scales the mix,
412        // so it applies the gain itself. This pins that second gain step to
413        // the dispatcher's own: a change to one without the other shows up
414        // here as samples that no longer match the buffer the stream sends.
415        let mut world = World::new();
416        constant_source(&mut world, 0.2);
417        world.root_scope_mut().spawn(bundle![GlobalAudioGain(3.0)]);
418        let format = AudioFormat {
419            sample_rate: 8,
420            channels: 1,
421        };
422        let handle = start_recording(&mut world, 1.0);
423        let mut out = [0.0f32; 4];
424        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
425        assert_eq!(out, [0.6f32; 4], "0.2 scaled by 3, below the clamp");
426        let recorded = handle.stop(&mut world).expect("the recording saw a fill");
427        assert_eq!(recorded.samples, out);
428    }
429
430    #[test]
431    fn a_recording_stops_at_its_limit() {
432        let mut world = World::new();
433        constant_source(&mut world, 0.0);
434        let format = AudioFormat {
435            sample_rate: 4,
436            channels: 1,
437        };
438        let handle = start_recording(&mut world, 0.5);
439        let mut out = [0.0f32; 8];
440        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
441        assert_eq!(handle.frame_count(), 2);
442        assert!(handle.is_full());
443        handle.cancel(&mut world);
444    }
445
446    #[test]
447    fn a_full_recording_keeps_nothing_from_later_fills() {
448        let mut world = World::new();
449        constant_source(&mut world, 0.5);
450        world.root_scope_mut().spawn(bundle![GlobalAudioGain(2.0)]);
451        let format = AudioFormat {
452            sample_rate: 4,
453            channels: 2,
454        };
455        let handle = start_recording(&mut world, 0.5);
456        let mut out = [0.0f32; 8];
457        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
458        // The limit is 0.5s at 4 Hz over 2 channels: four samples, not eight.
459        assert_eq!(handle.frame_count(), 2);
460        assert!(handle.is_full());
461        out.fill(1.0);
462        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
463        let recorded = handle.stop(&mut world).expect("the recording saw a fill");
464        assert_eq!(recorded.samples, vec![1.0; 4]);
465    }
466
467    #[test]
468    fn a_recording_nobody_filled_hands_back_nothing() {
469        let mut world = World::new();
470        let handle = start_recording(&mut world, 1.0);
471        assert!(!handle.is_full());
472        assert_eq!(handle.seconds(), 0.0);
473        assert_eq!(handle.stop(&mut world), None);
474    }
475
476    #[test]
477    fn a_wav_file_describes_its_samples() {
478        let format = AudioFormat {
479            sample_rate: 8000,
480            channels: 2,
481        };
482        let bytes = write_wav(format, &[0.0, -1.0, 1.0, 2.0]);
483        assert_eq!(&bytes[0..4], b"RIFF");
484        assert_eq!(&bytes[8..12], b"WAVE");
485        assert_eq!(&bytes[12..16], b"fmt ");
486        assert_eq!(&bytes[36..40], b"data");
487        assert_eq!(bytes.len(), 44 + 8);
488        assert_eq!(
489            u32::from_le_bytes(bytes[4..8].try_into().unwrap()),
490            44 + 8 - 8
491        );
492        assert_eq!(u16::from_le_bytes(bytes[20..22].try_into().unwrap()), 1);
493        assert_eq!(u16::from_le_bytes(bytes[22..24].try_into().unwrap()), 2);
494        assert_eq!(u32::from_le_bytes(bytes[24..28].try_into().unwrap()), 8000);
495        assert_eq!(u32::from_le_bytes(bytes[40..44].try_into().unwrap()), 8);
496        assert_eq!(i16::from_le_bytes(bytes[44..46].try_into().unwrap()), 0);
497        assert_eq!(
498            i16::from_le_bytes(bytes[46..48].try_into().unwrap()),
499            -i16::MAX
500        );
501        assert_eq!(
502            i16::from_le_bytes(bytes[50..52].try_into().unwrap()),
503            i16::MAX
504        );
505    }
506
507    #[test]
508    fn a_flac_stream_decodes_back_to_the_quantized_samples() {
509        let format = AudioFormat {
510            sample_rate: 8000,
511            channels: 2,
512        };
513        let samples = [0.0, -1.0, 1.0, 2.0];
514        let bytes = write_flac(format, &samples).expect("the stream encodes");
515        assert_eq!(&bytes[0..4], b"fLaC");
516        let mut reader =
517            claxon::FlacReader::new(bytes.as_slice()).expect("the stream is decodable");
518        let info = reader.streaminfo();
519        assert_eq!(info.channels, 2);
520        assert_eq!(info.sample_rate, 8000);
521        assert_eq!(
522            info.bits_per_sample, BITS_PER_SAMPLE as u32,
523            "the stream holds 16-bit samples"
524        );
525        // The trailing frame held fewer samples than a whole block, which
526        // without the fixup would leave the stream declaring a smaller
527        // minimum block size than it uses.
528        assert_eq!(info.min_block_size, info.max_block_size);
529        let decoded: Vec<i16> = reader
530            .samples()
531            .map(|sample| sample.expect("a decodable sample") as i16)
532            .collect();
533        assert_eq!(decoded, vec![0, -i16::MAX, i16::MAX, i16::MAX]);
534    }
535
536    #[test]
537    fn an_extension_or_name_names_the_recording_format() {
538        assert_eq!(
539            RecordingFormat::of("take.flac").unwrap(),
540            RecordingFormat::Flac
541        );
542        assert_eq!(
543            RecordingFormat::of("take.wav").unwrap(),
544            RecordingFormat::Wav
545        );
546        assert_eq!(
547            RecordingFormat::of("TAKE.FLAC").unwrap(),
548            RecordingFormat::Flac
549        );
550        assert_eq!(
551            RecordingFormat::from_name("wave").unwrap(),
552            RecordingFormat::Wav
553        );
554        assert_eq!(
555            RecordingFormat::from_name("flac").unwrap(),
556            RecordingFormat::Flac
557        );
558        assert!(RecordingFormat::of("take").is_err());
559        assert!(RecordingFormat::of("take.mp3").is_err());
560    }
561
562    #[test]
563    fn both_formats_hold_the_same_samples() {
564        let format = AudioFormat {
565            sample_rate: 8000,
566            channels: 1,
567        };
568        let samples = [0.0, -1.0, 1.0, 2.0];
569        let wav = RecordingFormat::Wav
570            .encode(format, &samples)
571            .expect("the WAV encodes");
572        let flac = RecordingFormat::Flac
573            .encode(format, &samples)
574            .expect("the stream encodes");
575        let mut reader = claxon::FlacReader::new(flac.as_slice()).expect("the stream is decodable");
576        let decoded: Vec<i16> = reader
577            .samples()
578            .map(|sample| sample.expect("a decodable sample") as i16)
579            .collect();
580        let pcm: Vec<i16> = wav[44..]
581            .as_chunks::<2>()
582            .0
583            .iter()
584            .map(|sample| i16::from_le_bytes(*sample))
585            .collect();
586        assert_eq!(decoded, pcm);
587    }
588}