feat(probe): --mic-burst — real-client mic pacing for jitter-buffer regression tests
apple / swift (push) Successful in 1m8s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled

The steady 5 ms mic-test cadence never trips host-side buffering bugs:
the WASAPI crackle (fixed in the previous commit) only reproduced under
a real client's bursty input tap. --mic-burst paces the tone the same
way (two 20 ms Opus packets every 40 ms), so recording the host mic and
counting silence gaps regression-tests the jitter buffer headlessly.
Validated against the fixed Windows host on the lab box: 15 s of bursty
tone, zero mid-stream gaps >=3 ms (gaps confined to the first 40 ms
priming window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:35:49 +00:00
parent 00acf5e44e
commit 136f6e8f0e
+32 -10
View File
@@ -41,7 +41,7 @@
//! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--remode WxHxFPS:SECS] //! Usage: `punktfunk-probe [--connect HOST:PORT] [--mode WxHxFPS] [--remode WxHxFPS:SECS]
//! [--out FILE] [--bitrate KBPS] [--codec auto|h264|hevc|av1] [--audio-channels 2|6|8] //! [--out FILE] [--bitrate KBPS] [--codec auto|h264|hevc|av1] [--audio-channels 2|6|8]
//! [--launch APP] [--name NAME] [--speed-test KBPS:MS] //! [--launch APP] [--name NAME] [--speed-test KBPS:MS]
//! [--input-test | --mic-test | --touch-test | --rich-input-test] //! [--input-test | --mic-test [--mic-burst] | --touch-test | --rich-input-test]
//! [--pin HEX | --pair PIN] [--compositor NAME] [--gamepad NAME] | --discover [SECS]` //! [--pin HEX | --pair PIN] [--compositor NAME] [--gamepad NAME] | --discover [SECS]`
//! Env: `PUNKTFUNK_CLIENT_10BIT=1` / `PUNKTFUNK_CLIENT_444=1` advertise the 10-bit / 4:4:4 caps. //! Env: `PUNKTFUNK_CLIENT_10BIT=1` / `PUNKTFUNK_CLIENT_444=1` advertise the 10-bit / 4:4:4 caps.
@@ -65,6 +65,9 @@ struct Args {
input_test: bool, input_test: bool,
/// `--mic-test` — stream a synthetic 440 Hz tone as the mic uplink (proves the mic path). /// `--mic-test` — stream a synthetic 440 Hz tone as the mic uplink (proves the mic path).
mic_test: bool, mic_test: bool,
/// `--mic-burst` — pace the mic-test like a real client's input tap (2× 20 ms per 40 ms),
/// the arrival shape that exercises host-side jitter buffering.
mic_burst: bool,
/// `--touch-test` — drag a synthetic finger in a circle (proves the touch path). /// `--touch-test` — drag a synthetic finger in a circle (proves the touch path).
touch_test: bool, touch_test: bool,
/// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs /// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs
@@ -205,6 +208,7 @@ fn parse_args() -> Args {
out: get("--out").map(String::from), out: get("--out").map(String::from),
input_test: argv.iter().any(|a| a == "--input-test"), input_test: argv.iter().any(|a| a == "--input-test"),
mic_test: argv.iter().any(|a| a == "--mic-test"), mic_test: argv.iter().any(|a| a == "--mic-test"),
mic_burst: argv.iter().any(|a| a == "--mic-burst"),
touch_test: argv.iter().any(|a| a == "--touch-test"), touch_test: argv.iter().any(|a| a == "--touch-test"),
rich_input_test: argv.iter().any(|a| a == "--rich-input-test"), rich_input_test: argv.iter().any(|a| a == "--rich-input-test"),
pin, pin,
@@ -740,9 +744,16 @@ async fn session(args: Args) -> Result<()> {
}); });
} }
// Mic plane: stream a synthetic 440 Hz tone as the mic uplink (0xCB), Opus-encoded 5 ms // Mic plane: stream a synthetic 440 Hz tone as the mic uplink (0xCB) — proves client→host
// stereo frames — proves client→host mic passthrough end to end without a real microphone // mic passthrough end to end without a real microphone (the host decodes it into its virtual
// (the host decodes it into its virtual PipeWire source; record that source to hear the tone). // source; record that source to hear the tone). Two pacing modes:
// default — Opus 5 ms frames on a steady 5 ms tick (smooth arrival).
// --mic-burst — two 20 ms Opus frames back-to-back every 40 ms, replicating a real
// client's input-tap cadence (the Mac client's AVAudioEngine tap yields
// ~2048-frame buffers → two packets per ~42 ms). This is the arrival
// pattern that exposed the Windows host's missing jitter buffer (constant
// crackle, 2026-07-03): a steady 5 ms stream never trips it. Record the
// host mic and count silence gaps to regression-test host-side buffering.
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
if args.mic_test { if args.mic_test {
tracing::warn!("--mic-test requires Linux (libopus) — skipped"); tracing::warn!("--mic-test requires Linux (libopus) — skipped");
@@ -750,6 +761,7 @@ async fn session(args: Args) -> Result<()> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if args.mic_test { if args.mic_test {
let conn2 = conn.clone(); let conn2 = conn.clone();
let burst = args.mic_burst;
tokio::spawn(async move { tokio::spawn(async move {
let mut enc = let mut enc =
match opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip) { match opus::Encoder::new(48_000, opus::Channels::Stereo, opus::Application::Voip) {
@@ -760,15 +772,23 @@ async fn session(args: Args) -> Result<()> {
} }
}; };
let _ = enc.set_bitrate(opus::Bitrate::Bits(64_000)); let _ = enc.set_bitrate(opus::Bitrate::Bits(64_000));
tracing::info!("mic-test: streaming a 440 Hz tone as the mic uplink"); // Frame size + tick per pacing mode; `per_tick` packets are sent back-to-back.
let (frame, tick_ms, per_tick) = if burst {
(960usize, 40u64, 2u32) // 2× 20 ms every 40 ms — the bursty real-client shape
} else {
(240usize, 5u64, 1u32) // 5 ms frames on a smooth tick
};
tracing::info!(burst, "mic-test: streaming a 440 Hz tone as the mic uplink");
let mut phase = 0.0f32; let mut phase = 0.0f32;
let step = 2.0 * std::f32::consts::PI * 440.0 / 48_000.0; let step = 2.0 * std::f32::consts::PI * 440.0 / 48_000.0;
let mut pcm = [0f32; 240 * 2]; // 5 ms stereo let mut pcm = vec![0f32; frame * 2];
let mut out = [0u8; 4000]; let mut out = [0u8; 4000];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(5)); let mut interval = tokio::time::interval(std::time::Duration::from_millis(tick_ms));
for seq in 0u32.. { let mut seq = 0u32;
'stream: loop {
interval.tick().await; interval.tick().await;
for f in 0..240 { for _ in 0..per_tick {
for f in 0..frame {
let s = (phase.sin()) * 0.25; let s = (phase.sin()) * 0.25;
phase += step; phase += step;
if phase > std::f32::consts::PI * 2.0 { if phase > std::f32::consts::PI * 2.0 {
@@ -780,9 +800,11 @@ async fn session(args: Args) -> Result<()> {
if let Ok(n) = enc.encode_float(&pcm, &mut out) { if let Ok(n) = enc.encode_float(&pcm, &mut out) {
let d = punktfunk_core::quic::encode_mic_datagram(seq, now_ns(), &out[..n]); let d = punktfunk_core::quic::encode_mic_datagram(seq, now_ns(), &out[..n]);
if conn2.send_datagram(d.into()).is_err() { if conn2.send_datagram(d.into()).is_err() {
break; break 'stream;
} }
} }
seq = seq.wrapping_add(1);
}
} }
tracing::info!("mic-test: done"); tracing::info!("mic-test: done");
}); });