diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index d3e29fa..527a823 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -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 { - 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>, @@ -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, 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 = (0..2000).map(|i| (i as f32 / 2000.0) - 0.5).collect(); - let bytes: Vec = 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 = (0..2000).map(|i| (i as f32 / 2000.0) - 0.5).collect(); + let bytes: Vec = 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()));