8 plan files updated to reflect what's actually shipped vs. what each doc said before: - 2026-05-06-cross-stack-interop-test.md: 'Spec — ready to implement' → 'Implemented and merged' with pointers to results, gap matrix, and closure roadmap. - 2026-05-06-cross-stack-interop-test-results.md: scenario inventory updated to show all branches merged into claude/cross-stack-interop-test-XAbYB; suite-flake caveats flagged with cross-refs to the routing investigation; file inventory now lists BrowserInteropTest + PlaywrightDriver + the moved nestsClient/tests/browser-interop/ tree (no more 'in sister branches not yet merged' section). - 2026-05-06-cross-stack-interop-test-gap-matrix.md: T8/T10/T11/ T12/T13 all show hang ✅ + browser ✅; I13/I14 'NOT YET LANDED' callouts removed; the 'in sister branch' qualifier on T13 / I7 / I6 dropped; coverage-holes section replaced with caveat pointers to the routing investigation. - 2026-05-06-i4-stereo-cross-stack-scenario.md: 'Spec — ready for implementation' → 'Landed' with PR #2755 + the three test scenarios that ship the assertion. - 2026-05-06-phase4-browser-harness.md: 'Spec — ready for implementation' → 'Landed', with notes on how the implementation diverged (used Container.Legacy.Consumer not @moq/watch; cert pinning via serverCertificateHashes; I2/I3/I4/I13/I14 originally deferred but later landed). - 2026-05-06-phase4-browser-harness-results.md: status updated to reflect 4.D CI removed per maintainer ask; layout note for the nestsClient/tests/browser-interop/ relocation; full scenario list reconciled with results doc. - 2026-05-07-late-join-catalog-flake-investigation.md: status updated to mention00f6cba31+207057374were reverted, and link to 2026-05-07-moq-relay-routing-investigation.md as the action plan. Adds note that the flake also affects four browser-tier scenarios after Browser I7 landed. - 2026-05-01-quic-stream-cliff-investigation.md: adds an HCgOY- override banner — recommendation 2 (framesPerGroup = 5) was superseded 4 days later by HCgOY field tests landing the prod default at 50. Cross-refs the reconciliation + production- rerun plans. Open follow-up #3 ('reset to 1') updated with the same context. No code or test changes.
15 KiB
Plan: I4 stereo cross-stack interop scenario
Status: ✅ Landed. Production-side change merged to main as
PR #2755 (refactor(nests): per-stream channel count + AudioBroadcastConfig). Test scenarios merged into
claude/cross-stack-interop-test-XAbYB:
HangInteropTest.amethyst_speaker_to_hang_listener_stereo_440_660(forward)HangInteropTest.rust_hang_publish_stereo_to_kotlin_listener_440_660(reverse)BrowserInteropTest.chromium_listener_stereo_440_660(forward, browser-tier)
The spec text below is preserved for archaeology.
Origin: nestsClient/plans/2026-05-06-cross-stack-interop-test.md
table row I4: "Stereo Opus (numberOfChannels=2); freq differs L/R
(440 / 660)". Both forward (Amethyst speaker → hang-listen) and
reverse (hang-publish → Amethyst listener) are P0.
Branch convention: new branch — don't fold into the cross-
stack-test branch. Suggested name feat/nests-stereo-broadcast.
Goal
End-to-end verify that an Amethyst Kotlin speaker broadcasting a
stereo Opus stream is intelligible to the reference
hang-listen Rust binary, and vice versa, with a different
frequency on each channel asserted independently in the decoded
PCM. The scenario lands as two new tests on the existing
HangInteropTest suite gated by -DnestsHangInterop=true.
What's blocking I4 today
Three pieces of :nestsClient production code hard-code mono:
-
Catalog rendition —
MoqLiteHangCatalog.opusMono48k(...)is the only catalog factory and it pinsnumberOfChannels = 1. The cachedOPUS_MONO_48K_AUDIO_DATA_JSON_BYTESis the only catalog payload the speaker ships (referenced inMoqLiteNestsSpeaker.kt:160andReconnectingNestsSpeaker.kt:589). A stereo broadcast needs a new catalog factory + a new cached payload. -
Audio format —
AudioFormat.CHANNELS = 1is a top-level constant innestsClient/src/commonMain/.../audio/Audio.kt. Used byMediaCodecOpusEncoder(encoder configuration) andMediaCodecOpusDecoder(decoder configuration). A stereo broadcast can't simply override this without breaking every mono call site. -
Listener decoder —
MoqLiteNestsListenerconstructs theOpusDecoderwithchannelCount = 1(search the file). For listener-side stereo support it needs to read the catalog'snumberOfChannelsand pass that to the decoder factory.
The encoder itself (MediaCodecOpusEncoder) already accepts a
channels: Int constructor parameter on Android — but it's never
called with 2. Same for JvmOpusEncoder (test-side, already
supports stereo via channelCount).
Production-side change set
1. Make AudioFormat.CHANNELS a per-stream concern, not a global
Rename / repurpose:
object AudioFormat {
const val SAMPLE_RATE_HZ: Int = 48_000
/** Default mono — most call sites don't override. */
const val DEFAULT_CHANNELS: Int = 1
const val FRAME_SIZE_SAMPLES: Int = 960
const val FRAME_DURATION_US: Long = 20_000
const val BYTES_PER_SAMPLE: Int = 2
}
Audit every reference to AudioFormat.CHANNELS. Each call site
that's actually mono-fixed (e.g. mic capture defaults) should
inline 1; everything else should take a channelCount parameter.
This is the largest part of the change — concrete files to touch:
MediaCodecOpusEncoder.kt(Android): already haschannels: Int = 1constructor parameter, no change needed.MediaCodecOpusDecoder.kt(Android): same.JvmOpusEncoder.kt(jvmTest): no change.JvmOpusDecoder.kt(jvmTest): no change.AudioRecordCapture(Android microphone source): keep mono hard-coded — the microphone is a single-channel device.NestPlayer/AudioPlayer: needs to know the rendition's channel count to size its mixer /AudioTrackconfig. Add achannelCountparameter toAudioPlayer.start()(or a per-streamAudioPlayerFactory(channelCount)interface).NestMoqLiteBroadcaster.peakAmplitude(pcm: ShortArray): currently treats the array as planar mono. Stereo PCM is interleaved L/R; the function must compute peak across both channels. Either iterate stride-2 explicitly or passchannelCountand skip the level callback for stereo.
2. Catalog factory + cache
Drop MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES's
status as the only fast-path catalog. Either:
A) Add a parallel OPUS_STEREO_48K_AUDIO_DATA_JSON_BYTES constant
built from opusMono48k(...).copy(audio.renditions[X].copy(numberOfChannels=2))
shape, OR
B) Generalise to opus48k(audioTrackName, numberOfChannels) and
memoise both shapes via a small map keyed on
(trackName, numberOfChannels).
(B) is cleaner — it generalises to future (48k, 2 channels, 64 kbit/s) etc. variants without exploding the constant count.
The wire shape stays byte-stable per-shape because
encodeJsonBytes() already pins encodeDefaults = false +
explicitNulls = false + a deterministic field order.
Wire MoqLiteNestsSpeaker.startBroadcasting and
ReconnectingNestsSpeaker to read the channel count from a
broadcastConfig: AudioBroadcastConfig parameter (new) — pass
it through to the catalog factory. Default
AudioBroadcastConfig(channelCount = 1) so existing callers are
unaffected.
3. Listener decoder discovery
MoqLiteNestsListener.subscribeSpeaker today builds the decoder
with mono. It already subscribes to the catalog track in
parallel — the patch is to block on the first catalog message,
read audio.renditions[<audioTrack>].numberOfChannels, and
construct the OpusDecoder with that count.
Concrete change: make subscribeSpeaker suspend-await
hang::CatalogConsumer::next() once before opening the audio
subscription, plumb the discovered channel count into the
OpusDecoder factory call.
Edge case: if the catalog's numberOfChannels field is missing
(older publishers), default to 1 (matches the kdoc on
MoqLiteHangCatalog.AudioRendition.numberOfChannels).
Test side
Stereo SineWaveAudioCapture
Extend the existing test fixture to support stereo:
class SineWaveAudioCapture(
private val freqHz: Int = 440,
/**
* Per-channel frequency overrides. If null, every channel
* runs at [freqHz] (matches mono-broadcast-of-a-stereo).
* If non-null, must have [channelCount] entries — useful
* for I4 where left = 440 and right = 660 lets the FFT
* assertion bisect each channel cleanly.
*/
private val freqHzPerChannel: IntArray? = null,
private val channelCount: Int = 1,
private val amplitude: Short = 16_383,
) : AudioCapture {
override suspend fun readFrame(): ShortArray? {
val out = ShortArray(FRAME_SIZE_SAMPLES * channelCount)
for (i in 0 until FRAME_SIZE_SAMPLES) {
for (ch in 0 until channelCount) {
val freq = freqHzPerChannel?.get(ch) ?: freqHz
val v = (amplitude * sin(2.0 * PI * freq * (sampleIdx + i) / SAMPLE_RATE_HZ)).toInt()
out[i * channelCount + ch] = v.toShort()
}
}
// … existing pacing + sampleIdx update …
}
}
Pacing logic stays as-is (one delay(FRAME_NANOS / 1M) per frame).
PcmAssertions.assertFftPeak per-channel
The existing helper assumes mono (interprets the array as planar samples). For stereo, add an overload:
fun assertFftPeakStereo(
interleaved: FloatArray,
expectedHzL: Double,
expectedHzR: Double,
halfWindowHz: Double = 5.0,
sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
)
That deinterleaves into two FloatArrays and runs the existing
FFT assertion on each. ZCR can be done the same way.
hang-listen already supports stereo
Verify by reading the existing
nestsClient/tests/hang-interop/hang-listen/src/main.rs: it already passes
audio_cfg.channel_count into opus::Decoder::new(...) and
allocates a stereo PCM buffer if the catalog says so. No Rust
change needed.
hang-publish already supports stereo
Verify by reading nestsClient/tests/hang-interop/hang-publish/src/main.rs:
the --channels <1|2> flag plumbs through to the catalog, the
opus encoder, and the sine generator. No Rust change needed.
(Note: the current sine generator uses the same frequency on
both channels. For I4 reverse-direction we'd want
--freq-hz-l 440 --freq-hz-r 660 to generate a true L/R
asymmetric tone. Small Rust addition.)
Two new HangInteropTest scenarios
/** I4 forward — Kotlin speaker broadcasts L=440 R=660 stereo. */
@Test
fun amethyst_speaker_to_hang_listener_stereo_440_660() = runBlocking {
val out = runSpeakerToHangListen(
speakerSeconds = 5,
captureFirstFrame = false,
captureFactoryOverride = {
SineWaveAudioCapture(
channelCount = 2,
freqHzPerChannel = intArrayOf(440, 660),
)
},
encoderFactoryOverride = { JvmOpusEncoder(channelCount = 2) },
broadcastConfig = AudioBroadcastConfig(channelCount = 2),
)
val pcm = readFloat32Pcm(out.pcmFile)
val warmup = AudioFormat.SAMPLE_RATE_HZ / 25 * 2 // stereo: skip 2x
val analysed = pcm.copyOfRange(warmup, pcm.size)
PcmAssertions.assertFftPeakStereo(analysed, 440.0, 660.0)
}
/** I4 reverse — hang-publish R/L stereo → Kotlin listener. */
@Test
fun rust_hang_publish_stereo_to_kotlin_listener_440_660() = runBlocking {
// … hang-publish with --channels 2 --freq-hz-l 440 --freq-hz-r 660 …
// … Kotlin listener via connectNestsListener; decoder discovers
// numberOfChannels = 2 from the catalog and builds a stereo
// JvmOpusDecoder …
// … assert FFT peaks per channel as above …
}
runSpeakerToHangListen needs three new optional parameters
(captureFactoryOverride, encoderFactoryOverride,
broadcastConfig); existing callers pass nothing and keep mono
behavior.
Phases
Total: ~1.5 days.
Phase 1 — production-side prep (~5 hr).
- Refactor
AudioFormat.CHANNELS→ audit + per-stream parameterisation. - Generalise
MoqLiteHangCatalog.opusMono48ktoopus48k(name, channels)- memoise the JSON bytes per shape.
- Plumb
AudioBroadcastConfig(channelCount)throughconnectNestsSpeaker/MoqLiteNestsSpeaker/ReconnectingNestsSpeaker. - Plumb catalog-discovered channel count through
connectNestsListener/MoqLiteNestsListener.
Verify Android + Desktop builds compile; existing mono Kotlin↔Kotlin tests stay green.
Phase 2 — test-side fixtures (~2 hr).
5. Extend SineWaveAudioCapture with channelCount /
freqHzPerChannel.
6. Add PcmAssertions.assertFftPeakStereo /
assertZeroCrossingRateStereo (deinterleave + run mono helpers).
7. Extend runSpeakerToHangListen with capture/encoder/config
overrides.
Phase 3 — Rust publisher tweak (~30 min).
8. Add --freq-hz-l / --freq-hz-r to hang-publish so the
reverse direction has a per-channel asymmetric tone. Default
to --freq-hz value for both when unset (back-compat).
Rebuild via cargo build --release -p hang-publish.
Phase 4 — wire I4 forward + reverse (~3 hr).
9. Land amethyst_speaker_to_hang_listener_stereo_440_660 in
HangInteropTest.
10. Land rust_hang_publish_stereo_to_kotlin_listener_440_660
in HangInteropTest (or a new HangInteropReverseTest if
the file gets too long).
11. Verify both green with 3 sequential
./gradlew :nestsClient:jvmTest -DnestsHangInterop=true --rerun-tasks runs.
Risks + mitigations
| Risk | Mitigation |
|---|---|
AudioFormat.CHANNELS = 1 audit misses a call site |
Grep across the entire repo (amethyst/, commons/, desktopApp/, nestsClient/) for AudioFormat.CHANNELS and CHANNELS = 1; change each by hand. |
Android AudioTrack channel-mask change breaks mono playback |
The default constant stays DEFAULT_CHANNELS = 1; existing call sites that omit channelCount keep mono behavior. Mono regression test (NestPlayerTest) catches drift. |
| Listener-side catalog-await blocks subscription forever if the publisher never emits the catalog | Use withTimeoutOrNull(2_000) around the catalog read; default to mono on timeout (with a warning log) to preserve the failure-tolerant existing behavior. |
| Stereo Opus interleaved PCM byte-order surprises (LE vs BE on the wire) | Opus is endianness-neutral on the wire; PCM in the codec API is always native-endian short[]. Deinterleave in software. |
opusMono48k callers in :commons (the parser) aren't tested in this plan |
The parser is deserialise-only and accepts any rendition map. Verify by adding one unit test in :commons that round-trips a stereo catalog. |
| The catalog hook race (Phase 2 results doc) shows up worse for stereo because of the larger initial frame | Same fix as I1: keep framesPerGroup = 5 and have the test sequence speaker.startBroadcasting → delay 150 ms → spawn hang-listen. The race is a pre-existing condition, not stereo-specific. |
Definition of done
HangInteropTest.amethyst_speaker_to_hang_listener_stereo_440_660green, 3 sequential--rerun-tasksruns no flake.HangInteropTest.rust_hang_publish_stereo_to_kotlin_listener_440_660green, same stability.- Existing mono tests (
amethyst_speaker_to_hang_listener_static_tone_440,late_join_listener_still_decodes_tail,mid_broadcast_mute_shortens_decoded_pcm,subscribe_drop_for_unknown_track,long_broadcast_60s_tone_round_trips,rust_hang_publish_to_rust_hang_listener_round_trip_440) stay green. - The
KotlinSpeakerKotlinListenerThroughNativeRelayTestdiagnostic still passes when run with-DnestsHangInteropDiagnostic=true. - Android instrumented tests on a real device: at least one
stereo broadcast end-to-end through the
nostrnests/nestsDocker harness (gated by-DnestsInterop=true). The production flow has to work, not just the cross-stack:nestsClientsub-suite. - Results filed at
nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario-results.mdsummarising what landed, any deviations from this plan, and any production code follow-ups discovered during the audit.
Out of scope (intentionally)
- Multi-bitrate per channel. Stereo as one 64 kbit/s rendition is enough; per-channel rate-tuning is a renderer concern.
- 5.1 / spatial audio. Catalog field is
numberOfChannels, but the audio pipeline assumes interleaved planar — beyond stereo would need a separate plan. - Browser side I4.
nestsClient/tests/browser-interop/doesn't exist yet (Phase 4 of the parent plan); when it lands the same I4 shape ports straight to aBrowserInteropTest. - Per-channel mute. The existing
setMuted(true)mutes the whole broadcast; a per-channel mute is a UI concern out of scope here.
When picking up
This plan is self-contained. The agent should:
- Read
nestsClient/plans/2026-05-06-cross-stack-interop-test.md(the parent plan) andnestsClient/plans/2026-05-06-cross-stack-interop-test-results.md(Phase 1 + 2 status) to understand the harness. - Skim
nestsClient/src/jvmTest/.../interop/native/HangInteropTest.ktfor the existing scenario shape — the stereo test reuses the samerunSpeakerToHangListenhelper. - Implement Phase 1 (production prep) FIRST, with the existing mono tests as the regression net. Don't mix production refactors with the I4 test wiring — keep two clean commits.
- After every phase: run
./gradlew :nestsClient:jvmTest -DnestsHangInterop=trueand confirm green. Don't proceed to the next phase until the prior is clean. - Each scenario commits separately. The production-side refactor (Phase 1) commits as a single unit.