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
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:
+32
-10
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user