//! Local call recording to a mono 16-bit PCM WAV file. //! //! Records the **full call as you experienced it**: the mixed incoming audio //! (everyone you hear) summed with your own transmitted mic, into a single mono //! WAV. Writing is driven by the playout mixer (one [`Recorder::write_frame`] //! per produced 20ms frame, paced by the hardware clock); your mic arrives //! separately from the capture thread via [`Recorder::push_mic`] and is buffered //! in a small FIFO so the two independently-clocked streams stay roughly aligned. //! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag //! between your voice and the recording is bounded) — harmless for a voice //! recording, no realtime crackle concern. //! //! No external crates: the WAV writer emits the 44-byte canonical header itself //! and patches the two size fields on [`Recorder::finalize`]. use std::collections::VecDeque; use std::fs::{File, OpenOptions}; use std::io::{self, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; /// Capture sample rate (mono, 48kHz, matching the rest of the audio path). const SAMPLE_RATE: u32 = 48_000; const BITS_PER_SAMPLE: u16 = 16; const CHANNELS: u16 = 1; const RIFF_DATA_OVERHEAD: u64 = 36; const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD; const MAX_NAME_ATTEMPTS: usize = 1_000; /// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift /// if the capture clock runs persistently faster than playout — past this we drop /// the oldest mic audio rather than let the offset grow without limit. const MAX_MIC_FIFO: usize = SAMPLE_RATE as usize / 5; /// A minimal canonical PCM WAV writer (mono S16LE). Writes a placeholder header /// up front, streams sample data, then patches the RIFF + data chunk sizes on /// [`WavWriter::finalize`]. pub struct WavWriter { file: File, /// Bytes of PCM data written so far (for the size fields). data_bytes: u64, } impl WavWriter { /// Create the file and write the 44-byte header with zeroed size fields. pub fn new(path: &Path) -> io::Result { Self::from_file(File::create(path)?) } /// Start a WAV in an already-opened file. This lets callers choose atomic /// create-new semantics instead of the truncating behavior of `File::create`. fn from_file(mut file: File) -> io::Result { file.write_all(&Self::header(0))?; Ok(Self { file, data_bytes: 0, }) } /// The 44-byte canonical WAV/PCM header for the given data length in bytes. fn header(data_bytes: u32) -> [u8; 44] { let byte_rate = SAMPLE_RATE * CHANNELS as u32 * (BITS_PER_SAMPLE as u32 / 8); let block_align = CHANNELS * (BITS_PER_SAMPLE / 8); let mut h = [0u8; 44]; h[0..4].copy_from_slice(b"RIFF"); h[4..8].copy_from_slice(&(36 + data_bytes).to_le_bytes()); h[8..12].copy_from_slice(b"WAVE"); h[12..16].copy_from_slice(b"fmt "); h[16..20].copy_from_slice(&16u32.to_le_bytes()); // fmt chunk size h[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM h[22..24].copy_from_slice(&CHANNELS.to_le_bytes()); h[24..28].copy_from_slice(&SAMPLE_RATE.to_le_bytes()); h[28..32].copy_from_slice(&byte_rate.to_le_bytes()); h[32..34].copy_from_slice(&block_align.to_le_bytes()); h[34..36].copy_from_slice(&BITS_PER_SAMPLE.to_le_bytes()); h[36..40].copy_from_slice(b"data"); h[40..44].copy_from_slice(&data_bytes.to_le_bytes()); h } /// Append PCM samples to the data chunk. pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> { let added_bytes = u64::try_from(samples.len()) .ok() .and_then(|len| len.checked_mul(2)) .ok_or_else(|| io::Error::other("WAV sample buffer too large"))?; let new_data_bytes = self .data_bytes .checked_add(added_bytes) .ok_or_else(|| io::Error::other("WAV data size overflow"))?; if new_data_bytes > MAX_RIFF_DATA_BYTES { return Err(io::Error::other("WAV too large for RIFF")); } let mut buf = Vec::with_capacity(samples.len() * 2); for &s in samples { buf.extend_from_slice(&s.to_le_bytes()); } self.file.write_all(&buf)?; self.data_bytes = new_data_bytes; Ok(()) } /// Patch the RIFF + data size fields and flush. Consumes the writer. pub fn finalize(mut self) -> io::Result<()> { let data_bytes = u32::try_from(self.data_bytes) .map_err(|_| io::Error::other("WAV too large for RIFF"))?; let riff_size = self .data_bytes .checked_add(RIFF_DATA_OVERHEAD) .and_then(|size| u32::try_from(size).ok()) .ok_or_else(|| io::Error::other("WAV too large for RIFF"))?; self.file.seek(SeekFrom::Start(4))?; self.file.write_all(&riff_size.to_le_bytes())?; self.file.seek(SeekFrom::Start(40))?; self.file.write_all(&data_bytes.to_le_bytes())?; self.file.flush()?; Ok(()) } } /// A live call recorder: a [`WavWriter`] plus a small mic FIFO that aligns your /// transmitted mic with the playout mixer's incoming-mix frames. pub struct Recorder { writer: WavWriter, /// Your transmitted mic samples, awaiting alignment with the next mix frame. mic_fifo: VecDeque, path: PathBuf, } impl Recorder { /// Create a recording at `dir/.wav`. The directory is assumed to /// exist (the caller creates it). pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result { let filename = timestamp_filename(now_unix_secs); let stem = filename.trim_end_matches(".wav"); for attempt in 1..=MAX_NAME_ATTEMPTS { let name = if attempt == 1 { filename.clone() } else { format!("{stem}-{attempt}.wav") }; let path = dir.join(name); match OpenOptions::new().write(true).create_new(true).open(&path) { Ok(file) => { return Ok(Self { writer: WavWriter::from_file(file)?, mic_fifo: VecDeque::new(), path, }); } Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, Err(e) => return Err(e), } } Err(io::Error::new( io::ErrorKind::AlreadyExists, "recording filename suffixes exhausted", )) } /// The path being written. pub fn path(&self) -> &Path { &self.path } /// Buffer a frame of your transmitted mic audio. Bounded: if the FIFO exceeds /// [`MAX_MIC_FIFO`] (capture outrunning playout), the oldest samples are /// dropped so the recording's mic offset can't grow without limit. pub fn push_mic(&mut self, frame: &[i16]) { self.mic_fifo.extend(frame.iter().copied()); let overflow = self.mic_fifo.len().saturating_sub(MAX_MIC_FIFO); if overflow > 0 { self.mic_fifo.drain(..overflow); } } /// Write one recording frame: the incoming mix summed (saturating) with the /// next aligned slice of buffered mic. Mic samples beyond what's buffered are /// treated as silence (you weren't transmitting), so quiet stretches record /// the incoming mix alone. pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> { let mut out = Vec::with_capacity(mixed.len()); for &m in mixed { let mic = self.mic_fifo.pop_front().unwrap_or(0); let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32); out.push(sum as i16); } self.writer.write_samples(&out) } /// Finish the file, patching its size fields. Consumes the recorder. pub fn finalize(self) -> io::Result<()> { self.writer.finalize() } } /// Civil date (year, month, day) from a count of days since the Unix epoch. /// Howard Hinnant's `civil_from_days`; valid across the whole practical range. fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719_468; let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097; let doe = z - era * 146_097; // [0, 146096] let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] let mp = (5 * doy + 2) / 153; // [0, 11] let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] (if m <= 2 { y + 1 } else { y }, m, d) } /// A sortable, human-readable recording filename from a Unix timestamp (UTC): /// `peerspeak-YYYY-MM-DD_HHMMSS.wav`. pub fn timestamp_filename(unix_secs: u64) -> String { let days = (unix_secs / 86_400) as i64; let rem = unix_secs % 86_400; let (y, m, d) = civil_from_days(days); let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); format!("peerspeak-{y:04}-{m:02}-{d:02}_{h:02}{mi:02}{s:02}.wav") } #[cfg(test)] mod tests { use super::*; #[test] fn timestamp_filename_is_utc_and_padded() { // 1_700_000_000 = 2023-11-14 22:13:20 UTC. assert_eq!( timestamp_filename(1_700_000_000), "peerspeak-2023-11-14_221320.wav" ); // Epoch. assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav"); } #[test] fn same_second_recordings_get_unique_files_without_truncation() { let dir = std::env::temp_dir().join(format!( "peerspeak-collision-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let mut first = Recorder::create(&dir, 1_700_000_000).unwrap(); first.write_frame(&[123, 456]).unwrap(); let first_path = first.path().to_path_buf(); first.finalize().unwrap(); let original = std::fs::read(&first_path).unwrap(); let second = Recorder::create(&dir, 1_700_000_000).unwrap(); let second_path = second.path().to_path_buf(); assert_ne!(second_path, first_path); assert_eq!(std::fs::read(&first_path).unwrap(), original); second.finalize().unwrap(); let _ = std::fs::remove_dir_all(&dir); } #[test] fn wav_header_round_trips_sizes() { let dir = std::env::temp_dir(); let path = dir.join(format!("peerspeak-test-{}.wav", std::process::id())); let mut w = WavWriter::new(&path).unwrap(); // 100 samples = 200 data bytes. w.write_samples(&[1234i16; 100]).unwrap(); w.finalize().unwrap(); let bytes = std::fs::read(&path).unwrap(); assert_eq!(&bytes[0..4], b"RIFF"); assert_eq!(&bytes[8..12], b"WAVE"); assert_eq!(&bytes[36..40], b"data"); // RIFF size = 36 + data, data = 200. let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]); assert_eq!(data, 200); assert_eq!(riff, 236); // File is header + data. assert_eq!(bytes.len(), 44 + 200); let _ = std::fs::remove_file(&path); } #[test] fn wav_writer_rejects_data_that_would_overflow_riff_header() { let dir = std::env::temp_dir(); let path = dir.join(format!("peerspeak-overflow-{}.wav", std::process::id())); let mut w = WavWriter::new(&path).unwrap(); w.data_bytes = MAX_RIFF_DATA_BYTES - 1; let before_len = std::fs::metadata(&path).unwrap().len(); let err = w.write_samples(&[0]).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::Other); assert_eq!(w.data_bytes, MAX_RIFF_DATA_BYTES - 1); assert_eq!(std::fs::metadata(&path).unwrap().len(), before_len); drop(w); let _ = std::fs::remove_file(&path); } #[test] fn mic_is_summed_with_mix_when_present() { let dir = std::env::temp_dir(); let mut r = Recorder { writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id()))) .unwrap(), mic_fifo: VecDeque::new(), path: PathBuf::new(), }; r.push_mic(&[1000, 2000, 3000]); // write_frame pops mic per-sample and sums; we can't read the file mid-stream, // so assert the FIFO drains exactly by frame length. r.write_frame(&[10, 20]).unwrap(); assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left"); r.write_frame(&[0, 0]).unwrap(); assert_eq!( r.mic_fifo.len(), 0, "remaining mic sample consumed; rest is silence" ); let _ = r.finalize(); } #[test] fn mic_fifo_is_capped() { let dir = std::env::temp_dir(); let mut r = Recorder { writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id()))) .unwrap(), mic_fifo: VecDeque::new(), path: PathBuf::new(), }; r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]); assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap"); let _ = r.finalize(); } }