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::num::NonZeroU8;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::Instant;
14
15const BITS_PER_SAMPLE: usize = 24;
16const CHANNELS: usize = 2;
17/// Hard cap — refuses to record beyond this to protect RAM.
18/// At 48 kHz stereo f32: 15 min ≈ 345 MB.
19pub const MAX_MINUTES: u32 = 15;
20
21/// Container / codec for a recording.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RecordFormat {
24    /// Lossless 24-bit FLAC — biggest file, master-quality.
25    Flac,
26    /// OGG Vorbis at quality 0.6 (~128 kbps) — smaller, streamable,
27    /// near-transparent for ambient material.
28    Ogg,
29}
30
31impl RecordFormat {
32    pub fn label(self) -> &'static str {
33        match self {
34            RecordFormat::Flac => "flac",
35            RecordFormat::Ogg => "ogg",
36        }
37    }
38
39    pub fn extension(self) -> &'static str {
40        match self {
41            RecordFormat::Flac => "flac",
42            RecordFormat::Ogg => "ogg",
43        }
44    }
45
46    pub fn toggle(self) -> Self {
47        match self {
48            RecordFormat::Flac => RecordFormat::Ogg,
49            RecordFormat::Ogg => RecordFormat::Flac,
50        }
51    }
52}
53
54/// Handle shared between audio callback and UI thread.
55///
56/// - Callback: `buffer.lock()` and pushes f32 interleaved samples.
57/// - UI: calls `stop()` to swap out the buffer and spawn encoder.
58pub struct RecorderState {
59    buffer: Mutex<Option<Vec<f32>>>,
60    pub started_at: Mutex<Option<Instant>>,
61    pub sample_rate: u32,
62    pub max_samples: usize,
63    /// Which container to write when the user stops recording.
64    /// Defaults to FLAC; toggled by the `f` key in the TUI.
65    pub format: Mutex<RecordFormat>,
66}
67
68impl RecorderState {
69    pub fn new(sample_rate: u32) -> Arc<Self> {
70        let max_samples = MAX_MINUTES as usize * 60 * sample_rate as usize * CHANNELS;
71        Arc::new(Self {
72            buffer: Mutex::new(None),
73            started_at: Mutex::new(None),
74            sample_rate,
75            max_samples,
76            format: Mutex::new(RecordFormat::Flac),
77        })
78    }
79
80    pub fn is_recording(&self) -> bool {
81        self.buffer.lock().is_some()
82    }
83
84    pub fn elapsed_seconds(&self) -> f32 {
85        self.started_at
86            .lock()
87            .map(|t| t.elapsed().as_secs_f32())
88            .unwrap_or(0.0)
89    }
90
91    pub fn start(&self) {
92        let mut buf = self.buffer.lock();
93        if buf.is_none() {
94            *buf = Some(Vec::with_capacity(self.sample_rate as usize * CHANNELS * 30));
95        }
96        *self.started_at.lock() = Some(Instant::now());
97    }
98
99    /// Called from the audio callback for every output frame.
100    pub fn push_frame(&self, l: f32, r: f32) {
101        let mut guard = self.buffer.lock();
102        if let Some(buf) = guard.as_mut() {
103            if buf.len() + 2 <= self.max_samples {
104                buf.push(l);
105                buf.push(r);
106            }
107        }
108    }
109
110    /// Stop capture and spawn the encoder thread. Returns target path.
111    pub fn stop_and_encode(&self, dir: &Path) -> Result<PathBuf> {
112        std::fs::create_dir_all(dir).context("create recordings dir")?;
113        let samples = self.buffer.lock().take().ok_or_else(|| anyhow!("not recording"))?;
114        *self.started_at.lock() = None;
115        let format = *self.format.lock();
116
117        let name = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string();
118        let path = dir.join(format!("{name}.{}", format.extension()));
119        let sr = self.sample_rate;
120        let target = path.clone();
121
122        std::thread::spawn(move || {
123            let result = match format {
124                RecordFormat::Flac => encode_flac(&samples, sr, &target),
125                RecordFormat::Ogg => encode_ogg(&samples, sr, &target),
126            };
127            match result {
128                Ok(()) => tracing::info!(
129                    "wrote {} ({:.1}s, {:.1} MB)",
130                    target.display(),
131                    samples.len() as f32 / (sr as f32 * CHANNELS as f32),
132                    std::fs::metadata(&target)
133                        .map(|m| m.len() as f32 / 1_048_576.0)
134                        .unwrap_or(0.0),
135                ),
136                Err(e) => tracing::error!(
137                    "{} encode failed for {}: {e}",
138                    format.label().to_uppercase(),
139                    target.display()
140                ),
141            }
142        });
143
144        Ok(path)
145    }
146
147    pub fn toggle_format(&self) -> RecordFormat {
148        let mut f = self.format.lock();
149        *f = f.toggle();
150        *f
151    }
152
153    pub fn current_format(&self) -> RecordFormat {
154        *self.format.lock()
155    }
156}
157
158fn encode_flac(samples: &[f32], sample_rate: u32, path: &Path) -> Result<()> {
159    // f32 [-1, 1] → i32 24-bit signed.
160    let scale = ((1i32 << (BITS_PER_SAMPLE - 1)) - 1) as f32;
161    let int_samples: Vec<i32> = samples
162        .iter()
163        .map(|&s| (s.clamp(-1.0, 1.0) * scale) as i32)
164        .collect();
165
166    use flacenc::error::Verify;
167    let config = flacenc::config::Encoder::default()
168        .into_verified()
169        .map_err(|(_, e)| anyhow!("flacenc config verify: {e:?}"))?;
170
171    let source = flacenc::source::MemSource::from_samples(
172        &int_samples,
173        CHANNELS,
174        BITS_PER_SAMPLE,
175        sample_rate as usize,
176    );
177    let stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size)
178        .map_err(|e| anyhow!("flacenc encode: {e:?}"))?;
179
180    use flacenc::component::BitRepr;
181    let mut sink = flacenc::bitsink::ByteSink::new();
182    stream
183        .write(&mut sink)
184        .map_err(|e| anyhow!("flacenc write: {e:?}"))?;
185
186    std::fs::write(path, sink.as_slice())
187        .with_context(|| format!("write flac to {}", path.display()))?;
188    Ok(())
189}
190
191/// Encode the interleaved f32 buffer as OGG Vorbis (quality ≈0.6,
192/// ~128 kbps — transparent for ambient material at ~1/5 the size of
193/// FLAC). `vorbis_rs` consumes planar `&[&[f32]]` so we deinterleave
194/// once into two channel vectors before handing off.
195fn encode_ogg(samples: &[f32], sample_rate: u32, path: &Path) -> Result<()> {
196    let frames = samples.len() / CHANNELS;
197    let mut left = Vec::with_capacity(frames);
198    let mut right = Vec::with_capacity(frames);
199    for frame in samples.chunks_exact(CHANNELS) {
200        left.push(frame[0]);
201        right.push(frame[1]);
202    }
203
204    let file = std::fs::File::create(path)
205        .with_context(|| format!("create {}", path.display()))?;
206
207    let sr = std::num::NonZeroU32::new(sample_rate)
208        .ok_or_else(|| anyhow!("sample rate must be non-zero"))?;
209    let channels = NonZeroU8::new(CHANNELS as u8)
210        .ok_or_else(|| anyhow!("channels must be non-zero"))?;
211
212    let mut encoder = vorbis_rs::VorbisEncoderBuilder::new(sr, channels, file)
213        .map_err(|e| anyhow!("vorbis builder: {e}"))?
214        .build()
215        .map_err(|e| anyhow!("vorbis build: {e}"))?;
216
217    encoder
218        .encode_audio_block([&left[..], &right[..]])
219        .map_err(|e| anyhow!("vorbis encode: {e}"))?;
220    encoder
221        .finish()
222        .map_err(|e| anyhow!("vorbis finish: {e}"))?;
223
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn synth_samples(seconds: f32, sr: u32) -> Vec<f32> {
232        let n = (seconds * sr as f32) as usize;
233        let mut samples = Vec::with_capacity(n * 2);
234        for i in 0..n {
235            let v = (i as f32 / sr as f32 * 440.0 * std::f32::consts::TAU).sin() * 0.5;
236            samples.push(v);
237            samples.push(v);
238        }
239        samples
240    }
241
242    #[test]
243    fn encodes_short_buffer_to_valid_ogg() {
244        let sr = 48_000u32;
245        let samples = synth_samples(0.1, sr);
246        let dir = tempfile::tempdir().unwrap();
247        let path = dir.path().join("t.ogg");
248        encode_ogg(&samples, sr, &path).unwrap();
249        let bytes = std::fs::read(&path).unwrap();
250        assert!(bytes.len() > 100, "ogg too small: {}", bytes.len());
251        // OGG magic: 'OggS' at start.
252        assert_eq!(&bytes[..4], b"OggS");
253    }
254
255    #[test]
256    fn encodes_short_buffer_to_valid_flac() {
257        // 0.1s of a 440 Hz sine, stereo — smallest viable input.
258        let sr = 48_000u32;
259        let n = sr as usize / 10;
260        let mut samples = Vec::with_capacity(n * 2);
261        for i in 0..n {
262            let v = (i as f32 / sr as f32 * 440.0 * std::f32::consts::TAU).sin() * 0.5;
263            samples.push(v);
264            samples.push(v);
265        }
266        let dir = tempfile::tempdir().unwrap();
267        let path = dir.path().join("t.flac");
268        encode_flac(&samples, sr, &path).unwrap();
269        let bytes = std::fs::read(&path).unwrap();
270        assert!(bytes.len() > 100, "flac too small: {}", bytes.len());
271        // FLAC magic: 'fLaC' at start.
272        assert_eq!(&bytes[..4], b"fLaC");
273    }
274}