fix(nests): scope moq-lite publisher to a single track to stop catalog hijack

Listeners stuck on a spinning speaker avatar with no audio (against
both Amethyst-hosted and nostrnests.com-hosted Nests). The trace
captured on a working speaker phone showed every group keyed to
subId=1 / track='catalog.json' even though the broadcaster was
pumping Opus frames:

    13:21:27.570 inbound SUBSCRIBE id=1 track='catalog.json'
    13:21:27.574 inbound SUBSCRIBE id=0 track='audio/data'
    13:21:27.609 openGroup seq=0 keyedOnSubId=1 track='catalog.json'
    13:21:27.610 broadcaster: send accepted (subscriber attached)
    ... every subsequent group keyed=1, every audio frame on the
        catalog stream

Root cause: `PublisherStateImpl.openNextGroupLocked` keyed each uni
stream off `inboundSubs.first()` with no track filter. The kdoc
asserted "Inbound subscription set is expected to be small (1 in
nests's listener-per-room model)" — that was true when listeners
only opened the audio sub. Once the listener side started opening a
catalog sub alongside (commits ~Apr 2026), whichever SUBSCRIBE
arrived first (typically the catalog one, by ~4 ms in this trace)
became the routing target for all audio frames. The relay forwarded
those frames to the listener with subscribeId=catalog, the listener
routed them to its catalog handler, RoomSpeakerCatalog.parseOrNull
returned null (it's not JSON), and the audio subscription's frames
channel never received anything — so onSpeakerActivity never fired
and the spinner stayed forever.

Fix: per moq-lite Lite-03, a publisher is responsible for one
(broadcast, track) tuple. Thread `track` through `MoqLiteSession.publish`
and have `PublisherStateImpl.registerInboundSubscription` reject
inbound SUBSCRIBEs whose track doesn't match — those still receive
SUBSCRIBE_OK from the bidi handler (best-effort per Lite-03's
optional-content semantics for catalog) but don't influence the
audio routing. `MoqLiteNestsSpeaker.startBroadcasting` passes
`track = MoqLiteNestsListener.AUDIO_TRACK` (= "audio/data").

Test callsites (MoqLiteSessionTest, NostrnestsProdAudioTransmissionTest,
NostrNestsSustainedSendOutcomesInteropTest) updated to pass the new
required parameter; all use track="audio/data" matching what they
exercise.

The diagnostic logs from the prior commits stay in — they're how we
caught this and they'll catch the next one. They can be stripped in
a follow-up once the fix is confirmed in the field.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
This commit is contained in:
Claude
2026-05-04 17:28:18 +00:00
parent 9b6e09838e
commit 43720dd14d
5 changed files with 55 additions and 14 deletions
@@ -75,8 +75,19 @@ class MoqLiteNestsSpeaker internal constructor(
// Per the audio-rooms NIP draft + JS reference
// (`@moq/publish/screen-B680RFft.js:5641`), publishers
// claim a broadcast suffix equal to their pubkey hex.
val publisher = session.publish(broadcastSuffix = speakerPubkeyHex)
// claim a broadcast suffix equal to their pubkey hex and
// the audio data sits on track "audio/data". The publisher
// must be track-scoped because listeners typically open
// BOTH a catalog subscribe AND an audio subscribe per
// speaker; without the track filter the publisher would
// route Opus frames onto whichever subscribe arrived first
// (in practice the catalog one) and the audio subscription
// would silently starve.
val publisher =
session.publish(
broadcastSuffix = speakerPubkeyHex,
track = MoqLiteNestsListener.AUDIO_TRACK,
)
// From here on, the publisher is registered in the session
// and has a live announce/subscribe path. Anything that
// throws before we hand a working handle back to the caller