Fix jitter restart and WAV size overflow

This commit is contained in:
2026-06-16 17:28:41 -04:00
parent 20643a24de
commit 44bad7b70b
3 changed files with 130 additions and 14 deletions
+55 -8
View File
@@ -22,6 +22,8 @@ use std::path::{Path, PathBuf};
const SAMPLE_RATE: u32 = 48_000; const SAMPLE_RATE: u32 = 48_000;
const BITS_PER_SAMPLE: u16 = 16; const BITS_PER_SAMPLE: u16 = 16;
const CHANNELS: u16 = 1; const CHANNELS: u16 = 1;
const RIFF_DATA_OVERHEAD: u64 = 36;
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift /// 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 /// if the capture clock runs persistently faster than playout — past this we drop
@@ -34,7 +36,7 @@ const MAX_MIC_FIFO: usize = SAMPLE_RATE as usize / 5;
pub struct WavWriter { pub struct WavWriter {
file: File, file: File,
/// Bytes of PCM data written so far (for the size fields). /// Bytes of PCM data written so far (for the size fields).
data_bytes: u32, data_bytes: u64,
} }
impl WavWriter { impl WavWriter {
@@ -42,7 +44,10 @@ impl WavWriter {
pub fn new(path: &Path) -> io::Result<Self> { pub fn new(path: &Path) -> io::Result<Self> {
let mut file = File::create(path)?; let mut file = File::create(path)?;
file.write_all(&Self::header(0))?; file.write_all(&Self::header(0))?;
Ok(Self { file, data_bytes: 0 }) Ok(Self {
file,
data_bytes: 0,
})
} }
/// The 44-byte canonical WAV/PCM header for the given data length in bytes. /// The 44-byte canonical WAV/PCM header for the given data length in bytes.
@@ -68,21 +73,40 @@ impl WavWriter {
/// Append PCM samples to the data chunk. /// Append PCM samples to the data chunk.
pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> { 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); let mut buf = Vec::with_capacity(samples.len() * 2);
for &s in samples { for &s in samples {
buf.extend_from_slice(&s.to_le_bytes()); buf.extend_from_slice(&s.to_le_bytes());
} }
self.file.write_all(&buf)?; self.file.write_all(&buf)?;
self.data_bytes += (samples.len() * 2) as u32; self.data_bytes = new_data_bytes;
Ok(()) Ok(())
} }
/// Patch the RIFF + data size fields and flush. Consumes the writer. /// Patch the RIFF + data size fields and flush. Consumes the writer.
pub fn finalize(mut self) -> io::Result<()> { 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.seek(SeekFrom::Start(4))?;
self.file.write_all(&(36 + self.data_bytes).to_le_bytes())?; self.file.write_all(&riff_size.to_le_bytes())?;
self.file.seek(SeekFrom::Start(40))?; self.file.seek(SeekFrom::Start(40))?;
self.file.write_all(&self.data_bytes.to_le_bytes())?; self.file.write_all(&data_bytes.to_le_bytes())?;
self.file.flush()?; self.file.flush()?;
Ok(()) Ok(())
} }
@@ -209,11 +233,29 @@ mod tests {
let _ = std::fs::remove_file(&path); 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] #[test]
fn mic_is_summed_with_mix_when_present() { fn mic_is_summed_with_mix_when_present() {
let dir = std::env::temp_dir(); let dir = std::env::temp_dir();
let mut r = Recorder { let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id()))).unwrap(), writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
.unwrap(),
mic_fifo: VecDeque::new(), mic_fifo: VecDeque::new(),
path: PathBuf::new(), path: PathBuf::new(),
}; };
@@ -223,7 +265,11 @@ mod tests {
r.write_frame(&[10, 20]).unwrap(); r.write_frame(&[10, 20]).unwrap();
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left"); assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
r.write_frame(&[0, 0]).unwrap(); r.write_frame(&[0, 0]).unwrap();
assert_eq!(r.mic_fifo.len(), 0, "remaining mic sample consumed; rest is silence"); assert_eq!(
r.mic_fifo.len(),
0,
"remaining mic sample consumed; rest is silence"
);
let _ = r.finalize(); let _ = r.finalize();
} }
@@ -231,7 +277,8 @@ mod tests {
fn mic_fifo_is_capped() { fn mic_fifo_is_capped() {
let dir = std::env::temp_dir(); let dir = std::env::temp_dir();
let mut r = Recorder { let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id()))).unwrap(), writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
.unwrap(),
mic_fifo: VecDeque::new(), mic_fifo: VecDeque::new(),
path: PathBuf::new(), path: PathBuf::new(),
}; };
+62 -3
View File
@@ -59,6 +59,10 @@ const PRIME_TIMEOUT_TICKS: usize = 25;
/// badly behind, so we drop the oldest and resync rather than grow unbounded. /// badly behind, so we drop the oldest and resync rather than grow unbounded.
const MAX_BUFFERED_FRAMES: usize = 32; const MAX_BUFFERED_FRAMES: usize = 32;
/// Sequence discontinuities larger than this (~10s at 20ms/frame) are treated
/// as a restarted/new stream, not ordinary packet loss or reordering.
const MAX_REASONABLE_SEQ_GAP: u32 = 500;
pub struct JitterBuffer { pub struct JitterBuffer {
decoder: OpusDecoder, decoder: OpusDecoder,
/// Reorder window: sequence number -> encoded Opus payload. /// Reorder window: sequence number -> encoded Opus payload.
@@ -116,6 +120,14 @@ impl JitterBuffer {
} }
} }
fn reset_to_stream(&mut self, seq: u32, payload: Vec<u8>) {
self.packets.clear();
self.packets.insert(seq, payload);
self.next_seq = None;
self.clean_run = 0;
self.buffering_ticks = 0;
}
/// Store a received packet, dropping ones we've already played past and /// Store a received packet, dropping ones we've already played past and
/// bounding total depth. /// bounding total depth.
pub fn insert(&mut self, seq: u32, payload: Vec<u8>) { pub fn insert(&mut self, seq: u32, payload: Vec<u8>) {
@@ -124,9 +136,19 @@ impl JitterBuffer {
if let Some(next) = self.next_seq if let Some(next) = self.next_seq
&& seq_before(seq, next) && seq_before(seq, next)
{ {
if next.wrapping_sub(seq) > MAX_REASONABLE_SEQ_GAP {
self.reset_to_stream(seq, payload);
return;
}
self.note_disruption(); self.note_disruption();
return; return;
} }
if let Some(next) = self.next_seq
&& seq.wrapping_sub(next) > MAX_REASONABLE_SEQ_GAP
{
self.reset_to_stream(seq, payload);
return;
}
self.packets.insert(seq, payload); self.packets.insert(seq, payload);
while self.packets.len() > MAX_BUFFERED_FRAMES { while self.packets.len() > MAX_BUFFERED_FRAMES {
@@ -283,6 +305,44 @@ mod tests {
assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered
} }
#[test]
fn far_behind_sequence_resets_as_restarted_stream() {
let mut jb = JitterBuffer::new().unwrap();
jb.next_seq = Some(5_000);
jb.packets.insert(5_000, vec![9]);
jb.clean_run = 12;
jb.buffering_ticks = 4;
jb.insert(0, vec![1]);
assert_eq!(jb.next_seq, None);
assert_eq!(jb.packets.len(), 1);
assert_eq!(jb.packets.get(&0).map(Vec::as_slice), Some(&[1][..]));
assert_eq!(jb.clean_run, 0);
assert_eq!(jb.buffering_ticks, 0);
}
#[test]
fn far_ahead_sequence_resets_to_bound_plc_run() {
let mut jb = JitterBuffer::new().unwrap();
jb.next_seq = Some(10);
jb.packets.insert(10, vec![9]);
jb.clean_run = 12;
jb.buffering_ticks = 4;
let jumped_seq = 10 + MAX_REASONABLE_SEQ_GAP + 1;
jb.insert(jumped_seq, vec![2]);
assert_eq!(jb.next_seq, None);
assert_eq!(jb.packets.len(), 1);
assert_eq!(
jb.packets.get(&jumped_seq).map(Vec::as_slice),
Some(&[2][..])
);
assert_eq!(jb.clean_run, 0);
assert_eq!(jb.buffering_ticks, 0);
}
#[test] #[test]
fn test_seq_before_ordering() { fn test_seq_before_ordering() {
// Basic ordering // Basic ordering
@@ -358,7 +418,7 @@ mod tests {
fn is_idle_reflects_buffer_state() { fn is_idle_reflects_buffer_state() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
let mut jb = JitterBuffer::new().unwrap(); let mut jb = JitterBuffer::new().unwrap();
// Fresh buffer // Fresh buffer
assert!(jb.is_idle()); assert!(jb.is_idle());
@@ -369,7 +429,7 @@ mod tests {
// Prime (3 frames) // Prime (3 frames)
jb.insert(1, frame(&mut enc, 1000)); jb.insert(1, frame(&mut enc, 1000));
jb.insert(2, frame(&mut enc, 1000)); jb.insert(2, frame(&mut enc, 1000));
// Drain past the end so it underruns // Drain past the end so it underruns
assert!(jb.pop_frame().is_some()); assert!(jb.pop_frame().is_some());
assert!(jb.pop_frame().is_some()); assert!(jb.pop_frame().is_some());
@@ -635,4 +695,3 @@ mod tests {
assert_eq!(jb.clean_run, 0); assert_eq!(jb.clean_run, 0);
} }
} }
+13 -3
View File
@@ -51,11 +51,21 @@ Proposed future scope:
Reason for not implementing: the current `run_playback` / `run_capture` code does not retain stream node or port ids, and changing `AUTOCONNECT` behavior plus adding manual `pw-link` calls could destabilize the working audio path. That matches the assignment's "bail if risky" instruction. Reason for not implementing: the current `run_playback` / `run_capture` code does not retain stream node or port ids, and changing `AUTOCONNECT` behavior plus adding manual `pw-link` calls could destabilize the working audio path. That matches the assignment's "bail if risky" instruction.
## Backlog A21/A22 - correctness fixes
- Fixed A21 in `src/core/jitter.rs`: implausibly large sequence discontinuities now reset the per-peer jitter stream instead of being treated as ordinary late packets or packet loss.
- The reset threshold is `500` frames, about 10 seconds at 20 ms/frame. That covers both same-identity sender restart back to sequence 0 and a faulty/malicious jump far ahead that would otherwise force a long PLC run.
- Added jitter regression tests for both far-behind restart and far-ahead jump cases.
- Fixed A22 in `src/audio/recorder.rs`: `WavWriter` now tracks data bytes as `u64`, checks additions before writing, and rejects data that cannot fit both the RIFF size field and the `data` chunk size field.
- Added a WAV overflow regression test that exercises the limit without creating a huge file.
Unverified: the same-identity peer restart has not been exercised in a live 2-machine call; the WAV fix is counter/size-field tested, not a real >12h recording.
## Verification ## Verification
- `cargo test --lib` passed: 285 passed, 0 failed, 2 ignored. - `cargo test --lib` passed: 288 passed, 0 failed, 2 ignored.
- `cargo clippy --all-targets` passed. - `cargo clippy --all-targets` passed.
- `cargo build --release` passed. - `cargo build --release` passed.
- `cargo fmt --check` reports broad repo-wide formatting diffs, including untouched files; I did not run `cargo fmt` to avoid unrelated churn. - Formatted the touched Rust files with `rustfmt --edition 2024`; I did not run repo-wide `cargo fmt` to avoid unrelated formatting churn.
No new dependencies were added. I did not run git commands. No new dependencies were added. Runtime/manual/field verification is still pending for audio-device and 2-machine behavior.