Commit Graph

46 Commits

Author SHA1 Message Date
Claude 10ad69f1af nestsClient: pack 5 Opus frames per moq-lite group to dodge the prod stream cliff
Production sweep against nostrnests.com cliffed at exactly 99 frames received
across every multi-frame scenario (frames200/400, 30s, 120s, etc.) when the
broadcaster opened one QUIC uni stream per Opus frame. The investigation in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md walks through
why the obvious "raise the stream cap" hypothesis didn't pan out (publisher.send
was never returning false past the cap, the local QuicConnection was already
seeing the relay extend MAX_STREAMS_UNI dynamically).

The same sweep showed sweep_frames_per_group_5 / _20 / _all delivering 100/100
frames cleanly. The cliff scales with stream-creation rate, not byte volume or
total stream count — so packing N frames per group (one uni stream per N frames
instead of per frame) bypasses whatever the underlying limit is.

NestMoqLiteBroadcaster:
  - Add framesPerGroup constructor param, default 5 (≈ 100 ms of audio per
    moq-lite group). Stream-creation rate drops from 50/sec to 10/sec, well
    below the production cliff threshold.
  - Counter resets after each endGroup; trailing partial group flushes its
    FIN on capture EOF so the listener doesn't sit on a half-open stream.

MoqLiteNestsSpeaker + connectNestsSpeaker:
  - Plumb framesPerGroup through to the broadcaster. Default flows from
    NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP so production callers
    get the mitigation without code changes; tests can override to 1 to
    keep their groupId-per-frame assertions.

Tests:
  - All harness interop tests (RoundTrip, LateJoin, MultiPeer, Mute,
    Unsubscribe, SpeakerClose, both Reconnecting variants) plus the prod
    NostrnestsProdAudioTransmissionTest now pass framesPerGroup = 1
    explicitly to preserve their per-frame group/object id assertions.
  - All :quic and :nestsClient JVM tests pass.

Trade-off: a brand-new subscriber that joins mid-broadcast picks up at the
next group boundary. With 5 frames/group the late-join initial gap is up
to 100 ms (perceptually inaudible for live audio). The previous wire shape
gave at most 20 ms.

Investigation plan for the underlying QUIC cliff lives in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md. The
attempted-then-reverted suspend/STREAMS_BLOCKED commit is summarised
under "Attempt 1" in that doc.
2026-05-01 14:06:21 +00:00
Claude 19b534a9e2 fix(nestsClient): register inbound moq-lite subscription before sending Ok
The publisher's inbound-bidi handler wrote SubscribeOk to the bidi
before calling registerInboundSubscription. The peer's first
publisher.send() after observing Ok could race the registration on
dispatchers that resume the peer's continuation before the handler's
(notably Windows under Dispatchers.Default), causing send to observe
an empty inboundSubs and return false. Reordering makes the peer's
view of Ok a happens-after of the registration.

Fixes the Windows-only failure in
MoqLiteSessionTest.publisher_acks_subscribe_and_pushes_group_data_on_uni_stream.
2026-04-29 22:14:23 +00:00
Vitor Pamplona 98e62b167b Merge pull request #2621 from vitorpamplona/claude/review-nostr-nests-compliance-hKBnS
feat(nests): proactive JWT refresh + reconnect for speaker path
2026-04-28 13:56:06 -04:00
Claude d8ab4fd99b fix(nests): rotate moq-lite groups + reconnect after publisher cycle
To make a SubscribeHandle survive a publisher session swap on the same
relay, three things had to change together:

1. Broadcaster emits one Opus frame per moq-lite group
   (`publisher.send` + `publisher.endGroup`). moq-lite's "from-latest"
   subscribe semantics deliver a new subscriber the NEXT group's
   frames; without per-frame rotation a subscriber that attaches
   mid-broadcast waits forever for the (single, never-ending) group
   to end.
2. MoqLiteSession opens its announce-watch bidi synchronously before
   the first subscribe, dispatches subscribe + announce frames over a
   single long-running collector (varint type code hoisted outside
   `collect`), and FINs the publisher's currentGroup when an inbound
   subscribe bidi closes — so the next send opens a fresh group keyed
   off the live subscriber instead of the recycled one.
3. ReconnectingNestsListener no longer breaks on terminal=Closed in
   the orchestrator; the user-driven stop path goes through
   `orchestrator.cancel()`, so any other Closed (peer-driven
   transport close, half-broken session, publisher recycle) is a
   reconnect trigger.

Round-trip interop test updated to assert `groupId == idx` (one group
per frame) rather than `groupId == 0`. All three reconnecting-listener
interop scenarios now pass against the real moq-rs relay:
happy-path, session-swap, and listener-survives-publisher-recycle.
2026-04-28 17:06:09 +00:00
Claude 851045c654 fix(nests): listener subscriptions survive publisher recycle
Two layered fixes for the gap captured in
nestsClient/plans/2026-04-28-listener-survives-publisher-recycle.md.

Session layer (MoqLiteSession.kt):
  - Lazy single shared announce-watch pump per session, opened on
    first subscribe. moq-lite Lite-03 has no explicit "publisher
    gone" message on the subscribe bidi (the relay keeps that
    bidi open across publisher cycles in case a fresh publisher
    takes over the suffix), so the announce stream's Ended event
    is the only reliable signal.
  - On Announce(Ended) for a broadcast suffix, close the
    matching ListenerSubscription's frames Channel and remove it
    from the map. The wrapper-level frames.consumeAsFlow() flow
    ends naturally — same shape as a user-driven
    handle.unsubscribe() — so the wrapper pump's collect-
    completion path drives the re-issue.

Wrapper layer (ReconnectingNestsListener.kt):
  - Inner re-subscribe `while (currentCoroutineContext().isActive)`
    loop in reissuingSubscribe. When the underlying frames flow
    completes (publisher cycled, signalled by the session layer
    above), re-issue subscribe against the same listener with a
    100 ms backoff. moq-lite supports subscribe-before-announce so
    a re-subscribe issued during the gap attaches cleanly when
    the next publisher comes up under the same suffix.

Verified against the real moq-rs relay (host build, external
mode): the new
NostrNestsReconnectingListenerInteropTest.subscribe_handle_survives_publisher_recycle
test passes — single SubscribeHandle keeps emitting frames across
multiple speaker JWT-refresh cycles. Speaker reconnect tests
still pass too.

In production, this closes the audio dropout that would have
fired every 9 minutes per speaker JWT refresh on long Nest calls
(see the plan doc for the original diagnosis).

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
2026-04-28 15:06:07 +00:00
Claude d41a24f945 feat(nests): empty-stage hint, local hush, moderator/force-mute moderation
Three improvements stacked into one commit since they share files:

#3 Empty-stage hint: StageGrid no longer disappears when nobody is on
   stage. The "Stage" label stays and a quiet "Waiting for speakers…"
   line keeps the strip visible so the room doesn't look broken before
   the first speaker arrives.

#5 Local per-speaker hush: AudioPlayer gains a setVolume(Float)
   default-no-op method; AudioTrackPlayer composes mute and volume
   multiplicatively into AudioTrack.setVolume so a hushed stream stays
   silent regardless of mute state. NestViewModel exposes
   locallyHushed: ImmutableSet<String> in NestUiState plus
   setLocalHushed(pubkey, hushed) — applied at attach time so a
   re-subscribe of an already-hushed speaker stays silent. New "Hush
   this speaker" / "Restore this speaker" row in
   ParticipantHostActionsSheet, available to anyone (it affects only
   our own playback, nothing on the wire).

#4 Host moderation gaps: wires Promote-to-Moderator using the existing
   RoomParticipantActions.setRole(ROLE.MODERATOR) builder — UI gap
   only, no protocol change. Adds a new AdminCommandEvent.Action.MUTE
   variant + AdminCommandEvent.forceMute(room, target) builder; the
   sheet emits it on "Force-mute speaker" and the
   AdminCommandsCollector dispatches incoming MUTE actions to a new
   NestViewModel.onForceMuted() that routes through the existing
   setMicMuted(true) path. Honor-based, same trust model as KICK —
   relays don't enforce signer authority, the client checks the
   signer is host or moderator on the active kind-30312.
2026-04-28 12:41:23 +00:00
Claude 04ee2c3106 feat(nests): pulse the speaker ring with live audio level
Adds an end-to-end audio-amplitude pipeline so on-stage speakers'
green ring throbs in time with their voice (closer to Spaces /
Clubhouse than the previous binary "is in speakingNow" indicator).

NestPlayer.play() now takes an onLevel callback and computes the
normalized peak of each decoded 16-bit PCM frame. NestViewModel
exposes audioLevels: StateFlow<Map<String, Float>>; raw 50 Hz
updates from the decode loop are coalesced into a 10 Hz publish
tick (LEVEL_TICK_MS) so the StateFlow doesn't spam recompositions
across a busy stage. The map is cleared on speaker close, on the
speaking-timeout sweep, and on teardown.

In MemberCell the speaking ring's width animates between
AVATAR_RING_WIDTH (3 dp) and MAX_RING_WIDTH (7 dp) via
animateDpAsState, smoothing the tick into a continuous halo.
Muted-publisher and idle states are unchanged.

Adds NestPlayerTest coverage for the new callback (level math +
empty-PCM short-circuit).
2026-04-28 12:03:13 +00:00
Claude d37eb10b8c feat(nests): proactive JWT refresh + reconnect for speaker path
Mirror the listener's ReconnectingNestsListener for the publish side.
moq-auth issues 600 s bearer tokens; without proactive refresh, a user
holding the stage past 10 minutes silently drops when the relay tears
down the WebTransport session and stays off the air until they manually
re-tap Talk. The new wrapper recycles the session at 540 s so the relay
never sees an expired token, and re-issues publishing onto each fresh
session with the user's mute intent replayed on the new handle.

VM swap is a one-line change to DefaultNestsSpeakerConnector. The
caller-owned BroadcastHandle is now the wrapper's stable handle that
survives every refresh.

Six unit tests cover happy path, refresh-without-failure-state, mute
replay across recycle, close idempotence, first-attempt-failure
exception propagation, and post-close startBroadcasting guard.

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
2026-04-28 03:17:57 +00:00
Claude dc3ac31ae4 refactor: rename Audio Room → Nest project-wide
Aligns class names, package paths, string resource keys, UI text and
intent actions with the Nests branding used by the EGG specs in
nestsClient/specs/. Mechanical rename — no behavior change.

- Folders: audiorooms/ → nests/ (5 paths across amethyst, quartz)
- 30+ class renames (AudioRoom* → Nest*, AudioRooms* → Nests*)
- String resource keys audio_room_* → nest_*
- UI strings "Audio Room"/"Audio Rooms" → "Nest"/"Nests" (incl. all locales)
- Intent extras AUDIO_ROOM_* → NEST_*
- Compose route Route.AudioRooms → Route.Nests

