The Linux build path of the vlc-setup plugin pulls vlc-plugins-linux from
Maven Central (ir.mahozad:vlc-plugins-linux), which only ships 3.0.20 and
3.0.20-2 — there is no 3.0.21 artifact yet. With vlcVersion set to 3.0.21,
both the CI pre-fetch step and the in-Gradle vlcDownload task hit a 404.
Match VLC_VERSION in build.yml and document the lag so future bumps wait
for the Maven artifact to catch up.
https://claude.ai/code/session_01GrZLMi3sdp6frwREmQ9cUi
NestViewModel.connect() launches an infinite cliff-detector loop in
viewModelScope (`while(true) { delay(...) }`). The existing tests in
NestViewModelTest call connect() but never disconnect/onCleared, so
that loop stays alive. Under runTest's virtual scheduler each delay
returns instantly, the loop spins millions of iterations per second of
real time, and runTest never reaches the idle state — every test
wedges until the per-test deadline (60 s × 17 tests => :commons:jvmTest
hangs for ~17 minutes).
Wrap each test body with runVmTest, which runs the body inside a
try/finally that calls disconnect() on every VM created by
newViewModel before runTest tries to drain. teardown() cancels
cliffDetectorJob, the scheduler becomes idle, and the test returns.
A 10 s runTest timeout is the safety net — healthy tests now finish
in milliseconds, and tripping the timeout signals a new viewModelScope
coroutine that needs its own teardown call.
Verified: :commons:jvmTest --rerun-tasks now completes in 52 s
(409 tests, 0 failures); NestViewModelTest's 17 tests run in 0.083 s
total versus the previous indefinite hang.
https://claude.ai/code/session_01R9wUxnRJrr299W8TzRyFs7
The full-screen Nest stage's green speaking indicator had two issues:
1. **Cropping at the top.** The glow halo (drawBehind, up to MAX_GLOW_RADIUS
past the avatar) and detached outer ring rendered onto the avatar's own
draw layer, which only had the avatar's bounds (75dp circle). The
surrounding stage Surface (RoundedCornerShape(20dp)) clipped the topmost
pixels of the first row's glow. Wrap the avatar+badges in an outer Box
that reserves [ringPadding] (max of MAX_GLOW_RADIUS / OUTER_RING_GAP +
OUTER_RING_MAX_WIDTH) and move the drawBehind there, drawing relative to
the inner avatar circle. Badge corner-alignment stays on the inner Box
so role/hand/mic badges still anchor to the avatar.
2. **Lit up on mic-on, not on actual speech.** `onSpeakerActivity` was
called once per MoQ object received — i.e. every ~20 ms while the mic
was open, regardless of whether the frame contained voice, silence, or
room noise. That meant the green ring was effectively a "mic is on"
indicator. Split the heartbeat (kept in `onSpeakerActivity`, which
still drives the cliff detector and clears the connecting overlay)
from the speaking detector (now in `onAudioLevel`, gated on the
decoded peak amplitude clearing SPEAKING_LEVEL_THRESHOLD = 0.06,
~-24 dBFS). The existing 250 ms expiry job gives natural hysteresis
between syllables.
Production trace showed a single failed recycle (relay accepted SUBSCRIBE
but never opened uni-streams) leaving the user with ~22s of dead air —
the 30s cooldown blocked any retry while audio never recovered, then
they'd give up. The cooldown couldn't tell "recovered then re-stalled"
(safe to wait) from "recycle did nothing" (need to retry sooner).
Replace with a per-attempt backoff: 0 → 5s → 12s → 24s → 30s cap. Reset
to attempt 0 on any real frame after the recycle, so a recover-then-
restall always re-fires immediately. Cumulative wall-clock to 4 recycles
goes from "4 in 30s" (the moq-rs wedge case) to "4 in 41s", protecting
the relay while letting the typical single-failure case retry inside 5s.
Also stop overwriting `lastFrameAt[pubkey]` on recycle: keeping the real
last-frame timestamp is what lets the predicate distinguish the two
cases via `lastFrameAt > lastRecycleAt`.
Hardening on the leave path:
- mark `closed` @Volatile so the cliff-detector finally block reliably
observes the value set by `leave()` / `onCleared()` (visible in the
trace as `cliff-detector EXITED ... closed=false` after a Leave press).
- replace `runCatching { recycleSession() }` with explicit
`catch (CancellationException)` re-throw so a Leave during recycle
exits promptly rather than running one extra loop iteration.
https://claude.ai/code/session_015euGg6AERTRGj7HYysKnLV
The previous priority-then-round-robin shape applied the rotating
start-index globally over the sorted list, so the cross-tier order
flipped on alternate drains: drain N had high-priority first, drain
N+1 advanced streamRoundRobinStart and had low-priority first. The
priority hint was silently defeated under any sustained traffic.
Replace it with strict priority across tiers + round-robin only
within each same-priority tier. Higher tiers always drain ahead of
lower ones; same-priority peers continue to take turns via the
existing rotating start. Default-priority callers see no behaviour
change (single tier, identical rotation semantics).
Tighten the test: drain twice and assert the higher-priority stream
emits first on BOTH drains — the regression case that the single-
drain version of the test missed. Add a regression guard for the
same-priority round-robin so a future refactor can't silently
serialise on the first stream.
https://claude.ai/code/session_01KWdr4RjVvyYZfEuPVaQfUa
Bias the QUIC connection writer's drain loop toward higher-priority
streams so moq-lite group streams with newer (higher) sequence numbers
drain ahead of older ones under congestion. Implements the T11.3
follow-up flagged in nestsClient/plans/2026-05-06-stream-priority-
followup.md (now removed).
QuicStream gets a `@Volatile var priority: Int = 0`. The writer's
streamsView iteration is replaced by a stable sortedByDescending pass
so same-priority streams keep their existing rotating-start round
robin while higher-priority tiers always drain first.
WebTransportWriteStream gains a `setPriority(Int)` hook; the QUIC-
backed adapter forwards to the underlying QuicStream, while the
in-memory test fakes treat it as a no-op. MoqLiteSession.openGroupStream
calls `uni.setPriority(sequence)` (saturating to Int.MAX_VALUE) to
mirror moq-rs's `Publisher::serve_group`.
Tests: a new InMemoryQuicPipe.decryptClientApplicationFrames helper
walks past coalesced long-header packets to surface 1-RTT frames,
which lets QuicConnectionWriterTest assert that the higher-priority
stream's StreamFrame lands first inside a single drained packet.
https://claude.ai/code/session_01KWdr4RjVvyYZfEuPVaQfUa
Audit-9: NestViewModel.kt is 2112 lines and growing, ~1000 of which
are subscription-lifecycle state machine concerns intertwined with
the room-level public API. Pulling out the full
`NestSubscriptionManager` is a multi-week refactor with subtle
coupling (catalog readiness affects spinner state, mute has effective
+ per-speaker flavours, expiry jobs need the parent scope) and
warrants its own focused review pass.
For now, take the small tractable subset:
- Extract `ActiveSubscription` from a `private inner class` in
NestViewModel to its own file as `internal class
ActiveSubscription` in the same package. The class is purely
state-holding (handle, roomPlayer, player, isPlaying); zero VM
coupling beyond the slot map's value type. Same visibility for
NestViewModel callers; one less private helper class buried
1500 lines into NestViewModel.kt.
- File `commons/plans/2026-05-06-nest-subscription-manager-extraction.md`
documents the deferred full extraction: target shape, state
that moves, methods that move, what stays in VM, why deferred,
when to land. Picks up the next person who opens NestViewModel.kt
rather than leaving them to re-derive the rationale.
Audit-14: T11.3 (stream priority for moq-lite group uni streams) was
deferred from the T11 commit (drop bestEffort=true) because the
:quic-side change touches the writer's hot path and warrants its own
review pass. New file `nestsClient/plans/2026-05-06-stream-priority-followup.md`
spells out:
- Why: bestEffort=true was incidentally biasing drain order toward
newer groups; without it, the writer's round-robin order can
serve a stale group when a fresh one is more useful.
- Target shape: `QuicStream.priority` field, sortedByDescending
in the writer's send-frame loop, `WebTransportWriteStream.setPriority`
pass-through, `MoqLiteSession.openGroupStream` calls
setPriority(sequence). With code sketches.
- Test: pin iteration order via the writer's emitted-frames tape.
- Risk profile: starvation, perf cost of per-pass sort, compat.
- When to land: after interop verification stabilises.
No code changes in this commit beyond the ActiveSubscription move.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
`MoqLiteSession.kt` was 1526 lines mixing the session state machine
(announce pump, subscribe pump, group-stream demux, publisher state)
with the public types its API hands back to consumers. The session-
internal `PublisherStateImpl` inner class is tightly coupled to the
session's outer scope (state lock, transport, scope.launch, openGroupStream)
and is left in place — extracting it is a separate refactor with its
own design choices.
Extract the standalone caller-facing types:
- `MoqLiteFrame.kt` — the `MoqLiteFrame` data class with its
custom `equals` / `hashCode` for ByteArray content equality.
- `MoqLiteHandles.kt` — `MoqLiteSubscribeHandle`,
`MoqLiteAnnouncesHandle`, and `MoqLiteSubscribeException`.
All three are "what the caller gets back from a subscribe /
announce" + "how protocol-level rejections surface."
- `MoqLitePublisherHandle.kt` — the public publisher interface
that the session's internal `PublisherStateImpl` implements.
`internal constructor` on the handles keeps them un-instantiable
outside the package.
Same package, same visibility, no call-site changes needed.
`MoqLiteSession.kt` shrinks from 1526 to 1372 lines, focused on
session lifecycle + the inner `PublisherStateImpl`. Tests +
spotless green.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
`MoqLiteNestsSpeaker.kt` mixed three top-level concerns at 387 lines:
- The `MoqLiteNestsSpeaker` class itself — the speaker entry point
that builds a publisher + broadcaster pair on
`startBroadcasting`.
- `MoqLiteBroadcastHandle` — internal `BroadcastHandle`
implementation tying the broadcaster, audio publisher, and
catalog publisher together with a fixed-order shutdown.
- `HotSwappablePublisherSource` — internal interface that lets
`ReconnectingNestsSpeaker.runHotSwapIteration` retarget a
long-lived broadcaster onto fresh moq-lite session publishers
without restarting the AudioRecord / Opus encoder pipeline.
The handle and the interface are independently reachable from
`ReconnectingNestsSpeaker` and have no behavioural coupling to
`MoqLiteNestsSpeaker` beyond a `parent` reference (handle) or an
`as?` cast (interface). Move each to its own file:
- `MoqLiteBroadcastHandle.kt` (109 lines).
- `HotSwappablePublisherSource.kt` (62 lines).
`MoqLiteNestsSpeaker.kt` is now 276 lines focused on the speaker
class. Same package, same `internal` visibility — no call-site
changes needed elsewhere. Tests + spotless green.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
The original `MoqLiteMessages.kt` mixed three categories of declaration:
- One `MoqLiteAlpn` object — the WebTransport sub-protocol
advertisement strings.
- Four enums — `MoqLiteControlType`, `MoqLiteDataType`,
`MoqLiteAnnounceStatus`, `MoqLiteSubscribeResponseType` — that
are wire-format discriminators the codec reads at message
boundaries to choose which body to decode.
- Eight data classes / objects — `MoqLiteAnnouncePlease`,
`MoqLiteAnnounce`, `MoqLiteSubscribe`, `MoqLiteSubscribeOk`,
`MoqLiteSubscribeDrop`, `MoqLiteSubscribeDropCode`,
`MoqLiteGroupHeader`, `MoqLiteProbe` — the body shapes themselves.
317 lines, three concerns, one file. Split by responsibility:
- `MoqLiteAlpn.kt` — the ALPN object alone.
- `MoqLiteControlCodes.kt` — the four discriminator enums.
- `MoqLiteMessages.kt` — body data classes only.
Same package, same visibility, identical wire behaviour. Imports
across the codebase resolve to the new locations automatically
since everything stayed in `com.vitorpamplona.nestsclient.moq.lite`.
Tests pass unchanged.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
Audit-12 cleanup. The audio-pipeline sources mixed two import styles:
some files used `com.vitorpamplona.quartz.utils.Log.d/w/e` fully-
qualified (NestPlayer's 13 sites, AudioTrackPlayer's 8, two each in
the encoder/decoder, three in NestMoqLiteBroadcaster) while
AudioRecordCapture had already migrated to a top-level
`import com.vitorpamplona.quartz.utils.Log`. The lambda overload
(`Log.d(tag) { lazyMessage }`) is what the find-non-lambda-logs
skill enforces and is the only style the rest of the codebase uses;
the fully-qualified form is verbose noise on every call site.
Add the `import com.vitorpamplona.quartz.utils.Log` line to each
file and rewrite call sites to bare `Log.d` / `Log.w` / `Log.e`. No
behaviour change; logs still go through PlatformLog with the same
tag and lazy-message contract. Five files updated, ~30 call sites.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
G4: Audit-9 (catalog-driven decoder reconfig) plumbed numberOfChannels
end-to-end but left sampleRate hardcoded at AudioFormat.SAMPLE_RATE_HZ
in MediaCodecOpusDecoder.buildOpusIdHeader / buildFormat and in
AudioTrackPlayer's MinBufferSize / 250 ms target / setSampleRate
calls. For Opus this is benign in practice — Codec2 always emits
48 kHz PCM regardless of OpusHead inputSampleRate — but hardcoding
the constant means a future codec or container variant whose
decoder DOES respect input sample rate would mis-clock playback.
And the OpusHead identification header should match what the
catalog declares either way.
Thread sampleRate alongside channelCount through every layer:
- MediaCodecOpusDecoder constructor takes
`sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ`. buildFormat
and buildOpusIdHeader both take it as a parameter.
- AudioTrackPlayer constructor takes the same. Used in
AudioTrack.getMinBufferSize, the 250 ms-equivalent target
bytes calculation (`(sampleRate / 4) * BYTES_PER_SAMPLE *
channelCount`), AndroidAudioFormat.Builder.setSampleRate, and
the diagnostic log.
- decoderFactory and playerFactory in NestViewModel become
`(channelCount: Int, sampleRate: Int) -> ...`.
- awaitDecoderChannelCount → awaitAudioPipelineConfig, returning
a private `AudioPipelineConfig(channelCount, sampleRate)`
struct so openSubscription handles both fields uniformly.
`sampleRate` falls back to AudioFormat.SAMPLE_RATE_HZ on
timeout / non-positive declaration with a warning log.
- NestViewModelFactory + NestViewModelTest updated to the
two-arg factory shape.
G5: documented the SUBSCRIBE_BUFFER safety budget on
CATALOG_AWAIT_TIMEOUT_MS's kdoc. With the current 500 ms timeout +
SUBSCRIBE_BUFFER = 64-frame DROP_OLDEST flow + 50 fps Opus, at
most 25 frames buffer during the wait — leaving ≥ 39 frames of
margin (≈ 780 ms) before the oldest frame would be evicted. Even
at the production framesPerGroup = 50 (1 group/sec) cadence this
never trips during normal startup. No code change; just pinning
the rationale so a future timeout bump is checked against the
buffer size.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
Spec for an end-to-end interop suite that verifies Amethyst is
intelligible to and can hear the canonical NostrNests stack without
Docker. Two P0 reference stacks:
- Rust path via the kixelated/moq `hang` crate (catches wire-shape
regressions cheaply)
- Browser path via @moq/watch + @moq/publish in headless Chromium
driven by Playwright (catches Chromium QUIC, WebCodecs, and
AudioWorklet quirks the Rust path can't see)
Architecture: native moq-relay subprocess (cargo build), in-process
ES256 JWT minter (replaces moq-auth Node sidecar), self-signed P-256
TLS cert, native cargo binaries for hang-listen / hang-publish /
udp-loss-shim, bun static server hosting the browser harness.
Test matrix I1-I15 covers every audit-branch wire fix (T1-T14) plus
the framesPerGroup=50 + WebCodecs warmup interactions only the
browser stack exercises. Phases 1-2 + 4 are the 3-day P0 deliverable;
Phases 3 + 5 are the P1 hot-swap + transport-robustness follow-up.
Self-contained: another agent should be able to execute Phases 1-2 +
4 end-to-end from this spec alone.
https://claude.ai/code/session_01FZPQiniPmb88pwY9i78eqA
Replace the nick-fields/retry wrapper around the Gradle invocation with a
curl-based pre-fetch step. The vlc-setup plugin writes its downloads to
${gradleUserHomeDir}/vlcSetup/ with overwrite(false), so dropping the
archives there ahead of time turns vlcDownload / upxDownload into no-ops.
curl --retry-all-errors --retry-max-time 900 tolerates a sustained
get.videolan.org outage far better than the plugin's de.undercouch
Download (retries(4) + 5min readTimeout, which still hit
SocketTimeoutException on Windows).
The actions/cache step at ~/.gradle/vlcSetup still captures the result
for subsequent runs; the prefetch only does real work on cache miss.
The in-build retries(4) in desktopApp/build.gradle.kts stays as a third
line of defense.
URLs and on-disk paths are read straight from the plugin source
(ir.mahozad.vlc-setup 0.1.0); macOS skips UPX because UPX cannot compress
.dylib files.
Two audit-pass gaps:
G1 — `MoqLiteNestsListener.Companion.stripLegacyTimestampPrefix`
is the entire receive-side wire-format converter for audio frames
(every web speaker's Opus packet flows through it) and had zero
unit tests. A regression here is invisible to users — silent decode
failure of every web speaker — and to interop tests, because both
sides agree on the same bug. New StripLegacyTimestampPrefixTest pins:
- all four QUIC varint-length tiers (1 / 2 / 4 / 8 bytes), one
test per tier with a representative timestamp value plus a
tag-byte assertion so the test fails loudly if Varint.encode's
tier-selection changes.
- empty-payload and malformed-payload fast paths (returns
payload unchanged, same instance — no allocation).
- round-trip against `NestMoqLiteBroadcaster`'s exact wire
shape (`varint(timestamp_us) + raw_opus_packet`) across five
timestamp values spanning all four tiers.
G6 — `RoomSpeakerCatalog.primaryAudio()` previously returned
`audio.renditions.values.firstOrNull()` with no container filter.
A future publisher that emits CMAF before legacy in iteration
order would surface the CMAF rendition, and our decoder pipeline
would then feed CMAF MOOF/MDAT bytes to its legacy
`varint(ts)+opus` parser and decode garbage. Filter to
`container.kind == Container.LEGACY_KIND` so:
- mixed CMAF+legacy publishers always surface the legacy entry
regardless of map iteration order
- CMAF-only publishers correctly return null (caller falls
back to "unknown codec / no audio")
- publishers that omit `container` entirely also return null —
treating "no container declared" as "legacy by default" would
silently mis-decode a CMAF-only publisher that just forgot
to spell out `container.kind`
LEGACY_KIND const lives on `Container.Companion` so call sites
share one canonical spelling. Three new test cases pin the new
filter behaviour (mixed iteration order, CMAF-only, missing
container). The pre-existing `toleratesUnknownKeys` test is
updated to declare a legacy container — the unknown-key
tolerance we're checking is about unrecognised siblings, not
container-presence.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
The Windows leg of the Test/Build workflow has been intermittently failing
with SocketTimeoutException inside :desktopApp:vlcDownload, even after the
in-build retry budget (retries(4) + 5min readTimeout in
desktopApp/build.gradle.kts) and the actions/cache of ~/.gradle/vlcSetup.
When get.videolan.org is unreachable for long enough to exhaust the inner
retries, the whole job dies on a cache miss.
Wrap the Test+Build step in nick-fields/retry@v4 (max_attempts: 2,
timeout_minutes: 45) so one outer retry can ride out a sustained
videolan.org outage. This mirrors the proven pattern already used in
create-release.yml for the release artifact build.
Three audit follow-ups, all in the audio publish hot path:
Audit-3: MediaCodecOpusEncoder.encode could busy-loop on a buggy
encoder that emits BUFFER_FLAG_CODEC_CONFIG on every dequeue. The
existing format-change path has a one-shot `formatChangeAbsorbed`
guard for exactly this reason; the new CSD-skip path didn't. Cap
consecutive CSD skips per encode call at MAX_CSD_SKIPS_PER_CALL=4
(generous: Codec2 emits 1 OpusHead or 2 OpusHead+OpusTags in
practice). On overshoot, log a warning and return ByteArray(0) —
the broadcaster's `if (opus.isEmpty()) continue` already handles
that contract.
Audit-6: NestMoqLiteBroadcaster's per-frame send path allocated
twice per Opus packet — once for the timestamp Varint and once
for the concatenated `varint+opus` payload. At 50 fps × N
speakers that's measurable young-gen pressure. Switch to the
same single-allocation pattern PublisherStateImpl.send already
uses: compute Varint.size(timestampUs), allocate the final
payload buffer once, write the varint directly into it via
Varint.writeTo, then copy the opus bytes. Drops audio-thread
allocations from 3/frame to 1/frame (the third was the wrap in
PublisherStateImpl.send, already optimised).
Audit-7: MoqLiteNestsSpeaker.startBroadcasting and
ReconnectingNestsSpeaker.runHotSwapIteration both encode the same
fixed catalog JSON on every call — once per broadcast start and
once per JWT-refresh hot-swap iteration. Cache the encoded bytes
on MoqLiteHangCatalog.Companion.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES
so the kotlinx.serialization run happens at class-init time and
both call sites read a static reference. Trivial perf, but
co-locates the "default Amethyst publish shape" in one place.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
Audit-1: the publisher-boundary rebuild path released the old decoder
BEFORE asking the factory for a replacement (`runCatching {
decoder.release() }; decoder = factory()`). If `factory()` threw —
MediaCodec contention, audio policy denial mid-session, etc. — the
released decoder stayed assigned to the field and every subsequent
`decoder.decode(payload)` would throw `IllegalStateException` with
no recovery path. The room would silently fail for that subscription
until torn down.
Build the replacement first; only release the old one and swap on
success. On factory failure, log and keep the existing decoder
running. Cross-publisher Opus predictor state is wrong for one
group but at least audio keeps playing.
Audit-4+5: companion test cleanup —
- Drop the `byteArrayOf(0x0A) + it` / `0x0B` dead expressions in
the FakeOpusDecoder closures of the existing boundary-rebuild
test. They allocated a ByteArray and concatenated, then threw
the result away.
- Drop the local `IntBox` helper and use
`java.util.concurrent.atomic.AtomicInteger` like the rest of
the codebase does (`MoqLiteSession`, `MoqLiteNestsListener`).
Same semantics, no new vocabulary.
New test pins the dangling-field fix:
`publisher_boundary_keeps_old_decoder_when_factory_throws` flows
two MoqObjects with different trackAliases through a NestPlayer
whose factory always throws; asserts both frames decode through
the original decoder and the original decoder is released exactly
once on `stop()`.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
`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
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
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
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
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
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
Re-investigation of `kixelated/moq` commit 45db108 ("moq-lite/moq-relay:
hop-based clustering") revealed that Lite-04 IS wire-incompatible with
our Lite-03 codec on Announce / AnnounceInterest / Probe — contradicting
the earlier reasoning that motivated 4b73626. Specifically:
- Announce.hops: Lite-03 writes a single varint count of hops; Lite-04
writes count + count × varint Origin ids. (`rs/moq-lite/src/lite/
announce.rs:67-73` branches on Version explicitly.)
- AnnounceInterest: Lite-04 added an `exclude_hop` varint after the
prefix.
- Probe: Lite-04 added an `rtt` varint after `bitrate`.
Subscribe / SubscribeOk / SubscribeDrop / Group / GroupHeader framing is
unchanged across Lite-03↔Lite-04 — but the Announce/Probe drift alone is
enough to desync a Lite-04-preferring relay if it picks `moq-lite-04`
from our advertised list. We'd encode Announce hops as a bare varint
where the peer expects `len + len × u62`, and the connection aborts on
the first Announce exchange.
Pin `wt-available-protocols` back to `["moq-lite-03"]` until
`MoqLiteCodec` gains version-aware Announce / AnnounceInterest / Probe
codecs and `MoqLiteAnnounce.hops` becomes a list rather than a single
varint. Reverting 4b73626 — the LITE_04 constant stays in MoqLiteAlpn
for documentation, but is no longer plumbed into the factory's
sub-protocol list.
Reverts the wire-format expansion in 4b73626. The factory and ALPN
kdocs are rewritten to spell out the codec diff so the next person who
considers re-enabling Lite-04 reads the actual blocker first.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
Web speakers (kixelated/hang reference) emit stereo Opus when the
user's AudioContext picks a stereo input — and our catalog now lets
us know that ahead of decoder construction. Previously the decoder
+ AudioTrack were both hardcoded to mono via AudioFormat.CHANNELS, so
a stereo web publisher's frames either threw on the Opus TOC byte
mismatch, decoded with downmix artifacts, or output left-channel-only
depending on the device's MediaCodec implementation.
Wire the catalog-discovered numberOfChannels through:
- MediaCodecOpusDecoder accepts `channelCount: Int = 1`. Drives
MediaFormat.createAudioFormat's channel count, the OpusHead CSD-0
channel byte, and the per-frame output buffer size (stereo frame =
2× shorts vs mono). Validates 1..2 — RFC 7845 mapping family 0
covers both mono and stereo without an explicit channel mapping
table; multichannel needs family 1 which we don't support.
- AudioTrackPlayer accepts `channelCount: Int = 1`. Drives the
AudioTrack channel mask (CHANNEL_OUT_MONO vs CHANNEL_OUT_STEREO)
and the 250 ms wall-clock buffer-size target (stereo doubles bytes
per sample).
- NestViewModel.openSubscription now starts the catalog fetch
BEFORE waiting for it, then awaits `_speakerCatalogs[pubkey]` for
CATALOG_AWAIT_TIMEOUT_MS (500 ms) before constructing the decoder
+ player. With the speaker-side emit-on-subscribe hook in place
(3417e44), the catalog frame is on the wire within one round-trip
of the SUBSCRIBE_OK, so the wait typically resolves in tens of ms.
Falls back to mono if the catalog never arrives — covers legacy
publishers that don't emit a catalog and the failure-mode where
the relay never forwards the catalog group.
- decoderFactory + playerFactory signatures change from
`() -> X` to `(channelCount: Int) -> X`. Two call sites updated:
NestViewModelFactory (production) and NestViewModelTest's
newViewModel helper.
Speaker side stays mono-only — our encoder is fixed at
AudioFormat.CHANNELS=1 and the catalog we publish declares
numberOfChannels=1. This change is exclusively about handling
incoming audio from publishers that don't share that constraint.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
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
Per NIP-05, when the local part is '_', the address should display as
just the domain. The profile header was calling Nip05Id.toValue() which
always returns 'name@domain', producing '_@houseofeff.com' on screen.
Match the pattern already used in NIP05VerificationDisplay,
AwardBadgeScreen, RelayManagementScreen, and NewCommunityScreen.
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
nos.lol (strfry) sometimes returns one extra event past the requested
limit, pushing the second EOSE past the hard-coded slot 111 and failing
the run. Replace the fixed 112-message assertion with EOSE-driven
termination plus structural checks that allow the relay to return up to
limit + 1 events for each sub.
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
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
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
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
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