Commit Graph

253 Commits

Author SHA1 Message Date
Claude 7e76ab1139 fix(nests): T11 drop bestEffort=true on moq-lite group uni streams
`MoqLiteSession.openGroupStream` was opening each group's QUIC uni
stream with `bestEffort = true`. `:quic`'s `SendBuffer.markLost` with
that flag drops lost STREAM ranges WITHOUT retransmit AND WITHOUT
`RESET_STREAM` (`quic/src/commonMain/kotlin/com/vitorpamplona/quic/
stream/SendBuffer.kt:300-309`). The peer's `ReceiveBuffer` ends up
with chunks at `[0, P)` and `[Q, end)` and a permanent hole at
`[P, Q)`; the application's `incoming` Flow parks on the hole forever.

Web watchers (`@moq/hang` `Container.Consumer`) park their
`Group.readFrame` until the relay's 30 s `MAX_GROUP_AGE` ages the
broadcast queue out — manifesting as a 30 s silent dropout per lost
packet on lossy networks (cellular, mobile WiFi, congested home
routers). This is a real-world bug that's invisible on a clean LAN.

The reference implementation (kixelated/moq-rs `Publisher::serve_group`,
`rs/moq-lite/src/lite/publisher.rs:347-406`) writes to reliable QUIC
streams with no `set_unreliable` call — `bestEffort` was Amethyst's
private optimisation with no peer-side support. Drop it; let `:quic`
retransmit lost ranges normally. A retransmit arriving 50–150 ms late
still falls inside hang's default ~200 ms jitter buffer, so the cost
is marginal extra bandwidth on retransmits and the win is no more
silent dropouts on lossy networks.

T11.2 — orthogonality check: stream-cliff fix is independent.
Re-read `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`
and grep'd both the plan and `NestMoqLiteBroadcaster.kt` for
`bestEffort` / `best_effort` — neither references it. The cliff fix
is `framesPerGroup = 5/50` (cadence reduction); load-bearing on
stream-creation RATE, not loss handling. Drop is safe.

T11.3 (stream priority — newer groups drain first under congestion)
deferred to a follow-up commit; hooking it into `:quic`'s
send-frame loop is bigger than a one-line change and warrants its
own task. The kixelated/moq-rs publisher uses
`stream.set_priority(priority.current())` to bias the writer; without
it, our drain order under congestion is FIFO across streams rather
than newest-first, which can mean the listener catches up on a stale
group when a fresh one is more useful. Doesn't block today —
production audio rarely hits transport congestion at 1 group/sec
cadence.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:18:23 +00:00
Claude 4714e3c723 fix(nests): T13 reset Opus decoder on publisher boundary in NestPlayer
The reissuing-subscribe wrapper splices fresh-publisher frames into the
same `SharedFlow` that NestPlayer consumes, so across a JWT-refresh
hot-swap (speaker side) or a cliff-detector recycle (listener side)
the decoder receives a discontinuous frame stream while keeping its
Opus predictor state. Result: an audible warble at every publisher
cycle. Single-stream tests don't catch it because they never present
two distinct publishers.

Detect the boundary via `MoqObject.trackAlias` — the underlying
`MoqLiteSession.subscribe` assigns a fresh subscribeId per SUBSCRIBE,
which the listener wrapper surfaces verbatim as `trackAlias` on every
emitted object. A change between consecutive objects = new publisher.

Add an optional `decoderFactory: (() -> OpusDecoder)?` to NestPlayer.
When non-null, NestPlayer tracks `lastTrackAlias` and on a change
releases the current decoder and rebuilds via the factory. The
default `null` preserves the legacy single-decoder behaviour so
existing tests / callers that don't care about boundaries stand
unchanged.

NestViewModel.openSubscription wires the factory: a closure capturing
the catalog-derived `channelCount` so a rebuild reuses the SAME
channel layout — without that capture, a rebuild after a stereo-
publisher cycle would default to mono and silently downmix.

Two new NestPlayerTest cases pin the behaviour:
  - `publisher_boundary_rebuilds_decoder_when_factory_provided`:
    factory invoked twice (initial + boundary), first decoder
    released on boundary, second released on stop.
  - `publisher_boundary_no_op_when_factory_is_null`: legacy path
    holds onto the same decoder across trackAlias changes (unchanged
    semantics).

NestPlayer's first constructor parameter is now `initialDecoder`
(was `decoder`); positional-arg call sites are unchanged but the
named-arg call sites in the test suite are updated accordingly.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:00:52 +00:00
Claude be4e0b9f98 fix(nests): T12 carry audio group sequence across hot-swaps
Every JWT-refresh hot-swap reset the audio publisher's group sequence
counter to 0, because `PublisherStateImpl` always initialised
`nextSequence = 0L`. kixelated/hang's `Container.Consumer.#run`
discards any consumer with `sequence < this.#active`, so a watcher
whose `#active` had advanced to e.g. 50 from the previous session's
publisher would silently drop every group on the new session until
either `#active` rolls over or the watcher re-subscribes — audible as
"speaker goes silent for a minute every 9 minutes" (the proactive
JWT refresh window).

Carry the sequence forward end-to-end:

  - MoqLiteSession.publish gains a `startSequence: Long = 0L`
    parameter; PublisherStateImpl seeds `nextSequenceField` from it
    instead of hard-coding 0.

  - MoqLitePublisherHandle exposes a `nextSequence: Long` snapshot.
    `@Volatile`-backed so the hot-swap caller can read it without
    contending the publisher's gate; race-window between read and
    a concurrent send is sub-millisecond and would only produce a
    single duplicate sequence in the very unlikely overlap, which
    listeners tolerate.

  - HotSwappablePublisherSource.openPublisherForHotSwap takes
    `startSequence: Long = 0L`. MoqLiteNestsSpeaker passes it
    through to session.publish.

  - NestMoqLiteBroadcaster exposes its current publisher via a
    read-only `currentPublisher` so the hot-swap pump in
    ReconnectingNestsSpeaker.runHotSwapIteration can read its
    `nextSequence` before opening the replacement publisher and
    pass it as the seed.

  - Catalog publisher keeps `startSequence = 0L` (the default) —
    catalog isn't subject to the same `#active` accumulation
    because each new SUBSCRIBE triggers a fresh emit-on-subscribe
    write that resets the watcher's catalog state.

Listener-side change is none — the watcher already reads whatever
sequence we send. A new MoqLiteSessionTest pins the contract:
publishing with startSequence=42 makes the first uni stream's
GroupHeader.sequence == 42 and advances to 43 after the first send.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:51:45 +00:00
Claude c23da52795 fix(nests): T10 endGroup() on unmuted→muted transition
When the user mutes mid-broadcast, `NestMoqLiteBroadcaster`'s send loop
just `continue`s past the mute check, leaving the current group's QUIC
uni stream open with no FIN. The watcher's
`Container.Consumer.#runGroup` parks on `await group.consumer.readFrame()`
waiting for either another frame or a stream FIN — and gets neither.
kixelated/hang's UI surfaces this as a "stalled" indicator on the
speaker tile while we're muted, which is wrong: we're muted, not
stalled.

FIN the open uni stream once on the unmuted→muted edge via a
`wasMuted` latch that the muted→unmuted edge clears. Doesn't change
the timestamp counter — it still advances on muted frames so an
unmute's first timestamp reflects the muted duration as a real wall-
clock gap (not a collapse to zero).  `runCatching` around endGroup
because a transport-side close racing with the FIN is non-fatal —
the broadcaster's terminal-failure path picks up real breakage on
the next live frame.

Also resets `framesInCurrentGroup` to 0 so the post-unmute send path
opens a fresh group cleanly via the standard `currentGroup ?:
openNextGroupLocked()` branch in `PublisherStateImpl.send`.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:37:51 +00:00
Claude 96cfa1235a fix(nests): T8 skip BUFFER_FLAG_CODEC_CONFIG outputs in MediaCodecOpusEncoder
Android's `audio/opus` MediaCodec encoder emits `BUFFER_FLAG_CODEC_CONFIG`
output buffers BEFORE any audio buffer — typically the 19-byte OpusHead
identification header per RFC 7845, and (on some Codec2 stacks) the
OpusTags comment header. These are decoder-config blobs, NOT audio
frames. We weren't filtering them, so the first 1–2 wire frames every
encoder lifetime were OpusHead bytes wrapped in our legacy-container
varint(timestamp_us) prefix.

