Skip to main content

rust_synth/
recording.rs

1//! Master-output recorder → FLAC (lossless, 24-bit).
2//!
3//! The audio callback pushes interleaved L/R samples into a pre-allocated
4//! `Vec<f32>` protected by a `parking_lot::Mutex` (uncontended ≈ 25 ns).
5//! On stop the buffer is moved out and handed to a background thread
6//! that runs the FLAC encoder — UI stays responsive even for long takes.
7
8use anyhow::{anyhow, Context, Result};
9use parking_lot::Mutex;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Instant;
13
14const BITS_PER_SAMPLE: usize = 24;
15const CHANNELS: usize = 2;
16/// Hard cap — refuses to record beyond this to protect RAM.
17/// At 48 kHz stereo f32: 15 min ≈ 345 MB.
18pub const MAX_MINUTES: u32 = 15;
19
20/// Handle shared between audio callback and UI thread.
21///
22/// - Callback: `buffer.lock()` and pushes f32 interleaved samples.
23/// - UI: calls `stop()` to swap out the buffer and spawn encoder.
24pub struct RecorderState {
25    buffer: Mutex<Option<Vec<f32>>>,
26    pub started_at: Mutex<Option<Instant>>,
27    pub sample_rate: u32,
28    pub max_samples: usize,
29}
30
31impl RecorderState {
32    pub fn new(sample_rate: u32) -> Arc<Self> {
33        let max_samples = MAX_MINUTES as usize * 60 * sample_rate as usize * CHANNELS;
34        Arc::new(Self {
35            buffer: Mutex::new(None),
36            started_at: Mutex::new(None),
37            sample_rate,
38            max_samples,
39        })
40    }
41
42    pub fn is_recording(&self) -> bool {
43        self.buffer.lock().is_some()
44    }
45
46    pub fn elapsed_seconds(&self) -> f32 {
47        self.started_at
48            .lock()
49            .map(|t| t.elapsed().as_secs_f32())
50            .unwrap_or(0.0)
51    }
52
53    pub fn start(&self) {
54        let mut buf = self.buffer.lock();
55        if buf.is_none() {
56            *buf = Some(Vec::with_capacity(self.sample_rate as usize * CHANNELS * 30));
57        }
58        *self.started_at.lock() = Some(Instant::now());
59    }
60
61    /// Called from the audio callback for every output frame.
62    pub fn push_frame(&self, l: f32, r: f32) {
63        let mut guard = self.buffer.lock();
64        if let Some(buf) = guard.as_mut() {
65            if buf.len() + 2 <= self.max_samples {
66                buf.push(l);
67                buf.push(r);
68            }
69        }
70    }
71
72    /// Stop capture and spawn the encoder thread. Returns target path.
73    pub fn stop_and_encode(&self, dir: &Path) -> Result<PathBuf> {
74        std::fs::create_dir_all(dir).context("create recordings dir")?;
75        let samples = self.buffer.lock().take().ok_or_else(|| anyhow!("not recording"))?;
76        *self.started_at.lock() = None;
77
78        let name = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string();
79        let path = dir.join(format!("{name}.flac"));
80        let sr = self.sample_rate;
81        let target = path.clone();
82
83        std::thread::spawn(move || {
84            if let Err(e) = encode_flac(&samples, sr, &target) {
85                tracing::error!("FLAC encode failed for {}: {e}", target.display());
86            } else {
87                tracing::info!(
88                    "wrote {} ({:.1}s, {:.1} MB)",
89                    target.display(),
90                    samples.len() as f32 / (sr as f32 * CHANNELS as f32),
91                    std::fs::metadata(&target).map(|m| m.len() as f32 / 1_048_576.0).unwrap_or(0.0),
92                );
93            }
94        });
95
96        Ok(path)
97    }
98}
99
100fn encode_flac(samples: &[f32], sample_rate: u32, path: &Path) -> Result<()> {
101    // f32 [-1, 1] → i32 24-bit signed.
102    let scale = ((1i32 << (BITS_PER_SAMPLE - 1)) - 1) as f32;
103    let int_samples: Vec<i32> = samples
104        .iter()
105        .map(|&s| (s.clamp(-1.0, 1.0) * scale) as i32)
106        .collect();
107
108    use flacenc::error::Verify;
109    let config = flacenc::config::Encoder::default()
110        .into_verified()
111        .map_err(|(_, e)| anyhow!("flacenc config verify: {e:?}"))?;
112
113    let source = flacenc::source::MemSource::from_samples(
114        &int_samples,
115        CHANNELS,
116        BITS_PER_SAMPLE,
117        sample_rate as usize,
118    );
119    let stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size)
120        .map_err(|e| anyhow!("flacenc encode: {e:?}"))?;
121
122    use flacenc::component::BitRepr;
123    let mut sink = flacenc::bitsink::ByteSink::new();
124    stream
125        .write(&mut sink)
126        .map_err(|e| anyhow!("flacenc write: {e:?}"))?;
127
128    std::fs::write(path, sink.as_slice())
129        .with_context(|| format!("write flac to {}", path.display()))?;
130    Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn encodes_short_buffer_to_valid_flac() {
139        // 0.1s of a 440 Hz sine, stereo — smallest viable input.
140        let sr = 48_000u32;
141        let n = sr as usize / 10;
142        let mut samples = Vec::with_capacity(n * 2);
143        for i in 0..n {
144            let v = (i as f32 / sr as f32 * 440.0 * std::f32::consts::TAU).sin() * 0.5;
145            samples.push(v);
146            samples.push(v);
147        }
148        let dir = tempfile::tempdir().unwrap();
149        let path = dir.path().join("t.flac");
150        encode_flac(&samples, sr, &path).unwrap();
151        let bytes = std::fs::read(&path).unwrap();
152        assert!(bytes.len() > 100, "flac too small: {}", bytes.len());
153        // FLAC magic: 'fLaC' at start.
154        assert_eq!(&bytes[..4], b"fLaC");
155    }
156}