fix(audio): mix mic into the recording at native rate via MicBridge (FR-CAP-7)
This commit is contained in:
+359
-26
@@ -22,9 +22,9 @@ use hound::{SampleFormat, WavSpec, WavWriter};
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
use wasapi::{
|
||||
@@ -154,6 +154,63 @@ pub fn list_capture_devices() -> Result<Vec<AudioDeviceInfo>, AudioError> {
|
||||
list_devices(&Direction::Capture)
|
||||
}
|
||||
|
||||
/// Shared channel the microphone capture fills and the loopback capture drains,
|
||||
/// so the user's voice is mixed into the recording at the loopback's native rate
|
||||
/// (FR-CAP-7 in the saved `.wav`, not just the transcript). The loopback thread
|
||||
/// publishes its `rate` once it opens so the mic knows what to resample to; `buf`
|
||||
/// is capped to ~0.5s to bound the two independent clocks' drift.
|
||||
#[cfg(feature = "audio")]
|
||||
pub struct MicBridge {
|
||||
rate: AtomicU32,
|
||||
buf: Mutex<std::collections::VecDeque<f32>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
impl MicBridge {
|
||||
pub fn shared() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
rate: AtomicU32::new(0),
|
||||
buf: Mutex::new(std::collections::VecDeque::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Loopback: take `n` mic samples aligned to this callback's frames, padding
|
||||
/// with silence if the mic hasn't produced enough yet (underrun).
|
||||
fn pull(&self, n: usize) -> Vec<f32> {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
if let Ok(mut buf) = self.buf.lock() {
|
||||
for _ in 0..n {
|
||||
out.push(buf.pop_front().unwrap_or(0.0));
|
||||
}
|
||||
} else {
|
||||
out.resize(n, 0.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mic: append resampled samples, dropping the oldest beyond `cap` so a
|
||||
/// faster mic clock (overrun) can't grow the buffer or the latency unbounded.
|
||||
fn push(&self, samples: &[f32], cap: usize) {
|
||||
if let Ok(mut buf) = self.buf.lock() {
|
||||
buf.extend(samples);
|
||||
while buf.len() > cap {
|
||||
buf.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of audio frames in a raw WASAPI byte buffer of the given format.
|
||||
#[cfg(feature = "audio")]
|
||||
fn frame_count(bytes: &[u8], format: &WaveFormat) -> usize {
|
||||
let bytes_per_frame = (format.get_bitspersample() as usize / 8) * format.get_nchannels() as usize;
|
||||
if bytes_per_frame == 0 {
|
||||
0
|
||||
} else {
|
||||
bytes.len() / bytes_per_frame
|
||||
}
|
||||
}
|
||||
|
||||
/// Default Windows WASAPI implementation (Phase 1, feature `audio`).
|
||||
#[cfg(feature = "audio")]
|
||||
pub struct WasapiCapture;
|
||||
@@ -164,6 +221,7 @@ impl WasapiCapture {
|
||||
/// `Some` only for the loopback stream (the byte-accurate recording); the mic
|
||||
/// stream produces frames for transcription but no WAV. `direction` selects
|
||||
/// loopback (Render) vs. plain microphone capture (Capture).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_capture(
|
||||
&self,
|
||||
thread_name: &str,
|
||||
@@ -172,6 +230,7 @@ impl WasapiCapture {
|
||||
device_id: Option<&str>,
|
||||
frame_sink: FrameSink,
|
||||
event_sink: EventSink,
|
||||
bridge: Option<Arc<MicBridge>>,
|
||||
) -> Result<CaptureHandle, AudioError> {
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
@@ -191,6 +250,7 @@ impl WasapiCapture {
|
||||
&event_sink,
|
||||
&running_th,
|
||||
&paused_th,
|
||||
bridge.as_ref(),
|
||||
)
|
||||
})
|
||||
.map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?;
|
||||
@@ -201,6 +261,48 @@ impl WasapiCapture {
|
||||
thread,
|
||||
})
|
||||
}
|
||||
|
||||
/// Loopback capture that mixes the microphone (delivered via `bridge`) into
|
||||
/// the recording, so the saved `.wav` contains both sides of the meeting at
|
||||
/// the loopback's native rate/channels (FR-CAP-7).
|
||||
pub fn start_loopback_recording(
|
||||
&self,
|
||||
wav_path: &Path,
|
||||
device_id: Option<&str>,
|
||||
frame_sink: FrameSink,
|
||||
event_sink: EventSink,
|
||||
bridge: Arc<MicBridge>,
|
||||
) -> Result<CaptureHandle, AudioError> {
|
||||
self.start_capture(
|
||||
"wa-audio-capture",
|
||||
Some(wav_path),
|
||||
Direction::Render,
|
||||
device_id,
|
||||
frame_sink,
|
||||
event_sink,
|
||||
Some(bridge),
|
||||
)
|
||||
}
|
||||
|
||||
/// Microphone capture that both feeds the live-transcript mixer (`frame_sink`)
|
||||
/// and pushes its audio into `bridge` for the loopback thread to record.
|
||||
pub fn start_microphone_recording(
|
||||
&self,
|
||||
device_id: Option<&str>,
|
||||
frame_sink: FrameSink,
|
||||
event_sink: EventSink,
|
||||
bridge: Arc<MicBridge>,
|
||||
) -> Result<CaptureHandle, AudioError> {
|
||||
self.start_capture(
|
||||
"wa-mic-capture",
|
||||
None,
|
||||
Direction::Capture,
|
||||
device_id,
|
||||
frame_sink,
|
||||
event_sink,
|
||||
Some(bridge),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -219,6 +321,7 @@ impl AudioCapture for WasapiCapture {
|
||||
device_id,
|
||||
frame_sink,
|
||||
event_sink,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,6 +338,7 @@ impl AudioCapture for WasapiCapture {
|
||||
device_id,
|
||||
frame_sink,
|
||||
event_sink,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -378,6 +482,7 @@ const LEVEL_EMIT_INTERVAL: Duration = Duration::from_millis(50);
|
||||
/// WASAPI client and the WAV writer; exits (and finalizes the WAV) once `running`
|
||||
/// is cleared.
|
||||
#[cfg(feature = "audio")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn capture_loop(
|
||||
wav_path: Option<&Path>,
|
||||
direction: Direction,
|
||||
@@ -386,15 +491,25 @@ fn capture_loop(
|
||||
event_sink: &EventSink,
|
||||
running: &AtomicBool,
|
||||
paused: &AtomicBool,
|
||||
bridge: Option<&Arc<MicBridge>>,
|
||||
) -> Result<CaptureSummary, AudioError> {
|
||||
wasapi::initialize_mta()
|
||||
.ok()
|
||||
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
|
||||
|
||||
let is_loopback = matches!(direction, Direction::Render);
|
||||
// Only the loopback stream emits level updates (FR-CAP-5) and writes a WAV;
|
||||
// the mic stream just contributes frames to the transcript mixer (FR-CAP-7).
|
||||
let emit_level = wav_path.is_some();
|
||||
let mut session = open_capture_session(&direction, device_id)?;
|
||||
// Loopback publishes its rate so the mic knows what to resample to before
|
||||
// pushing into the shared bridge (mic-into-recording, FR-CAP-7).
|
||||
if is_loopback {
|
||||
if let Some(b) = bridge {
|
||||
b.rate
|
||||
.store(session.format.get_samplespersec(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
let spec = wav_spec_for(&session.format)?;
|
||||
let mut writer = match wav_path {
|
||||
Some(path) => Some(WavWriter::create(path, spec).map_err(|e| {
|
||||
@@ -409,6 +524,9 @@ fn capture_loop(
|
||||
.map_err(|e| AudioError::Capture(format!("start_stream failed: {e}")))?;
|
||||
|
||||
let mut resampler = Resampler::new(session.format.get_samplespersec());
|
||||
// Mic-only: resamples the mic to the loopback's rate for the recording bridge
|
||||
// (created lazily once the loopback has published its rate).
|
||||
let mut bridge_resampler: Option<Resampler> = None;
|
||||
let mut queue: std::collections::VecDeque<u8> = std::collections::VecDeque::new();
|
||||
let mut frames_written: u64 = 0;
|
||||
let mut last_level_emit = Instant::now() - LEVEL_EMIT_INTERVAL;
|
||||
@@ -440,6 +558,13 @@ fn capture_loop(
|
||||
.start_stream()
|
||||
.map_err(|e| AudioError::Capture(format!("restart_stream failed: {e}")))?;
|
||||
resampler = Resampler::new(new_session.format.get_samplespersec());
|
||||
bridge_resampler = None;
|
||||
if is_loopback {
|
||||
if let Some(b) = bridge {
|
||||
b.rate
|
||||
.store(new_session.format.get_samplespersec(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
session = new_session;
|
||||
let _ = event_sink.try_send(CaptureEvent::DeviceChanged {
|
||||
recovered: true,
|
||||
@@ -458,11 +583,34 @@ fn capture_loop(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Loopback: mix the mic in per-frame as we write (mic silence when it
|
||||
// hasn't produced enough), so the recording holds both sides (FR-CAP-7).
|
||||
if let Some(w) = writer.as_mut() {
|
||||
frames_written += write_wav_bytes(w, &bytes, &session.format)?;
|
||||
let mic = match bridge {
|
||||
Some(b) if is_loopback => b.pull(frame_count(&bytes, &session.format)),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
frames_written += write_wav_bytes(w, &bytes, &session.format, &mic)?;
|
||||
}
|
||||
let mono = decode_mono_f32(&bytes, &session.format)?;
|
||||
|
||||
// Mic: feed the shared bridge (resampled to the loopback's rate) so the
|
||||
// loopback thread can fold it into the recording.
|
||||
if !is_loopback {
|
||||
if let Some(b) = bridge {
|
||||
let rate = b.rate.load(Ordering::Relaxed);
|
||||
if rate != 0 {
|
||||
let rs = bridge_resampler.get_or_insert_with(|| {
|
||||
Resampler::new_to(session.format.get_samplespersec(), rate)
|
||||
});
|
||||
let resampled = rs.process(&mono);
|
||||
if !resampled.is_empty() {
|
||||
b.push(&resampled, (rate / 2) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if emit_level && last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL {
|
||||
let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono)));
|
||||
last_level_emit = Instant::now();
|
||||
@@ -502,42 +650,59 @@ fn wav_spec_for(format: &WaveFormat) -> Result<WavSpec, AudioError> {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// quantizing the 32-bit-float mix down so `audio.wav` is ~half the size, and
|
||||
/// adding one `mic` sample per frame to every channel so the recording carries
|
||||
/// the user's voice too (FR-CAP-7); `mic` may be shorter than the frame count
|
||||
/// (missing entries mix as silence) or empty (loopback only). 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>>,
|
||||
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 u64;
|
||||
let channels = format.get_nchannels() as usize;
|
||||
if channels == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut frames = 0u64;
|
||||
match (sample_type, format.get_bitspersample()) {
|
||||
(SampleType::Float, 32) => {
|
||||
for chunk in bytes.chunks_exact(4) {
|
||||
let v = f32::from_le_bytes(chunk.try_into().unwrap());
|
||||
writer
|
||||
.write_sample(f32_to_i16(v))
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
for frame in bytes.chunks_exact(4 * channels) {
|
||||
let add = mic.get(frames as usize).copied().unwrap_or(0.0);
|
||||
for c in frame.chunks_exact(4) {
|
||||
let v = f32::from_le_bytes(c.try_into().unwrap()) + add;
|
||||
writer
|
||||
.write_sample(f32_to_i16(v))
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
}
|
||||
frames += 1;
|
||||
}
|
||||
Ok(bytes.len() as u64 / 4 / channels.max(1))
|
||||
}
|
||||
(SampleType::Int, 16) => {
|
||||
for chunk in bytes.chunks_exact(2) {
|
||||
let v = i16::from_le_bytes(chunk.try_into().unwrap());
|
||||
writer
|
||||
.write_sample(v)
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
for frame in bytes.chunks_exact(2 * channels) {
|
||||
let add = mic.get(frames as usize).copied().unwrap_or(0.0);
|
||||
for c in frame.chunks_exact(2) {
|
||||
let v = i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32 + add;
|
||||
writer
|
||||
.write_sample(f32_to_i16(v))
|
||||
.map_err(|e| AudioError::Capture(format!("wav write: {e}")))?;
|
||||
}
|
||||
frames += 1;
|
||||
}
|
||||
Ok(bytes.len() as u64 / 2 / channels.max(1))
|
||||
}
|
||||
(st, bits) => Err(AudioError::Device(format!(
|
||||
"unsupported capture format: {st} {bits}-bit"
|
||||
))),
|
||||
(st, bits) => {
|
||||
return Err(AudioError::Device(format!(
|
||||
"unsupported capture format: {st} {bits}-bit"
|
||||
)))
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// Quantize a `[-1.0, 1.0]` float sample to 16-bit PCM, clamping out-of-range
|
||||
@@ -594,14 +759,22 @@ const TARGET_SAMPLE_RATE: u32 = 16_000;
|
||||
/// resampler if transcription accuracy complaints trace back to aliasing.`
|
||||
struct Resampler {
|
||||
src_rate: u32,
|
||||
dst_rate: u32,
|
||||
pos: f64,
|
||||
carry: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Resampler {
|
||||
fn new(src_rate: u32) -> Self {
|
||||
Self::new_to(src_rate, TARGET_SAMPLE_RATE)
|
||||
}
|
||||
|
||||
/// Resample `src_rate` -> `dst_rate` (used for the mic->loopback-rate bridge
|
||||
/// as well as the 16kHz transcription path).
|
||||
fn new_to(src_rate: u32, dst_rate: u32) -> Self {
|
||||
Self {
|
||||
src_rate,
|
||||
dst_rate,
|
||||
pos: 0.0,
|
||||
carry: Vec::new(),
|
||||
}
|
||||
@@ -611,7 +784,7 @@ impl Resampler {
|
||||
if mono_in.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.src_rate == TARGET_SAMPLE_RATE {
|
||||
if self.src_rate == self.dst_rate {
|
||||
return mono_in.to_vec();
|
||||
}
|
||||
|
||||
@@ -622,7 +795,7 @@ impl Resampler {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let ratio = self.src_rate as f64 / TARGET_SAMPLE_RATE as f64;
|
||||
let ratio = self.src_rate as f64 / self.dst_rate as f64;
|
||||
let mut out = Vec::new();
|
||||
while (self.pos as usize) + 1 < buf.len() {
|
||||
let i = self.pos as usize;
|
||||
@@ -858,7 +1031,7 @@ mod tests {
|
||||
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();
|
||||
write_wav_bytes(&mut writer, &bytes, &format, &[]).unwrap();
|
||||
writer.finalize().unwrap();
|
||||
|
||||
let reader = hound::WavReader::open(&path).unwrap();
|
||||
@@ -880,6 +1053,56 @@ mod tests {
|
||||
assert_eq!(f32_to_i16(-2.0), -i16::MAX); // clamped to -32767
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_wav_bytes_mixes_the_mic_into_every_channel() {
|
||||
// Silent stereo loopback + one mic sample of 0.5 for the single frame
|
||||
// should land 0.5 (16383) on BOTH channels — the user's voice recorded.
|
||||
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 format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||
let mut writer = WavWriter::create(&path, wav_spec_for(&format).unwrap()).unwrap();
|
||||
// One stereo frame of silence (L=0.0, R=0.0).
|
||||
let bytes: Vec<u8> = [0.0f32, 0.0]
|
||||
.iter()
|
||||
.flat_map(|s| s.to_le_bytes())
|
||||
.collect();
|
||||
let frames = write_wav_bytes(&mut writer, &bytes, &format, &[0.5]).unwrap();
|
||||
writer.finalize().unwrap();
|
||||
assert_eq!(frames, 1);
|
||||
|
||||
let samples: Vec<i16> = hound::WavReader::open(&path)
|
||||
.unwrap()
|
||||
.into_samples::<i16>()
|
||||
.map(|s| s.unwrap())
|
||||
.collect();
|
||||
assert_eq!(samples, vec![16383, 16383]); // mic folded into both channels
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mic_bridge_pull_pads_with_silence_and_push_caps() {
|
||||
let bridge = MicBridge::shared();
|
||||
// Pull from empty -> all silence.
|
||||
assert_eq!(bridge.pull(3), vec![0.0, 0.0, 0.0]);
|
||||
// Push 5 with a cap of 3 -> only the newest 3 survive.
|
||||
bridge.push(&[1.0, 2.0, 3.0, 4.0, 5.0], 3);
|
||||
assert_eq!(bridge.pull(3), vec![3.0, 4.0, 5.0]);
|
||||
// Now empty again -> padded.
|
||||
assert_eq!(bridge.pull(2), vec![0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_new_to_downsamples_between_arbitrary_rates() {
|
||||
// 44.1kHz -> 16kHz still lands on a passthrough when rates match…
|
||||
let mut same = Resampler::new_to(16_000, 16_000);
|
||||
assert_eq!(same.process(&[0.1, 0.2, 0.3]), vec![0.1, 0.2, 0.3]);
|
||||
// …and produces fewer samples when downsampling (48k -> 44.1k).
|
||||
let mut down = Resampler::new_to(48_000, 44_100);
|
||||
let out = down.process(&(0..480).map(|i| i as f32).collect::<Vec<_>>());
|
||||
assert!(out.len() >= 430 && out.len() <= 450, "got {}", out.len());
|
||||
}
|
||||
|
||||
#[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()));
|
||||
@@ -1036,6 +1259,116 @@ mod tests {
|
||||
assert_eq!(ids.len(), devices.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "captures real loopback while playing a sound; run with --ignored"]
|
||||
fn loopback_16bit_wav_captures_played_audio() {
|
||||
// Definitive check that the 32-bit-float -> 16-bit write path (FR-CAP-8)
|
||||
// records real loopback audio (not silence): start capture, play a known
|
||||
// WAV through the default device, stop, and assert the 16-bit WAV has
|
||||
// non-zero samples.
|
||||
let dir = std::env::temp_dir().join(format!("wa-loop-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("audio.wav");
|
||||
let (frame_tx, _frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let (event_tx, _event_rx) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
|
||||
|
||||
let handle = match WasapiCapture.start(&path, None, frame_tx, event_tx) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("[loopback] could not open default render loopback: {e} (skipping)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Play an audible system sound through the default render device so
|
||||
// loopback has something to capture. PlaySync blocks for its duration.
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(New-Object Media.SoundPlayer 'C:\\Windows\\Media\\Alarm01.wav').PlaySync()",
|
||||
])
|
||||
.status();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let summary = WasapiCapture.stop(handle).unwrap();
|
||||
|
||||
let raw = std::fs::read(&path).unwrap();
|
||||
let data_at = raw.windows(4).position(|w| w == b"data").unwrap() + 8;
|
||||
let pcm = &raw[data_at..];
|
||||
let max_abs = pcm
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]).unsigned_abs())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
eprintln!(
|
||||
"[loopback] {}Hz {}ch, {} data bytes, peak i16={max_abs}",
|
||||
summary.sample_rate,
|
||||
summary.channels,
|
||||
pcm.len()
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(
|
||||
max_abs > 0,
|
||||
"16-bit loopback WAV is silent despite playing audio — real conversion bug"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "captures loopback+mic together while playing a sound; run with --ignored"]
|
||||
fn loopback_recording_with_mic_bridge_captures_played_audio() {
|
||||
// The exact production path when the mic is on: loopback + mic share a
|
||||
// MicBridge and the recording must still contain the loopback audio (mic
|
||||
// silence here just mixes in as 0). Proves the bridge doesn't zero or
|
||||
// deadlock the recording.
|
||||
let dir = std::env::temp_dir().join(format!("wa-mixrec-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("audio.wav");
|
||||
let bridge = MicBridge::shared();
|
||||
let (loop_tx, _lr) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let (mic_tx, _mr) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
|
||||
let (ev_tx, _ev) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
|
||||
let (ev_tx2, _ev2) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
|
||||
|
||||
let loop_h =
|
||||
match WasapiCapture.start_loopback_recording(&path, None, loop_tx, ev_tx, bridge.clone())
|
||||
{
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("[mixrec] no loopback device: {e} (skipping)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mic_h = WasapiCapture
|
||||
.start_microphone_recording(None, mic_tx, ev_tx2, bridge)
|
||||
.ok();
|
||||
|
||||
let _ = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(New-Object Media.SoundPlayer 'C:\\Windows\\Media\\Alarm01.wav').PlaySync()",
|
||||
])
|
||||
.status();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let summary = WasapiCapture.stop(loop_h).unwrap();
|
||||
if let Some(m) = mic_h {
|
||||
let _ = WasapiCapture.stop(m);
|
||||
}
|
||||
|
||||
let raw = std::fs::read(&path).unwrap();
|
||||
let data_at = raw.windows(4).position(|w| w == b"data").unwrap() + 8;
|
||||
let peak = raw[data_at..]
|
||||
.chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]).unsigned_abs())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
eprintln!(
|
||||
"[mixrec] {}Hz {}ch peak i16={peak}",
|
||||
summary.sample_rate, summary.channels
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(peak > 0, "combined loopback+mic recording is silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_render_device_resolves_a_real_enumerated_id() {
|
||||
let devices = list_render_devices().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user