use anyhow::{anyhow, Context, Result};
use parking_lot::Mutex;
use std::num::NonZeroU8;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
const BITS_PER_SAMPLE: usize = 24;
const CHANNELS: usize = 2;
pub const MAX_MINUTES: u32 = 15;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordFormat {
Flac,
Ogg,
}
impl RecordFormat {
pub fn label(self) -> &'static str {
match self {
RecordFormat::Flac => "flac",
RecordFormat::Ogg => "ogg",
}
}
pub fn extension(self) -> &'static str {
match self {
RecordFormat::Flac => "flac",
RecordFormat::Ogg => "ogg",
}
}
pub fn toggle(self) -> Self {
match self {
RecordFormat::Flac => RecordFormat::Ogg,
RecordFormat::Ogg => RecordFormat::Flac,
}
}
}
pub struct RecorderState {
buffer: Mutex<Option<Vec<f32>>>,
pub started_at: Mutex<Option<Instant>>,
pub sample_rate: u32,
pub max_samples: usize,
pub format: Mutex<RecordFormat>,
}
impl RecorderState {
pub fn new(sample_rate: u32) -> Arc<Self> {
let max_samples = MAX_MINUTES as usize * 60 * sample_rate as usize * CHANNELS;
Arc::new(Self {
buffer: Mutex::new(None),
started_at: Mutex::new(None),
sample_rate,
max_samples,
format: Mutex::new(RecordFormat::Flac),
})
}
pub fn is_recording(&self) -> bool {
self.buffer.lock().is_some()
}
pub fn elapsed_seconds(&self) -> f32 {
self.started_at
.lock()
.map(|t| t.elapsed().as_secs_f32())
.unwrap_or(0.0)
}
pub fn start(&self) {
let mut buf = self.buffer.lock();
if buf.is_none() {
*buf = Some(Vec::with_capacity(self.sample_rate as usize * CHANNELS * 30));
}
*self.started_at.lock() = Some(Instant::now());
}
pub fn push_frame(&self, l: f32, r: f32) {
let mut guard = self.buffer.lock();
if let Some(buf) = guard.as_mut() {
if buf.len() + 2 <= self.max_samples {
buf.push(l);
buf.push(r);
}
}
}
pub fn stop_and_encode(&self, dir: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(dir).context("create recordings dir")?;
let samples = self.buffer.lock().take().ok_or_else(|| anyhow!("not recording"))?;
*self.started_at.lock() = None;
let format = *self.format.lock();
let name = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string();
let path = dir.join(format!("{name}.{}", format.extension()));
let sr = self.sample_rate;
let target = path.clone();
std::thread::spawn(move || {
let result = match format {
RecordFormat::Flac => encode_flac(&samples, sr, &target),
RecordFormat::Ogg => encode_ogg(&samples, sr, &target),
};
match result {
Ok(()) => tracing::info!(
"wrote {} ({:.1}s, {:.1} MB)",
target.display(),
samples.len() as f32 / (sr as f32 * CHANNELS as f32),
std::fs::metadata(&target)
.map(|m| m.len() as f32 / 1_048_576.0)
.unwrap_or(0.0),
),
Err(e) => tracing::error!(
"{} encode failed for {}: {e}",
format.label().to_uppercase(),
target.display()
),
}
});
Ok(path)
}
pub fn toggle_format(&self) -> RecordFormat {
let mut f = self.format.lock();
*f = f.toggle();
*f
}
pub fn current_format(&self) -> RecordFormat {
*self.format.lock()
}
}
fn encode_flac(samples: &[f32], sample_rate: u32, path: &Path) -> Result<()> {
let scale = ((1i32 << (BITS_PER_SAMPLE - 1)) - 1) as f32;
let int_samples: Vec<i32> = samples
.iter()
.map(|&s| (s.clamp(-1.0, 1.0) * scale) as i32)
.collect();
use flacenc::error::Verify;
let config = flacenc::config::Encoder::default()
.into_verified()
.map_err(|(_, e)| anyhow!("flacenc config verify: {e:?}"))?;
let source = flacenc::source::MemSource::from_samples(
&int_samples,
CHANNELS,
BITS_PER_SAMPLE,
sample_rate as usize,
);
let stream = flacenc::encode_with_fixed_block_size(&config, source, config.block_size)
.map_err(|e| anyhow!("flacenc encode: {e:?}"))?;
use flacenc::component::BitRepr;
let mut sink = flacenc::bitsink::ByteSink::new();
stream
.write(&mut sink)
.map_err(|e| anyhow!("flacenc write: {e:?}"))?;
std::fs::write(path, sink.as_slice())
.with_context(|| format!("write flac to {}", path.display()))?;
Ok(())
}
fn encode_ogg(samples: &[f32], sample_rate: u32, path: &Path) -> Result<()> {
let frames = samples.len() / CHANNELS;
let mut left = Vec::with_capacity(frames);
let mut right = Vec::with_capacity(frames);
for frame in samples.chunks_exact(CHANNELS) {
left.push(frame[0]);
right.push(frame[1]);
}
let file = std::fs::File::create(path)
.with_context(|| format!("create {}", path.display()))?;
let sr = std::num::NonZeroU32::new(sample_rate)
.ok_or_else(|| anyhow!("sample rate must be non-zero"))?;
let channels = NonZeroU8::new(CHANNELS as u8)
.ok_or_else(|| anyhow!("channels must be non-zero"))?;
let mut encoder = vorbis_rs::VorbisEncoderBuilder::new(sr, channels, file)
.map_err(|e| anyhow!("vorbis builder: {e}"))?
.build()
.map_err(|e| anyhow!("vorbis build: {e}"))?;
encoder
.encode_audio_block([&left[..], &right[..]])
.map_err(|e| anyhow!("vorbis encode: {e}"))?;
encoder
.finish()
.map_err(|e| anyhow!("vorbis finish: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn synth_samples(seconds: f32, sr: u32) -> Vec<f32> {
let n = (seconds * sr as f32) as usize;
let mut samples = Vec::with_capacity(n * 2);
for i in 0..n {
let v = (i as f32 / sr as f32 * 440.0 * std::f32::consts::TAU).sin() * 0.5;
samples.push(v);
samples.push(v);
}
samples
}
#[test]
fn encodes_short_buffer_to_valid_ogg() {
let sr = 48_000u32;
let samples = synth_samples(0.1, sr);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.ogg");
encode_ogg(&samples, sr, &path).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.len() > 100, "ogg too small: {}", bytes.len());
assert_eq!(&bytes[..4], b"OggS");
}
#[test]
fn encodes_short_buffer_to_valid_flac() {
let sr = 48_000u32;
let n = sr as usize / 10;
let mut samples = Vec::with_capacity(n * 2);
for i in 0..n {
let v = (i as f32 / sr as f32 * 440.0 * std::f32::consts::TAU).sin() * 0.5;
samples.push(v);
samples.push(v);
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.flac");
encode_flac(&samples, sr, &path).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.len() > 100, "flac too small: {}", bytes.len());
assert_eq!(&bytes[..4], b"fLaC");
}
}