amethyst-with-monero
253 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
(
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
5310b3f574 | Merge remote-tracking branch 'origin/main' into claude/fix-nests-audio-receiver-HCgOY | ||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
a36ccb5692 |
fix(nests): close the relay-cliff at the source — framesPerGroup 10→50, cliff-threshold 4s→2.5s
Two-phone production logs at commit |
||
|
|
457e0f5997 |
fix(nests): wrapper.announces() ALSO needs channelFlow (collectLatest emits cross-coroutine)
The channelFlow conversion in |
||
|
|
f17e7adfa7 |
fix(nests): announces flow MUST use channelFlow, not flow{}
The diagnostic logging in |
||
|
|
1fc8dbceee |
debug(nests): trace announce-flow chain to localise still-empty _announcedSpeakers
The replay=64 SharedFlow fix in
|
||
|
|
d6517cf346 |
fix(nests): announce SharedFlow late-attach race dropped Active updates
The cliff-detector diagnostic logs from the user's two-phone repro
(commit
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
9f542a6ff4 |
fix(nests): retry SUBSCRIBE on relay-FIN instead of permanently giving up
When a listener joins a Nest before the speaker starts publishing,
moq-rs FINs the SUBSCRIBE bidi immediately without sending
SubscribeOk or SubscribeDrop. MoqLiteSession.subscribe surfaces that
as a MoqLiteSubscribeException("subscribe stream FIN before reply"),
which the listener wrapper translated to MoqProtocolException via
MoqLiteNestsListener.wrapSubscription.
The reconnecting wrapper's inner re-issue loop then did:
val handle = try { opener(listener) } catch (...) { null } ?: break
— breaking out of the loop forever. The outer collectLatest only
re-runs on listener swap (session reconnect), so once the first
SUBSCRIBE failed the audio path stayed dead until the user manually
disconnected and reconnected. With the typical join-order being
"listener taps Join before speaker taps Start", this hit nearly
every nest.
The kdoc on pumpAnnounceWatch claims "moq-lite supports subscribe-
before-announce, so a subscribe issued during the gap … attaches
cleanly when the new publisher comes up" — true for some publisher-
cycle gaps mid-broadcast, but verifiably false for the cold-start
case where the publisher hasn't existed yet on the relay's view of
the namespace. Production trace (commit
|
||
|
|
43720dd14d |
fix(nests): scope moq-lite publisher to a single track to stop catalog hijack
Listeners stuck on a spinning speaker avatar with no audio (against
both Amethyst-hosted and nostrnests.com-hosted Nests). The trace
captured on a working speaker phone showed every group keyed to
subId=1 / track='catalog.json' even though the broadcaster was
pumping Opus frames:
13:21:27.570 inbound SUBSCRIBE id=1 track='catalog.json'
13:21:27.574 inbound SUBSCRIBE id=0 track='audio/data'
13:21:27.609 openGroup seq=0 keyedOnSubId=1 track='catalog.json'
13:21:27.610 broadcaster: send accepted (subscriber attached)
... every subsequent group keyed=1, every audio frame on the
catalog stream
Root cause: `PublisherStateImpl.openNextGroupLocked` keyed each uni
stream off `inboundSubs.first()` with no track filter. The kdoc
asserted "Inbound subscription set is expected to be small (1 in
nests's listener-per-room model)" — that was true when listeners
only opened the audio sub. Once the listener side started opening a
catalog sub alongside (commits ~Apr 2026), whichever SUBSCRIBE
arrived first (typically the catalog one, by ~4 ms in this trace)
became the routing target for all audio frames. The relay forwarded
those frames to the listener with subscribeId=catalog, the listener
routed them to its catalog handler, RoomSpeakerCatalog.parseOrNull
returned null (it's not JSON), and the audio subscription's frames
channel never received anything — so onSpeakerActivity never fired
and the spinner stayed forever.
Fix: per moq-lite Lite-03, a publisher is responsible for one
(broadcast, track) tuple. Thread `track` through `MoqLiteSession.publish`
and have `PublisherStateImpl.registerInboundSubscription` reject
inbound SUBSCRIBEs whose track doesn't match — those still receive
SUBSCRIBE_OK from the bidi handler (best-effort per Lite-03's
optional-content semantics for catalog) but don't influence the
audio routing. `MoqLiteNestsSpeaker.startBroadcasting` passes
`track = MoqLiteNestsListener.AUDIO_TRACK` (= "audio/data").
Test callsites (MoqLiteSessionTest, NostrnestsProdAudioTransmissionTest,
NostrNestsSustainedSendOutcomesInteropTest) updated to pass the new
required parameter; all use track="audio/data" matching what they
exercise.
The diagnostic logs from the prior commits stay in — they're how we
caught this and they'll catch the next one. They can be stripped in
a follow-up once the fix is confirmed in the field.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
9b6e09838e |
debug(nests): expose silent failures inside subscribe / announce / reissue
Round-2 instrumentation after phone-B trace showed silence between
SUBSCRIBE_OK-expected and the first audio frame. Three additions:
- MoqLiteSession.subscribe: log the SUBSCRIBE-bytes-flushed transition,
every chunk arriving on the response bidi (chunks=N), and any
exception or natural close on bidi.incoming() that previously
swallowed the cause.
- MoqLiteSession.announce: same treatment for the AnnouncePlease bidi
— chunks=N, natural-close, and exception path now visible.
- ReconnectingNestsListener.reissuingSubscribe: previously had a
`catch (_: Throwable) { null }` black hole on the re-issue pump
(line 354); now logs the throwable's class + message before the
pump breaks. Also logs natural-close of handle.objects so a
publisher cycle vs a real failure are distinguishable in the trace.
These narrow the listener-stuck-on-spinner diagnosis from
"something downstream of subscribe never fires" to which exact step
inside `MoqLiteSession.subscribe` is silent: the write, the response
read, or the framing.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
62bedf1372 |
debug(nests): log QUIC↔moq seam in pumpUniStreams + pumpInboundBidis
Splits the diagnosis into "QUIC delivered streams" vs "moq routed them". Without this seam log, a silent NestRx trace after SUBSCRIBE_OK gives no signal whether the relay is forwarding (and :quic isn't delivering streams to the moq pump) or moq is dropping frames. Also surfaces the prior `_: Throwable` swallow in both pumps — those hid transport-close exceptions which are exactly the kind of clue we want when the listener silently stalls. https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ |
||
|
|
726894362f |
debug(nests): wire NestRx/NestTx logs across listener and speaker paths
Diagnostic instrumentation to localise the listener-stuck-on-spinner bug.
None of these are intended for merge — pair logs with a logcat capture,
diagnose, then revert.
Receiver (tag NestRx):
- MoqLiteNestsListener: log subscribeSpeaker / subscribeCatalog entry
and per-update RoomAnnouncement emission to the VM
- MoqLiteSession.subscribe: log SUBSCRIBE_OK / SUBSCRIBE_DROP outcomes
- MoqLiteSession.drainOneGroup: log every uni group header decode
and warn on subscription-lookup miss (frame dropped)
- MoqLiteSession.pumpAnnounceWatch: log every announce update and
flag the Ended branch that closes the subscription's frames
- NestViewModel: log VM.openSubscription wiring and the first-frame
trigger that clears the connectingSpeakers spinner
Speaker (tag NestTx):
- MoqLiteSession.publish: log entry suffix
- MoqLiteSession.handleInboundBidi: log inbound AnnouncePlease and
SUBSCRIBE dispatches plus FIN cleanup
- MoqLiteSession PublisherStateImpl: log first inbound subscriber
attach and every group-stream open
- NestMoqLiteBroadcaster: log when publisher.send returns false
(no inbound subscriber on relay) and the subscriber-attached
transition, plus surface throws that runCatching previously
swallowed only into onError
Capture with `adb logcat -s NestRx:D NestTx:D`.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
5d955eff99 |
feat: animate local speaker ring from MoQ frame transmission
Wire the local user's avatar into the existing speaking-ring animation so the host/speaker sees the same green ring + amplitude glow on their own cell that remote speakers already get. Ground truth is the broadcaster's `publisher.send(opus)` success: the new `onLevel` callback fires only after a frame actually leaves on the wire, computed from the raw PCM peak (shared `peakAmplitude` util used by both the player decode loop and the broadcaster capture loop). This means the animation reflects what the relay sees, not any UI-side mute / role state that could be stale or buggy — if frames are flowing, the ring plays. Plumbing: `NestsSpeaker.startBroadcasting` gains an optional `onLevel` that the moq-lite + IETF broadcasters both honour, the reconnecting wrapper replays it on every session reissue (alongside the existing mute-intent replay), and `NestViewModel.startBroadcast` hands it a lambda that calls the existing `onSpeakerActivity` / `onAudioLevel` plumbing keyed on the local pubkey. The 250 ms speaking-timeout fades the ring naturally when the user mutes or stops broadcasting. |
||
|
|
523e8a92ec |
fix(nests): play audio-room output through STREAM_MUSIC, not STREAM_VOICE_CALL
`AudioTrackPlayer` was constructing its `AudioTrack` with `USAGE_VOICE_COMMUNICATION` + `CONTENT_TYPE_SPEECH` so the room would behave like a phone call (volume rocker controls call volume, ducks notifications). The `AudioTrack` then renders on `STREAM_VOICE_CALL`, which Android only services audibly while the device is in `MODE_IN_COMMUNICATION`. Nests never drives `AudioManager.mode` (only NIP-100's `CallAudioManager` does), so on most devices the playback either dropped to the earpiece at near-zero volume or produced no audio at all — making rooms appear silent on both phones. Fix: route through `USAGE_MEDIA` + `CONTENT_TYPE_SPEECH` (= `STREAM_MUSIC`) so audio-room playback comes out of the loudspeaker by default and the volume rocker controls it any time. Same approach Twitter/X Spaces and Clubhouse take for hands-free audio rooms. Echo cancellation still works on the capture side via `MediaRecorder.AudioSource.VOICE_COMMUNICATION` regardless of the playback usage. Also update `NestForegroundService.requestAudioFocus` to request focus under the matching `USAGE_MEDIA` attributes so the focus claim and the playback attributes line up. |
||
|
|
7c1af48ae7 |
fix(nestsClient): wait for mute replay in setMuted_intent_replays test
ScriptedSpeaker publishes startCount > 0 from inside startBroadcasting, so polling startCount alone races the wrapper pump's subsequent `if (desiredMuted) handle.setMuted(true)` step. Under load (full suite run on CI) the assertion fired before the pump replayed mute intent, producing setMutedCalls=0. Wait for the post-condition the assertion checks instead. |
||
|
|
8b7785a7e8 |
fix(nestsClient): token-parse hardening, mint timeout, IPv6 [], broadcaster-bail signal
- NestsTokenResponse.parse catch list now includes IllegalStateException so a malformed escape from a misbehaving auth server surfaces as NestsException instead of crashing the listener. - OkHttpNestsClient gains a configurable callTimeoutMs (default 90 s) enforcing an upper bound on the entire mintToken round-trip including retries. Without it a stalled server suspends the reconnect orchestrator indefinitely (the orchestrator's openOnce step parks on mintToken). 90 s leaves headroom over the worst-case 63 s 429 retry chain documented in MAX_RATE_LIMIT_RETRIES. - parseEndpoint tightens IPv6 bracket check from `closeBracket > 0` to `> 1`, rejecting the empty `[]` literal. - NestBroadcaster + NestMoqLiteBroadcaster gain an `onTerminalFailure` callback that fires once when the consecutive-send-error guard bails. MoqLiteNestsSpeaker wires this to flip the speaker state to Failed, giving ReconnectingNestsSpeaker the signal it needs to recycle the session — without this hook the broadcaster bailed silently and the outward speaker state stayed on Broadcasting forever. Adds regression test: - onTerminalFailure_fires_once_after_consecutive_send_failures 225 tests pass, 0 failures. Android target compiles clean. |
||
|
|
702885f4cb |
fix(nestsClient): fix race + leaks + Android encoder/decoder bugs from second audit
A fresh audit caught a real race in the round-1 subscribe() rewrite plus several distinct bugs in the Android MediaCodec layer. **MoqLiteSession.subscribe() race regression** — the launched collector that reads the SubscribeResponse and watches for peer FIN ran cleanup (remove from subscriptionsBySubscribeId, close frames) before subscribe() itself reached the post-await registration. If the publisher FIN'd immediately after sending Ok, the collector exited first against an empty map; subscribe() then registered the subscription, leaving frames in the map with no live collector to ever close it on transport drop. Consumer hung forever — exactly the failure mode round-1 fix #3 was supposed to prevent. Fix: pre-register the subscription BEFORE launching the collector. Drop unused `ok` field from ListenerSubscription. **MoqLiteNestsSpeaker.startBroadcasting publisher leak** — if session.publish() succeeded but broadcaster.start() then threw (mic permission denied, AudioRecord allocation failure), the publisher was never closed and stayed registered as session.activePublisher, permanently blocking subsequent startBroadcasting calls. **runCatching{suspend close} swallowing CancellationException** — MoqLiteNestsSpeaker.close, MoqLiteBroadcastHandle.close, MoqLiteNestsListener.close all wrapped suspending closes in runCatching, breaking structured cancellation when the parent scope cancelled teardown. Replaced with explicit cancel-rethrowing try/catch. **MediaCodecOpusDecoder ArrayList<Short> boxing on every audio frame** — at 50 fps × 960 samples × N speakers ≈ 48 000 boxed Short allocations/sec/speaker on the audio hot path. Rewritten to write directly into a pre-sized ShortArray via ShortBuffer.get(dst, off, len). **MediaCodecOpusEncoder bugs** - KEY_AAC_PROFILE = AACObjectLC was being set on an audio/opus encoder. Meaningless for Opus; stricter Codec2 stacks on Android 13+ reject the configure() call with IllegalArgumentException and surface as DeviceUnavailable. Removed. - The drain loop's INFO_OUTPUT_FORMAT_CHANGED branch had no progress guard. A buggy encoder re-emitting FORMAT_CHANGED without producing output would busy-spin against the 10 ms dequeue timeout. Now absorbed at most once per encode call. Same guard added to the decoder. Adds regression test: - frames_flow_completes_when_peer_FINs_immediately_after_Ok 224 tests pass, 0 failures. Android target compiles clean. |
||
|
|
9729f3a20c |
fix(nestsClient): perf + robustness — jitter, frame buffer, defensive bounds, send-error guard
- NestsReconnectPolicy gains a `jitter` parameter (default 0.3, AWS-
style equal jitter). Without it, every client reconnecting after
a relay restart retries on the identical 1s/2s/4s/… schedule and
thunders the relay during recovery. delayForAttempt also accepts
an explicit Random source for deterministic tests.
- MoqLiteFrameBuffer decouples capacity from size so power-of-two
growth actually amortises (the old shape allocated a fresh
ByteArray then truncated capacity back to `needed` per chunk,
defeating doubling). Adds a guard against varint reads past the
live region into uninitialised slack capacity.
- prefixWithType inlines the size-prefix wrap so a publisher
SubscribeOk/Drop reply doesn't allocate three ByteArrays.
- MoqLiteSubscribeOk gains the same `init { require(priority in 0..255) }`
bounds check its sibling MoqLiteSubscribe has — a buggy caller
building a malformed Ok reply now fails loudly instead of writing
a truncated byte on the wire.
- NestBroadcaster + NestMoqLiteBroadcaster track consecutive
publisher.send exception count and bail after 250 (~5 s at 50 fps)
instead of holding the mic open forever on a permanently dead
transport. publisher.send returning `false` (no inbound
subscriber) is NOT counted — empty rooms are a normal state.
Adds 8 regression tests:
- 5 in MoqLiteFrameBufferTest (multi-chunk reads, back-to-back
payloads, growth amortisation, compact, varint past-live guard)
- 3 in NestsReconnectPolicyTest (jitter spread band, jitter=0
determinism, jitter=1 collapses to 0..base)
223 tests pass, 0 failures.
|
||
|
|
f3a942dbd0 |
fix(nestsClient): more bugs from review — IETF parser, leaks, IPv6, URL encoding, structured concurrency
- Unknown IETF control message types now skip just the unknown frame
instead of dropping the entire merge buffer (a peer sending a
draft-17 message we don't enumerate — FETCH, GOAWAY, MAX_SUBSCRIBE_ID
— would otherwise wedge the pump). Adds MoqUnknownTypeException
carrying bytesConsumed so runControlPump can advance past it.
- pumpUniStreams + pumpInboundBidis wrap their inner collect in
coroutineScope so per-stream drains are children of the pump's job
(was: launched on the outer scope as siblings, leaking past
cancelAndJoin until the transport's own flow errored out).
- parseEndpoint handles IPv6 authorities ([::1], [2001:db8::1]:4443).
Naive lastIndexOf(':') used to find a colon inside the address.
- buildRelayConnectTarget percent-encodes the namespace path so a
malicious / careless `d` tag containing `?`, `#`, `&`, ` ` etc.
can't truncate the URL and shove the JWT into the wrong slot.
- Reconnect orchestrators (listener + speaker) replace
`runCatching { openOnce / close / startBroadcasting }` with explicit
try/catch that rethrows CancellationException, so cooperative
cancellation from the parent scope dies promptly instead of running
one more iteration after the cancel.
Adds three regression tests:
- parseEndpoint_handles_IPv6_authorities
- buildRelayConnectTarget_percent_encodes_path_unsafe_chars
- unknown_message_type_throws_typed_exception_with_full_frame_size
215 tests pass, 0 failures.
|
||
|
|
eb20ac2c2a |
fix(nestsClient): bug + perf fixes from Nests implementation review
- send() now nulls currentGroup and returns false on uni-stream write failure (was: silently returned true while reusing the dead stream) - Subscribe bidi gets a single long-running collector that closes the frames channel on peer FIN / transport tear-down (was: consumer could hang forever on a black-holed UDP path with no Announce(Ended)) - AudioException.Kind gains EncoderError; mic-side encode failures no longer mis-route through DecoderError handlers - MoqCodec.decode bounds the payload-length varint before .toInt() so a peer-driven absurd length can't wedge the parser forever - Publish hot-path framing collapses to a single ByteArray allocation (was: Varint.encode + plus operator, two allocs per Opus frame at 50 fps) - Drops the now-unused readSubscribeResponseFromBidi / readSizePrefixedFromBidiInto / EarlyExit helpers Adds a regression test that asserts the frames flow completes when the peer FINs the subscribe bidi. 213 tests pass, 0 failures. |
||
|
|
897287be5f |
nestsClient: expose moq-lite max_latency on subscribeSpeaker + correct plan framing
The moq-lite Lite-03 spec puts subscriber-side group-staleness control in
max_latency on the SUBSCRIBE frame: 0 = unlimited (relay falls back to its
MAX_GROUP_AGE = 30 s default); a positive value tells the relay to evict
groups older than that in favour of newer ones during transient backpressure.
Plumb it through:
NestsListener.subscribeSpeaker(pubkey, maxLatencyMs = 0L)
└─ MoqLiteNestsListener → MoqLiteSession.subscribe(.., maxLatencyMillis)
└─ DefaultNestsListener (IETF) ignores — no equivalent on that wire
└─ ReconnectingHandle re-issues across reconnects
Default stays at 0L for back-compat with the JS reference watcher; callers
opt in for low-latency live audio.
Also rewrites nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
with corrected framing: of the four "upstream issues" the prior draft listed,
only our own missing MAX_STREAMS_UNI emission was a real bug (RFC 9000 §4.6
violation, fixed in
|