feat(audio): write_wav_split — stereo mic-left/loopback-right capture (FR-SPK)

New split writer + a `split` flag through the capture path: when the mic is on,
audio.wav is recorded 2ch with the mic on the left channel and the loopback
(downmixed) on the right, keeping the two sources separate in the file. Same
byte size as today's summed stereo. Transcription still downmixes L+R (= the
summed signal). Unit test covers L=mic / R=loopback.
This commit is contained in:
iamdoubz committed 2026-07-14 12:04:44 -05:00
1 parent dec9251e7b
commit 5e5f61f3b7
1 file changed
+102 -7
+102 -7
View File
@@ -388,6 +388,7 @@ impl WasapiCapture {
bridge: Option<Arc<MicBridge>>,
voice_sample: Option<Arc<VoiceSample>>,
mic_activity: Option<Arc<MicActivity>>,
split: bool,
emit_level: bool,
) -> Result<CaptureHandle, AudioError> {
let running = Arc::new(AtomicBool::new(true));
@@ -411,6 +412,7 @@ impl WasapiCapture {
bridge.as_ref(),
voice_sample.as_ref(),
mic_activity.as_ref(),
split,
emit_level,
)
})
@@ -445,7 +447,8 @@ impl WasapiCapture {
Some(bridge),
None,
mic_activity,
true,
true, // split: mic-enabled loopback records mic-left / loopback-right
true, // emit_level
)
}
@@ -471,7 +474,8 @@ impl WasapiCapture {
Some(bridge),
voice_sample,
None,
true,
false, // split: mic stream has no writer
true, // emit_level
)
}
}
@@ -495,7 +499,8 @@ impl AudioCapture for WasapiCapture {
None,
None,
None,
true,
false, // split: loopback-only / mic stream — no split writer
true, // emit_level
)
}
@@ -515,7 +520,8 @@ impl AudioCapture for WasapiCapture {
None,
None,
None,
true,
false, // split: loopback-only / mic stream — no split writer
true, // emit_level
)
}
@@ -689,6 +695,10 @@ fn capture_loop(
bridge: Option<&Arc<MicBridge>>,
voice_sample: Option<&Arc<VoiceSample>>,
mic_activity: Option<&Arc<MicActivity>>,
// FR-SPK: when true, the loopback WAV is stereo L=mic / R=loopback (the mic
// is a separate track, not summed in) so diarization can run on the far side
// alone. Only meaningful for the loopback writer.
split: bool,
emit_level: bool,
) -> Result<CaptureSummary, AudioError> {
wasapi::initialize_mta()
@@ -711,7 +721,10 @@ fn capture_loop(
ma.set_rate(session.format.get_samplespersec());
}
}
let spec = wav_spec_for(&session.format)?;
let mut spec = wav_spec_for(&session.format)?;
if split && is_loopback {
spec.channels = 2; // L = mic, R = loopback (downmixed)
}
let mut writer = match wav_path {
Some(path) => Some(WavWriter::create(path, spec).map_err(|e| {
AudioError::Capture(format!("could not create {}: {e}", path.display()))
@@ -806,7 +819,11 @@ fn capture_loop(
* (session.format.get_bitspersample() as usize / 8);
let silence = vec![0u8; gap_frames * bpf];
let before = frames_written;
frames_written += write_wav_bytes(w, &silence, &session.format, &mic)?;
frames_written += if split {
write_wav_split(w, &silence, &session.format, &mic)?
} else {
write_wav_bytes(w, &silence, &session.format, &mic)?
};
if let Some(ma) = mic_activity {
ma.mark(before, &mic);
}
@@ -833,7 +850,11 @@ fn capture_loop(
_ => Vec::new(),
};
let before = frames_written;
frames_written += write_wav_bytes(w, &bytes, &session.format, &mic)?;
frames_written += if split {
write_wav_split(w, &bytes, &session.format, &mic)?
} else {
write_wav_bytes(w, &bytes, &session.format, &mic)?
};
if let Some(ma) = mic_activity {
ma.mark(before, &mic);
}
@@ -966,6 +987,53 @@ fn f32_to_i16(v: f32) -> i16 {
(v.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
}
/// Split layout (FR-SPK): write frames as **stereo, L = mic, R = loopback**
/// (downmixed to mono), so the two sources stay separate in `audio.wav` and
/// diarization can run on the far side alone. `loopback_bytes` is the raw WASAPI
/// mix buffer (all-zero for a synthesized-silence chunk); `mic` may be shorter
/// than the frame count (rest silent) or empty (mic underran). Returns frames.
fn write_wav_split(
writer: &mut WavWriter<BufWriter<File>>,
loopback_bytes: &[u8],
format: &WaveFormat,
mic: &[f32],
) -> Result<u64, AudioError> {
let sample_type = format
.get_subformat()
.map_err(|e| AudioError::Device(format!("unrecognized mix format: {e}")))?;
let channels = format.get_nchannels() as usize;
if channels == 0 {
return Ok(0);
}
let (bytes_per_sample, decode): (usize, fn(&[u8]) -> f32) = match (sample_type, format.get_bitspersample()) {
(SampleType::Float, 32) => (4, |c| f32::from_le_bytes(c.try_into().unwrap())),
(SampleType::Int, 16) => (2, |c| {
i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32
}),
(st, bits) => {
return Err(AudioError::Device(format!(
"unsupported capture format: {st} {bits}-bit"
)))
}
};
let mut frames = 0u64;
for frame in loopback_bytes.chunks_exact(bytes_per_sample * channels) {
// R = loopback downmixed to mono.
let loop_mono =
frame.chunks_exact(bytes_per_sample).map(decode).sum::<f32>() / channels as f32;
// L = mic for this frame (silent if the mic underran).
let mic_s = mic.get(frames as usize).copied().unwrap_or(0.0);
writer
.write_sample(f32_to_i16(mic_s))
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
writer
.write_sample(f32_to_i16(loop_mono))
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
frames += 1;
}
Ok(frames)
}
/// 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> {
@@ -1360,6 +1428,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_wav_split_puts_mic_left_and_loopback_right() {
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");
// 2ch float loopback input; split output is always 2ch 16-bit.
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
let spec = WavSpec {
channels: 2,
sample_rate: 48_000,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create(&path, spec).unwrap();
// One 2ch loopback frame [0.4, 0.6] → R = downmix 0.5; mic 0.2 → L.
let loop_bytes: Vec<u8> = [0.4f32, 0.6].iter().flat_map(|s| s.to_le_bytes()).collect();
let n = write_wav_split(&mut writer, &loop_bytes, &format, &[0.2]).unwrap();
writer.finalize().unwrap();
assert_eq!(n, 1);
let mut reader = hound::WavReader::open(&path).unwrap();
let s: Vec<i16> = reader.samples::<i16>().map(|x| x.unwrap()).collect();
assert!((s[0] as f32 / i16::MAX as f32 - 0.2).abs() < 0.001); // L = mic
assert!((s[1] as f32 / i16::MAX as f32 - 0.5).abs() < 0.001); // R = loopback downmix
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn f32_to_i16_clamps_and_scales() {
assert_eq!(f32_to_i16(0.0), 0);