The web watcher's WebCodecs `AudioDecoder` handles this by burning
warmup slots (`@moq/watch/src/audio/decoder.ts`'s `warmed <= 3` policy)
— so the listener mostly recovers — but on Android Codec2 stacks that
emit BOTH OpusHead AND OpusTags as separate CSD buffers, two of the
three warmup slots get absorbed by metadata and the listener hears a
tiny click on the next group rollover. The hot-swap path
(`ReconnectingNestsSpeaker`) repeats the warmup on every JWT refresh,
so this fired once every 9 minutes in production.

Filter via `bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG`
inside `encode`'s output-drain loop. CSD buffers are released and the
loop continues to the next dequeue rather than returning the bytes.
Logged once per encoder lifetime via `loggedCsdSkip` so we have proof
the path fired without flooding logcat — a stack that emits CSD on
every frame would otherwise be noisy.

Returning `ByteArray(0)` for the first call (because the only output
was a CSD + format-change pair) is unchanged behaviour and the
broadcaster's `if (opus.isEmpty()) continue` already handles it.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:37:38 +00:00
Claude 73722d2ad2 fix(nests): T14 recognise Goaway control type instead of silent FIN
moq-rs's `Publisher::run` accepts `ControlType::Goaway = 5` as a
graceful-shutdown signal that asks the publisher to migrate to a
different relay node (`rs/moq-lite/src/lite/publisher.rs`). Our enum
only defined Session/Announce/Subscribe/Fetch/Probe; an inbound
Goaway bidi fell through `MoqLiteControlType.fromCode` as `null`,
hit the unknown-control branch in `handleInboundBidi`, and was FIN'd
silently — losing the relay's shutdown notification entirely.

Add `Goaway(5L)` to the enum and a dedicated arm in `handleInboundBidi`
that logs the event and FINs cleanly. We don't act on the migration
request today (no body decode, no preferred-relay failover); the
`connectReconnecting*` wrappers' transport-loss reconnect path
already recovers from the eventual hard disconnect, so all this arm
needs is to surface the relay's intent in logcat instead of
swallowing it. Wire body decoding is left as a follow-up.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:37:19 +00:00
Claude ea90685a86 revert(nests): drop moq-lite-04 ALPN advertisement (codec is wire-incompatible)
Re-investigation of `kixelated/moq` commit 45db108 ("moq-lite/moq-relay:
hop-based clustering") revealed that Lite-04 IS wire-incompatible with
our Lite-03 codec on Announce / AnnounceInterest / Probe — contradicting
the earlier reasoning that motivated 4b73626. Specifically:

  - Announce.hops: Lite-03 writes a single varint count of hops; Lite-04
    writes count + count × varint Origin ids. (`rs/moq-lite/src/lite/
    announce.rs:67-73` branches on Version explicitly.)

  - AnnounceInterest: Lite-04 added an `exclude_hop` varint after the
    prefix.

  - Probe: Lite-04 added an `rtt` varint after `bitrate`.

Subscribe / SubscribeOk / SubscribeDrop / Group / GroupHeader framing is
unchanged across Lite-03↔Lite-04 — but the Announce/Probe drift alone is
enough to desync a Lite-04-preferring relay if it picks `moq-lite-04`
from our advertised list. We'd encode Announce hops as a bare varint
where the peer expects `len + len × u62`, and the connection aborts on
the first Announce exchange.

Pin `wt-available-protocols` back to `["moq-lite-03"]` until
`MoqLiteCodec` gains version-aware Announce / AnnounceInterest / Probe
codecs and `MoqLiteAnnounce.hops` becomes a list rather than a single
varint. Reverting 4b73626 — the LITE_04 constant stays in MoqLiteAlpn
for documentation, but is no longer plumbed into the factory's
sub-protocol list.

Reverts the wire-format expansion in 4b73626. The factory and ALPN
kdocs are rewritten to spell out the codec diff so the next person who
considers re-enabling Lite-04 reads the actual blocker first.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:37:02 +00:00
Claude 1c47fd2403 feat(nests): drive Opus decoder + AudioTrack channel count from catalog
Web speakers (kixelated/hang reference) emit stereo Opus when the
user's AudioContext picks a stereo input — and our catalog now lets
us know that ahead of decoder construction. Previously the decoder
+ AudioTrack were both hardcoded to mono via AudioFormat.CHANNELS, so
a stereo web publisher's frames either threw on the Opus TOC byte
mismatch, decoded with downmix artifacts, or output left-channel-only
depending on the device's MediaCodec implementation.

Wire the catalog-discovered numberOfChannels through:

- MediaCodecOpusDecoder accepts `channelCount: Int = 1`. Drives
  MediaFormat.createAudioFormat's channel count, the OpusHead CSD-0
  channel byte, and the per-frame output buffer size (stereo frame =
  2× shorts vs mono). Validates 1..2 — RFC 7845 mapping family 0
  covers both mono and stereo without an explicit channel mapping
  table; multichannel needs family 1 which we don't support.

- AudioTrackPlayer accepts `channelCount: Int = 1`. Drives the
  AudioTrack channel mask (CHANNEL_OUT_MONO vs CHANNEL_OUT_STEREO)
  and the 250 ms wall-clock buffer-size target (stereo doubles bytes
  per sample).

- NestViewModel.openSubscription now starts the catalog fetch
  BEFORE waiting for it, then awaits `_speakerCatalogs[pubkey]` for
  CATALOG_AWAIT_TIMEOUT_MS (500 ms) before constructing the decoder
  + player. With the speaker-side emit-on-subscribe hook in place
  (3417e44), the catalog frame is on the wire within one round-trip
  of the SUBSCRIBE_OK, so the wait typically resolves in tens of ms.
  Falls back to mono if the catalog never arrives — covers legacy
  publishers that don't emit a catalog and the failure-mode where
  the relay never forwards the catalog group.

- decoderFactory + playerFactory signatures change from
  `() -> X` to `(channelCount: Int) -> X`. Two call sites updated:
  NestViewModelFactory (production) and NestViewModelTest's
  newViewModel helper.

Speaker side stays mono-only — our encoder is fixed at
AudioFormat.CHANNELS=1 and the catalog we publish declares
numberOfChannels=1. This change is exclusively about handling
incoming audio from publishers that don't share that constraint.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:26:47 +00:00
Claude 87b43d4d78 fix(nests): bump preroll to 200 ms + surface persistent decode failures
Two audit follow-ups for receive-path interop with kixelated/moq web
publishers (NestsUI v2's reference encoder):

1. ROOM_PLAYER_PREROLL_FRAMES 5 → 10 (≈100 ms → ≈200 ms). The web
   reference encoder uses `groupDuration: 100ms` (5 Opus frames per
   group) and stacks another 100–200 ms of jitter on top via the
   relay's outbound forward queue under residential network
   conditions. 100 ms of preroll reliably underran on web speakers,
   producing audible clicks between adjacent groups; 200 ms covers
   the observed envelope while keeping perceived join latency well
   below the wire latency a listener already sees.

2. Per-subscription consecutive Opus decode-error counter in
   NestViewModel.openSubscription. Single-frame decode errors are
   normal noise — Opus PLC papers over a single bad frame and the
   prior code silently swallowed them — but a structural mismatch
   (wrong wire format, missing legacy-container varint strip,
   codec/channel-count mismatch, corrupted Opus) fails *every* frame
   in a row. The previous behaviour made exactly that class of bug
   invisible: no audio, no log, no UI signal.

   New logic increments a per-subscription AtomicLong on each
   DecoderError / EncoderError, resets on every successful onLevel
   tick. When the streak hits DECODE_ERROR_STREAK_LOG_THRESHOLD
   (50 frames ≈ 1 s of solid failures) we log ONE conspicuous warning
   tagged `NestRx` with the underlying throwable's stacktrace.
   Logged once per crossing (exact-equality, not modulus) so a stuck
   stream produces a single attention-grabbing line instead of a
   50 Hz flood.

   Picks the non-lambda Log.w overload deliberately so the throwable
   stacktrace survives — fires exactly once per stuck-stream streak,
   so the unconditional message-string build is fine.

Both changes are additive — no callers changed, no public API
touched. The stale `ROOM_PLAYER_PREROLL_FRAMES = 5` reference in
NestMoqLiteBroadcaster's framesPerGroup kdoc is updated to match.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 16:43:43 +00:00
Claude 3417e44a6a refactor(nests): catalog emit-on-subscribe hook replaces 2s republish loop
The catalog publisher's previous shape was "send once at startup
(silently fails if no subs are attached yet) + a 2 s republish loop".
That worked but had two problems:

1. The startup send is a no-op (PublisherStateImpl.send returns false
   when inboundSubs is empty), so the catalog isn't actually on the
   wire until the first loop tick — up to 2 s of catalog dead time
   for a watcher that subscribes immediately.
2. Re-emitting a static blob every 2 s for the broadcast's lifetime
   wastes a fresh group on the relay's per-track cache; not a hot path
   today but unnecessary.

Replace with an emit-on-subscribe hook. New API on MoqLitePublisherHandle:

  fun setOnNewSubscriber(hook: (suspend () -> Unit)?)

PublisherStateImpl fires the hook once per accepted (track-matching)
inbound SUBSCRIBE, OUTSIDE its serialisation lock so the hook can
safely call send/endGroup without deadlock. Track-mismatched subs
(SubscribeDrop reply) do NOT fire the hook — covered by a new
"hook does not fire on track mismatch" test.

MoqLiteNestsSpeaker.startBroadcasting and ReconnectingNestsSpeaker's
hot-swap iteration both now register a hook that writes the catalog
JSON on every fresh subscribe instead of looping. The
catalogRepublishJob field on MoqLiteBroadcastHandle and the
hotSwapCatalogJob field on ReissuingBroadcastHandle (plus their close
paths) are dropped.

Net effect for hang.js / NestsUI-v2 watchers:
  - Catalog reaches the watcher on the SAME group as the subscribe ack
    (no 0–2 s startup gap).
  - Late joiners hit the relay's track-latest cache for the catalog
    blob — a fresh hook fire only happens when the relay opens a new
    SUBSCRIBE bidi to us (cliff-detector recycle on listener side, or
    JWT-refresh on our side).
  - One catalog group per relay-side subscribe instead of one every
    2 s for the broadcast lifetime.

Two tests pin the new behaviour: hook fires on inbound subscribe;
hook does NOT fire on a track-mismatched subscribe (which gets a
SubscribeDrop reply and never enters inboundSubs).

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 16:10:12 +00:00
Claude 79c76d5831 fix(nests): reply SubscribeDrop on unsupported tracks instead of silent FIN
When a relay opens a SUBSCRIBE bidi for a track this session doesn't
publish (e.g. a watcher subscribes to `video/data` against a broadcast
that only declares `audio/data` + `catalog.json`), the session was
silently FINing the bidi without writing any reply. The peer's wait
on its receive side resolves to end-of-stream with no indication WHY —
indistinguishable from "publisher disappeared mid-subscribe."

Reply with [MoqLiteSubscribeDrop] carrying
[MoqLiteSubscribeDropCode.TRACK_DOES_NOT_EXIST] (0x04, mirroring the
IETF-MoQ `ErrorCode.TRACK_DOES_NOT_EXIST` convention) and an
informational reason phrase listing the tracks we DO publish, then
FIN. Matches what kixelated's `rs/moq-lite/src/lite/subscribe.rs`
expects on this path.

Doesn't change the happy path — every track NestsUI-v2 subscribes to
(`audio/data` + `catalog.json`) is now published — but is the
spec-correct behaviour for any future watcher that prospectively
subscribes to a track our publisher hasn't declared.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:32:32 +00:00
Claude 4b736263e0 feat(nests): advertise both moq-lite-03 and moq-lite-04 ALPNs
Production moq-relay (moq.nostrnests.com) and the kixelated browser
client both ship Lite-04 now. We previously only advertised
moq-lite-03 in the wt-available-protocols header, so a Lite-04-only
relay deployment would refuse the WebTransport CONNECT (no mutual
sub-protocol).

Add moq-lite-04 to the advertised list. The on-the-wire framing for
Subscribe / Group / Announce did NOT change between Lite-03 and
Lite-04, so the existing codec handles either selection unchanged —
this is purely a handshake-layer addition. moq-lite-03 stays listed
first so a relay that supports both still picks the previously-tested
path.

Constants now live on MoqLiteAlpn alongside LITE_03 and LEGACY rather
than being a string literal in QuicWebTransportFactory.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:27:21 +00:00
Claude 361dbbe201 fix(nests): stamp Opus frames at frame-index × frame-duration, not wall clock
The legacy-container timestamp on each audio frame was previously
captured at send-time via `frameStartMark.elapsedNow()`. That mark
advances on wall clock, but `current.send(...)` is a suspending call
that backs off on transport backpressure — so a frame captured at T
gets stamped at T+δ once the previous send drains, and the watcher's
WebCodecs AudioDecoder schedules playback at the wrong instant. Audible
as a slowly-growing offset between speaker and listener over the life
of a session that's seen any meaningful send-side queueing.

Replace with a frame-index counter pinned to the codec grid:
`timestampUs = nextFrameIndex * AudioFormat.FRAME_DURATION_US`. The
counter advances once per captured PCM frame BEFORE the encode/mute/
send path, so:

  - Encoder failures, empty-encoded frames, and muted frames each
    consume one slot — preserving the wall-clock gap on the wire so
    a 5 s mute shows up as a 5 s timestamp jump (matches the prior
    wall-clock behaviour for mute and silence).
  - Send-time stalls don't drift the timestamp: it's computed at the
    top of the iteration, not after the suspending send.
  - Survives publisher hot-swap (single-broadcast, single-encoder;
    the new publisher inherits the running counter) — same guarantee
    the prior `TimeMark` had.

Also adds [AudioFormat.FRAME_DURATION_US] = 20_000 (960 / 48 kHz × 1e6)
so the constant lives next to FRAME_SIZE_SAMPLES rather than being
implicit in every call site.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:23:29 +00:00
Claude 1b0740d4a8 feat(nests): emit catalog jitter hint matching Opus frame duration
hang.js's encoder publishes a `jitter` field (ms) in every audio
rendition; the watcher uses it to size its jitter buffer. Without it
the watcher falls back to a built-in default — works in practice today
but means our publishes deviate from the canonical hang shape and the
deviation could surface as audible buffer-sizing differences on a
future watcher revision that takes the field as required.

Add `jitter: 20` to MoqLiteHangCatalog.AudioRendition (matching the
Opus frame cadence: 960 samples at 48 kHz = 20 ms; same value hang.js
hard-codes at `js/publish/src/audio/encoder.ts:#runConfig`). Updates
the byte-exact MoqLiteHangCatalogTest assertion and the parallel
inline-literal in RoomSpeakerCatalogTest so both sides stay pinned.

The commons-side `RoomSpeakerCatalog` parser doesn't surface the new
field — `JsonMapper`'s `ignoreUnknownKeys = true` lets the watcher
ingest it silently. Adding a typed `jitter` field there is a
forward-compat task; not needed for interop with hang today.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:18:54 +00:00
Claude ff9bb25921 refactor(nests): replace hand-built catalog JSON with typed MoqLiteHangCatalog
The two SPEAKER_CATALOG_JSON call sites (MoqLiteNestsSpeaker for the
non-reconnecting path, ReconnectingNestsSpeaker for the JWT-refresh hot-
swap path) both built the manifest as a `\\` + `\"` + `+`-concatenated
string literal. One missed escape and the watcher's parser fails
silently — the broadcast becomes invisible to hang.js with no
indication of why. The two literals had also drifted independently in
review (same content today; nothing prevents drift tomorrow).

Replace both with `MoqLiteHangCatalog.opusMono48k(track).encodeJsonBytes()`,
a Serializable data class that encodes via kotlinx.serialization with
`encodeDefaults = false` + `explicitNulls = false` pinned (so a future
global default-flip can't surface `"bitrate": null` on hang.js's
parser).

The new type lives in :nestsClient because :nestsClient does not depend
on :commons and the parser-side `RoomSpeakerCatalog` (in :commons)
isn't reachable from the publish path. The two classes target the same
wire shape independently; a byte-exact assertion in the new
MoqLiteHangCatalogTest plus the existing RoomSpeakerCatalogTest's
inline-literal round-trip together pin both sides — desync on either
end fails one of the two tests immediately.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:13:47 +00:00
Claude 5310b3f574 Merge remote-tracking branch 'origin/main' into claude/fix-nests-audio-receiver-HCgOY 2026-05-06 03:18:09 +00:00
Claude 5b7e347ac0 feat(nests): align moq-lite catalog with kixelated/hang shape
Switch RoomSpeakerCatalog to the canonical hang catalog format
(camelCase audio.renditions[<track>] with container.kind), wrap
audio frames in the legacy varint(timestamp_us) prefix on publish,
and strip it on subscribe. Also reply to inbound Probe/Fetch/Session
bidis with a bitrate hint or clean FIN instead of hanging.
2026-05-06 03:16:01 +00:00
Claude 5389b32e52 feat(nests): keep catalog.json published across JWT refresh
ReissuingBroadcastHandle's hot-swap iteration now opens its own
catalog.json publisher on every fresh session, pushes the manifest
once, and launches the same 2 s republish loop that
MoqLiteNestsSpeaker.startBroadcasting uses for the legacy
non-reconnecting path. Each iteration tears down the prior session's
catalog publisher + republisher AFTER the new pair is installed, so
watchers see at most a brief overlap rather than a gap when the
600 s moq-auth bearer triggers a session recycle.

Without this, the catalog goes silent the moment the wrapper recycles
the underlying session; any watcher that attaches AFTER the recycle
finds nothing to subscribe to even though the audio path is hot-
swapped seamlessly. With it, both audio and catalog survive every
JWT refresh.

Catalog teardown is also added to ReissuingBroadcastHandle.close()
because — unlike the audio publisher which broadcaster.stop() handles
internally — the catalog has no broadcaster wrapper.
2026-05-06 01:06:36 +00:00
Claude babf7623d8 feat(nests): publish catalog.json so browsers can discover broadcasts
Two-phone Amethyst sessions stream Opus frames just fine on the wire,
but the kixelated/moq browser watcher (the canonical web reference) sees
nothing — because we never published a catalog.json track. moq-lite
watchers discover a broadcast's tracks by subscribing to the
`catalog.json` track on the broadcast suffix and parsing the JSON
manifest. Without it the watcher has nothing to subscribe to.

Two parts:

1. MoqLiteSession.publish now supports multiple tracks per session,
   sharing one broadcast suffix. The previous "one publisher per
   session" invariant rejected the second publish() with
   "already publishing", forcing a single track per session.

2. MoqLiteNestsSpeaker.startBroadcasting now opens both an audio
   publisher AND a catalog publisher, pushes the manifest once, and
   launches a 2s republish loop so late-attaching watchers don't wait
   indefinitely for the next refresh.

The manifest shape mirrors RoomSpeakerCatalog (kotlinx.serialization
data class on the listener side):

    {"version":1,"audio":[{"track":"audio/data","codec":"opus",
                           "sample_rate":48000,"channel_count":1}]}

Hard-coded for now since OpusEncoder is fixed at 48kHz mono.

Hot-swap path (ReconnectingNestsSpeaker session refresh) is NOT yet
wired to recycle the catalog publisher — acceptable trade-off for a
diagnostic landing; follow-up will move the catalog republisher into
HotSwappablePublisherSource so it survives JWT refreshes.
2026-05-06 00:56:43 +00:00
Claude a86f19f069 feat(nests): NestsTrace recorder for replayable session captures
Adds an opt-in JSONL event recorder behind every receiver-side
moq-lite + NestViewModel decision point so a real two-phone
production session can be captured and (in a follow-up) replayed
through the unmodified pipeline as a unit test. Step 1 of the
"capture-then-replay" plan from the prior conversation.

Off by default — production release builds that never call
`NestsTrace.setRecording(true)` pay one volatile-load + branch per
emit site. The fields-builder lambda doesn't run on the disabled
path, so call sites can do non-trivial string concat freely.

Output goes to logcat under tag `NestsTraceJsonl`. Capture with:

  adb logcat -c
  adb logcat -s NestsTraceJsonl:D -v raw > nest-trace.jsonl

The `-v raw` formatter strips the `D NestsTraceJsonl:` prefix so
the captured file is valid JSONL ready for replay tooling.

Schema per line: `{"t_ms":N,"kind":"K", ...kind-specific fields}`,
where `t_ms` is milliseconds since `setRecording(true)` was first
called. Field names are lower_snake_case for stability across
clients.

Wired into 13 call sites this commit (matched 1:1 with existing
NestRx/NestTx human log lines so the trace and the log line stay
adjacent and the diff stays small):

`MoqLiteSession`:
  - announce_bidi_opened (per session.announce call)
  - announce_pump_emit (per Active/Ended received on a bidi)
  - announce_bidi_ended_naturally / announce_bidi_threw
  - subscribe_send / subscribe_ok / subscribe_drop
  - subscribe_bidi_exited
  - announce_watch_update / announce_watch_ended_closing_subs
  - uni_pump_started
  - group_header / group_fin / group_threw

`NestViewModel`:
  - vm_observe_announce (per ann emission)
  - cliff_tick (every CLIFF_DIAG_LOG_EVERY ticks: active + announced
    sets + per-pubkey lastFrameAt elapsed-ms)
  - cliff_recycle (when the detector forces a recycleSession())

Anonymisation: pubkeys + track names recorded verbatim — they're
already in the existing `NestRx`/`NestTx` log lines the user is
sharing. Frame payloads are NEVER recorded, only sizes — audio
content can't leak through a trace dump.

Tests: NestsTraceTest (9 cases) — exhaustive jsonStr/jsonArrStr
quoting + setRecording state-machine + emit-lambda-noop-when-
disabled coverage. The `emit` log-output side itself is untestable
in commonTest because `Log.d` writes to a platform actual; the
schema correctness we DO want to pin (a JSON syntax bug at one of
the 13 call sites would silently break replay) is covered by the
quoting helpers.

CliffDetectorTest 12/12 + MoqLiteSessionTest 11/11 still pass —
the trace wiring is purely additive next to existing log statements.

Follow-up: a `TraceReplayingTransport` reading these JSONL files
back through `WebTransportSession` to drive end-to-end regression
tests for the cliff-recovery scenarios captured from production.
2026-05-06 00:17:43 +00:00
Claude a36ccb5692 fix(nests): close the relay-cliff at the source — framesPerGroup 10→50, cliff-threshold 4s→2.5s
Two-phone production logs at commit 6e4df4a (run 18:37) showed the
listener-side cliff still hits at ~16 s of streaming (51 groups
delivered, sender continued to seq=80 = ~6 s of audio lost at the
tail before the receiver recycled). Recycling alone can't fix this
— it only spaces the pain out. The user wants no audio drops mid-
transmission, period. The leverage is to keep the relay's
per-subscriber forward queue from filling at all.

The cliff is rate-limited per uni-stream-creation, not per byte or
per frame — moq-rs's `serve_group` task pool can't drain new
`open_uni().await`s fast enough at high stream rates. Cutting the
stream creation rate cuts the cliff window proportionally:

  framesPerGroup |  streams/sec @ 50 fps  |  observed cliff window
  ---------------+------------------------+----------------------
       1         |       50               |   ~3 s (commit d6517cf)
       5         |       10               |   ~13 s
      10         |        5               |   ~16 s (commit 6e4df4a)
      50         |        1               |   not reached in
                                              `fpg-all` sweeps
     100         |      0.5               |   never observed

Bumping default to 50 (1 s of audio per uni stream, 1 stream/sec)
puts us in the regime where the relay's queue does not measurably
fill. `fpg-all` (100 frames in a single group) routinely delivers
100/100 in production sweeps, so 50 is well clear of the cliff
edge. We pick 50 not 100 because:

  - Late-join gap is bounded by group size — a listener joining
    mid-broadcast waits one group boundary for the first frame.
    1 s is within the ~250 ms AudioTrack jitter buffer +
    `ROOM_PLAYER_PREROLL_FRAMES = 5` audio-buffer envelope; 2 s
    starts to be perceptible.
  - QUIC stream RST drops the whole group on full reset. 1 s of
    skip on rare RST is bearable; 2 s is a noticeable seam.
  - Sender encode latency stays at 1 s — no worse than the
    natural buffering audio rooms already do for jitter.

Belt-and-suspenders: also tighten the cliff-detector's silence
threshold from 4 s to 2.5 s. Healthy streams now arrive every ~1 s
(one `drainOneGroup` FIN per group), so 2.5 s of silence is
unambiguously past two missed group cycles. Catches a still-
possible cliff event ~1.5 s earlier, shaving the audible gap from
~6 s (4 s detection + 2 s reconnect) to ~4.5 s if recovery is
needed at all.

Tests: existing `framesPerGroup`-explicit interop scenarios
(`fpg5`, `fpg20`, `fpg-all`) are unaffected — they pass an
explicit value and don't read the default. `CliffDetectorTest`
boundary cases updated to the new 2.5 s threshold;
`returnsAllStalledSpeakersAtOnceMixedWithFreshOnes` re-spaced its
fixture so charlie's 1.5 s frame age stays under threshold while
alice/bob's 4 s ages exceed it. 12/12 pass.
2026-05-05 22:47:07 +00:00
Claude 457e0f5997 fix(nests): wrapper.announces() ALSO needs channelFlow (collectLatest emits cross-coroutine)
The channelFlow conversion in f17e7ad fixed `MoqLiteNestsListener.announces`
but the SAME `IllegalStateException: Flow invariant is violated` kept
firing in the next two-phone repro (commit f17e7ad, run 15:46:51) — this
time the offending `flow{}` was one layer up, in
`ReconnectingNestsListener.announces`:

  override fun announces(): Flow<RoomAnnouncement> =
      flow {
          activeListener.collectLatest { listener ->
              ...
              runCatching {
                  listener.announces().collect { emit(it) }  // <-- HERE
              }
          }
      }

`Flow.collectLatest { lambda }` cancels and restarts a CHILD coroutine
for each upstream emission. The lambda body runs in that child, NOT in
the surrounding flow{} body's coroutine. So when the lambda invokes
`emit(it)`, it's emitting from a child coroutine. `flow{}`'s
SafeFlow guard rejects this with the same "Emission from another
coroutine is detected" error the inner listener was throwing before
my last fix.

Net effect on the user's repro: the inner channelFlow now correctly
sends RoomAnnouncement to the wrapper's `listener.announces().collect`
lambda, but that lambda's `emit(it)` to the wrapper's flow{} body
fails the same SafeFlow check, the wrapper's runCatching swallows the
exception (as `iter=2 inner collect ended IllegalStateException ...
fwd=1`), `_announcedSpeakers` stays empty, cliff detector never fires.

Fix: convert `ReconnectingNestsListener.announces` from `flow{} + emit`
to `channelFlow{} + send`, matching the inner listener's shape. The
combination of `channelFlow + collectLatest + send` is the canonical
pattern in kotlinx-coroutines for "switch-map" semantics with cross-
coroutine production. `awaitClose { }` is empty because `collectLatest`
on an infinite-StateFlow never completes naturally — cancellation
propagates through structured concurrency when the consumer cancels
the channelFlow.

Tests: every nestsClient + commons unit test still passes, including
the 12 CliffDetectorTest cases pinning the predicate's behaviour.

Together with f17e7ad (inner channelFlow), this should finally close
the chain: inner emits RoomAnnouncement on its channelFlow → wrapper's
collectLatest receives it inside its child coroutine → wrapper's
channelFlow.send forwards across the channel → consumer's
observeAnnounces collect receives → `_announcedSpeakers` populates →
cliff detector tick reports `announced=1` → on a real cliff event the
recycle fires.
2026-05-05 19:50:23 +00:00
Claude f17e7adfa7 fix(nests): announces flow MUST use channelFlow, not flow{}
The diagnostic logging in 1fc8dbc surfaced the real root cause —
not the SharedFlow replay race I fixed in d6517cf, but a strict
flow{}-builder concurrency-confinement violation.

The receiver-side log (15:34:40, repro after the diagnostic patch)
showed:

  session.announce(prefix='') bidi pump emit #1 status=Active suffix='fe52579aa30e' (chunks=1)
  MoqLiteNestsListener.announces bidi#2 #1 status=Active suffix='fe52579aa30e'
  MoqLiteNestsListener.announces bidi#2 finally (emissions=1)
  wrapper.announces() iter=2 inner collect ended IllegalStateException:
    Flow invariant is violated:
    Emission from another coroutine is detected.
    Child of StandaloneCoroutine{Active}@e9b5981, expected child of
    StandaloneCoroutine{Active}@8309a26.
    FlowCollector is not thread-safe and concurrent emissions are
    prohibited.
    To mitigate this restriction please use 'channelFlow' builder
    instead of 'flow'

The session's bidi pump (`scope.launch { bidi.incoming().collect …
updates.emit(decoded) }`) emits to the announce SharedFlow from the
PUMP's coroutine. SharedFlow's `collect { … }` resumes the lambda
inline on the emitter's coroutine when there's no dispatcher hop,
so `MoqLiteNestsListener.announces`' `handle.updates.collect {
emit(RoomAnnouncement(…)) }` lambda fires on the pump's coroutine,
not the flow{} body's coroutine. `flow {}`'s SafeFlow guard throws
on the cross-coroutine emit. The wrapper's
`runCatching { listener.announces().collect { emit(it) } }` swallows
the exception, the wrapper's collect ends with `fwd=1`, and
`_announcedSpeakers` stays empty for the lifetime of the session.

That, in turn, kept the cliff detector permanently gated:

  cliff-detector tick=N active=1 announced=0 lastFrameAges=[fe52579a=…ms]

even though the receiver was clearly subscribed and clearly seeing
frames. The cliff detector requires the speaker to be in
`announcedSpeakers` to recycle, so the relay-forward cliff at ~135
streams went undetected — the original two-phone receiver-side
silence symptom.

Fix: switch `MoqLiteNestsListener.announces()` from `flow { … }` to
`channelFlow { … }`. `channelFlow` is the documented escape hatch
for cross-coroutine emission (the error message itself recommends
it). The new shape:

  channelFlow {
      val handle = session.announce(prefix = "")
      val pump = launch {
          try {
              handle.updates.collect { send(RoomAnnouncement(…)) }
          } finally {
              withContext(NonCancellable) {
                  runCatching { handle.close() }
              }
          }
      }
      awaitClose { pump.cancel() }
  }

Notes:
  - `send` (channel) replaces `emit` (flow) — channelFlow buffers
    via a Channel, so cross-coroutine producers are explicitly
    supported.
  - The inner `collect` is wrapped in a launched child of the
    channelFlow scope so we can use `awaitClose` for the producer-
    side teardown contract channelFlow requires.
  - `handle.close` is suspend (FINs the bidi + joins the pump);
    putting it in the launched pump's `finally` under
    `NonCancellable` keeps the FIN reliable when the consumer
    cancels mid-stream.

Diagnostic logging from 1fc8dbc preserved — the next two-phone
repro should now show:

  observeAnnounces #1 active=true pubkey='fe52579aa30e' → ADD
  cliff-detector tick=N active=1 announced=1 lastFrameAges=[…]

…and on a real cliff event, the recycle should fire.

Tests: full suite still passes
  - CliffDetectorTest: 12/12
  - NestPlayerTest: 12/12
  - NestBroadcasterTest: 6/6
  - MoqLiteSessionTest: 11/11
  - All other nestsClient jvmTest classes (~240 tests across 37
    classes) green.
2026-05-05 19:39:23 +00:00
Claude 1fc8dbceee debug(nests): trace announce-flow chain to localise still-empty _announcedSpeakers
The replay=64 SharedFlow fix in d6517cf did NOT close the receiver's
"announced=0" symptom — the next two-phone repro (15:20:08 onward)
still shows the cliff detector ticking with `active=1 announced=0`
indefinitely, even after the session-internal pumpAnnounceWatch
clearly received an Active update. Either moq-rs sends Active to
only the FIRST announce bidi (so the VM-level second bidi never
gets it), OR the chain from that bidi to `_announcedSpeakers` is
broken at a layer my last fix didn't visit.

Adds focused logging at every step of the announce chain so the
next repro pins down which one. All NestRx-tagged:

`MoqLiteSession.announce` (per-bidi pump):
  - "session.announce(prefix='…') bidi opened, pump launching"
  - "session.announce(prefix='…') bidi pump emit #N status=… suffix='…' chunks=…"
  - "session.announce(prefix='…') bidi.incoming() ended naturally chunks=… emits=…"
  - per-emission so we can compare bidi#1 (session-internal) vs
    bidi#2 (VM-level) emit counts. If bidi#2 has 0 emits, the
    relay is sending Actives to only one bidi.

`MoqLiteNestsListener.announces`:
  - "MoqLiteNestsListener.announces flow STARTING (state=…)"
  - "MoqLiteNestsListener.announces opened bidi#2 (VM-level)"
  - per-emission "bidi#2 #N status=… suffix='…'"
  - "bidi#2 collect ENDED naturally" / "finally emissions=N"

`ReconnectingNestsListener.announces` (wrapper):
  - "wrapper.announces() flow starting collect on activeListener"
  - "wrapper.announces() iter=N activeListener=set/null"
  - "wrapper.announces() iter=N inner state=Connected/Closed/…"
  - "wrapper.announces() iter=N inner collect ended naturally/X fwd=N"

`NestViewModel.observeAnnounces`:
  - "observeAnnounces launching collect on l.announces()"
  - per-emission "observeAnnounces #N active=… pubkey='…' → ADD/REMOVE"
  - "observeAnnounces collect ENDED naturally/X (emissions=N,
    _announcedSpeakers.size=…)"

After the next repro, the receiver-side log will show one of:
- bidi#2 never opens (terminalOrConnected != Connected somehow)
- bidi#2 opens, no chunks/emits → relay-side issue
- bidi#2 emits, but `MoqLiteNestsListener.announces` doesn't
  forward → bug in the flow body
- forwarding works but observeAnnounces collect ends → bug in
  `ReconnectingNestsListener.announces` collectLatest behavior
- everything works but `_announcedSpeakers.size=0` → mutation lost

Tests: full suite still passes (CliffDetectorTest 12, NestPlayerTest
12, NestBroadcasterTest 6, MoqLiteSessionTest 11, …).
2026-05-05 19:27:19 +00:00
Claude d6517cf346 fix(nests): announce SharedFlow late-attach race dropped Active updates
The cliff-detector diagnostic logs from the user's two-phone repro
(commit f2205dc, run 15:06) showed the smoking gun — every tick from
the receiver-side cliff detector said `announced=0` even though an
Active announce had clearly been received earlier (the session-level
`pumpAnnounceWatch` logged it). The cliff detector only acts on
speakers that are in `_announcedSpeakers`, so it never tripped, even
when frames clearly stopped flowing for 6+ s.

Root cause: `MoqLiteSession.announce` builds a `MutableSharedFlow`
with `replay = 0` to bridge the bidi pump and the consumer's
`collect`. With Kotlin's `MutableSharedFlow(replay = 0)`, an `emit`
that happens BEFORE the first collector subscribes is silently
dropped — there's no buffer for items emitted into a flow with no
subscribers. The bidi pump is launched inside `announce()` and
starts collecting `bidi.incoming()` immediately; the caller (the
flow returned by `MoqLiteNestsListener.announces()`) attaches its
collector AFTER `session.announce()` returns. In between, if the
relay sends an Active update (which it does the moment the
broadcaster's session is registered), the pump's emit hits a flow
with no collectors and is lost.

In the user's logs both an internal session-level
`pumpAnnounceWatch` AND the VM-level `observeAnnounces` open
separate announce bidis. The internal one started first and won
the race for its bidi's Active. The VM-level one lost — the bidi
pump emitted Active before the consumer subscribed, the SharedFlow
dropped it, and `_announcedSpeakers` never populated.

Fix: change the announce SharedFlow to `replay = 64` with
`BufferOverflow.DROP_OLDEST`. Late-attaching collectors now see up
to the last 64 announces (far more than nests rooms have
speakers — typical stage size is ≤ 10). The bidi pump's emit
still never suspends, so QUIC-level backpressure can't propagate.
The replay cache evicts the oldest item if more than 64 announces
arrive before any collector attaches; for the production workload
this is unreachable.

Tests: nestsClient jvmTest passes 57/57 unchanged
(MoqLiteCodecTest, MoqLiteFrameBufferTest, MoqLitePathTest,
MoqLiteSessionTest, NestBroadcasterTest, NestPlayerTest). The
session-level pumpAnnounceWatch path used the same SharedFlow but
its caller subscribes from `scope.launch { pumpAnnounceWatch(...) }`
inside `ensureAnnounceWatchStarted` — close enough in time that it
won the race in production. The fix closes the race for both
callers regardless of timing.
2026-05-05 19:11:51 +00:00
Claude bd681a0a5f test(nests): close FRAME1 delivery race in subscribeSpeaker_survives_session_swap
The test emitted FRAME1 into first.frames and immediately called
first.fail(...), trusting that FRAME1 would propagate through the pump
to the wrapper's frames before the session swap. emit() is
non-suspending — it only enqueues FRAME1 into the pump's slot. On
slower hosts (observed on macOS CI) the orchestrator's reconnect path
can flip activeListener to the second session before the pump's
collect lambda runs, at which point collectLatest cancels
pump-iteration-1 mid-resume and FRAME1 is dropped.  The consumer's
take(2) then only ever sees FRAME2 and the async's withTimeout fires
after 5 s.

Add a consumerProgress StateFlow that the consumer's collector bumps
on each frame, and wait for it to reach 1 before failing the listener.
Same shape as the existing consumerSubscribed gate that closed the
"emit before consumer subscribes" race.
2026-05-05 18:06:56 +00:00
Claude cb61082bc4 fix(nests): close receiver-audio dropout cluster + auto-start broadcast on host create
Six changes that together close out the bug class the user reported
(receiver "blinks green ring for ~5 s then stops") and the related
host-side "circular spinner until first unmute" UX issue. Each is
defensible independently; together they form a defense-in-depth
against the moq-rs production-relay forward-queue cliff documented
in `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.

#3 — `NestMoqLiteBroadcaster.onLevel` only fires when
`current.send(opus)` returned `true` (frame reached an inbound
subscriber). Two-phone logs showed the host's green/glow firing for
the first ~7 s while no listener was attached and after a relay-side
cliff while no audio was on the wire. The local "I'm broadcasting"
ring now tracks the wire, not the runCatching outcome.

#5 — `DEFAULT_FRAMES_PER_GROUP: 5 → 10` (200 ms of audio per
group, 5 streams/sec). Two-phone production logs showed the relay
still cliffing at ~135 streams under sustained load with the old
5-frame default — same bug class the plan thought it had closed,
just shifted by half. Doubling the pack roughly doubles the
worst-case cliff window. Kept the existing `framesPerGroup = 5`
test scenarios intact since they're explicit on the call site.

#4 — `SUBSCRIBE_RETRY_BACKOFF_INITIAL_MS: 250 → 100`. Logs of the
join path showed 5 retries (250+500+1000+1000+1000 = 3.75 s) of
"subscribe stream FIN before reply" before the relay observed the
broadcast's announce. New ladder is 100+200+400+800+1000 = 2.5 s.
The MAX cap stays at 1000 ms so a permanently-gone publisher still
doesn't hammer the relay.

#2 — `NestViewModel.openSubscription` now passes
`maxLatencyMs = 500L` on every speaker SUBSCRIBE (new constant
`ROOM_AUDIO_MAX_LATENCY_MS`). Tells moq-rs to evict groups older
than 500 ms from the per-subscriber forward queue rather than
queuing them up to the relay's `MAX_GROUP_AGE = 30 s` default —
caps the queue growth that triggers the cliff. The wire support
existed in the listener; only the call site was passing 0L.

#1 — listener-side cliff detector. New `cliffDetectorJob` in
`NestViewModel` periodically scans `activeSubscriptions` against
`lastFrameAt` (per-pubkey monotonic TimeMark, updated in
`onSpeakerActivity`). When a speaker has been announced Active AND
we have an active subscription AND no frames have arrived for
`ROOM_AUDIO_CLIFF_TIMEOUT_MS = 4 s`, force a `recycleSession()` so
the orchestrator opens a fresh QUIC transport — a clean reset of
the relay's per-subscriber forward queue. `ROOM_AUDIO_CLIFF_COOLDOWN_MS = 8 s`
prevents back-to-back recycles while the new session is mid-handshake.
Started from `launchConnect` after `observeAnnounces`, cancelled in
`teardown`. Per-pubkey `lastFrameAt` entry dropped on
`closeSubscription` so a removed speaker doesn't trigger a stale
recycle.

#6 — auto-start broadcast pre-muted on host create.
`NestViewModel.startBroadcast` gets an `initialMuted: Boolean = false`
parameter; when `true` the broadcaster opens the publisher session,
calls `setMuted(true)` before the UI flips to `Broadcasting`, and the
mic stays open + capture loop keeps running with `if (muted) continue`
gating sends. New `LaunchedEffect(speakerPubkeyHex)` in
`OnStageIdleControls` calls `startBroadcast(initialMuted = true)`
automatically when mic permission is already granted, so a host who
just created a room sees their avatar transition to "Live (silent)"
and listeners can subscribe immediately without waiting for the host
to tap "Talk" first. Tap-to-talk path also passes `initialMuted = true`
now — unmute is a separate, explicit step on the mic toggle that
appears once we're in `Broadcasting(isMuted = true)`. Permission-
denied case still requires the manual Talk-button tap to launch the
runtime permission prompt.

Diagnostic logs from the previous two debug commits remain in place
(throttled to ≤ 1 line/sec/speaker at 50 fps capture cadence) so the
next two-phone repro can validate the fixes against logcat directly.
2026-05-05 16:44:39 +00:00
Claude be8dd0a3bc debug(nests): add playback-path logs to localise receiver silence
The wire is healthy: drainOneGroup logs show every group reaching the
receiver with 5 frames each, no drops, no parse errors, no pump exits,
no announce-ended teardown. Yet the green ring on the receiver still
blinks-and-stops. The bug must be in the playback pipeline below
drainOneGroup. Add NestPlay-tagged logs there so the next repro
identifies whether the failure is decoder, AudioTrack allocation,
beginPlayback, or write-blocking.

NestPlayer.play (`NestPlay` tag):
  - log start with prerollFrames
  - log preroll flush + beginPlayback transition
  - throttled per-N counters: receivedObjects / decodedFrames /
    emptyDecodes / enqueued
  - log decoder.decode throws explicitly
  - log empty-pcm decoder returns (separate from throws)
  - log enqueue duration when > 50 ms (catches AudioTrack.write-blocking)
  - log flow COMPLETED / cancelled / pipeline threw with final counts

AudioTrackPlayer (`NestPlay` tag):
  - log start() + the constructed AudioTrack's state / playState /
    bufferSizeBytes / minBuffer
  - log beginPlayback's t.play() return + post-call state
  - log AudioTrack.write partial / error returns
  - log setMuted / setVolume changes (silent mute is a known
    suspect for "audio not playing")

MediaCodecOpusDecoder (`NestPlay` tag):
  - log codec name on successful allocation
  - log allocation failure with exception type / message

All log calls use the lambda overload so format work only runs when
the tag is enabled. Throttled counters keep logcat at < 1 line/sec
per speaker even at 50 fps capture cadence.
2026-05-05 15:56:47 +00:00
Claude 3a2010d9c0 debug(nests): add diagnostic logs to localise receiver audio dropout
Adds focused logs at every suspicious point on the moq-lite receive +
publish paths so we can pin down why the receiver phone shows a brief
~5s window of activity then nothing while the speaker phone's UI looks
healthy.

Receiver-side (`NestRx` tag):
  - SUBSCRIBE send / SUBSCRIBE_OK / SUBSCRIBE bidi exit (with reason)
  - announce-watch update per Active/Ended event, plus which subs get
    closed when an Ended hits
  - pumpUniStreams start + naturally-ended / threw with stream count
  - drainOneGroup header, FIN (frames + droppedNoSub + trySend fail
    counters), and per-throw with stream sequence
  - wrapper handle attached / objects flow ENDED with emitted count

Broadcaster-side (`NestTx` tag):
  - inbound ANNOUNCE / SUBSCRIBE accepted / track-mismatch ignored
  - registerInboundSubscription / removeInboundSubscription with
    inboundSubs.size
  - throttled "send returning false (no subs / publisher closed)" so
    the speaker UI's "I'm broadcasting" claim can be cross-checked
    against frames actually leaving the wire
  - send threw (consecutive count) so a sustained openUniStream
    failure surfaces before MAX_CONSECUTIVE_SEND_ERRORS bails
  - openGroupStream open + per-throw

All log calls use the lambda overload so the throttled/string-format
work only runs when the tag is enabled.
2026-05-05 15:36:17 +00:00
Claude 003cf42564 fix(nests): self-audit pass — two real bugs + robustness + tests
Audit of the four prior commits found two genuine regressions and two
robustness gaps in the new code paths.

Bug A: NestForegroundService.networkCallback dropped the publish for
the most important scenario it was added to handle. The earlier guard
`if (previous != null && previous != network)` suppressed both the
registration-time first onAvailable AND the legitimate WiFi-loss-
then-cellular-available path. On a WiFi → cellular handover the
sequence is `onLost(wifi)` (clears currentDefaultNetwork to null)
followed by `onAvailable(cellular)` — which then looks identical to
the registration callback to the guard, so no publish fires and the
QUIC session sits on the dead socket until PTO. Replaced the implicit
"previous == null" suppression with an explicit `seenInitialNetwork`
flag that's set true on the first onAvailable and never cleared, so
post-onLost reconnects publish correctly.

Bug B: requestAudioFocus result handling was too permissive. The
shape `if (result == AUDIOFOCUS_REQUEST_FAILED) TransientLoss else
Granted` falls through to Granted on the runCatching exception path
(`result == null`) and on AUDIOFOCUS_REQUEST_DELAYED (= 2) — meaning
audio plays over an active call when the OS hasn't actually released
focus. Switched to a strict `if (result == AUDIOFOCUS_REQUEST_GRANTED)`
check; everything else (FAILED, DELAYED, exception) starts the VM
muted.

Robustness: NestViewModel.openSubscription's onError callback used to
swallow every AudioException, which fit the per-packet decoder-error
case but turned PlaybackFailed and DeviceUnavailable from a deferred
beginPlayback into a permanent "Connecting…" spinner on the speaker
tile. Now we discriminate by AudioException.Kind: decoder/encoder
errors stay swallowed (Opus PLC papers them over), but PlaybackFailed
and DeviceUnavailable roll the slot back so a future reconcile can
retry.

Pre-roll ordering swap + tests: NestPlayer.play used to call
beginPlayback BEFORE flushing the pre-roll buffer, leaving a
microsecond window where the AudioTrack hardware was playing against
an empty buffer. AudioTrack MODE_STREAM explicitly supports write()
before play(), so flush-then-beginPlayback is the textbook pattern
and what the fix now does. Three regression tests cover:
  - pre-roll defers beginPlayback until threshold is met (and the
    flush-then-begin ordering is observable)
  - partial pre-roll flushes when the upstream flow ends early
  - empty flow doesn't begin playback at all
The FakeAudioPlayer grows beginPlaybackCount + queuedAtBeginPlayback
fields so the tests can assert ordering directly.
2026-05-05 13:04:18 +00:00
Claude 6237c02c6f fix(nests): platform-side audio robustness — focus, AEC, route obs, network handover
Four follow-up fixes from the post-audit review (#4 / #5 / #6 / #7).

#6 AcousticEchoCanceler / NoiseSuppressor / AGC on the AudioRecord
   session. The VOICE_COMMUNICATION input source engages the platform
   echo canceller automatically on most modern Android devices, but a
   small set of older / OEM-customised devices only attach AEC under
   MODE_IN_COMMUNICATION — which an audio-room app deliberately
   avoids. Attaching the standalone audiofx effects to the
   AudioRecord's session id covers those devices without rerouting
   through the call audio path. All three are best-effort and a no-op
   on devices where the source already engages them.

#4 Real audio focus handling. The previous OnAudioFocusChangeListener
   was a no-op based on the assumption that the OS would auto-duck
   us; it doesn't (CONTENT_TYPE_SPEECH streams aren't auto-ducked).
   Inbound phone calls were mixing on top of room audio.
   - New `NestAudioFocusBus` (commons) — process-wide enum signal,
     decoupled from android.media so commons stays platform-free.
   - NestForegroundService translates AUDIOFOCUS_GAIN / LOSS_TRANSIENT*
     / LOSS into the bus enum.
   - NestViewModel observes the bus and silences both the listener
     playback (effective listen-mute = user OR focus) and the
     broadcast mic (effective mic-mute = user OR focus). User-visible
     mute states stay the user's choice so a focus regain restores
     them automatically.
   - The pipeline keeps running while focus is lost (decoder, capture,
     network) so unmute is sample-accurate when the call ends.

#5 AudioDeviceCallback observability. Registers a callback in
   NestForegroundService that logs Bluetooth / wired / USB headset
   attach + detach with device type + name. Doesn't drive playback
   decisions — Android's auto-routing handles route swaps — but
   makes "audio cut out when I plugged in headphones" reports
   correlatable with a concrete event for the first time.

#7 Network-change → fast reconnect. Without this, a Wi-Fi → cellular
   handover left the QUIC connection sitting on a now-dead socket
   until its PTO fired (~30 s) before the wrapper noticed. Now:
   - New `NestNetworkChangeBus` (commons) — collapses bursts of
     onLost/onAvailable into a single recycle event.
   - NestsListener + NestsSpeaker grow `recycleSession()` (default
     no-op); the reconnecting wrappers override to close the inner
     session so their orchestrator opens a fresh one.
   - NestForegroundService registers a default-network callback;
     suppresses the first onAvailable (registration callback)
     and only publishes on actual default-network changes.
   - NestViewModel observes the bus and calls recycleSession on
     both wrappers. The SubscribeHandle re-issuance pump (listener)
     and the hot-swap publisher pump (speaker) cut existing
     subscriptions / broadcasts onto the new session as soon as
     it lands — same paths the JWT-refresh recycle uses.
   - Manifest gains ACCESS_NETWORK_STATE for
     registerDefaultNetworkCallback.
2026-05-05 12:42:47 +00:00
Claude e4e55d1df6 fix(nests): three more dropout sources from the post-audit review
1. Split AudioPlayer.start() into allocate + beginPlayback to restore
   the synchronous DeviceUnavailable error path that the previous
   pre-roll fix collapsed.
   - AudioPlayer gains `beginPlayback()` (default no-op) so test
     fakes / desktop sinks aren't forced to grow a method they
     don't need.
   - AudioTrackPlayer.start() now allocates the AudioTrack + audio-
     priority writer thread but does NOT call AudioTrack.play();
     beginPlayback() flips the device into the playing state.
     AudioTrack in MODE_STREAM explicitly supports write() before
     play() per the platform docs, which is exactly the contract
     pre-roll wants.
   - NestPlayer.play() now calls player.start() synchronously
     (caller catches DeviceUnavailable + rolls back the slot like
     before) and defers beginPlayback() until pre-roll fills.

2. MediaCodecOpusDecoder: drain output + retry input dequeue before
   dropping a frame. The prior 10 ms input-buffer dequeue followed
   by an unconditional `return ShortArray(0)` turned every transient
   stall (thermal throttling, GC pause, output not yet pulled) into
   a 20 ms audio gap. Now we drain whatever output is ready —
   freeing input slots — then retry input dequeue with a 5 ms
   timeout. Only after both misses do we drop the packet, and even
   then we return any output samples that the drain produced so
   the player doesn't underrun on the back of one tight cycle. The
   decoder's drain logic is extracted into a `drainAvailableOutput`
   helper so both the pressure-relief path and the post-queue main
   drain share it.

3. ReconnectingNestsListener: exponential backoff for the opener-
   throws retry path. Replaces the flat 1 000 ms `SUBSCRIBE_RETRY_BACKOFF_MS`
   with 250 → 500 → 1 000 ms, reset on first successful subscribe.
   The 250 ms floor is well under moq-rs's typical announce-
   propagation latency (< 200 ms), so a subscribe-before-announce
   miss usually recovers fast enough that the wrapper SharedFlow's
   ~1.3 s buffer hides the gap entirely.
2026-05-05 12:17:59 +00:00
Claude 076b301d84 fix(nests): close 4 audio-dropout sources across listener + speaker
1. Listener-side pre-roll + bigger playback buffer + audio-priority thread.
   - NestPlayer buffers 5 decoded frames (~100 ms) before starting
     the AudioPlayer, masking the first-frame underrun that fires
     whenever Compose / GC briefly stalls Main.
   - AudioTrackPlayer sizes the AudioTrack at max(minBuffer*16, 250 ms)
     instead of minBuffer*4 (~80 ms) and writes via a per-instance
     audio-priority single-thread executor (Process.THREAD_PRIORITY_AUDIO)
     instead of Dispatchers.IO, so WRITE_BLOCKING never contends with
     unrelated IO work.

2. MoqLiteSession.subscribe: hoist response typeCode out of the
   collect lambda. readVarint advances pos permanently while
   readSizePrefixed only rolls back its own length-varint, so a
   chunk-split between type and body would re-read the body's size
   prefix as the type code on the next chunk and misframe the
   response. Mirrors the same fix already in handleInboundBidi.

3. MoqLiteSession.subscribe: register the subscription in the map
   BEFORE writing the SUBSCRIBE bytes on the wire. The relay can
   open the first group's uni stream before our continuation
   re-enters [state] to register; if so, drainOneGroup looked the
   id up against an empty map and silently dropped the frame
   (~1 frame / 20 ms gap on first attach). Wrap the post-register
   writes so a transport-failure unwinds the orphan registration.

4. Hot-swap moq-lite publisher across JWT-refresh boundaries.
   - NestMoqLiteBroadcaster: publisher is now @Volatile + supports
     swapPublisher(); capture loop snapshots the reference per frame
     and resets framesInCurrentGroup on swap.
   - MoqLiteNestsSpeaker implements a new internal
     HotSwappablePublisherSource interface that exposes
     openPublisherForHotSwap(track) without spinning up a broadcaster.
   - ReissuingBroadcastHandle keeps a single long-lived broadcaster
     across session recycles when the speaker supports hot swap;
     legacy IETF / fake speakers fall back to close-then-restart.
   - connectReconnectingNestsSpeaker.orchestrator hoists the old-
     session close onto a sibling launch so the wrapper can swap
     the publisher into the broadcaster on the new session before
     the old session's WebTransport drops. Eliminates the previously-
     accepted 50–150 ms audible silence at every JWT refresh.
2026-05-05 03:06:40 +00:00
Claude fba0a5c952 feat(quic): bestEffort streams + park CC plan indefinitely
After drafting the congestion-control plan we concluded the audio-rooms
workload doesn't actually need CC — speakers push ~8 KB/sec, which
never fills any modern link's capacity. The one real concern that
surfaced — STREAM retransmit wasting bandwidth on stale Opus frames
on lossy uplinks — is much cheaper to fix directly than to bound via
a 14-test CC subsystem.

SendBuffer gains a `bestEffort: Boolean = false` constructor flag.
When true, markLost drops the lost ranges instead of moving them to
the retransmit queue and lets the underlying byte storage compact as
if the bytes had been ACK'd. The FIN flag (if covered) also stays
sent — best-effort skips FIN re-emission too. The peer may end up
with a truncated stream; moq-lite's per-stream timeouts handle that.

Plumbed through QuicStream → QuicConnection.openUniStream(bestEffort)
→ QuicWebTransportSessionState.openUniStream(bestEffort) →
WebTransportSession.openUniStream(bestEffort). Default is false
everywhere, so reliable streams (HTTP/3 control, moq-lite SUBSCRIBE
bidi, etc.) keep RFC 9000 §3.5 semantics.

MoqLiteSession.openGroupStream now passes `bestEffort = true` —
group streams carry a single Opus packet, are real-time, and don't
benefit from retransmit.

Internal cleanup: `removeOverlap`'s `ackedNotLost: Boolean` parameter
became `OverlapAction { ACK, RETRANSMIT, DROP }` so the third best-
effort disposition has a name. Same code paths, same tests, just
clearer at the call site.

CC plan (quic/plans/2026-05-05-congestion-control.md) is updated to
"parked indefinitely" with a note that this commit is the lighter-
weight alternative that addresses the only practical concern. The
plan is preserved as a reference if a future workload justifies CC.

New tests: SendBufferBestEffortTest (6 cases — reliable baseline,
best-effort drops, FIN drop in best-effort mode, partial overlap,
idempotent stale loss, ACK path still works).

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 02:06:08 +00:00
Claude c3d6cadffb fix(quic): raise stream-id cap to 1M to support multi-hour Nests; strip diagnostic logs
Two changes once the cliff is confirmed sidestepped:

1. Raise initialMaxStreamsUni from 10 000 → 1 000 000.

   At framesPerGroup=5 with 20 ms Opus frames the relay opens ~10
   uni streams/sec to a listener. The half-window threshold check
   (`count + initialMaxStreamsUni/2 >= advertisedMaxStreamsUni`)
   now trips at count=500 000 ≈ 13.9 hours of continuous audio.
   For any realistic Nest the rolling MAX_STREAMS_UNI extension
   path — which is what tripped the moq-rs cliff — is dormant.

   Memory cost: QuicConnection.streams grows for the connection's
   lifetime (no removal in current model), so 2 hours costs ~72k
   stream entries. Per-stream overhead is small enough that this
   is tolerable for an audio-room workload; bounded growth is a
   known follow-up.

2. Strip the high-frequency diagnostic logs that were added during
   investigation. Production keeps:
     - SUBSCRIBE_DROP (rare error)
     - MAX_STREAMS_UNI / MAX_STREAMS_BIDI emit (should never fire
       in normal operation now; if it does, we want to know)
     - pumpUniStreams / pumpInboundBidis ended (pump death)
     - announce / subscribe bidi.incoming() exception path
     - ReconnectingHandle.opener throw + retry

   Stripped:
     - per-stream "transport delivered uni stream #N"
     - per-group "uni grpHdr id=N seq=M"
     - per-group "openGroup seq=N keyedOnSubId=…"
     - per-25-stream peerInitiatedUniCount milestone
     - per-chunk "subscribe id=N: bidi chunk #M"
     - per-update RoomAnnouncement
     - 5-second QuicWebTransportSession flow-control snapshot ticker
     - "VM.openSubscription ->/<- subscribeSpeaker"
     - "VM.onSpeakerActivity FIRST frame"
     - "broadcaster: send accepted (subscriber attached)"
     - "broadcaster: publisher.send returned false (no inbound subscriber)"
     - "first inbound subscriber attached"
     - "ignoring inbound SUBSCRIBE id=N track=catalog.json"
     - "publish suffix=…"
     - "transport delivered inbound bidi #N"
     - "inbound AnnouncePlease prefix=…"
     - "inbound SUBSCRIBE id=… track=…"
     - "inbound SUBSCRIBE FIN'd: removing id=…"

   The diagnostic logs can be re-added behind a debug flag later if
   we need to chase a different regression.

Confirmed durable for at least 15 s of continuous audio against
nostrnests.com production with the prior 10 000-cap fix; bumping to
1 000 000 expands the headroom to multi-hour broadcasts without any
new code path firing.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 21:55:49 +00:00
Claude 36c707f98a debug(quic): periodic 5s flow-control snapshot from QuicWebTransportSession
Listener cliffs at uni stream #61 even though MAX_STREAMS_UNI(150)
was emitted at count=50 — and the cliff number is variable across
runs (124 in one trace, 61 in another), which strongly suggests the
limiter isn't the stream-id cap any more. Need visibility into what
QUIC flow-control state looks like at the moment streams stop.

QuicWebTransportSession now optionally takes a parentScope and, when
provided, launches a daemon coroutine that calls
QuicConnection.flowControlSnapshot() every 5 s. The snapshot dumps
the fields the cliff-investigation plan
(nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md)
identified as smoking-gun candidates:

  - peerInitiatedUniCount + advertisedMaxStreamsUni (stream-id cap)
  - peerInitMaxStreamsUni (peer's view of our cap, from handshake)
  - sendCredit + consumed (connection-level data flow control)
  - pendingBytes / pendingStreams (anything stuck in send buffers)
  - udpRecvDatagrams + udpRecvBytes (raw socket-level reception)

The factory passes its parentScope into the session so the snapshot
job dies cleanly with the same supervisor that owns the driver.
QuicWebTransportFactory tests and SendTraceScenario don't pass a
scope and won't see the periodic logger.

Filter `adb logcat -s NestQuic:D` and look for "snapshot peerInitiatedUni=…".
At cliff time the snapshot will print whichever counter is wedged.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 20:28:41 +00:00
Claude 9f542a6ff4 fix(nests): retry SUBSCRIBE on relay-FIN instead of permanently giving up
When a listener joins a Nest before the speaker starts publishing,
moq-rs FINs the SUBSCRIBE bidi immediately without sending
SubscribeOk or SubscribeDrop. MoqLiteSession.subscribe surfaces that
as a MoqLiteSubscribeException("subscribe stream FIN before reply"),
which the listener wrapper translated to MoqProtocolException via
MoqLiteNestsListener.wrapSubscription.

The reconnecting wrapper's inner re-issue loop then did:

    val handle = try { opener(listener) } catch (...) { null } ?: break

— breaking out of the loop forever. The outer collectLatest only
re-runs on listener swap (session reconnect), so once the first
SUBSCRIBE failed the audio path stayed dead until the user manually
disconnected and reconnected. With the typical join-order being
"listener taps Join before speaker taps Start", this hit nearly
every nest.

The kdoc on pumpAnnounceWatch claims "moq-lite supports subscribe-
before-announce, so a subscribe issued during the gap … attaches
cleanly when the new publisher comes up" — true for some publisher-
cycle gaps mid-broadcast, but verifiably false for the cold-start
case where the publisher hasn't existed yet on the relay's view of
the namespace. Production trace (commit 283e776):

    14:23:29.556 subscribe id=0 track='audio/data': SUBSCRIBE bytes flushed
    14:23:29.597 subscribe id=0: bidi closed BEFORE any response parsed
    14:23:29.617 ReconnectingHandle.opener threw MoqProtocolException — pump breaks
    14:23:33.604 announce update status=Active suffix=…  (4 s later)
    14:23:34.218 publish suffix=…  (speaker starts publishing)

Fix: instead of `break`, treat opener-throw as a transient failure
and retry after SUBSCRIBE_RETRY_BACKOFF_MS (1 s). The outer
collectLatest still cancels the inner loop on listener swap, and
unsubscribeAction.cancel() still tears the pump down on consumer
release, so the retry loop is bounded by both the session and the
caller. 1 s is well over moq-rs's typical announce-propagation
window (< 200 ms in traces) and short enough that the listener
attaches within a second of the speaker actually publishing.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 18:28:42 +00:00
Claude 43720dd14d 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
2026-05-04 17:28:18 +00:00
Claude 9b6e09838e debug(nests): expose silent failures inside subscribe / announce / reissue
Round-2 instrumentation after phone-B trace showed silence between
SUBSCRIBE_OK-expected and the first audio frame. Three additions:

- MoqLiteSession.subscribe: log the SUBSCRIBE-bytes-flushed transition,
  every chunk arriving on the response bidi (chunks=N), and any
  exception or natural close on bidi.incoming() that previously
  swallowed the cause.
- MoqLiteSession.announce: same treatment for the AnnouncePlease bidi
  — chunks=N, natural-close, and exception path now visible.
- ReconnectingNestsListener.reissuingSubscribe: previously had a
  `catch (_: Throwable) { null }` black hole on the re-issue pump
  (line 354); now logs the throwable's class + message before the
  pump breaks. Also logs natural-close of handle.objects so a
  publisher cycle vs a real failure are distinguishable in the trace.

These narrow the listener-stuck-on-spinner diagnosis from
"something downstream of subscribe never fires" to which exact step
inside `MoqLiteSession.subscribe` is silent: the write, the response
read, or the framing.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 17:16:28 +00:00
Claude 62bedf1372 debug(nests): log QUIC↔moq seam in pumpUniStreams + pumpInboundBidis
Splits the diagnosis into "QUIC delivered streams" vs "moq routed
them". Without this seam log, a silent NestRx trace after SUBSCRIBE_OK
gives no signal whether the relay is forwarding (and :quic isn't
delivering streams to the moq pump) or moq is dropping frames.

Also surfaces the prior `_: Throwable` swallow in both pumps — those
hid transport-close exceptions which are exactly the kind of clue
we want when the listener silently stalls.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 14:05:03 +00:00
Claude 726894362f debug(nests): wire NestRx/NestTx logs across listener and speaker paths
Diagnostic instrumentation to localise the listener-stuck-on-spinner bug.
None of these are intended for merge — pair logs with a logcat capture,
diagnose, then revert.

Receiver (tag NestRx):
  - MoqLiteNestsListener: log subscribeSpeaker / subscribeCatalog entry
    and per-update RoomAnnouncement emission to the VM
  - MoqLiteSession.subscribe: log SUBSCRIBE_OK / SUBSCRIBE_DROP outcomes
  - MoqLiteSession.drainOneGroup: log every uni group header decode
    and warn on subscription-lookup miss (frame dropped)
  - MoqLiteSession.pumpAnnounceWatch: log every announce update and
    flag the Ended branch that closes the subscription's frames
  - NestViewModel: log VM.openSubscription wiring and the first-frame
    trigger that clears the connectingSpeakers spinner

Speaker (tag NestTx):
  - MoqLiteSession.publish: log entry suffix
  - MoqLiteSession.handleInboundBidi: log inbound AnnouncePlease and
    SUBSCRIBE dispatches plus FIN cleanup
  - MoqLiteSession PublisherStateImpl: log first inbound subscriber
    attach and every group-stream open
  - NestMoqLiteBroadcaster: log when publisher.send returns false
    (no inbound subscriber on relay) and the subscriber-attached
    transition, plus surface throws that runCatching previously
    swallowed only into onError

Capture with `adb logcat -s NestRx:D NestTx:D`.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 13:10:24 +00:00
Claude 5d955eff99 feat: animate local speaker ring from MoQ frame transmission
Wire the local user's avatar into the existing speaking-ring animation so
the host/speaker sees the same green ring + amplitude glow on their own
cell that remote speakers already get.

Ground truth is the broadcaster's `publisher.send(opus)` success: the new
`onLevel` callback fires only after a frame actually leaves on the wire,
computed from the raw PCM peak (shared `peakAmplitude` util used by both
the player decode loop and the broadcaster capture loop). This means the
animation reflects what the relay sees, not any UI-side mute / role
state that could be stale or buggy — if frames are flowing, the ring
plays.

Plumbing: `NestsSpeaker.startBroadcasting` gains an optional `onLevel`
that the moq-lite + IETF broadcasters both honour, the reconnecting
wrapper replays it on every session reissue (alongside the existing
mute-intent replay), and `NestViewModel.startBroadcast` hands it a
lambda that calls the existing `onSpeakerActivity` / `onAudioLevel`
plumbing keyed on the local pubkey. The 250 ms speaking-timeout fades
the ring naturally when the user mutes or stops broadcasting.
2026-05-02 01:17:40 +00:00
Claude 523e8a92ec fix(nests): play audio-room output through STREAM_MUSIC, not STREAM_VOICE_CALL
`AudioTrackPlayer` was constructing its `AudioTrack` with
`USAGE_VOICE_COMMUNICATION` + `CONTENT_TYPE_SPEECH` so the room would
behave like a phone call (volume rocker controls call volume, ducks
notifications). The `AudioTrack` then renders on `STREAM_VOICE_CALL`,
which Android only services audibly while the device is in
`MODE_IN_COMMUNICATION`. Nests never drives `AudioManager.mode` (only
NIP-100's `CallAudioManager` does), so on most devices the playback
either dropped to the earpiece at near-zero volume or produced no audio
at all — making rooms appear silent on both phones.

Fix: route through `USAGE_MEDIA` + `CONTENT_TYPE_SPEECH` (=
`STREAM_MUSIC`) so audio-room playback comes out of the loudspeaker by
default and the volume rocker controls it any time. Same approach
Twitter/X Spaces and Clubhouse take for hands-free audio rooms. Echo
cancellation still works on the capture side via
`MediaRecorder.AudioSource.VOICE_COMMUNICATION` regardless of the
playback usage.

Also update `NestForegroundService.requestAudioFocus` to request focus
under the matching `USAGE_MEDIA` attributes so the focus claim and the
playback attributes line up.
2026-05-01 20:50:12 +00:00
Claude 7c1af48ae7 fix(nestsClient): wait for mute replay in setMuted_intent_replays test
ScriptedSpeaker publishes startCount > 0 from inside startBroadcasting,
so polling startCount alone races the wrapper pump's subsequent
`if (desiredMuted) handle.setMuted(true)` step. Under load (full suite
run on CI) the assertion fired before the pump replayed mute intent,
producing setMutedCalls=0. Wait for the post-condition the assertion
checks instead.
2026-05-01 20:26:18 +00:00
Claude 8b7785a7e8 fix(nestsClient): token-parse hardening, mint timeout, IPv6 [], broadcaster-bail signal
- NestsTokenResponse.parse catch list now includes IllegalStateException
  so a malformed escape from a misbehaving auth server surfaces as
  NestsException instead of crashing the listener.
- OkHttpNestsClient gains a configurable callTimeoutMs (default 90 s)
  enforcing an upper bound on the entire mintToken round-trip including
  retries. Without it a stalled server suspends the reconnect
  orchestrator indefinitely (the orchestrator's openOnce step parks on
  mintToken). 90 s leaves headroom over the worst-case 63 s 429
  retry chain documented in MAX_RATE_LIMIT_RETRIES.
- parseEndpoint tightens IPv6 bracket check from `closeBracket > 0` to
  `> 1`, rejecting the empty `[]` literal.
- NestBroadcaster + NestMoqLiteBroadcaster gain an `onTerminalFailure`
  callback that fires once when the consecutive-send-error guard bails.
  MoqLiteNestsSpeaker wires this to flip the speaker state to Failed,
  giving ReconnectingNestsSpeaker the signal it needs to recycle the
  session — without this hook the broadcaster bailed silently and the
  outward speaker state stayed on Broadcasting forever.

Adds regression test:
  - onTerminalFailure_fires_once_after_consecutive_send_failures

225 tests pass, 0 failures. Android target compiles clean.
2026-05-01 18:42:19 +00:00
Claude 702885f4cb fix(nestsClient): fix race + leaks + Android encoder/decoder bugs from second audit
A fresh audit caught a real race in the round-1 subscribe() rewrite plus
several distinct bugs in the Android MediaCodec layer.

**MoqLiteSession.subscribe() race regression** — the launched collector
that reads the SubscribeResponse and watches for peer FIN ran cleanup
(remove from subscriptionsBySubscribeId, close frames) before subscribe()
itself reached the post-await registration. If the publisher FIN'd
immediately after sending Ok, the collector exited first against an
empty map; subscribe() then registered the subscription, leaving frames
in the map with no live collector to ever close it on transport drop.
Consumer hung forever — exactly the failure mode round-1 fix #3 was
supposed to prevent. Fix: pre-register the subscription BEFORE launching
the collector. Drop unused `ok` field from ListenerSubscription.

**MoqLiteNestsSpeaker.startBroadcasting publisher leak** — if
session.publish() succeeded but broadcaster.start() then threw (mic
permission denied, AudioRecord allocation failure), the publisher was
never closed and stayed registered as session.activePublisher,
permanently blocking subsequent startBroadcasting calls.

**runCatching{suspend close} swallowing CancellationException** —
MoqLiteNestsSpeaker.close, MoqLiteBroadcastHandle.close,
MoqLiteNestsListener.close all wrapped suspending closes in
runCatching, breaking structured cancellation when the parent scope
cancelled teardown. Replaced with explicit cancel-rethrowing try/catch.

**MediaCodecOpusDecoder ArrayList<Short> boxing on every audio frame**
— at 50 fps × 960 samples × N speakers ≈ 48 000 boxed Short
allocations/sec/speaker on the audio hot path. Rewritten to write
directly into a pre-sized ShortArray via ShortBuffer.get(dst, off, len).

**MediaCodecOpusEncoder bugs**
- KEY_AAC_PROFILE = AACObjectLC was being set on an audio/opus
  encoder. Meaningless for Opus; stricter Codec2 stacks on Android
  13+ reject the configure() call with IllegalArgumentException and
  surface as DeviceUnavailable. Removed.
- The drain loop's INFO_OUTPUT_FORMAT_CHANGED branch had no progress
  guard. A buggy encoder re-emitting FORMAT_CHANGED without producing
  output would busy-spin against the 10 ms dequeue timeout. Now
  absorbed at most once per encode call. Same guard added to the
  decoder.

Adds regression test:
  - frames_flow_completes_when_peer_FINs_immediately_after_Ok

224 tests pass, 0 failures. Android target compiles clean.
2026-05-01 18:42:19 +00:00
Claude 9729f3a20c fix(nestsClient): perf + robustness — jitter, frame buffer, defensive bounds, send-error guard
- NestsReconnectPolicy gains a `jitter` parameter (default 0.3, AWS-
  style equal jitter). Without it, every client reconnecting after
  a relay restart retries on the identical 1s/2s/4s/… schedule and
  thunders the relay during recovery. delayForAttempt also accepts
  an explicit Random source for deterministic tests.
- MoqLiteFrameBuffer decouples capacity from size so power-of-two
  growth actually amortises (the old shape allocated a fresh
  ByteArray then truncated capacity back to `needed` per chunk,
  defeating doubling). Adds a guard against varint reads past the
  live region into uninitialised slack capacity.
- prefixWithType inlines the size-prefix wrap so a publisher
  SubscribeOk/Drop reply doesn't allocate three ByteArrays.
- MoqLiteSubscribeOk gains the same `init { require(priority in 0..255) }`
  bounds check its sibling MoqLiteSubscribe has — a buggy caller
  building a malformed Ok reply now fails loudly instead of writing
  a truncated byte on the wire.
- NestBroadcaster + NestMoqLiteBroadcaster track consecutive
  publisher.send exception count and bail after 250 (~5 s at 50 fps)
  instead of holding the mic open forever on a permanently dead
  transport. publisher.send returning `false` (no inbound
  subscriber) is NOT counted — empty rooms are a normal state.

Adds 8 regression tests:
  - 5 in MoqLiteFrameBufferTest (multi-chunk reads, back-to-back
    payloads, growth amortisation, compact, varint past-live guard)
  - 3 in NestsReconnectPolicyTest (jitter spread band, jitter=0
    determinism, jitter=1 collapses to 0..base)

223 tests pass, 0 failures.
2026-05-01 18:42:19 +00:00
Claude f3a942dbd0 fix(nestsClient): more bugs from review — IETF parser, leaks, IPv6, URL encoding, structured concurrency
- Unknown IETF control message types now skip just the unknown frame
  instead of dropping the entire merge buffer (a peer sending a
  draft-17 message we don't enumerate — FETCH, GOAWAY, MAX_SUBSCRIBE_ID
  — would otherwise wedge the pump). Adds MoqUnknownTypeException
  carrying bytesConsumed so runControlPump can advance past it.
- pumpUniStreams + pumpInboundBidis wrap their inner collect in
  coroutineScope so per-stream drains are children of the pump's job
  (was: launched on the outer scope as siblings, leaking past
  cancelAndJoin until the transport's own flow errored out).
- parseEndpoint handles IPv6 authorities ([::1], [2001:db8::1]:4443).
  Naive lastIndexOf(':') used to find a colon inside the address.
- buildRelayConnectTarget percent-encodes the namespace path so a
  malicious / careless `d` tag containing `?`, `#`, `&`, ` ` etc.
  can't truncate the URL and shove the JWT into the wrong slot.
- Reconnect orchestrators (listener + speaker) replace
  `runCatching { openOnce / close / startBroadcasting }` with explicit
  try/catch that rethrows CancellationException, so cooperative
  cancellation from the parent scope dies promptly instead of running
  one more iteration after the cancel.

Adds three regression tests:
  - parseEndpoint_handles_IPv6_authorities
  - buildRelayConnectTarget_percent_encodes_path_unsafe_chars
  - unknown_message_type_throws_typed_exception_with_full_frame_size

215 tests pass, 0 failures.
2026-05-01 18:42:19 +00:00
Claude eb20ac2c2a fix(nestsClient): bug + perf fixes from Nests implementation review
- send() now nulls currentGroup and returns false on uni-stream write
  failure (was: silently returned true while reusing the dead stream)
- Subscribe bidi gets a single long-running collector that closes the
  frames channel on peer FIN / transport tear-down (was: consumer
  could hang forever on a black-holed UDP path with no Announce(Ended))
- AudioException.Kind gains EncoderError; mic-side encode failures no
  longer mis-route through DecoderError handlers
- MoqCodec.decode bounds the payload-length varint before .toInt() so
  a peer-driven absurd length can't wedge the parser forever
- Publish hot-path framing collapses to a single ByteArray allocation
  (was: Varint.encode + plus operator, two allocs per Opus frame at
  50 fps)
- Drops the now-unused readSubscribeResponseFromBidi /
  readSizePrefixedFromBidiInto / EarlyExit helpers

Adds a regression test that asserts the frames flow completes when
the peer FINs the subscribe bidi.

213 tests pass, 0 failures.
2026-05-01 18:42:19 +00:00
Claude 897287be5f nestsClient: expose moq-lite max_latency on subscribeSpeaker + correct plan framing
The moq-lite Lite-03 spec puts subscriber-side group-staleness control in
max_latency on the SUBSCRIBE frame: 0 = unlimited (relay falls back to its
MAX_GROUP_AGE = 30 s default); a positive value tells the relay to evict
groups older than that in favour of newer ones during transient backpressure.

Plumb it through:

  NestsListener.subscribeSpeaker(pubkey, maxLatencyMs = 0L)
    └─ MoqLiteNestsListener → MoqLiteSession.subscribe(.., maxLatencyMillis)
    └─ DefaultNestsListener (IETF) ignores — no equivalent on that wire
    └─ ReconnectingHandle re-issues across reconnects

Default stays at 0L for back-compat with the JS reference watcher; callers
opt in for low-latency live audio.

Also rewrites nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
with corrected framing: of the four "upstream issues" the prior draft listed,
only our own missing MAX_STREAMS_UNI emission was a real bug (RFC 9000 §4.6
violation, fixed in d391ae1d). The relay's per-subscriber queue policy,
unbounded FuturesUnordered, no-timeout open_uni().await, and lack of
lagging-consumer detection are spec-permitted architectural decisions in a
gap moq-lite explicitly leaves to implementations — the spec puts staleness
control on the subscriber via max_latency, which we were sending as 0 the
whole time. Reframed as "feature request worth filing upstream", not "bugs".
2026-05-01 18:30:37 +00:00