feat(audio): write recordings as 16-bit PCM to halve .wav size (FR-CAP-8)
This commit is contained in:
1 parent
96b30ed99a
commit
34b4f4ae09
1 file changed
+41
-28
+41
-28
@@ -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()));
|
||||
|
||||
Reference in new issue
Block a user