Commit Graph

167 Commits

Author SHA1 Message Date
Claude cb6b1d9bbb feat(nests): T16 Phase 2 — I1 amethyst speaker → hang-listen green
Bisected the I1 forward-direction failure to `framesPerGroup`
cardinality, not a wire-format defect. Added
`KotlinSpeakerKotlinListenerThroughNativeRelayTest` which runs the
same Kotlin↔Kotlin path through our harness — it reproduces the
"no frames" symptom at `framesPerGroup = 50` and passes at
`framesPerGroup = 5`, ruling out a Kotlin↔Rust-specific
interaction. The 50-frame default writes ~6 KB onto a single uni
stream; moq-relay 0.10.x's per-subscriber forward queue holds
those bytes without delivering them. This matches the audit
already documented in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
(which recommends `framesPerGroup = 5` as the safe production
cadence).

`HangInteropTest.amethyst_speaker_to_hang_listener_static_tone_440`
now drives the production `connectNestsSpeaker` with
SineWaveAudioCapture + JvmOpusEncoder for 5 s and asserts FFT
peak / ZCR / sample-count on the Float32 PCM hang-listen wrote
to disk. Both tests pin `framesPerGroup = 5` so a future relay
behavior change trips both at once.

The repo's `NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP = 50`
should move to `5` to match the cliff plan and what the
production deployment uses on the wire — flagged as a follow-up
in the results doc; out of scope here.

Verified: 3 sequential `./gradlew :nestsClient:jvmTest
-DnestsHangInterop=true --rerun-tasks` runs green.

https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
2026-05-06 21:49:58 +00:00
Claude cbf631ac77 feat(nests): T16 Phase 2 — JVM Opus + Rust↔Rust E2E interop test
Lands the test-side audio codec + the first end-to-end interop
scenario through the harness:

- JvmOpusEncoder / JvmOpusDecoder via club.minnced:opus-java 1.1.1
  (JNA bindings + bundled libopus.so / .dylib / .dll natives).
  Verified by JvmOpusRoundTripTest — sine 440 Hz survives encode →
  decode with FFT peak preserved + ZCR within 5%.
- SineWaveAudioCapture now paces to real time (20 ms / frame)
  rather than running open-loop. Mirrors how a real microphone
  source blocks on hardware; without it the broadcaster floods
  the relay at compute speed.
- HangInteropTest.rust_hang_publish_to_rust_hang_listener_round_trip_440
  drives hang-publish + hang-listen as subprocesses through the
  harness's moq-relay and asserts FFT peak / ZCR / sample-count
  on the decoded PCM. Verified green on Linux x86_64.
