feat(audio): write recordings as 16-bit PCM to halve .wav size (FR-CAP-8)

This commit is contained in:
iamdoubz committed 2026-07-06 17:51:53 -05:00
1 parent 96b30ed99a
commit 34b4f4ae09
1 file changed
+41 -28
+41 -28
View File
@@ -9,9 +9,11 @@
//! crate's actual source and doesn't hold for this dependency; the capture loop
//! below waits on the WASAPI event handle (with a short timeout so it can also
//! notice a stop/pause request) rather than busy-polling.
//! - Writes PCM to disk continuously, in the device's native format, so `audio.wav`
//! is a byte-accurate capture (audio = source of truth). A downmixed/resampled
//! 16kHz-mono copy is pushed into the bounded `FrameSink` for live transcription;
//! - Writes PCM to disk continuously as 16-bit at the device's native rate/channels
//! (FR-CAP-8), so `audio.wav` is a compact but faithful recording (audio = source
//! of truth) — about half the size of the 32-bit-float mix. A downmixed/resampled
//! 16kHz-mono copy (from the full-resolution samples) is pushed into the bounded
//! `FrameSink` for live transcription;
//! a full consumer drops preview frames (`try_send`) without blocking the disk
//! write or the capture thread.
//! - Does no inference itself.
@@ -486,25 +488,23 @@ fn capture_loop(
})
}
/// The on-disk recording is always 16-bit PCM at the device's native rate and
/// channel count — half the size of the 32-bit-float mix format WASAPI hands us,
/// with no perceptible quality loss for speech (FR-CAP-8). The full-resolution
/// native samples still feed transcription untouched (see `decode_mono_f32`).
fn wav_spec_for(format: &WaveFormat) -> Result<WavSpec, AudioError> {
let sample_format = match format
.get_subformat()
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?
{
SampleType::Float => SampleFormat::Float,
SampleType::Int => SampleFormat::Int,
};
Ok(WavSpec {
channels: format.get_nchannels(),
sample_rate: format.get_samplespersec(),
bits_per_sample: format.get_bitspersample(),
sample_format,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
})
}
/// Write raw WASAPI capture bytes to the WAV writer in their native format.
/// Returns the number of frames written. Only the two mix formats WASAPI shared
/// mode actually produces in practice (32-bit float, 16-bit PCM) are supported;
/// Write raw WASAPI capture bytes to the WAV writer as 16-bit PCM (FR-CAP-8),
/// quantizing the 32-bit-float mix down on the way so `audio.wav` is ~half the
/// size. Returns the number of frames written. Only the two mix formats WASAPI
/// shared mode actually produces (32-bit float, 16-bit PCM) are supported;
/// anything else is a loud error rather than a silently corrupt recording.
fn write_wav_bytes(
writer: &mut WavWriter<BufWriter<File>>,
@@ -520,7 +520,7 @@ fn write_wav_bytes(
for chunk in bytes.chunks_exact(4) {
let v = f32::from_le_bytes(chunk.try_into().unwrap());
writer
.write_sample(v)
.write_sample(f32_to_i16(v))
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
}
Ok(bytes.len() as u64 / 4 / channels.max(1))
@@ -540,6 +540,12 @@ fn write_wav_bytes(
}
}
/// Quantize a `[-1.0, 1.0]` float sample to 16-bit PCM, clamping out-of-range
/// values so an over-unity sample wraps to a click instead of full-scale noise.
fn f32_to_i16(v: f32) -> i16 {
(v.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
}
/// Downmix raw WASAPI capture bytes to mono `f32` in `[-1.0, 1.0]`, at the
/// device's native sample rate (resampling to 16kHz happens separately).
fn decode_mono_f32(bytes: &[u8], format: &WaveFormat) -> Result<Vec<f32>, AudioError> {
@@ -835,24 +841,23 @@ mod tests {
}
#[test]
fn wav_writer_roundtrip_produces_valid_header_and_duration() {
fn wav_writer_writes_16bit_from_a_float_mix_and_keeps_duration() {
// A 32-bit-float mix is captured but written as 16-bit PCM at the same
// rate/channels (FR-CAP-8) — half the bytes, same frame count.
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("audio.wav");
let spec = WavSpec {
channels: 2,
sample_rate: 48_000,
bits_per_sample: 32,
sample_format: SampleFormat::Float,
};
let mut writer = WavWriter::create(&path, spec).unwrap();
let samples: Vec<f32> = (0..2000).map(|i| (i as f32 / 2000.0) - 0.5).collect();
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
// format.get_subformat()/get_bitspersample() need a real WaveFormat; build one
// via WaveFormat::new instead of a live device for this pure unit test.
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
let spec = wav_spec_for(&format).unwrap();
assert_eq!(spec.bits_per_sample, 16);
assert_eq!(spec.sample_format, SampleFormat::Int);
let mut writer = WavWriter::create(&path, spec).unwrap();
let samples: Vec<f32> = (0..2000).map(|i| (i as f32 / 2000.0) - 0.5).collect();
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
write_wav_bytes(&mut writer, &bytes, &format).unwrap();
writer.finalize().unwrap();
@@ -860,13 +865,21 @@ mod tests {
let read_spec = reader.spec();
assert_eq!(read_spec.channels, 2);
assert_eq!(read_spec.sample_rate, 48_000);
assert_eq!(read_spec.bits_per_sample, 32);
assert_eq!(read_spec.bits_per_sample, 16);
let frame_count = reader.duration();
assert_eq!(frame_count as usize, samples.len() / 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn f32_to_i16_clamps_and_scales() {
assert_eq!(f32_to_i16(0.0), 0);
assert_eq!(f32_to_i16(1.0), i16::MAX);
assert_eq!(f32_to_i16(2.0), i16::MAX); // clamped, no wrap
assert_eq!(f32_to_i16(-2.0), -i16::MAX); // clamped to -32767
}
#[test]
fn read_wav_mono_16k_reads_a_normally_finalized_file() {
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));