Five confirmed findings from the 2026-06-22 adversarial bug sweep: - S-01: clamp PipeWire capture chunk size to the mapped slice before indexing, so a bad reported size can't panic (= process abort) from the RT capture callback. Extracted testable for_each_capture_sample. - F-04: reserve ring occupancy before publishing a frame on the PipeWire playback path (mirrors the cpal fix), preventing the RT consumer from popping an uncounted sample and wrapping fill_gauge to usize::MAX, which permanently wedged mixer pacing. Extracted publish_frame. - F-09: GameDetector::spawn now returns io::Result and retains its JoinHandle (joined on Drop); core fuses a closed watch receiver to None via next_game_change so a dead detector can't busy-loop select!. - F-08: collision-free recording paths — Recorder::create and the multitrack session dir use create_new/create_dir with bounded suffix retry, so two recordings in the same second no longer truncate the first. - S-02: bound the Windows SteamPath registry read (<=4 KiB, even length, re-checked type/returned length) before allocating/decoding. 403 lib tests pass (+6), clippy --all-targets clean. Implemented by Codex, reviewed + gates re-run by senior. Co-Authored-By: Codex <codex@openai.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
338 lines
13 KiB
Rust
338 lines
13 KiB
Rust
//! 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> {
|
|
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<Self> {
|
|
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<i16>,
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl Recorder {
|
|
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
|
|
/// exist (the caller creates it).
|
|
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
|
|
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();
|
|
}
|
|
}
|