- hang-publish gains --track-name (default "audio/data" matching
  Amethyst's MoqLiteNestsListener.AUDIO_TRACK) and decouples
  --relay-url from --broadcast so the URL path can be the
  namespace and the broadcast can be a relative announce suffix.
- hang-listen's tail "cancelled" error is treated as EOF after
  any frames have been collected, so a clean publisher shutdown
  no longer surfaces as exit=1.

The forward-direction I1 scenario (Amethyst Kotlin speaker → hang
listener) is still gated by an open Amethyst-side wire issue: the
audio uni stream delivers Group control headers but no frame
payloads. Documented in
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md
with concrete pickup steps for a follow-up session.

https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
2026-05-06 21:32:36 +00:00
Claude 284a203070 feat(nests): T16 Phase 1 — cross-stack interop harness scaffolding
Lands the load-bearing infra for the hang/Rust cross-stack interop
test plan (nestsClient/plans/2026-05-06-cross-stack-interop-test.md):
the cargo workspace, Gradle wiring to install moq-relay /
moq-token-cli + build sidecars, the Kotlin harness that boots a
real moq-relay subprocess on a random ephemeral UDP port with
self-signed TLS and unrestricted public auth, plus the signal-domain
PCM assertion library and deterministic sine-wave AudioCapture that
Phase 2 scenarios will assert against.

Phase-1 deviations from the spec are summarised in
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md
— notably: --auth-public "" instead of the JWT minter (no
security-relevant difference for wire-format scenarios), --tls-generate
instead of in-Kotlin cert generation, cargo-install of upstream
binaries instead of an embedded kixelated/moq checkout, and
hang-listen / hang-publish / udp-loss-shim shipped as Phase-1 stubs
that compile + accept --flags but do nothing. Phase 2 fills those
in (and adds JVM Opus encoder/decoder).

Verified green via:
  ./gradlew :nestsClient:jvmTest \
      --tests "com.vitorpamplona.nestsclient.interop.native.*" \
      --tests "com.vitorpamplona.nestsclient.audio.PcmAssertionsTest" \
      -DnestsHangInterop=true

Default mode (no flag) cleanly skips the harness-dependent test.

https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
2026-05-06 20:39:57 +00:00
Vitor Pamplona e144226eee Merge pull request #2750 from vitorpamplona/claude/debug-audio-dropout-n0g6Z
Add audio format negotiation and cliff detection for Nests
2026-05-06 16:07:59 -04:00
Claude 28358b4141 refactor(nests): extract ActiveSubscription + plan deferred manager refactor (Audit-9, Audit-14)
Audit-9: NestViewModel.kt is 2112 lines and growing, ~1000 of which
are subscription-lifecycle state machine concerns intertwined with
the room-level public API. Pulling out the full
`NestSubscriptionManager` is a multi-week refactor with subtle
coupling (catalog readiness affects spinner state, mute has effective
+ per-speaker flavours, expiry jobs need the parent scope) and
warrants its own focused review pass.

For now, take the small tractable subset:

  - Extract `ActiveSubscription` from a `private inner class` in
    NestViewModel to its own file as `internal class
    ActiveSubscription` in the same package. The class is purely
    state-holding (handle, roomPlayer, player, isPlaying); zero VM
    coupling beyond the slot map's value type. Same visibility for
    NestViewModel callers; one less private helper class buried
    1500 lines into NestViewModel.kt.

  - File `commons/plans/2026-05-06-nest-subscription-manager-extraction.md`
    documents the deferred full extraction: target shape, state
    that moves, methods that move, what stays in VM, why deferred,
    when to land. Picks up the next person who opens NestViewModel.kt
    rather than leaving them to re-derive the rationale.

Audit-14: T11.3 (stream priority for moq-lite group uni streams) was
deferred from the T11 commit (drop bestEffort=true) because the
:quic-side change touches the writer's hot path and warrants its own
review pass. New file `nestsClient/plans/2026-05-06-stream-priority-followup.md`
spells out:

  - Why: bestEffort=true was incidentally biasing drain order toward
    newer groups; without it, the writer's round-robin order can
    serve a stale group when a fresh one is more useful.
  - Target shape: `QuicStream.priority` field, sortedByDescending
    in the writer's send-frame loop, `WebTransportWriteStream.setPriority`
    pass-through, `MoqLiteSession.openGroupStream` calls
    setPriority(sequence). With code sketches.
  - Test: pin iteration order via the writer's emitted-frames tape.
  - Risk profile: starvation, perf cost of per-pass sort, compat.
  - When to land: after interop verification stabilises.

No code changes in this commit beyond the ActiveSubscription move.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 20:04:56 +00:00
Claude e9d19e5de4 refactor(nests): extract MoqLiteFrame + handles + publisher interface (Audit-8)
`MoqLiteSession.kt` was 1526 lines mixing the session state machine
(announce pump, subscribe pump, group-stream demux, publisher state)
with the public types its API hands back to consumers. The session-
internal `PublisherStateImpl` inner class is tightly coupled to the
session's outer scope (state lock, transport, scope.launch, openGroupStream)
and is left in place — extracting it is a separate refactor with its
own design choices.

Extract the standalone caller-facing types:

  - `MoqLiteFrame.kt` — the `MoqLiteFrame` data class with its
    custom `equals` / `hashCode` for ByteArray content equality.
  - `MoqLiteHandles.kt` — `MoqLiteSubscribeHandle`,
    `MoqLiteAnnouncesHandle`, and `MoqLiteSubscribeException`.
    All three are "what the caller gets back from a subscribe /
    announce" + "how protocol-level rejections surface."
  - `MoqLitePublisherHandle.kt` — the public publisher interface
    that the session's internal `PublisherStateImpl` implements.
    `internal constructor` on the handles keeps them un-instantiable
    outside the package.

Same package, same visibility, no call-site changes needed.
`MoqLiteSession.kt` shrinks from 1526 to 1372 lines, focused on
session lifecycle + the inner `PublisherStateImpl`. Tests +
spotless green.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:50:24 +00:00
Claude 6f649e76d8 refactor(nests): extract MoqLiteBroadcastHandle + HotSwappablePublisherSource (Audit-10)
`MoqLiteNestsSpeaker.kt` mixed three top-level concerns at 387 lines:

  - The `MoqLiteNestsSpeaker` class itself — the speaker entry point
    that builds a publisher + broadcaster pair on
    `startBroadcasting`.
  - `MoqLiteBroadcastHandle` — internal `BroadcastHandle`
    implementation tying the broadcaster, audio publisher, and
    catalog publisher together with a fixed-order shutdown.
  - `HotSwappablePublisherSource` — internal interface that lets
    `ReconnectingNestsSpeaker.runHotSwapIteration` retarget a
    long-lived broadcaster onto fresh moq-lite session publishers
    without restarting the AudioRecord / Opus encoder pipeline.

The handle and the interface are independently reachable from
`ReconnectingNestsSpeaker` and have no behavioural coupling to
`MoqLiteNestsSpeaker` beyond a `parent` reference (handle) or an
`as?` cast (interface). Move each to its own file:

  - `MoqLiteBroadcastHandle.kt` (109 lines).
  - `HotSwappablePublisherSource.kt` (62 lines).

`MoqLiteNestsSpeaker.kt` is now 276 lines focused on the speaker
class. Same package, same `internal` visibility — no call-site
changes needed elsewhere. Tests + spotless green.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:47:08 +00:00
Claude fb7b6b7cd9 refactor(nests): split MoqLiteMessages.kt by concern (Audit-11)
The original `MoqLiteMessages.kt` mixed three categories of declaration:

  - One `MoqLiteAlpn` object — the WebTransport sub-protocol
    advertisement strings.
  - Four enums — `MoqLiteControlType`, `MoqLiteDataType`,
    `MoqLiteAnnounceStatus`, `MoqLiteSubscribeResponseType` — that
    are wire-format discriminators the codec reads at message
    boundaries to choose which body to decode.
  - Eight data classes / objects — `MoqLiteAnnouncePlease`,
    `MoqLiteAnnounce`, `MoqLiteSubscribe`, `MoqLiteSubscribeOk`,
    `MoqLiteSubscribeDrop`, `MoqLiteSubscribeDropCode`,
    `MoqLiteGroupHeader`, `MoqLiteProbe` — the body shapes themselves.

317 lines, three concerns, one file. Split by responsibility:

  - `MoqLiteAlpn.kt` — the ALPN object alone.
  - `MoqLiteControlCodes.kt` — the four discriminator enums.
  - `MoqLiteMessages.kt` — body data classes only.

Same package, same visibility, identical wire behaviour. Imports
across the codebase resolve to the new locations automatically
since everything stayed in `com.vitorpamplona.nestsclient.moq.lite`.
Tests pass unchanged.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:43:45 +00:00
Claude a96ec9db00 chore(nests): standardise Log import in audio-pipeline files
Audit-12 cleanup. The audio-pipeline sources mixed two import styles:
some files used `com.vitorpamplona.quartz.utils.Log.d/w/e` fully-
qualified (NestPlayer's 13 sites, AudioTrackPlayer's 8, two each in
the encoder/decoder, three in NestMoqLiteBroadcaster) while
AudioRecordCapture had already migrated to a top-level
`import com.vitorpamplona.quartz.utils.Log`. The lambda overload
(`Log.d(tag) { lazyMessage }`) is what the find-non-lambda-logs
skill enforces and is the only style the rest of the codebase uses;
the fully-qualified form is verbose noise on every call site.

Add the `import com.vitorpamplona.quartz.utils.Log` line to each
file and rewrite call sites to bare `Log.d` / `Log.w` / `Log.e`. No
behaviour change; logs still go through PlatformLog with the same
tag and lazy-message contract. Five files updated, ~30 call sites.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:38:19 +00:00
Claude 8a49486b37 fix(nests): G4+G5 plumb catalog sampleRate through decoder + AudioTrack
G4: Audit-9 (catalog-driven decoder reconfig) plumbed numberOfChannels
end-to-end but left sampleRate hardcoded at AudioFormat.SAMPLE_RATE_HZ
in MediaCodecOpusDecoder.buildOpusIdHeader / buildFormat and in
AudioTrackPlayer's MinBufferSize / 250 ms target / setSampleRate
calls. For Opus this is benign in practice — Codec2 always emits
48 kHz PCM regardless of OpusHead inputSampleRate — but hardcoding
the constant means a future codec or container variant whose
decoder DOES respect input sample rate would mis-clock playback.
And the OpusHead identification header should match what the
catalog declares either way.

Thread sampleRate alongside channelCount through every layer:

  - MediaCodecOpusDecoder constructor takes
    `sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ`. buildFormat
    and buildOpusIdHeader both take it as a parameter.

  - AudioTrackPlayer constructor takes the same. Used in
    AudioTrack.getMinBufferSize, the 250 ms-equivalent target
    bytes calculation (`(sampleRate / 4) * BYTES_PER_SAMPLE *
    channelCount`), AndroidAudioFormat.Builder.setSampleRate, and
    the diagnostic log.

  - decoderFactory and playerFactory in NestViewModel become
    `(channelCount: Int, sampleRate: Int) -> ...`.

  - awaitDecoderChannelCount → awaitAudioPipelineConfig, returning
    a private `AudioPipelineConfig(channelCount, sampleRate)`
    struct so openSubscription handles both fields uniformly.
    `sampleRate` falls back to AudioFormat.SAMPLE_RATE_HZ on
    timeout / non-positive declaration with a warning log.

  - NestViewModelFactory + NestViewModelTest updated to the
    two-arg factory shape.

G5: documented the SUBSCRIBE_BUFFER safety budget on
CATALOG_AWAIT_TIMEOUT_MS's kdoc. With the current 500 ms timeout +
SUBSCRIBE_BUFFER = 64-frame DROP_OLDEST flow + 50 fps Opus, at
most 25 frames buffer during the wait — leaving ≥ 39 frames of
margin (≈ 780 ms) before the oldest frame would be evicted. Even
at the production framesPerGroup = 50 (1 group/sec) cadence this
never trips during normal startup. No code change; just pinning
the rationale so a future timeout bump is checked against the
buffer size.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:36:05 +00:00
Claude 1f2532d611 docs(nests): plan T16 cross-stack interop test (no Docker)
Spec for an end-to-end interop suite that verifies Amethyst is
intelligible to and can hear the canonical NostrNests stack without
Docker. Two P0 reference stacks:

  - Rust path via the kixelated/moq `hang` crate (catches wire-shape
    regressions cheaply)
  - Browser path via @moq/watch + @moq/publish in headless Chromium
    driven by Playwright (catches Chromium QUIC, WebCodecs, and
    AudioWorklet quirks the Rust path can't see)

Architecture: native moq-relay subprocess (cargo build), in-process
ES256 JWT minter (replaces moq-auth Node sidecar), self-signed P-256
TLS cert, native cargo binaries for hang-listen / hang-publish /
udp-loss-shim, bun static server hosting the browser harness.

Test matrix I1-I15 covers every audit-branch wire fix (T1-T14) plus
the framesPerGroup=50 + WebCodecs warmup interactions only the
browser stack exercises. Phases 1-2 + 4 are the 3-day P0 deliverable;
Phases 3 + 5 are the P1 hot-swap + transport-robustness follow-up.

Self-contained: another agent should be able to execute Phases 1-2 +
4 end-to-end from this spec alone.

https://claude.ai/code/session_01FZPQiniPmb88pwY9i78eqA
2026-05-06 19:25:38 +00:00
Claude 75f572ba3d test+fix(nests): pin stripLegacyTimestampPrefix; primaryAudio filters to legacy
Two audit-pass gaps:

G1 — `MoqLiteNestsListener.Companion.stripLegacyTimestampPrefix`
is the entire receive-side wire-format converter for audio frames
(every web speaker's Opus packet flows through it) and had zero
unit tests. A regression here is invisible to users — silent decode
failure of every web speaker — and to interop tests, because both
sides agree on the same bug. New StripLegacyTimestampPrefixTest pins:

  - all four QUIC varint-length tiers (1 / 2 / 4 / 8 bytes), one
    test per tier with a representative timestamp value plus a
    tag-byte assertion so the test fails loudly if Varint.encode's
    tier-selection changes.
  - empty-payload and malformed-payload fast paths (returns
    payload unchanged, same instance — no allocation).
  - round-trip against `NestMoqLiteBroadcaster`'s exact wire
    shape (`varint(timestamp_us) + raw_opus_packet`) across five
    timestamp values spanning all four tiers.

G6 — `RoomSpeakerCatalog.primaryAudio()` previously returned
`audio.renditions.values.firstOrNull()` with no container filter.
A future publisher that emits CMAF before legacy in iteration
order would surface the CMAF rendition, and our decoder pipeline
would then feed CMAF MOOF/MDAT bytes to its legacy
`varint(ts)+opus` parser and decode garbage. Filter to
`container.kind == Container.LEGACY_KIND` so:

  - mixed CMAF+legacy publishers always surface the legacy entry
    regardless of map iteration order
  - CMAF-only publishers correctly return null (caller falls
    back to "unknown codec / no audio")
  - publishers that omit `container` entirely also return null —
    treating "no container declared" as "legacy by default" would
    silently mis-decode a CMAF-only publisher that just forgot
    to spell out `container.kind`

LEGACY_KIND const lives on `Container.Companion` so call sites
share one canonical spelling. Three new test cases pin the new
filter behaviour (mixed iteration order, CMAF-only, missing
container). The pre-existing `toleratesUnknownKeys` test is
updated to declare a legacy container — the unknown-key
tolerance we're checking is about unrecognised siblings, not
container-presence.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:09:24 +00:00
Claude a94d12638f perf(nests): bound encoder CSD loop, single-alloc audio framing, cache catalog JSON
Three audit follow-ups, all in the audio publish hot path:

Audit-3: MediaCodecOpusEncoder.encode could busy-loop on a buggy
encoder that emits BUFFER_FLAG_CODEC_CONFIG on every dequeue. The
existing format-change path has a one-shot `formatChangeAbsorbed`
guard for exactly this reason; the new CSD-skip path didn't. Cap
consecutive CSD skips per encode call at MAX_CSD_SKIPS_PER_CALL=4
(generous: Codec2 emits 1 OpusHead or 2 OpusHead+OpusTags in
practice). On overshoot, log a warning and return ByteArray(0) —
the broadcaster's `if (opus.isEmpty()) continue` already handles
that contract.

Audit-6: NestMoqLiteBroadcaster's per-frame send path allocated
twice per Opus packet — once for the timestamp Varint and once
for the concatenated `varint+opus` payload. At 50 fps × N
speakers that's measurable young-gen pressure. Switch to the
same single-allocation pattern PublisherStateImpl.send already
uses: compute Varint.size(timestampUs), allocate the final
payload buffer once, write the varint directly into it via
Varint.writeTo, then copy the opus bytes. Drops audio-thread
allocations from 3/frame to 1/frame (the third was the wrap in
PublisherStateImpl.send, already optimised).

Audit-7: MoqLiteNestsSpeaker.startBroadcasting and
ReconnectingNestsSpeaker.runHotSwapIteration both encode the same
fixed catalog JSON on every call — once per broadcast start and
once per JWT-refresh hot-swap iteration. Cache the encoded bytes
on MoqLiteHangCatalog.Companion.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES
so the kotlinx.serialization run happens at class-init time and
both call sites read a static reference. Trivial perf, but
co-locates the "default Amethyst publish shape" in one place.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:37:19 +00:00
Claude ade8da3b5b fix(nests): NestPlayer keeps existing decoder when boundary factory throws
Audit-1: the publisher-boundary rebuild path released the old decoder
BEFORE asking the factory for a replacement (`runCatching {
decoder.release() }; decoder = factory()`). If `factory()` threw —
MediaCodec contention, audio policy denial mid-session, etc. — the
released decoder stayed assigned to the field and every subsequent
`decoder.decode(payload)` would throw `IllegalStateException` with
no recovery path. The room would silently fail for that subscription
until torn down.

Build the replacement first; only release the old one and swap on
success. On factory failure, log and keep the existing decoder
running. Cross-publisher Opus predictor state is wrong for one
group but at least audio keeps playing.

Audit-4+5: companion test cleanup —

  - Drop the `byteArrayOf(0x0A) + it` / `0x0B` dead expressions in
    the FakeOpusDecoder closures of the existing boundary-rebuild
    test. They allocated a ByteArray and concatenated, then threw
    the result away.

  - Drop the local `IntBox` helper and use
    `java.util.concurrent.atomic.AtomicInteger` like the rest of
    the codebase does (`MoqLiteSession`, `MoqLiteNestsListener`).
    Same semantics, no new vocabulary.

New test pins the dangling-field fix:
`publisher_boundary_keeps_old_decoder_when_factory_throws` flows
two MoqObjects with different trackAliases through a NestPlayer
whose factory always throws; asserts both frames decode through
the original decoder and the original decoder is released exactly
once on `stop()`.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:33:24 +00:00
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