Spec-aligned identifiers (MeetingSpaceEvent, meetingSpaces/, the nip53*
packages) are intentionally untouched — those are NIP-53 protocol
names, not "audio room" branding.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
2026-04-27 14:36:50 +00:00
Claude 43c5313674 chore(audio-rooms): align catalog/announces error timing + comment (audit #5-7)
* announces() now throws UnsupportedOperationException at CALL
    time (not the first collect) on the IETF default — matches
    subscribeCatalog's timing so both can be guarded with one
    `runCatching { listener.announces() }` per session rather than
    a runCatching around every collect site (audit #6).

  * KDoc on announces() documents the deliberate hot-vs-cold
    asymmetry with subscribeSpeaker/subscribeCatalog: announce
    data is room-state with a single VM consumer (cold flow is
    sufficient), audio is per-frame playout with multi-collect
    resilience needs (hot SharedFlow with DROP_OLDEST). The
    different shapes are intentional, not accidental (audit #5).

  * MoqLiteNestsListener.wrapSubscription's "IETF SubscribeHandle
    path conventionally surfaces" comment was scoped to the audio
    track when first written; now the same body serves both audio
    and catalog. Re-frame so the comment applies to either track
    (audit #7).

Test: NestsListenerCatalogTest's announces-throws case now
asserts the call itself throws, not the collect.
2026-04-27 02:28:24 +00:00
Claude e4fe23a1e5 fix(audio-rooms): DROP_OLDEST on the SubscribeHandle pump (audit #8)
The re-issuing pump's MutableSharedFlow used the default
onBufferOverflow = SUSPEND. A stalled consumer (decoder, player,
or anything downstream) back-pressured the pump → the inner
handle.objects.collect → the underlying transport → the relay.
Effect: the room caches up to 64 frames (~1.3s) on the consumer
side, then the relay slows down sending us audio.

For Opus audio the desired behavior is the inverse: drop OLD
frames so playback stays live after a UI hiccup or a foreground
transition. Switch to BufferOverflow.DROP_OLDEST so the relay
keeps streaming at full rate and the consumer's audio stays
synchronized with the speaker, at the cost of dropped frames
during stalls (which would have been late anyway).
2026-04-27 02:26:30 +00:00
Claude 062944de83 feat(audio-rooms): announce-driven speaker discovery (T4 #17b)
Surface moq-lite's ANNOUNCE flow on NestsListener so the audio
room can render an authoritative "actively broadcasting"
indicator independent of kind-10312 presence's `publishing`
flag. Same channel nostrnests' web client uses for its live
badges (`useRoomAnnouncements` in the JS reference).

Wire-up:
  * RoomAnnouncement(pubkey, active) data class — one update
    per publisher transition (Active → broadcast came up,
    inactive → broadcast went down).
  * NestsListener.announces(): Flow<RoomAnnouncement> with a
    default body that throws UnsupportedOperationException on
    the IETF reference path.
  * MoqLiteNestsListener implements via session.announce("")
    against the room's namespace; the suffix carries the
    speaker pubkey hex straight through.
  * ReconnectingNestsListener routes via collectLatest so the
    consumer-facing flow restarts against each new session
    after a refresh / reconnect (no SubscribeHandle re-issuance
    pump needed — announces is a cold per-collect stream).
  * AudioRoomViewModel.announcedSpeakers: StateFlow<Set<String>>
    populated by observeAnnounces. Active emissions add the
    pubkey, inactive emissions remove it. Cleared on teardown.
    IETF listeners leave the set empty; UI falls back to
    presence's publishingNow flag.

Tests:
  * NestsListenerCatalogTest — adds the announces() default-
    throws-on-collect case.

Closes the audit's Tier-4 #17b gap. UI integration (e.g. a green
"live" dot driven by announcedSpeakers) is a follow-up — the data
flow is in place and downstream consumers can opt in.
2026-04-27 02:03:40 +00:00
Claude efcd32f987 feat(audio-rooms): proactive JWT refresh in the reconnecting wrapper (T4 #16)
moq-auth issues bearer tokens with a 600 s lifetime. When a token
expires the relay tears down the WebTransport session and the
wrapper recovers via the regular Failed → Reconnecting → Connected
path with a brief audible dropout (smoothed by the SubscribeHandle
buffer pump but still user-visible as a Reconnecting chip).

Add `tokenRefreshAfterMs` to connectReconnectingNestsListener
(default 540 s — 1 min before the relay's 600 s expiry). The
orchestrator now waits for either a terminal listener state OR
the refresh deadline; whichever fires first wins. On refresh:

  * close the still-healthy listener,
  * skip the reconnect-schedule path (refresh is a planned
    cutover, not a backoff event),
  * loop straight to openOnce() which mints a fresh JWT and
    establishes a new session.

The SubscribeHandle re-issuance pump cuts subs over to the new
session, and the wrapper's outward state never enters Reconnecting
during the cutover — the user-facing UI stays Connected end-to-end.
Setting tokenRefreshAfterMs <= 0 disables the behavior.

Tests:
  * ReconnectingNestsListenerTest gains
    `proactive_token_refresh_recycles_listener_without_failure_state`
    — drives the refresh path with a 50 ms window, captures every
    state emission, asserts neither Reconnecting nor Failed appear
    during a clean recycle.
2026-04-27 01:57:19 +00:00
Claude 2ec19fe961 docs(audio-rooms): catalog KDoc reflects the now-shipped subscribe path
The MoqLiteNestsListener KDoc still pointed to "we'll add a
parallel subscribe in a follow-up". The follow-up shipped in
the previous commit; refresh the doc to describe the actual
behavior so anyone reading the listener doesn't think catalog
support is still missing.
2026-04-27 01:21:29 +00:00
Claude e484e3b210 feat(audio-rooms): subscribeCatalog on NestsListener (moq-lite only)
Surface moq-lite's `catalog.json` track on the NestsListener API.
The catalog publishes one JSON object per group describing the
broadcast (codec, sample rate, optional speaker-side hints) and
is the canonical channel a watcher reads first to discover
available tracks.

Wire-up:
  * NestsListener.subscribeCatalog(pubkey) — interface method with
    a default body that throws UnsupportedOperationException, so
    the IETF DefaultNestsListener fails loud rather than returning
    a flow that never delivers data.
  * MoqLiteNestsListener overrides it to wrap
    `session.subscribe(broadcast = pubkey, track = "catalog.json")`
    through the same MoqObject mapping the audio path uses.
  * ReconnectingNestsListener routes both subscribeSpeaker and
    subscribeCatalog through a shared `reissuingSubscribe` helper —
    catalog handles also survive session swaps via the
    MutableSharedFlow re-issuance pump.

Tests:
  * NestsListenerCatalogTest — verifies the interface default
    rejects with UnsupportedOperationException so a caller wired
    against the IETF reference path fails fast.

VM/UI consumers (e.g. "speaker codec" tooltip) are intentionally
out of scope for this commit; this exposes the capability so a
follow-up can plug in a JSON parser + per-pubkey catalog cache.
2026-04-27 01:20:22 +00:00
Claude 04ac8d3157 fix(nests-reconnect): break the StateFlow collect + survive session swaps (T4 #2)
Two related bugs in connectReconnectingNestsListener:

1. Orchestrator never advanced past the first Failed. The reconnect
   loop used `listener.state.collect { ... return@collect }` to
   "exit" on a terminal state, but `return@collect` only returns
   from the lambda — a StateFlow's collect keeps running. As a
   result, after the first transport failure the orchestrator
   re-entered the lambda for every subsequent emission and never
   reached the outer `delay(delayMs)` / openOnce() that opens the
   next session. Switch to `onEach { mirror } + first { terminal }`
   so the upstream completes deterministically.

2. SubscribeHandle stopped emitting after a reconnect (the
   long-deferred Tier-4 follow-up). Reissue the subscription on
   every activeListener swap by feeding a wrapper-side
   MutableSharedFlow from a `collectLatest` pump that re-calls
   `listener.subscribeSpeaker(...)` against each fresh session.
   The buffer (64 frames ≈ 1.3 s of Opus) covers a typical
   re-handshake without dropping speech. Unsubscribing the
   logical handle cancels the pump and forwards a best-effort
   unsubscribe to the live underlying handle.

Tests: ReconnectingNestsListenerTest covers both happy-path
session swap (frames keep arriving) and the unsubscribe-before-swap
path. Uses real coroutines + Dispatchers.Default rather than
runTest because the test scheduler doesn't auto-advance the
StateFlow + delay chain reliably under UnconfinedTestDispatcher.
2026-04-27 00:47:36 +00:00
Claude cdf930938d feat(audio-rooms): connectReconnectingNestsListener (T4 #2)
Wraps `connectNestsListener` with a transport-loss reconnect loop
that consumes [NestsReconnectPolicy] for exponential backoff.
Mirrors the JS reference's `Connection.Reload` behaviour.

  connectReconnectingNestsListener(httpClient, transport, scope,
                                    room, signer, policy = default,
                                    connector = real)

Behaviour:
  * Returned [NestsListener] forwards the underlying listener's
    state while a session is alive.
  * On Failed (transport / handshake error), the orchestrator
    increments the attempt counter, surfaces
    [NestsListenerState.Reconnecting(attempt, delayMs)] for that
    delay, then opens a fresh session via the injected connector.
  * On Closed (user-driven disconnect), the loop exits — Closed
    is terminal.
  * `policy.isExhausted(attempt+1)` halts the loop when
    [NestsReconnectPolicy.maxAttempts] hits. Default policy is
    Int.MAX_VALUE so a long-running room keeps trying.

Subscribe-handle preservation across a reconnect is intentionally
NOT in this commit. Caller-owned [SubscribeHandle]s bind to the
SESSION; once the session is replaced, the handle's flow stops
emitting. The KDoc spells this out so callers can either:
  1. Re-subscribe inside their own
     `state.collectLatest { if (Connected) sub() }` loop
  2. Wait for the future MutableSharedFlow-buffered upgrade flagged
     in the Tier-4 plan ("MutableSharedFlow per handle that the
     per-session pump emits into").

Test seam: the `connector: suspend () -> NestsListener` parameter
defaults to the real connect call. Tests can pass a scripted fake
that returns a sequence of (Failed, Connected, Failed, Connected)
listeners and verify the orchestrator walks the policy correctly
— production path is unchanged.
2026-04-26 23:38:09 +00:00
Claude 2e38aa6c77 feat(audio-rooms): NestsReconnectPolicy + Reconnecting state (T4 #2 foundation)
Lays the foundation for the moq-lite reconnect-with-backoff path:

  NestsReconnectPolicy — exponential backoff settings.
                         (initial=1s, multiplier=2, max=30s) by
                         default, mirroring kixelated/moq's JS
                         reference (`delay: { initial: 1000,
                         multiplier: 2, max: 30000 }`).
                         maxAttempts defaults to Int.MAX_VALUE
                         since a long-running room should keep
                         trying as long as the user hasn't left;
                         the Composable's onDispose is the cancel
                         signal.
                         NoRetry sentinel for first-shot-or-fail
                         tests / single-room demos.

  delayForAttempt(n)   — 1-indexed; doubles per attempt; clamps at
                         maxDelayMs. n<1 returns 0.
  isExhausted(n)       — n >= maxAttempts (next retry forbidden).
  init { require(...) } — ctor guards reject zero/negative initial,
                          multiplier <= 1, max < initial, attempts < 1.

  NestsListenerState.Reconnecting / NestsSpeakerState.Reconnecting
  — new state variants with (attempt, delayMs). UI consumes via
  the existing NestsListenerState → ConnectionUiState mapper which
  surfaces Reconnecting under OpeningTransport for the v1 chip;
  a future commit can add a dedicated "Attempt N in Mms" UI.

Tests:
  * First attempt = initialDelayMs
  * Doubling via attempt index until maxDelayMs ceiling
  * Non-standard multiplier still respects ceiling
  * Zero/negative attempt → 0 delay
  * isExhausted at the boundary
  * NoRetry exhausts after attempt 1
  * Constructor rejects invalid inputs

The orchestration layer (mint-fresh-JWT → reopen WT → re-issue
SubscribeHandles + the speaker-side equivalent) is the heavier
follow-up. Deliberately split because:
  1. The state + policy can ship and be consumed by callers ready
     to handle Reconnecting today — no behaviour change for those
     who don't.
  2. The full session-resurrection path needs `MutableSharedFlow`
     buffering per SubscribeHandle so app code's `Flow<MoqObject>`
     doesn't notice the swap. Substantial refactor; better as its
     own commit with its own tests.
2026-04-26 23:05:19 +00:00
Claude 5a86cdd4e4 fix(audio-rooms): SubscribeResponse framing matches moq-lite-03
Lite-03's SubscribeResponse on the response side of a Subscribe bidi
is two pieces concatenated:

  type   varint  (0 = Ok, 1 = Drop)
  body   size-prefixed bytes

The type discriminator sits OUTSIDE the body's size prefix. Earlier
drafts wrapped the whole thing in one outer size prefix; Lite-03 split
them. See `rs/moq-lite/src/lite/subscribe.rs::SubscribeResponse::encode`
(the `_` arm, which matches Lite-03+).

Our codec was producing — and reading — the older outer-wrapped form,
so against a Lite-03 relay we mis-parsed the type discriminator (`0`
for Ok) as a "size=0" outer prefix and surfaced a confusing
`MoqCodecException: truncated varint at offset=0 (remaining=0)` from
inside `decodeSubscribeResponse`. The first byte the relay sent was
the type, not a size, and our reader peeled it off as the outer length.

Fix touches three pieces:

* `encodeSubscribeOk` / `encodeSubscribeDrop` emit
  `type + size_prefixed(body)` directly (no outer wrap), via a small
  `prefixWithType` helper.
* `decodeSubscribeResponse` reads `type` then a length-prefixed body,
  decodes the body in its own reader, and asserts the outer payload
  is fully consumed.
* `readSubscribeResponseFromBidi` walks chunks into the buffer until
  both the type varint and the size-prefixed body have arrived, then
  re-emits the contiguous `[type][size][body]` slab so the decoder
  parses it self-contained. Reuses the `EarlyExit` collector pattern
  the publisher-inbound path already uses; avoids `Flow.iterator()`
  which doesn't exist in kotlinx.coroutines.

Tests:
* `MoqLiteCodecTest::subscribe{Ok,Drop}_round_trips` no longer
  `peelSizePrefix` the encoded bytes — the new wire form has no outer
  prefix to strip; the bytes go straight through `decodeSubscribeResponse`.
* `MoqLiteSessionTest::publisher_acks_subscribe_and_pushes_group_data_on_uni_stream`
  also drops `MoqLiteFrameBuffer().readSizePrefixed()` on the ack chunk
  for the same reason.

Verified end-to-end against a bare-metal moq-relay 0.10.25 + moq-auth
(`-DnestsInteropExternal=true`):

* `NostrNestsRoundTripInteropTest::production_speaker_broadcasts_to_production_listener_via_real_relay`
  passes — speaker → listener → 8 frames round-trip cleanly.
* `NostrNestsMultiPeerInteropTest::one_speaker_fans_out_to_two_listeners`
  passes — same speaker reaches both listeners with the full frame
  stream.
* The `listener_subscribed_before_announce_receives_late_frames` and
  `two_speakers_in_same_room_deliver_independently_to_one_listener`
  cases still fail with `subscribe stream FIN before reply` — those
  are different relay-behavior issues (the v1 relay seems to FIN
  subscribes against unannounced broadcasts and against a second
  speaker on the same listener), unrelated to the codec.
2026-04-26 20:55:04 +00:00
Claude 1a26b23448 style(audio-rooms): import-and-use instead of inline FQNs
Replace inline `com.vitorpamplona.…` references in code + KDoc with
imports + the short class name across the audio-rooms surface. KDoc
references that would have introduced an `ui` → `model` import cycle
(e.g. NestsServerListState referring to CreateAudioRoomViewModel) are
demoted to plain text instead of clickable `[Class]` links.

No behavioural change.
2026-04-26 20:11:02 +00:00
Claude bc43168032 chore(audio-rooms): post-moq-lite cleanup + KDoc + plan refresh
Tidy items now that the moq-lite swap is complete on both sides:

  - Drop the no-op `supportedMoqVersions: List<Long>` parameter from
    `connectNestsListener` / `connectNestsSpeaker`. moq-lite negotiates
    via ALPN, no caller passed a value, and the project rule forbids
    no-op back-compat shims.
  - `NostrNestsRoundTripInteropTest` KDoc + comments now describe the
    moq-lite framing path (one Subscribe bidi for `audio/data`, group
    uni streams with `DataType=0` + GroupHeader + size-prefixed frames)
    instead of the stale IETF "OBJECT_DATAGRAMs / SETUP" framing.
  - `DefaultNestsListener` / `DefaultNestsSpeaker` KDoc now flags them
    as IETF MoQ-transport reference impls — production uses
    `MoqLiteNests*`. They stay around for the IETF unit-test suite.
  - `audio-rooms-completion.md` Phase M5 / M6 / M7 marked **DONE** —
    the plan was written before the moq-lite gap was discovered, so
    it described the work as IETF-MoQ-publisher additions; the
    moq-lite path lands the same outcome via a different protocol.
2026-04-26 18:18:33 +00:00
Claude 71cf99dc22 feat(audio-rooms): moq-lite speaker side end-to-end (phase 5c-speaker)
Production speaker path now runs on moq-lite, so connectNestsSpeaker
exchanges real moq-lite framing with the nostrnests reference relay.

Transport layer:
  - WebTransportSession.incomingBidiStreams() — peer-initiated bidi
    flow. moq-lite publishers receive Announce + Subscribe bidis from
    the relay (rs/moq-lite/src/lite/publisher.rs:40 uses
    Stream::accept(session)), so the abstraction grew the
    accept-bidi-from-peer surface.
  - WebTransportSession.openUniStream() — locally-opened uni stream
    for group push (rs/moq-lite/src/lite/publisher.rs:338 uses
    session.open_uni()).
  - :quic WtPeerStreamDemux StrippedWtStream now carries optional
    send/finish closures. The demux takes the QuicConnectionDriver
    so wakeups fire after each app-level write on a peer-initiated
    bidi.
  - FakeWebTransport now exposes incomingBidiStreams + openUniStream
    directly; the openPeerUniStream test helper went away (production
    flow covers it).

Session layer:
  - MoqLiteSession.publish(suffix) — claims a broadcast suffix and
    lazily launches a relay→us bidi pump. ControlType=Announce reads
    AnnouncePlease, replies Active(suffix). ControlType=Subscribe reads
    body, replies SubscribeOk, registers the inbound subscription.
  - MoqLitePublisherHandle — startGroup / send / endGroup / close
    semantics. send opens a uni stream per group with DataType=0 +
    GroupHeader and pushes varint(size)+payload frames. close emits
    Announce(Ended) on every active announce bidi, FINs the uni.

Application layer:
  - AudioRoomMoqLiteBroadcaster — sibling of AudioRoomBroadcaster but
    drives MoqLitePublisherHandle (keeps IETF broadcaster intact for
    its unit tests).
  - MoqLiteNestsSpeaker — NestsSpeaker adapter, mirror of
    MoqLiteNestsListener on the publish side.
  - connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and
    returns MoqLiteNestsSpeaker.

Tests:
  - 4 new MoqLiteSessionTest cases:
    publisher_replies_to_announcePlease_with_active_announce,
    publisher_acks_subscribe_and_pushes_group_data_on_uni_stream,
    publisher_send_returns_false_when_no_inbound_subscriber,
    publisher_close_emits_ended_announce.

Verified :commons:compileKotlinJvm + :amethyst:compilePlayDebugKotlin
both still compile against the swap.

Docs (plans + CLAUDE.md) refreshed to reflect speaker-side landing.
2026-04-26 18:01:00 +00:00
Claude 41f4dcd9ac feat(audio-rooms): connectNestsListener uses moq-lite (phase 5d)
Production wiring switch — `connectNestsListener` now opens a
moq-lite (Lite-03) session against the WebTransport peer instead of
running the IETF MoQ-transport SETUP handshake. The downstream
NestsListener interface is unchanged; consumer code in commons /
amethyst keeps working.

Listener flow (per the audio-rooms NIP draft + nostrnests JS reference):
  - subscribeSpeaker(pubkey) → MoqLiteSession.subscribe(broadcast =
    pubkey, track = "audio/data")
  - frames map to MoqObject so AudioRoomPlayer / AudioRoomViewModel
    keep working unchanged. Per-frame `objectId` is synthesised as a
    monotonic counter (moq-lite has no per-frame ID); `groupId` =
    moq-lite group sequence; `trackAlias` = subscribe id.

NestsListenerState.Connected.negotiatedMoqVersion now reports
MOQ_LITE_03_VERSION (a synthetic constant carrying the ALPN suffix
in the low bytes) since moq-lite has no in-band SETUP message.

NestsConnectTest:
  - Drop the SETUP-faking peer side — moq-lite has no SETUP, so
    connectNestsListener returns Connected immediately after the WT
    handshake.
  - Assert MOQ_LITE_03_VERSION on Connected.

Speaker path (`connectNestsSpeaker`) is untouched — it still uses
the IETF DefaultNestsSpeaker. Speaker-side moq-lite needs
`acceptBidiStream` on the WebTransportSession interface so the relay
can open Announce/Subscribe bidis to the publisher; tracked in
plans/2026-04-26-moq-lite-gap.md phase-5c-speaker.

Verified `:commons:compileKotlinJvm` + `:amethyst:compilePlayDebugKotlin`
both still compile against the swap.
2026-04-26 17:29:51 +00:00
Claude 4e136cacf4 feat(audio-rooms): MoqLiteSession listener-side (phase 5c-listener)
Listener-side moq-lite (Lite-03) session that wraps a
WebTransportSession and exposes:

  - announce(prefix) — opens an Announce bidi with
    ControlType=Announce + AnnouncePlease(prefix), surfaces a flow
    of relay Announce updates (Active/Ended)
  - subscribe(broadcast, track, ...) — opens a Subscribe bidi with
    ControlType=Subscribe + body, reads the size-prefixed response,
    returns a MoqLiteSubscribeHandle whose frames flow yields each
    frame the publisher pushes
  - close() — cancels pumps, FINs every active subscribe bidi
    (moq-lite UNSUBSCRIBE is "FIN the bidi"), closes the transport

Group demux: a single inbound-uni-stream pump per session reads the
DataType byte (Group=0) + size-prefixed group header, then routes
each subsequent (size, payload) frame to the matching
subscriptionsBySubscribeId entry's frame channel.

Speaker side stops at the announce/subscribe interface — moq-lite
publisher flows depend on accept_bi() / server-initiated bidi
streams (see clarifying agent summary in plans/2026-04-26-moq-lite-gap.md).
That's a follow-up; the WebTransportSession interface needs an
acceptBidiStream() method first.

FakeWebTransport.openPeerUniStream() — new helper so tests can
inject group data from the peer side without going through the
session's outbound uni stream API.

Tests in MoqLiteSessionTest — runBlocking-based (not runTest) so
real-time channel coordination works without virtual-time
artifacts:
  - subscribe_writes_request_and_returns_handle_on_ok
  - subscribe_throws_on_drop_response
  - announce_streams_relay_updates
  - groups_are_demuxed_by_subscribeId (no cross-talk)
  - unsubscribe_FINs_the_subscribe_bidi
2026-04-26 17:24:17 +00:00
Claude fb47a4cf75 feat(audio-rooms): moq-lite codec primitives + message types (phase 5a/5b)
First two phases of the moq-lite (Lite-03) implementation per
nestsClient/plans/2026-04-26-moq-lite-gap.md. No production wiring
yet — the production helpers still call the IETF MoqSession.

What landed:
  - MoqLitePath — mandatory wire-boundary normalisation (strip
    leading/trailing/duplicate `/`), join, path-component-aware
    stripPrefix. Mirrors rs/moq-lite/src/path.rs semantics.
  - MoqLiteAlpn / MoqLiteControlType / MoqLiteDataType /
    MoqLiteAnnounceStatus / MoqLiteSubscribeResponseType — wire
    enums for ALPN ("moq-lite-03"), per-bidi ControlType varints,
    Group uni-stream type byte, announce status, subscribe
    response type.
  - Message data classes — AnnouncePlease, Announce, Subscribe,
    SubscribeOk, SubscribeDrop, GroupHeader, Probe.
  - MoqLiteCodec — encode/decode for each message, with the
    size-prefix envelope baked in. Handles the off-by-one
    `0 = None, n = Some(n−1)` trick for startGroup/endGroup,
    coerces booleans to 0/1, validates priority fits in u8,
    rejects unknown status/response bytes. Path normalisation
    applied at every encode + decode boundary.
  - MoqLitePathTest, MoqLiteCodecTest — round-trip tests for
    every codec entry point + negative paths (oversized priority,
    invalid ordered byte, unknown status, trailing garbage).

All MoqWriter / MoqReader / MoqCodecException primitives reused
from the IETF MoQ codec — same varint and length-prefix shapes.
2026-04-26 16:46:55 +00:00
Claude 7f48e52541 docs(audio-rooms): full moq-lite wire spec + IETF gap call-outs
Background research turned up the complete moq-lite (Lite-03) wire
format from kixelated/moq-rs and @moq/lite v0.1.7. Folded the spec
into nestsClient/plans/2026-04-26-moq-lite-gap.md as a phase-5
implementation plan:

  - ALPN ("moq-lite-03"); no SETUP/control message in Lite-03 (the WT
    handshake IS the handshake)
  - Per-request bidi streams keyed by ControlType varint (Announce=1,
    Subscribe=2, Fetch=3, Probe=4)
  - AnnouncePlease(prefix) / Announce(status, suffix, hops) shape
  - Subscribe with priority (raw u8), ordered, maxLatency (ms),
    startGroup/endGroup (off-by-one None-encoded), reply Ok/Drop
  - Group = uni stream with (DataType=0, subscribeId, sequence)
    header followed by varint-length frames until QUIC FIN
  - No datagrams; no per-frame envelope beyond size
  - Mandatory path normalisation; FIN-as-unsubscribe; RESET_STREAM
    for errors

Phase-5a..e implementation plan included (~1 week scope).

Doc + KDoc updates so the IETF MoQ-transport code is correctly
labelled and the moq-lite gap is discoverable from every entry point:

  - .claude/CLAUDE.md project description and architecture diagram
  - nestsClient/plans/2026-04-26-audio-rooms-completion.md status block
  - MoqSession.kt, MoqMessage.kt, MoqObject.kt, MoqCodec.kt KDoc — flag
    these as "IETF draft-ietf-moq-transport-17, NOT moq-lite", with
    pointers to the gap doc
  - NestsConnect.kt — note that step 3 of the listener handshake
    (SETUP) does NOT match nostrnests's relay framing
2026-04-26 16:39:44 +00:00
Claude 1887bd1fa7 test/refactor(audio-rooms): nostrnests wire-shape fixes + interop expansion (phase 4)
Wire-shape corrections discovered while scoping the interop test suite
against the real moq-rs relay:

  1. WebTransport CONNECT path is now /<moqNamespace> (matches the
     relay's claims.root prefix check). Previously hardcoded "/anon".
  2. JWT travels in the ?jwt=<token> query parameter, not the
     Authorization header — moq-rs only reads the query param. The
     bearer-token path on QuicWebTransportFactory is now unused for
     nests; left in place for non-nests WebTransport servers.
  3. Harness `moqEndpoint` is the relay base URL only; the connect
     helpers append /<namespace>?jwt=<token> themselves.

Interop test additions (all -DnestsInterop=true gated, default-skipped):

  - NostrNestsAuthFailureInteropTest — locks in the moq-auth sidecar's
    rejection paths (missing/wrong-scheme Authorization, NIP-98 signed
    for the wrong URL, malformed namespace per the strict regex,
    publish=true grant for any caller — sidecar does NOT gate by NIP-53
    hostlist).
  - NostrNestsAuthEndpointsInteropTest — /health, /.well-known/jwks.json
    shape (must contain ES256/P-256), 404 on unknown route.
  - NostrNestsMultiPeerInteropTest — multi-listener fan-out, multi-
    speaker isolation, subscribe-before-announce. Code is wired through
    production connectNestsSpeaker / connectNestsListener; will pass
    once the moq-lite gap (below) is resolved.

Major finding documented in nestsClient/plans/2026-04-26-moq-lite-gap.md:
nostrnests's stack uses moq-lite (kixelated's variant), NOT IETF
draft-ietf-moq-transport which `:nestsClient` currently implements. The
two are wire-incompatible — single-string broadcast/track names vs. byte
tuples, different ANNOUNCE/SUBSCRIBE framing. The wire-shape fixes here
make the WebTransport CONNECT itself succeed, but the post-CONNECT MoQ
framing layer still needs a moq-lite codec before round-trip / multi-peer
tests can pass against real nests. Pursued as a separate phase.
2026-04-26 15:53:02 +00:00
Claude beec8204e5 refactor(audio-rooms): NestsClient API matches nostrnests reality (phase 2/3)
The Phase-1 interop harness exposed a substantial mismatch between our
production HTTP client and what the nostrnests reference server actually
exposes. This commit refactors `:nestsClient` and the wiring above it so
the production code path can talk to a real moq-auth + moq-relay.

| Aspect    | Before                                    | After (matches nostrnests/moq-auth/src/index.ts) |
|-----------|-------------------------------------------|--------------------------------------------------|
| Method    | GET                                       | POST                                             |
| URL       | `<base>/<roomId>`                         | `<base>/auth`                                    |
| Body      | none                                      | `{"namespace":"nests/<kind>:<host>:<roomId>","publish":bool}` |
| Response  | `{endpoint, token, codec, sample_rate}`   | `{token}` only                                   |
| Endpoint  | from response                             | from event's `endpoint` tag (passed via `NestsRoomConfig.endpoint`) |
| NIP-98    | bound to GET URL                          | bound to POST URL + body hash                    |

Type changes:
- New `NestsRoomConfig` data class bundling (authBaseUrl, endpoint,
  hostPubkey, roomId, kind). Built by the caller (UI / VM) from the
  NIP-53 kind 30312 event before invoking connectNests*.
- `NestsRoomConfig.moqNamespace()` produces the exact format
  moq-auth's NAMESPACE_REGEX expects: `nests/<kind>:<hex64>:<roomId>`.
- `NestsRoomInfo` deleted; replaced with a tiny `NestsTokenResponse(token)`
  matching the real response shape.
- `NestsClient.resolveRoom(serviceBase, roomId, signer): NestsRoomInfo`
  → `NestsClient.mintToken(room, publish, signer): String`. The
  publish flag drives the JWT claims (`get` for listeners, `put`
  for speakers).

Wire path:
- `OkHttpNestsClient` now POSTs `<authBase>/auth` with a JSON body
  and a NIP-98 Authorization header bound to (POST, url, body-hash).
- `connectNestsListener` / `connectNestsSpeaker` take `room:
  NestsRoomConfig` instead of split (serviceBase, roomId), pass
  `publish=false` / `publish=true` respectively, and use the room's
  `endpoint` (not a server-returned one) for the WebTransport
  connect. The minted JWT is the bearer token.
- `NestsListenerState.Connected` / `NestsSpeakerState.Connected` /
  `Broadcasting` carry the `room: NestsRoomConfig` instead of the old
  `roomInfo: NestsRoomInfo`.
- MoQ TrackNamespace for the room is now a single segment whose
  bytes are `room.moqNamespace()` — the simplest mapping to the
  relay's JWT claim check (`root: "<namespace>"`); Phase-3 round-trip
  test will confirm and adjust if the relay expects a multi-segment
  tuple.

Wiring above:
- `AudioRoomViewModel` constructor: replaces `(serviceBase, roomId)`
  with `(room: NestsRoomConfig)`. Connector seam interfaces
  (NestsListenerConnector, NestsSpeakerConnector) follow the same
  shape.
- `AudioRoomViewModelFactory` (Android) takes `room: NestsRoomConfig`.
- `AudioRoomActivity` adds `EXTRA_AUTH_BASE_URL`, `EXTRA_ENDPOINT`,
  `EXTRA_HOST_PUBKEY`, `EXTRA_KIND` Intent extras (was just service
  + roomId) and reconstructs `NestsRoomConfig` in onCreate. Drops
  `EXTRA_SERVICE_BASE`.
- `AudioRoomJoinCard` reads `event.endpoint()` + `event.pubKey` +
  `event.kind` in addition to `event.service()`; rooms missing any
  of those are silently un-joinable (the event author didn't host
  on a nests-compatible relay).
- `AudioRoomActivityContent` takes `room: NestsRoomConfig` in place
  of (serviceBase, roomId) and threads it down.

Phase-1 ping test rewired to use the production `OkHttpNestsClient`
end-to-end against the real `/auth`, asserting we get back a
3-segment JWT.

Existing in-process tests updated for the new types: NestsConnectTest,
NestsSpeakerTest, AudioRoomViewModelTest. NestsRoomInfoTest renamed to
NestsRoomConfigTest with new cases for the namespace formatter and the
auth-URL helper. All 80 in-process tests still green.

Phase 3 (next) will add the full round-trip interop test that runs
production `connectNestsListener` + `connectNestsSpeaker` through the
real moq-relay — that's where MoQ wire-format assumptions (draft
revision, OBJECT_DATAGRAM layout, namespace tuple shape) get verified
or get followup audit findings.
2026-04-26 14:42:26 +00:00
Claude 2a932dc974 fix(audio-rooms): clean up deferred audit items (VM + Android + MoQ comment)
Lands the audit follow-ups that didn't require external input. Only the
wire-format draft pinning (MoQ #1, #8) remains deferred — that's gated
on the M4 manual interop pass, and the same-room-PIP-re-entry corner
case (Android #5) — Android has no programmatic PIP-exit API.

ViewModel:
- VM #10: serialize disconnect→connect via a tracked `pendingCloseJob`.
  teardown() records the listener.close() launch (when not finalCleanup);
  the next launchConnect() awaits it before opening a fresh transport.
  Eliminates the brief two-QUIC-session overlap that some MoQ relays
  reject by deduping on client pubkey.
- VM #4: extracted shared connect-launch body into `launchConnect(triggerRetryOnFailure)`
  so connect() / connectInternal() share one implementation. Removed
  the near-duplicate viewModelScope.launch block.
- VM #8b: setMicMuted no longer silently swallows handle failures.
  BroadcastUiState.Broadcasting gains a `muteError: String?` field that
  the UI can surface as an inline message; cleared on the next successful
  toggle. The broadcast itself stays running with its previous mute
  state — only the mute toggle failed.
- VM #6: documented the dispatcher-confinement contract in the class
  kdoc. Audit was theoretical — every map mutation already runs on
  viewModelScope (Dispatchers.Main.immediate on Android, same dispatcher
  the MoQ flow's onEach callback uses because the player launch lives in
  viewModelScope). Future cross-thread callers must marshal explicitly.

Android:
- Android #2: AudioRoomActivity.toggleMuteSignal type tightened from
  `MutableSharedFlow<Unit>` to `SharedFlow<Unit>` so external code can't
  tryEmit into it. Internal emit uses the private `_toggleMuteSignal`.
- Android #10: presence debounce-publisher (the LaunchedEffect keyed on
  micMutedTag) now skips entirely when micMutedTag is null. Stops the
  duplicate first-frame publish where heartbeat fires immediately AND
  debounce-publisher fires 500 ms later, both with muted=null. Once the
  user goes live the debounce-publisher kicks in for state changes.

MoQ session:
- MoQ #11: send() rollback comment rewritten to say "monotonic; gaps
  acceptable per spec, this just minimises them on full-fanout failures"
  instead of the misleading "strictly contiguous" claim.

Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.

Still deferred:
- MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs
  M4 interop input from `nostrnests.com` to know what the relay actually
  speaks.
- Android #5: same-room re-entry from MainActivity while in PIP doesn't
  auto-exit PIP. Android has no programmatic PIP-exit API; user must tap
  the expand button. Corner case.
- Test coverage gaps (round-1 VM #10, round-2 VM #13): retry-counter +
  broadcast state + setMicMuted-no-handle + server-Closed cleanup +
  double-connect-while-Failed. Each is a small dedicated test using the
  existing connector-seam pattern; landing as a separate test-only commit.
2026-04-26 13:18:51 +00:00
Claude ed793e8eb3 fix(audio-rooms): round-2 audit — pump self-join (CRITICAL) + 5 other findings
Round 2 audit (3 parallel agents reviewing every change since the
previous follow-up commit) caught one CRITICAL regression and several
HIGH/MED items. Most round-1 fixes verified clean.

CRITICAL fix (audit round-2 MoQ #1):
- Pump exception handlers added in the previous commit call `close()`
  from inside the failing pump's own coroutine. `close()` now does
  `controlPumpJob?.join()` to drain in-flight writes — but the Job we
  try to join is the very Job we're inside, so `join()` suspends
  forever (lambda can't finish until close returns; close can't
  return until lambda finishes). `runCatching` doesn't help — `join()`
  doesn't throw, it suspends. This deadlocks the entire session
  whenever a pump fails.
  Fix: skip the join when the current coroutine IS the job we're
  joining. `currentCoroutineContext()[Job]` identifies the caller; we
  compare and bypass.

HIGH fixes:
- MoQ #2 (regression): concurrent unannounce() + post-OK
  AnnounceError handler could both write UNANNOUNCE on the wire (some
  relays disconnect on UNANNOUNCE for an unknown namespace). Fix:
  `AnnounceHandleImpl.unannounceWritten: Boolean` flag, set under
  stateMutex by whichever writer goes first; the other path skips.
- VM #3 (new): `connect()` overwrote `listener` if invoked from a
  Failed-with-stale-listener state, leaking the previous MoQ session.
  Fix: call `teardown(targetState=Idle, finalCleanup=false)` before
  launching the new attempt when listener or stateObserverJob is
  still alive.
- VM #7 (new): `openSubscription` allocated decoder + player via the
  factories, then attached them to the slot. If the VM scope was
  cancelled between `decoderFactory()` and `slot.attach(...)`, the
  native MediaCodec / AudioTrack leaked because nothing was tracking
  them yet. Fix: nest a try/catch that releases both on any throw
  (including `CancellationException`) before re-throwing.

MED fixes:
- MoQ #7 (new): `capture.start()` could throw before `job` was
  assigned, leaving the broadcaster in a half-started state where
  future `start()` calls would re-pass the guards and double-start
  the mic. Fix: try/catch around capture.start; on throw, set
  `stopped = true` + run capture.stop and propagate.
- MoQ #8 (new): `stopped` was read across threads (setMuted from
  caller, stop from anywhere) without a `@Volatile` barrier.
  Visibility hazard. Fix: `@Volatile private var stopped`.
- Android #12 (new): after the user granted RECORD_AUDIO via the
  Settings deep-link, `permissionDenied` stayed `true` because the
  launcher callback never fired — the warning + Open-settings button
  remained visible until the user tapped Talk again. Fix: derive
  `showDenialWarning` from `permissionDenied AND
  ContextCompat.checkSelfPermission(...) != GRANTED`. Re-checks every
  recomposition (including post-Settings return).

Round-1 fixes verified clean by this audit:
- pending-deferred completeExceptionally on close
- SubscribeDoneStatus codes (UNSUBSCRIBED=0x00, TRACK_ENDED=0x03)
- suspend `stop()` conversions on broadcaster + player
- gate release before NestsSpeaker teardown chain
- unannounce() ordering on thrown wire-write
- SharedFlow PIP signal (rapid double-tap behavior is correct)
- RECEIVER_NOT_EXPORTED gate (constant 4 doesn't collide; round-1
  collision claim was incorrect)
- onUserLeaveHint guards (PIP from lobby, no PIP support)
- foreground service `startForeground`-always-first contract
  (`Result.onFailure` is `inline`, the `return` IS a non-local
  return from `onStartCommand` — verified)
- 4-hour wake-lock cap
- AudioRoomBridge.clear() in AccountViewModel.onCleared

Still deferred:
- VM #6 (round-1 carryover): unsynchronized speakingExpiryJobs map.
  Cross-thread mutation under contention. Needs ConcurrentHashMap or
  Dispatchers.Main.immediate marshalling.
- VM #10 (round-2 new): brief two-QUIC-session overlap during
  rapid disconnect→connect.
- Android #5 (round-2 new): same-room re-entry from MainActivity
  while in PIP doesn't auto-exit PIP.
- MoQ #1, #8 (round-1 carryover): wire-format draft pinning. Still
  blocked on M4 manual interop input.

Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.
2026-04-26 12:32:22 +00:00
Claude 0b1ec52f79 fix(audio-rooms): audit follow-up — MoQ HIGH/MED + VM concurrency + Android polish
Round 2 of the audit-driven cleanup. Lands every HIGH and most MED
findings from the protocol / ViewModel / Android lifecycle audits that
weren't fixed in the previous commit. The wire-format draft pinning
(audit MoQ #1, #8) is intentionally deferred until the M4 manual interop
pass against `nostrnests.com` reveals which draft revision the relay is
actually speaking.

MoQ session fixes:
- #5 UNANNOUNCE wire-write now happens BEFORE removing announces[ns],
  so an inbound SUBSCRIBE during the teardown window sees the namespace
  as withdrawn (sessionClosed=true → SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST))
  instead of "namespace never existed".
- #4 dispatchControlMessage(AnnounceError) now distinguishes pre-OK and
  post-OK errors. Post-OK is a session-level kick: mark the handle
  closed, send UNANNOUNCE, then drop. Pre-OK still rolls back the
  optimistic announces[] insert as before.
- #6 close() now joins the cancelled control + datagram pumps before
  calling controlStream.finish(), so an in-flight SUBSCRIBE_OK /
  SUBSCRIBE_DONE / SUBSCRIBE_ERROR write can complete its
  writeMutex.withLock { ... } critical section. Previously cancellation
  could truncate a frame mid-flight and we'd send FIN over a corrupted
  stream.
- #9 pumps wrapped in try/catch that calls close(...) on unexpected
  exceptions, so a transport-died-mid-session no longer leaves the
  session thinking it's healthy with new subscribe/announce calls
  hanging on a dead peer.
- #10 TrackPublisher.send rolls back nextObjectId when every datagram
  fan-out fails (transport down). The audio-rooms NIP wants strictly
  contiguous object ids per group; a gap from a fully-failed send
  would trip strict subscribers.
- #13 DefaultNestsSpeaker.close drops `gate` before calling
  activeHandle.close() / session.close(). The teardown chain runs
  cancelAndJoin on the broadcaster + sends SUBSCRIBE_DONE per attached
  subscriber + joins MoQ pumps; holding the gate through all of that
  blocked any other concurrent API call on this speaker.

Resource lifecycle (audit MoQ #11/#12):
- AudioRoomBroadcaster.stop() now `cancelAndJoin`s the loop before
  releasing the encoder + closing the publisher. The loop's last
  encoder.encode/publisher.send no longer races
  encoder.release()/publisher.close() — both produced use-after-release
  on native MediaCodec on Android, the latter sent orphan
  OBJECT_DATAGRAMs to subscribers we'd just told SUBSCRIBE_DONE.
- AudioRoomPlayer.stop() promoted to `suspend` + cancelAndJoin for the
  same reason: decoder.release() ran while the decode loop was still
  inside MediaCodec.decode(...), undefined behaviour. Updated VM call
  sites (closeSubscription, teardown) to route both player.stop() and
  handle.unsubscribe() through one launched coroutine via the new
  `detach(): Pair<AudioRoomPlayer?, SubscribeHandle?>` shape.

ViewModel fixes:
- #4 auto-retry uses a single `retryPending: Boolean` flag instead of
  `Job.isActive`. Two scheduleAutoRetry calls could previously both
  pass `Job.isActive == false` (the launched body had just started)
  and stack a second retry on top of one already running.
- #7 setMicMuted updates the UI INSIDE the launched coroutine, after
  the suspending broadcastHandle.setMuted() returns. Previously the
  indicator could claim "muted" while audio was still on the wire if
  the handle's setMuted suspended on a gate.
- #8 connect() cancels the previous stateObserverJob before kicking
  off the new attempt, so a delayed Failed/Closed emission from the
  old listener can no longer clobber the fresh Connecting UI.
- #9 disconnect() clears requestedSpeakers, so a fresh connect() to a
  different room (or the same room after a long pause) doesn't reuse
  a stale speaker snapshot.
- #12 updateSpeakers filters out the user's own pubkey: subscribing
  to your own forwarded audio would echo through the local playback
  device whenever the broadcast track loops back from the relay.

Android lifecycle / PIP / service:
- #6 PIP aspect ratio flipped from 9:16 (portrait sliver) to 16:9
  (landscape) so the row of avatars under the title actually fits.
- #7 process-death recovery: when AudioRoomBridge is empty (previous
  process's AccountViewModel is gone), redirect to MainActivity
  before finish() so the user lands somewhere meaningful instead of
  a black-flash.
- #8 AudioRoomForegroundService.onStartCommand always calls
  startForeground first, on every invocation including ACTION_STOP.
  startForegroundService's 5-second contract requires it; previously
  the STOP path skipped it. startForeground itself wrapped in
  runCatching so a foreground-not-allowed exception bails cleanly
  rather than leaking the wake-lock.
- #10 wake-lock timeout reduced 12 h → 4 h. Stuck connections that
  fail to detect a network drop no longer hold the device awake for
  half a day.
- #11 presence-event spam fix: split the publish loop into a
  heartbeat keyed only on (address, handRaised) and a separate
  debounced state-change publisher keyed on micMutedTag. Every mute
  toggle previously triggered a full sign + publish + relay round
  trip; now we coalesce within a 500 ms window.
- #12 final "leaving" presence routed through GlobalScope.launch
  instead of rememberCoroutineScope (which is cancelled on dispose,
  so the leave event almost never reached the relay).
- #14 RECORD_AUDIO denial recovery: when the user has tapped "Don't
  ask again", the launcher silently returns false. New "Open
  settings" button deep-links to the app's settings page so the user
  can re-grant the permission and try again.
- #15 setPictureInPictureParams now updates outside PIP too, so the
  next entry shows the correct mute-state icon without an extra flip.
- #16 onNewIntent override: a second Join tap for a different room
  finishes the current Activity and starts a fresh one with the new
  extras, instead of singleTask silently keeping the old room
  running.

Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.

Audit findings still deferred (all documented inline / in this commit):
- MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs
  the M4 manual interop pass to confirm what nests is actually speaking.
- VM #6: confined map mutation under a single dispatcher. Current
  setup (Dispatchers.Main via setMain in tests, viewModelScope in
  prod) is functionally fine; full belt-and-suspenders confining is a
  separate concurrency review.
- VM #10: test coverage gaps for retry + speaker reconcile cycle +
  setMicMuted no-handle case + server-initiated Closed leaves stale
  state. Each is a dedicated test.
- Android #18: startListening / promoteToMicrophone race. Mitigation
  (always declaring microphone foreground type) requires unconditional
  RECORD_AUDIO grant which listener-only users won't have.
2026-04-26 12:04:18 +00:00
Claude f0b27654ba test(audio-rooms) + fix: round-trip test + audit pass
Adds an end-to-end MoQ round-trip test and lands the highest-severity
findings from a 3-agent audit (protocol / ViewModel / Android lifecycle)
of the M5–M7 + Activity work.

Round-trip test (`:nestsClient` MoqRoundTripTest):
- Two MoqSession.client() instances (publisher + subscriber) talk
  through a hand-rolled in-test relay coroutine that mirrors a real
  MoQ relay's wire behavior (forwards CLIENT_SETUP / SERVER_SETUP /
  ANNOUNCE / SUBSCRIBE / SUBSCRIBE_OK / OBJECT_DATAGRAM).
- 100-Opus-frame test exercises the full publisher → wire →
  subscriber path, asserting payload bytes + monotonic group/object
  ids round-trip correctly. Catches any drift between our publisher
  and our own subscriber's wire format.
- Second test verifies SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) flows
  back as MoqProtocolException when the publisher hasn't openTrack'd.

MoQ protocol fixes (CRITICAL audit findings):
- SubscribeDoneStatus constants were inverted: had UNSUBSCRIBED=0x01
  (peer reads as INTERNAL_ERROR) and TRACK_ENDED=0x00 (peer reads as
  UNSUBSCRIBED). Swapped to draft-stable values: UNSUBSCRIBED=0x00,
  TRACK_ENDED=0x03.
- Pending CompletableDeferreds for in-flight SUBSCRIBE / ANNOUNCE on
  session close were `cancel()`-ed, which propagates as
  CancellationException — caller's entire scope cancels instead of
  catching a domain MoqProtocolException. Switched all sites to
  `completeExceptionally(MoqProtocolException("session closed"))`
  including unsubscribe-while-pending-OK.

ViewModel fixes:
- openSubscription race: re-check `activeSubscriptions[pubkey] === slot
  && !closed` AFTER the suspending `subscribeSpeaker` returns; if the
  user removed the speaker mid-flight, fire-and-forget UNSUBSCRIBE
  rather than attaching a leaked SubscribeHandle + AudioRoomPlayer to
  a discarded slot.
- Server-initiated `Closed` listener state now triggers
  `teardown(targetState=Closed)` and resets the auto-retry counter,
  so a transport-died-mid-handshake doesn't leave stale subscriptions
  in the VM map until the Activity finishes.
- Cleanup-scope split: `disconnect()` (user-driven) routes the close
  through `viewModelScope` (still alive); `onCleared()` routes it
  through a process-lived `cleanupScope` (default GlobalScope, tests
  pass backgroundScope) so MoQ control frames (UNSUBSCRIBE,
  UNANNOUNCE, SUBSCRIBE_DONE) actually land before the QUIC
  transport drops, instead of being eaten by the cancelled
  viewModelScope.

Android lifecycle fixes:
- AudioRoomBridge.clear() wired into AccountViewModel.onCleared next
  to the existing CallSessionBridge.clear() — no more cross-account
  AccountViewModel leak after logout/switch.
- registerReceiver flag now gated on Build.VERSION.SDK_INT
  TIRAMISU+ — RECEIVER_NOT_EXPORTED on pre-33 devices collides with
  RECEIVER_VISIBLE_TO_INSTANT_APPS. PendingIntents stay
  package-scoped via setPackage(packageName).
- onUserLeaveHint no longer enters PIP unconditionally: gated on
  ui.connection == Connected AND PackageManager
  FEATURE_PICTURE_IN_PICTURE present, so PIP-from-lobby /
  PIP-on-incompatible-device doesn't leave a frozen full-screen
  card in Recents.
- Replaced the process-wide singleton AudioRoomPipActions toggle
  Boolean with a per-Activity MutableSharedFlow<Unit> so a stale
  emission from a torn-down Activity can't leak into a new one.

ViewModel test was extended with `cleanupScope = backgroundScope`
plumbing so the post-disconnect `listener.close()` assertion remains
observable in the test scheduler.

Verified: spotlessApply clean; :commons:jvmTest (9 tests),
:nestsClient:jvmTest (80 tests including 2 new round-trip), and
:amethyst:compilePlayDebugKotlin all green.

Audit findings deferred to follow-up commits (none ship-blocking):
- Wire layout pinning vs draft-17 vs draft-11 (currently emits a
  draft-11-style OBJECT_DATAGRAM; we advertise draft-17). Resolve
  via the M4 manual interop pass against nostrnests.
- UNANNOUNCE racing inbound SUBSCRIBE; control-pump cancel half-write
  (audit MoQ #5, #6) — small ordering tweaks.
- Two retry coroutines stacking under fast Failed bursts (audit VM #4).
- AudioRoomForegroundService startForeground always-first contract
  (audit Android #8).
2026-04-26 09:05:00 +00:00
Claude 0afde79afd feat(audio-rooms): M6 + M7 — AudioRoomBroadcaster + NestsSpeaker API
M6 (AudioRoomBroadcaster): Inverse of AudioRoomPlayer. Pulls PCM frames
from an AudioCapture, runs them through an OpusEncoder, and pushes the
resulting Opus packets into a MoqSession.TrackPublisher as
OBJECT_DATAGRAMs. setMuted keeps the capture + encoder running so unmute
is sample-accurate; encode failures are reported via onError but don't
tear the loop down.

M7 (NestsSpeaker): Mirror of NestsListener for the host / speaker path.
- `NestsSpeaker.startBroadcasting()` — sends ANNOUNCE for the room's
  namespace (`["nests", roomId]`), opens a TrackPublisher named after
  this user's pubkey hex, wires an AudioRoomBroadcaster onto it.
- `BroadcastHandle.setMuted` / `close()` — speaker-side equivalents of
  the listener's mute / disconnect controls.
- `NestsSpeakerState` sealed hierarchy (Idle / Connecting{step} /
  Connected / Broadcasting{isMuted} / Failed / Closed).
- `connectNestsSpeaker` orchestration mirrors `connectNestsListener`
  end-to-end (HTTP → WebTransport → MoQ setup), then returns a
  `DefaultNestsSpeaker` ready for `startBroadcasting`.

Tests:
- AudioRoomBroadcasterTest (5 tests): pcm-flow ordering, mute behavior,
  encoder warmup skip, encode-error tolerance, idempotent stop.
- NestsSpeakerTest (2 tests): start/mute/close state transitions,
  double-start rejection.

Bug caught + fixed during M7 testing: `DefaultNestsSpeaker.close()`
held its `gate` mutex while calling `handle.close()` which then called
back through `parent.broadcastClosed()` → self-deadlock. Fixed by
making `broadcastClosed` lockless (StateFlow updates are atomic; the
activeHandle compare-and-set is benign under the gate's exclusion of
concurrent startBroadcasting calls).

Verified: `:nestsClient:jvmTest` (78 tests, all green) +
`./gradlew spotlessApply` clean.
2026-04-26 03:22:02 +00:00
Claude 5c32996f93 feat(audio-rooms): M5 MoQ publisher path — ANNOUNCE + inbound SUBSCRIBE + OBJECT emit
Lifts MoqSession from listener-only to bidirectional. A session can now
ANNOUNCE a track namespace, register one TrackPublisher per track name
under it, accept inbound SUBSCRIBEs from the peer, and emit
OBJECT_DATAGRAMs that fan out to every attached subscriber. This is what
the speaker / host path needs in nests — phases M6/M7 layer audio
capture + a NestsSpeaker API on top.

Public API additions:
- `MoqSession.announce(namespace, parameters)` — sends ANNOUNCE, awaits
  ANNOUNCE_OK, returns an `AnnounceHandle`.
- `AnnounceHandle.openTrack(name)` — registers a `TrackPublisher` for a
  track under the announced namespace. Idempotent insert is rejected.
- `TrackPublisher.send(payload)` — encodes one MoQ OBJECT_DATAGRAM and
  emits it to every currently-attached subscriber. Group id is fixed at
  zero per the audio-rooms NIP draft; object ids are monotonic.
- `TrackPublisher.close()` — sends SUBSCRIBE_DONE to attached
  subscribers, removes the track from its parent announce.
- `AnnounceHandle.unannounce()` — sends UNANNOUNCE on the wire, closes
  every registered publisher.
- `ErrorCode.TRACK_DOES_NOT_EXIST` and `SubscribeDoneStatus.{TRACK_ENDED,
  UNSUBSCRIBED}` constants for the SUBSCRIBE_ERROR / SUBSCRIBE_DONE
  values we emit.

Internal additions:
- `pendingAnnounces` keyed by namespace, mirroring the existing
  `pendingSubscribes` pattern.
- `announces` map tracking live publishers per namespace.
- `inboundSubscribers` + `publisherSubscribers` so the control-pump can
  fan an inbound SUBSCRIBE to the right TrackPublisher and so
  TrackPublisher.send can snapshot subscribers without per-OBJECT
  locking.
- Control pump now routes ANNOUNCE_OK / ANNOUNCE_ERROR (publisher-side
  ack) and inbound SUBSCRIBE / UNSUBSCRIBE (we're being asked to send
  OBJECTs, or stop). Unknown-track SUBSCRIBE replies with
  SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) so the peer doesn't hang.
- `close()` propagates session death into every AnnounceHandleImpl /
  TrackPublisherImpl so any in-flight `send` short-circuits cleanly.

All shared-state mutation is funneled through the existing `stateMutex`
(no `synchronized` — that's JVM-only and would break commonMain). The
session-wide `writeMutex` continues to serialize control-stream writes.

Tests (new in MoqSessionTest):
- announce → ANNOUNCE_OK happy path; UNANNOUNCE wire frame on close.
- ANNOUNCE_ERROR surfaces as MoqProtocolException.
- End-to-end: announce + openTrack + peer SUBSCRIBE → SUBSCRIBE_OK +
  three OBJECT_DATAGRAMs round-trip with intact group/object ids.
- send() returns false when no subscribers are attached (no buffering).
- Inbound SUBSCRIBE for unknown track under an announced namespace
  replies SUBSCRIBE_ERROR.

Verified: `:nestsClient:jvmTest` (12 tests, 0 failures) + `:quic:jvmTest`
(green) + `./gradlew spotlessApply` clean.
2026-04-26 03:11:33 +00:00
Claude f9aa762d9b feat(audio-rooms): M1 listener-only wire-up — Connect button + Connected chip + mute
Wires `connectNestsListener` into the existing NIP-53 audio-room "stage" so
a user can tap Connect, see the live connection state, and hear hosts
speaking. Per the audio-rooms completion plan
(`nestsClient/plans/2026-04-26-audio-rooms-completion.md`) phase M1.

UI (amethyst):
- AudioRoomStage gets a state-chip / Connect / Disconnect / Mute row
  above the existing hand-raise. State chip walks ResolvingRoom →
  OpeningTransport → MoQ-handshake → Connected; Failed surfaces the
  reason inline with a Retry button. The 30312 `service` tag drives
  visibility — rooms hosted on non-nests servers show "Audio not
  available" and the rest of the stage UI continues to work.

ViewModel (commons):
- New `AudioRoomViewModel` in commons/.../viewmodels/ owns one
  `NestsListener` and one `AudioRoomPlayer` per active speaker, exposes
  a single `StateFlow<AudioRoomUiState>`, and reconciles subscriptions
  whenever the screen pushes a new speaker set via `updateSpeakers`.
  Audio-pipeline construction is injected via `decoderFactory` /
  `playerFactory` so a future desktop port can reuse the orchestration
  once Compose Desktop has WebTransport. Unit-tested with a fake
  `NestsListenerConnector` driving state transitions directly.

nestsClient:
- `AudioPlayer.setMuted(Boolean)` joins the interface; `AudioTrackPlayer`
  routes it through `AudioTrack.setVolume(0f/1f)`. Mute keeps the
  decode + network pipeline running so unmute is sample-accurate.

Build:
- `:commons` commonMain now `implementation(project(":nestsClient"))` so
  the shared VM can see `NestsListener` / `AudioRoomPlayer` / interfaces.
  Concrete OkHttp / Quic / MediaCodec / AudioTrack actuals stay in
  `:nestsClient`'s platform source sets and are bound by an
  Android-side `AudioRoomViewModelFactory` co-located with the screen.

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
2026-04-26 02:54:43 +00:00
Claude 4338e5e6c4 docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.

quic/plans/2026-04-26-quic-stack-status.md:
  Post-mortem of the original docs/plans/2026-04-22 plan. Documents
  what shipped vs what was estimated, the actual package layout (~8.5k
  LoC, 39 test files, 5 audit rounds), the crypto delegation surface
  (Quartz only — no BouncyCastle, no JNI), interop verification status
  (aioquic + picoquic; nests not yet), and known deferred items
  (STREAM retransmit, Initial-key discard, etc.).

nestsClient/plans/2026-04-26-audio-rooms-completion.md:
  Punch list to ship audio rooms end-to-end:
    M1 Listener wire-up in Amethyst UI
    M2 Multi-speaker audience UX
    M3 Foreground service for backgrounded playback
    M4 Manual interop pass against nostrnests.com
    M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
    M6 Capture → encode → publish pipeline
    M7 NestsSpeaker API
    M8 App polish (reconnect, leave cleanup)
    M9 Foreground service for speakers
  ~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.

Inline doc cleanup:
  * Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
    from active code; replaced with "today" or pointers to the
    completion plan
  * Removed "Kwik-based stub" references; QuicWebTransportFactory and
    surrounding docs now describe :quic as the production path
  * TlsClient header reflects non-null certificateValidator + the
    JdkCertificateValidator / PermissiveCertificateValidator split
  * SendBuffer header documents the best-effort no-retransmit mode
    explicitly (was hidden behind a "Phase L will fix this" note)
  * MoqMessage / MoqObject / MoqSession reflect listener-side as
    shipped + publisher-side as Phase M5

CLAUDE.md:
  * Module list now includes :quic and :nestsClient (was 5 modules,
    now 7)
  * Architecture diagram + sharing philosophy explain what each new
    module owns

No production behaviour changes; doc + comment-only edits. Tests green.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-26 01:38:48 +00:00
Claude 2d541c6fd4 feat(quic): Phase A — module foundations
Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate
the QUIC varint codec out of :nestsClient where it was incidentally living.
Add the connection-ID, packet-number-space, and UDP socket primitives that
the rest of the QUIC client will build on.

Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md.

- New :quic module wired into settings.gradle, with commonMain + jvmAndroid
  source sets mirroring :quartz's structure.
- Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic;
  MoqBuffer/MoqCodec updated to import the new path.
- ConnectionId enforces the 0..20 byte length range and ships a randomizer
  backed by Quartz's RandomInstance.
- PacketNumberSpaceState tracks per-space outbound allocation + largest-received
  tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus
  the §17.1 minimum encode-length picker.
- UdpSocket is an expect class with a connected DatagramChannel actual on
  jvmAndroid using Dispatchers.IO (no Selector — one socket per connection).

All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN
vector match bit-for-bit.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 17:19:02 +00:00
davotoula 999184cced optimise imports 2026-04-24 18:15:58 +02:00
Claude b62e3dd0ec feat(nestsClient): MoQ ANNOUNCE family + Opus encoder + AudioRecord capture
Phase 4 (codec + audio capture slice). Adds the publisher-side MoQ
control messages and the Opus encode + microphone capture pieces a
speaker needs. The host-grants-speaker UI flow is deferred — that's
multi-screen UX that should be designed before being implemented.

commonMain (nestsclient.moq):
- 5 new MoqMessageType entries with codec round-trip + tests:
  * Announce (0x06): publisher offers a track namespace.
  * AnnounceOk (0x07): subscriber acknowledges.
  * AnnounceError (0x08): subscriber rejects with error code +
    reason phrase.
  * Unannounce (0x09): publisher withdraws a previously-announced
    namespace.
  * SubscribeDone (0x0B): publisher tells the subscriber no more
    objects are coming for this subscription, with stream count and
    reason.

commonMain (nestsclient.audio):
- New `OpusEncoder` interface — symmetric to `OpusDecoder`, one
  instance per outgoing track since Opus state is per-stream.
- New `AudioCapture` interface — `start()`, `readFrame()` returns a
  PCM frame or null when stopped, `stop()` releases the mic.

androidMain (nestsclient.audio):
- `MediaCodecOpusEncoder` — wraps `MediaCodec("audio/opus")` encoder
  variant (API 29+). 48 kHz mono in, 32 kbit/s VBR Opus out, 20 ms
  frames. Drains output queue per encode call.
- `AudioRecordCapture` — wraps `AudioRecord` from
  `MediaRecorder.AudioSource.VOICE_COMMUNICATION` so the platform's
  echo-cancellation + noise-suppression filters apply when available.
  Reads exactly one PCM frame per readFrame() call, retries on
  underrun, throws AudioException(DeviceUnavailable) on permission/
  resource failures.

commonTest:
- `AnnounceCodecTest` — 7 cases covering each message round-trip,
  concatenated decode in sequence, and a guard against accidentally
  reordering MoqMessageType enum codes.

Permission already declared:
- `RECORD_AUDIO` is already in amethyst/AndroidManifest.xml — no
  manifest change needed.

What this does NOT include:
- A publisher loop class (analogous to AudioRoomPlayer for publish
  direction) — can be added when the speak-button wiring lands.
- The host-grants-speaker UI — needs design input.
- STREAM_HEADER_SUBGROUP for stream-based object delivery — datagrams
  cover the listener happy-path; streams add reliability that nests
  may or may not require.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:33:55 +00:00
Claude c1355f1dd8 feat(nestsClient): NestsListener facade + connect orchestrator
Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) +
WebTransport (3b-1 stub) + MoQ session (3c) under a single
`connectNestsListener()` entry point with an observable state
machine, so audio-room callers see one resource and one StateFlow
instead of three layered handshakes. Pure commonMain — fully tested
against fakes; no network or Android needed.

commonMain (nestsclient):
- `NestsListener` interface — `state: StateFlow<NestsListenerState>`,
  `subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each
  speaker's track as `["nests", <roomId>]` with the speaker's pubkey
  hex as track name; subscribeSpeaker fills that in.
- `NestsListenerState` sealed class:
  * Idle
  * Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake)
  * Connected(roomInfo, negotiatedMoqVersion)
  * Failed(reason, cause?)
  * Closed
- `DefaultNestsListener` — straight delegation to MoqSession once
  connected.
- `connectNestsListener(httpClient, transport, scope, serviceBase,
  roomId, signer, supportedMoqVersions)` — walks the three handshake
  steps. Each failure short-circuits to Failed with a clear reason
  string and the underlying cause attached; transport is torn down on
  partial-handshake failure.
- `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays
  free of a URL-library dependency. Handles default-port stripping,
  rejects userinfo, preserves query strings.

commonTest:
- `NestsConnectTest` (5 cases, all using fakes):
  * Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP,
    asserts state lands on Connected with the right roomInfo +
    negotiated version, and verifies the transport saw the parsed
    authority/path/bearer.
  * resolveRoom failure short-circuits without touching transport.
  * Transport handshake failure short-circuits with the right kind in
    the reason.
  * Malformed endpoint URL short-circuits.
  * parseEndpoint covers default-port stripping, explicit port,
    pathless URL, query string preservation.

This completes the pure-Kotlin integration. The only seam left
between `connectNestsListener()` and audible audio is the Kwik-backed
WebTransportFactory implementation (Phase 3b-2). When that lands, an
Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to:

  state = MutableStateFlow(NestsListenerState.Idle)
  listener = connectNestsListener(...)
  for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(),
      AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects)

…and audio plays.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:21:38 +00:00
Claude 6e6fc4cabb feat(nestsClient): listener audio pipeline (Opus decode + AudioTrack)
Phase 3d-1 of the Clubhouse/nests integration. Adds the listener-side
audio pipeline that turns a SubscribeHandle's Flow<MoqObject> into
audible PCM through Android's MediaCodec + AudioTrack. Encoder /
AudioRecord (speaker publishing) lands in Phase 4.

commonMain (nestsclient.audio):
- `AudioFormat` constants — 48 kHz mono signed-16-bit, 20 ms frames
  (960 samples), matching the nests Opus profile.
- `OpusDecoder` interface — stateful per-track decoder.
- `AudioPlayer` interface — start/enqueue/stop, suspend on backpressure.
- `AudioRoomPlayer` — wires a Flow<MoqObject> through OpusDecoder into
  AudioPlayer. Decoder errors are surfaced via an `onError` callback
  but don't tear down the loop (one bad packet shouldn't kill the
  room); player errors are fatal. play()/stop() are single-shot per
  instance, with stop() idempotent and double-release-safe.
- `AudioException` with three canonical kinds (DecoderError,
  DeviceUnavailable, PlaybackFailed).

androidMain:
- `MediaCodecOpusDecoder` — wraps `MediaCodec("audio/opus")` with the
  RFC 7845 §5.1 Opus identification header in csd-0 and zeroed
  pre-skip / seek-pre-roll in csd-1 / csd-2. Drains the output queue
  per-packet, handles INFO_OUTPUT_FORMAT_CHANGED gracefully.
- `AudioTrackPlayer` — wraps `AudioTrack` in MODE_STREAM with USAGE_
  VOICE_COMMUNICATION / CONTENT_TYPE_SPEECH so the OS treats audio-
  room playback like a phone call (call-volume rocker, ducks
  notifications). Buffer = 4× minimum so the producer can fall behind
  ~80 ms (roughly the WebTransport datagram jitter on mobile).

Tests (commonTest, fake-based):
- `AudioRoomPlayerTest` — 7 cases covering happy-path decode +
  enqueue, decoder failure with continued loop, stop idempotency,
  double-play rejection, play-after-stop rejection, empty-PCM
  skip-enqueue, and live channel-backed flow.

The real MediaCodec / AudioTrack code is thin glue against Android
APIs and is validated manually on device — no Robolectric here, the
seams are the Fake* doubles.

Next: Phase 3d-2 wires NestsClient end-to-end (HTTP auth → MoqSession
listener subscribe → AudioRoomPlayer) plus the Amethyst-side
AudioRoomViewModel. After that the only remaining pure-MoQ work is
the Kwik handshake (Phase 3b-2).

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:12:25 +00:00
Claude b091b50fe6 feat(nestsClient): MoQ session pump + subscribe API
Phase 3c-3 of the Clubhouse/nests integration. Promotes MoqSession
from a one-shot SETUP runner to a real concurrent session: the
control-stream and datagram pumps run on a caller-supplied scope
after the handshake, and a public subscribe()/unsubscribe() API
delivers per-track OBJECT_DATAGRAMs as a Flow<MoqObject>.

commonMain (nestsclient.moq):
- MoqSession now takes a `pumpScope: CoroutineScope` so callers can
  bind the pumps to their lifecycle (test backgroundScope, viewmodel
  scope, etc.).
- After setup() succeeds, two background coroutines start:
  * Control-stream pump — buffers chunks across multiple writes,
    decodes complete frames, dispatches SUBSCRIBE_OK / SUBSCRIBE_ERROR
    to the matching CompletableDeferred, drops malformed frames
    without killing the session.
  * Datagram pump — decodes OBJECT_DATAGRAMs and routes each to the
    matching per-track sink keyed by track_alias.
- `subscribe(namespace, trackName, priority, filter)` sends SUBSCRIBE,
  awaits SUBSCRIBE_OK (throws MoqProtocolException on SUBSCRIBE_ERROR
  with the publisher's reason), and returns a SubscribeHandle whose
  `objects` flow emits inbound OBJECT_DATAGRAMs.
- `unsubscribe(subscribeId)` is idempotent; second call is a no-op.
  close() cancels pumps + tears down all in-flight subscribes cleanly.
- Per-track sink uses a bounded Channel with DROP_OLDEST overflow
  rather than MutableSharedFlow, so objects buffered between
  subscribe() returning and the consumer attaching are preserved
  (SharedFlow with replay=0 silently drops pre-subscription emissions).

transport (FakeWebTransport):
- Switched from `consumeAsFlow()` to `receiveAsFlow()` so a `.first()`
  during setup followed by a long-running pump `.collect{}` works on
  the same channel without the first call closing it.

Tests:
- Updated existing setup tests for the new `pumpScope` parameter.
- New `subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order`
  end-to-end: client subscribes, raw server peer (no MoqSession to
  avoid pump-vs-test contention) replies SUBSCRIBE_OK + 3 datagrams,
  client's flow yields all three in order with correct payloads.
- `subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error`.
- `datagrams_for_unknown_track_alias_are_dropped_silently` ensures
  the pump doesn't crash on orphan datagrams and the session stays
  closeable.

This completes the pure-Kotlin MoQ work. Remaining 3.x phases all
touch real audio (MediaCodec Opus, AudioTrack, AudioRecord) or the
real WebTransport handshake (Phase 3b-2 with Kwik).

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 06:58:52 +00:00
Claude d65ab7b616 feat(nestsClient): MoQ SUBSCRIBE family + OBJECT_DATAGRAM codecs
Phase 3c-2 of the Clubhouse/nests integration. Extends the MoQ
control-plane codec with the subscribe lifecycle messages (SUBSCRIBE,
SUBSCRIBE_OK, SUBSCRIBE_ERROR, UNSUBSCRIBE) and adds the
OBJECT_DATAGRAM wire format the listener uses to receive low-latency
Opus audio. Pure codec layer — no session-pump or network changes;
the subscribe/object delivery Flow arrives in Phase 3c-3.

commonMain (nestsclient.moq):
- New message types in `MoqMessageType`: Subscribe (0x03),
  SubscribeOk (0x04), SubscribeError (0x05), Unsubscribe (0x0A).
- New data classes in MoqMessage.kt:
  * `TrackNamespace` — tuple of byte strings, with `of(vararg String)`
    helper for the common UTF-8 case.
  * `SubscribeFilter` enum. Phase 3c-2 supports LatestGroup /
    LatestObject; AbsoluteStart/AbsoluteRange throw at construction
    (they need extra wire fields we'll add when nests needs them).
  * `Subscribe`, `SubscribeOk`, `SubscribeError`, `Unsubscribe` data
    classes. `SubscribeOk` validates contentExists + largest-id
    coupling at construction.
- New codecs in `MoqCodec` (namespace tuple + all four messages).
  Unknown filter codes on the wire are rejected.
- `MoqObject` data class + `MoqObjectDatagram` encode/decode for the
  OBJECT_DATAGRAM wire format (track_alias, group_id, object_id,
  publisher_priority, status, payload).

Tests:
- `SubscribeCodecTest` — round-trips for each message with multi-
  segment namespaces + parameters, SUBSCRIBE_OK with and without
  contentExists, concatenated decode in sequence, unknown-filter
  rejection, construction-time validation.
- `MoqObjectDatagramTest` — Opus-sized payload round-trip, zero-
  length payload with OBJECT_DOES_NOT_EXIST status, 8-byte-varint
  boundary coverage, truncated datagram rejection, out-of-range
  priority rejection.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:26:53 +00:00
Claude 37e24ce4f3 feat(nestsClient): MoQ varint + SETUP handshake codec
Phase 3c-1 of the Clubhouse/nests integration. Lands a MoQ-transport
control-plane codec (draft-ietf-moq-transport) and a session wrapper
that runs the SETUP handshake over any WebTransportSession. Purely
commonMain + fully tested end-to-end against FakeWebTransport — no
network or Kwik dependency required.

commonMain (nestsclient.moq):
- `Varint` — QUIC variable-length integer codec per RFC 9000 §16.
  Encode/decode/size, with typed truncation handling (decode returns
  null so callers can buffer more and retry).
- `MoqWriter` / `MoqReader` — append-only byte writer + bounds-checked
  reader used by the message codec.
- `MoqCodecException` — typed error for malformed frames.
- `MoqMessage` sealed class + `MoqMessageType` enum + `SetupParameter`.
  Phase 3c-1 covers just `ClientSetup` / `ServerSetup` (message types
  0x40 / 0x41).
- `MoqCodec.encode/decode` — wraps payload with `type (varint) +
  length (varint)`. Rejects unknown types and trailing bytes inside a
  declared payload window.
- `MoqSession.client/server` — attaches to a WebTransportSession and
  runs the CLIENT_SETUP / SERVER_SETUP handshake with version
  negotiation + configurable timeout.
- `MoqVersion` constants for draft-11 and draft-17.

Tests:
- `VarintTest` — all four RFC 9000 §A.1 sample vectors (1/2/4/8 byte),
  boundary round-trips, negative/overflow rejection, short-buffer
  returns null, bytesConsumed accuracy.
- `MoqCodecTest` — CLIENT_SETUP / SERVER_SETUP round-trip (empty and
  multi-param), multi-version negotiation, exact wire layout for
  ServerSetup(1L), truncated-frame returns null, concatenated frames
  decode with offset, unknown type rejected, trailing bytes rejected,
  zero-length parameter values allowed.
- `MoqSessionTest` — full SETUP exchange over FakeWebTransport (both
  sides happy path, client falling back to older version, server
  rejecting when no version overlap + client timing out cleanly).

SUBSCRIBE / ANNOUNCE / OBJECT_STREAM / OBJECT_DATAGRAM land in
Phase 3c-2 on top of the same MoqSession.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:21:15 +00:00
Claude 4ead4ccd5c feat(nestsClient): WebTransport abstraction + fake + Kwik stub
Phase 3b-1 of the Clubhouse/nests integration. Lands the
[WebTransportSession] abstraction the MoQ layer (Phase 3c) will code
against, so MoQ framing + tests can develop in parallel with the real
Kwik-based transport integration (deferred to Phase 3b-2).

commonMain:
- `WebTransportSession` — bidi/uni stream access, datagrams, close.
- `WebTransportBidiStream` / `WebTransportReadStream` /
  `WebTransportWriteStream` — minimal read/write surface.
- `WebTransportFactory.connect(authority, path, bearerToken)` for
  opening sessions.
- `WebTransportException(kind, ...)` with four canonical failure modes
  (HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI
  code doesn't need to know about library-specific exceptions.
- `FakeWebTransport.pair()` — in-memory, fully-wired client/server
  pair for unit-testing MoQ framing without touching a real QUIC stack.

jvmAndroid:
- `KwikWebTransportFactory` stub that throws
  `WebTransportException(NotImplemented)` at `connect()`. Doc spells
  out the handshake sequence (QUIC dial → H3 SETTINGS →
  `:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so
  Phase 3b-2 can drop the real implementation in without touching
  callers.

Tests:
- `FakeWebTransportTest` — datagram round-trip both directions,
  bidi-stream write visible on peer side, close() flips isOpen.
- `WebTransportExceptionTest` — kind + message + cause preserved.
- `KwikWebTransportFactoryTest` — `connect()` currently fails with the
  NotImplemented sentinel, so Phase 3b-2 can replace this test when
  the real handshake lands.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:10:28 +00:00
Claude 933b522273 feat(nestsClient): NIP-98 auth + nests room-info client
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module
`nestsClient` (Android + JVM targets) that owns the HTTP control plane
for talking to a nests audio-room backend. No transport/audio yet —
that arrives in 3b with WebTransport + MoQ.

Surface:
- `NestsAuth.header(signer, url, method)` signs a kind 27235 event via
  Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use
  `Authorization: Nostr <base64>` header value.
- `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown
  fields so newer nests server revisions don't break older clients).
- `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls
  `<serviceBase>/<roomId>` with the signed NIP-98 header and returns
  the MoQ endpoint + token the audio layer will need.
- `OkHttpNestsClient` (jvmAndroid) is the default implementation
  shared between Android and desktop JVM.

Tests:
- `NestsRoomInfoTest` (commonTest) covers full/minimal payloads,
  unknown-field tolerance, missing-endpoint rejection, and URL
  construction edge cases.
- `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the
  base64 back through Quartz's JacksonMapper, and asserts the
  signature verifies and the url+method tags bind correctly.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 02:58:24 +00:00