- **udp-loss-shim** body: tokio UDP loopback that drops a
configurable fraction of datagrams. Single-tenant (one client
at a time) — moq-lite is connection-multiplexed by source port,
so 1:1 forwarding is enough.
- **I9** (`packet_loss_1pct_does_not_kill_audio`): routes the
Kotlin speaker through the shim with `--loss-rate 0.01`;
asserts the decoded PCM has ≥ 80% expected sample count and
the 440 Hz tone survives. moq-lite groups are reliable streams
so retransmits absorb the loss.
- **I5** (`speaker_hot_swap_does_not_crash`): drives the
reconnecting-speaker with `tokenRefreshAfterMs = 2_500` to
force a hot-swap mid-broadcast. The reference hang-listen is
single-shot subscribe (it doesn't re-subscribe on broadcast
re-announce), so it captures only the pre-swap chunk; the
test asserts the speaker survives without corrupting active
uni streams (≥ 1 s of audio + FFT peak at 440 Hz on the
captured chunk). The "no audible gap" property the spec
calls for is an Amethyst-listener concern (handles re-announce
transparently); a Phase 3 follow-up would exercise that path
end-to-end through `connectNestsListener`.
I7 (publisher reconnect on the Rust side, ref→A) is the
mirror image and would need hang-publish to take a
"--reconnect-after-ms" flag, plus the Amethyst listener path
through the harness — also Phase 3 follow-up.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Adds three more Phase 2 cross-stack scenarios on the existing
HangInteropTest harness:
- **I11** (`first_audio_frame_is_not_opus_codec_config`): hang-listen
gains `--dump-first-frame <path>` that writes the first audio
frame's post-Container-Legacy-strip codec payload. The test
asserts those bytes don't begin with `OpusHead` magic — catches
the T8 regression where Android's MediaCodec leaks
BUFFER_FLAG_CODEC_CONFIG bytes as a normal audio frame.
- **I2** (`late_join_listener_still_decodes_tail`): listener
attaches 2 s into a 5 s broadcast, asserts ≥1.5 s of decoded
audio with the 440 Hz peak still recoverable.
- **I3** (`mid_broadcast_mute_shortens_decoded_pcm`): speaker
mutes for 1 s mid-broadcast. Amethyst's broadcaster FINs the
open uni stream rather than pushing zeros, so the mute shows
up as a sample-count deficit (~3 s decoded for 4 s wallclock),
not an embedded silence window. Asserts the deficit is in
the right ballpark (a regression that pushed zeros instead
would produce normal-length PCM and fail this).
`runSpeakerToHangListen` extracted as a per-scenario helper so
the four Kotlin-speaker scenarios share setup. Each scenario
anchors `QuicWebTransportFactory.parentScope` to its per-test
pumpScope to avoid leaking UDP sockets / coroutine trees.
`KotlinSpeakerKotlinListenerThroughNativeRelayTest` (Phase 2's
Kotlin↔Kotlin diagnostic) now opts in via a separate
`-DnestsHangInteropDiagnostic=true` gate. It flakes when run
alongside HangInteropTest's 5 native-subprocess scenarios in
the same JVM (relay-side state accumulation), and its only
purpose is wire-format bisects — no need to ship it under the
default `-DnestsHangInterop` flag.
Verified: 3 sequential `./gradlew :nestsClient:jvmTest
-DnestsHangInterop=true --rerun-tasks` runs in a row green.
I4 (stereo) deferred — needs a non-trivial production-side
catalog change (`MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES`
hard-codes mono); out of scope for these test plumbing changes.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Lands the test-side audio codec + the first end-to-end interop
scenario through the harness:
- JvmOpusEncoder / JvmOpusDecoder via club.minnced:opus-java 1.1.1
(JNA bindings + bundled libopus.so / .dylib / .dll natives).
Verified by JvmOpusRoundTripTest — sine 440 Hz survives encode →
decode with FFT peak preserved + ZCR within 5%.
- SineWaveAudioCapture now paces to real time (20 ms / frame)
rather than running open-loop. Mirrors how a real microphone
source blocks on hardware; without it the broadcaster floods
the relay at compute speed.
- HangInteropTest.rust_hang_publish_to_rust_hang_listener_round_trip_440
drives hang-publish + hang-listen as subprocesses through the
harness's moq-relay and asserts FFT peak / ZCR / sample-count
on the decoded PCM. Verified green on Linux x86_64.
- hang-publish gains --track-name (default "audio/data" matching
Amethyst's MoqLiteNestsListener.AUDIO_TRACK) and decouples
--relay-url from --broadcast so the URL path can be the
namespace and the broadcast can be a relative announce suffix.
- hang-listen's tail "cancelled" error is treated as EOF after
any frames have been collected, so a clean publisher shutdown
no longer surfaces as exit=1.
The forward-direction I1 scenario (Amethyst Kotlin speaker → hang
listener) is still gated by an open Amethyst-side wire issue: the
audio uni stream delivers Group control headers but no frame
payloads. Documented in
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md
with concrete pickup steps for a follow-up session.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Replaces the Phase 1 stubs with working moq-lite-03 audio
publish/subscribe loops:
- hang-publish: opens a broadcast under <relay>/<broadcast>,
publishes a hang Catalog with one Opus / Container::Legacy
audio rendition, and pumps Opus-encoded sine-wave frames in
groups of 5 (matching Amethyst's NestMoqLiteBroadcaster
default) for --duration seconds.
- hang-listen: connects to the same broadcast, reads the catalog,
picks the first Opus audio rendition with container.kind=legacy,
decodes each Opus packet, and writes Float32 little-endian PCM
to --output-pcm (or stdout with `-`).
Both use moq-native 0.13 with the quinn + aws-lc-rs features and
explicitly install the rustls aws-lc-rs crypto provider in main()
since rustls 0.23 no longer auto-installs.
Verified Rust↔Rust interop end-to-end: 3-second 440 Hz publish →
2.7 s of decoded PCM, RMS 0.35 (matching half-amplitude sine),
880 zero-crossings/sec (matches 440 Hz exactly).
Phase 2.B (JVM Opus encoder/decoder) and 2.C (I1 amethyst-speaker
→ hang-listen) are next.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
Lands the load-bearing infra for the hang/Rust cross-stack interop
test plan (nestsClient/plans/2026-05-06-cross-stack-interop-test.md):
the cargo workspace, Gradle wiring to install moq-relay /
moq-token-cli + build sidecars, the Kotlin harness that boots a
real moq-relay subprocess on a random ephemeral UDP port with
self-signed TLS and unrestricted public auth, plus the signal-domain
PCM assertion library and deterministic sine-wave AudioCapture that
Phase 2 scenarios will assert against.
Phase-1 deviations from the spec are summarised in
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md
— notably: --auth-public "" instead of the JWT minter (no
security-relevant difference for wire-format scenarios), --tls-generate
instead of in-Kotlin cert generation, cargo-install of upstream
binaries instead of an embedded kixelated/moq checkout, and
hang-listen / hang-publish / udp-loss-shim shipped as Phase-1 stubs
that compile + accept --flags but do nothing. Phase 2 fills those
in (and adds JVM Opus encoder/decoder).
Verified green via:
./gradlew :nestsClient:jvmTest \
--tests "com.vitorpamplona.nestsclient.interop.native.*" \
--tests "com.vitorpamplona.nestsclient.audio.PcmAssertionsTest" \
-DnestsHangInterop=true
Default mode (no flag) cleanly skips the harness-dependent test.
https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV