Skip to content
4 changes: 4 additions & 0 deletions quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,18 @@ removed).
- [Rust FETCH parity](/quest/m1/ietf-fetch-parity.md) - Rust finishes an answered joining FETCH's head, refuses a lost head with DOES_NOT_EXIST, and sends no timestamps without TIMESCALE, as the JS publisher does
- [SUBSCRIBE honors the properties opt-out](/quest/m1/subscribe-timestamps.md) - the Rust publisher sends untimed objects to a SUBSCRIBE that declines Track Properties, and the Rust subscriber asks for them when it needs timestamps
- [JS FETCH timeout test](/quest/m1/js-fetch-timeout-test.md) - a JS joining FETCH still waiting on its SUBSCRIBE at the deadline is refused with TIMEOUT
- [JS joins at the current group](/quest/m1/ietf-js-join.md) - a late JS moq-transport subscriber gets the group in progress whole through a joining FETCH or fill, as Rust does
- [IETF fill timeout](/quest/m1/ietf-fill-timeout.md) - Rust honors native FILL_TIMEOUT to bound upstream waiting on drafts 18 and 22, alongside local content-age enforcement
- [Catalog estimates settle](/quest/m1/catalog-estimates.md) - hang quantizes jitter and delay rises, and MSF publishes once its estimates are made and then only for track changes
- [IETF takeover](/quest/m1/ietf-takeover.md) - carry the shared takeover contract through the negotiated IETF epoch extension
- [Identity and recovery guide](/quest/m1/identity-guide.md) - explain epochs, pinned catalogs, lazy jobs, restart and version compatibility as they exist; takeover adds its own section
- [Native rung disable](/quest/m1/simulcast-rung-disable.md) - OBS stops sending the top renditions its grant cannot fund, as the Rust/FFI publishers do, and a shaped uplink through moq-ffi proves the resume
- [Audio jitter target](/quest/m1/audio-jitter-target/README.md) - the playout target is default in both languages; a manual browser proof and a native trace replay remain
- [A/V clock](/quest/m1/av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles
- [Container reads drop backfill](/quest/m1/read-backfill.md) - the JS container consumer drops groups below its read cursor like Rust
- [Buffered playback subscribes in ascending order](/quest/m1/buffered-ascending.md) - a buffered `<moq-watch>` asks for the oldest group first, so a voice reply's opening arrives first, and audio's head hold shrinks to the floor
- [Push-to-talk loses no speech](/quest/m1/push-to-talk/README.md) - consumers hold a disabled rendition, and an unmute encodes at once with the mic still open
- [Always-on encoding](/quest/m1/eager-encode/README.md) - publishers encode while enabled whatever the demand, so the past exists to fetch; on-demand is opt-in, and audio returns to it through a raw backbuffer
- [TS fidelity and recovery](/quest/m1/ts-reliability/README.md) - both modes have explicit profiles, automated validation, loss tests, and a dedicated guide
- [WebGPU on Safari](/quest/m1/webgpu-safari.md) - the WebGPU renderer is verified on Safari 26 for macOS and iOS
- [CI housekeeping](/quest/m1/ci-housekeeping.md) - remove the actionlint queue ignore once a release after 1.7.12 reaches the dev shell
Expand Down
59 changes: 59 additions & 0 deletions quest/m1/catalog-estimates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# [M] Catalog estimates settle before they churn

## Goal

An unhinted moq-mux publisher's catalogs stop changing for small estimate
creep. Hang republishes `jitter` and `delay` only when they cross a coarse
step. The MSF catalog publishes once its estimates are made and then changes
only when the track list or a track's identity does. MSF-01 §5.3 fixes a
declared track's attributes.

## Plan

From [#7067](https://github.com/moq-dev/moq/issues/7067): an SDI encoder
through moq-c published 68 catalog versions in 32 s, and 16 even with every
hint from [#7068](https://github.com/moq-dev/moq/pull/7068). Of the ten that
weren't structural, eight were `delay` rising in ~8 ms steps.
[#6595](https://github.com/moq-dev/moq/pull/6595) already measures from
timestamps. `delay` still follows the cross-track write order, which tracks
runtime encode latency. It only rises, at most once per `ESTIMATE_WINDOW`
(`rs/moq-mux/src/catalog/tracks.rs:655-700`).

Decided 2026-10-28 (maintainer):

- **`jitter` and `delay` stay in hang.** @moq/watch, moq-audio and the TS
export use them as latency floors, and a receiver can't measure cross-track
skew itself. Hang keeps updating them in place; only a decoder identity
change publishes a new track
([#7110](https://github.com/moq-dev/moq/pull/7110)).
- **Hang quantizes the rise.** A new `jitter` or `delay` republishes only when
it crosses the next multiple of the track's frame duration. Measurement is
unchanged.
- **MSF deduplicates.** `emit` (`rs/moq-msf` producer, `catalog/producer.rs:221-243`)
skips a group whose serialized catalog matches the previous one.
- **MSF waits for estimates, then freezes them.** MSF publishes once every
track with an unhinted estimate has closed its first group and carried
`RATE_WINDOW` (1 s, `catalog/estimate.rs:7`) of media, whichever is later.
The minimum duration matters because bitrate is measured over that window:
20 ms audio groups would otherwise freeze it absent. Its estimated fields
then stay as first declared, and MSF republishes only for track-list or
identity changes. A track whose every estimated field is hinted publishes
at once. moq-c can hint bitrate and framerate but not jitter or delay
([#7068](https://github.com/moq-dev/moq/pull/7068)), so its tracks wait.
- **No opt-out.** Quantizing and freezing remove the churn without new config
surface on fields players rely on.
- The HLS pointer goes away with
[time-addressed consumers](/quest/m1/time-addressing/consumers.md), so it is
out of scope here.

Tests: a measured `delay` creeping in 1 ms steps republishes hang once per
frame-duration step. Identical MSF content publishes once. An unhinted MSF
catalog appears once the first groups close and a second of media has
passed, carries a bitrate for 20 ms audio groups, and keeps its estimates.

Public API: none. Wire: fewer catalog groups; MSF estimate fields frozen at
first declaration.

## Closes

- [#7067](https://github.com/moq-dev/moq/issues/7067) - a catalog describes what a track is: keep channel measurements from churning it
61 changes: 61 additions & 0 deletions quest/m1/eager-encode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Always-on encoding

## Goal

First-party publishers capture and encode while a source is enabled,
whatever the subscriber demand, so the recent past exists for FETCH and a
subscription's `maxDelay` to serve. On-demand encoding stays available as an
explicit opt-in. Video stays always-on. Audio becomes on-demand again once it
can keep raw frames up to `maxAge` while nobody listens.

## Plan

Decided 2026-10-28 (maintainer), from [#7101](https://github.com/moq-dev/moq/issues/7101):

- **Default always-on, in every language.** A demand-gated encoder leaves
nothing for a late subscriber to fetch, and FETCH and `maxDelay` make the
past part of the model. Nobody relies on on-demand encoding; the value is
in the networking. On-demand becomes an opt-in on each publisher that has
it today.
- **Rejected for video:** keeping a backbuffer of raw frames, too costly.
Audio does keep one, in [raw audio on demand](/quest/m1/eager-encode/raw-audio.md),
where it is cheap.
- **Stay on-demand by default:** moq-transcode rungs (a server-side ladder per
broadcast, and a past FETCH is already served by transcoding from the
source), the moq-boy demo, and the CARLA demo. Each has its own gating,
which is unchanged; their docs present on-demand as their choice.
- Bandwidth reservations stay keyed on demand: nothing is sent without a
subscriber.
- An unwatched rendition encodes at its configured bitrate (decided
2026-10-28). It needs no remembered state, a past viewer's grant is stale
anyway, and the backlog is served from cache rather than paced live.
Rejected: the last granted target, and the lowest rung.

Already opt-in, with no built-in gate: moq-c (`moq_encode_*_demand`), moq-ffi,
the wrappers, OBS and moq-gst. Only their docs change.

The epic's own work, once the children land:

- Docs: `doc/lib/rs/moq-net.md:25` ("publish only while someone is
watching"), `doc/concept/use-case/contribution.md` (on-demand becomes an
option), the moq-c doc comment at `rs/moq-c/src/audio.rs:433`, and the
Swift/Python/Go/Dart wrapper docs.
- Rewrite the quests that assume demand-gated capture:
[studio replay](/quest/m3/studio/replay.md) and its compose sources ("arm
replay = local subscriber"), [iOS capture](/quest/m2/mobile-capture-ios.md),
[video hardware](/quest/m3/video-hardware.md), and
[time-addressed consumers](/quest/m1/time-addressing/consumers.md)'s
HLS-only-viewer clause. [An HLS viewer starts capture](/quest/m1/hls-record-demand.md)
narrows to the opt-in mode.

## Required

- [@moq/publish encodes while enabled](/quest/m1/eager-encode/js.md) - audio and video encoders drop the demand gate, with an on-demand opt-in
- [moq-video captures while enabled](/quest/m1/eager-encode/video.md) - the camera stays open and encodes while enabled, with an on-demand opt-in
- [moq-audio captures while enabled](/quest/m1/eager-encode/audio.md) - the microphone stays open and encodes while enabled, with an on-demand opt-in
- [moq-cli on-demand flag](/quest/m1/eager-encode/cli.md) - `moq publish` capture encodes always, unless `--on-demand`
- [Raw audio on demand](/quest/m1/eager-encode/raw-audio.md) - audio encodes on demand again, from raw frames kept up to `maxAge`

## Related

- [Push-to-talk loses no speech](/quest/m1/push-to-talk/README.md) - the late-subscriber half of an unmute this fixes
20 changes: 20 additions & 0 deletions quest/m1/eager-encode/audio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# [S] moq-audio captures and encodes while enabled

## Goal

`moq-audio`'s capture supervisor keeps the microphone open and encodes while
enabled, whatever the demand. An on-demand option restores today's behavior.

## Plan

Decided 2026-10-28 (maintainer); see the [epic](/quest/m1/eager-encode/README.md).

- The gate: the supervisor at `rs/moq-audio/src/encode/capture.rs:1035-1142`
(idle until used, closes the device when unused), the demand trait at
`:755-770`, and `Control::new` (`:455-485`) and `publish_capture` (`:675`).
- Update `lib.rs:22-25`, `aec.rs:179` and `doc/lib/rs/moq-audio.md:24`.

Tests: with no subscriber, the capture publisher writes groups. The opt-in
keeps the device closed until demand arrives.

Public API: default behavior change, plus an additive option. Wire: none.
25 changes: 25 additions & 0 deletions quest/m1/eager-encode/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# [S] moq-cli capture encodes always unless --on-demand

## Goal

`moq publish` capture of a camera, display or microphone encodes from start
to finish by default. `--on-demand` opens and encodes only while subscribed,
as today.

## Plan

Decided 2026-10-28 (maintainer); see the [epic](/quest/m1/eager-encode/README.md).
`rs/moq-cli/src/publish.rs:592` calls both `publish_capture`s, so the default
follows the libraries. The flag selects their on-demand option. Update the
comment there and `doc/bin/cli.md:250`. Name the flag under the repository's
CLI rule.

Tests: the CLI's capture args parse `--on-demand`, and the default publishes
without a subscriber.

Public API: CLI default change, plus a new flag. Wire: none.

## Required

- [moq-video captures while enabled](/quest/m1/eager-encode/video.md) - the video option this selects
- [moq-audio captures while enabled](/quest/m1/eager-encode/audio.md) - the audio option this selects
29 changes: 29 additions & 0 deletions quest/m1/eager-encode/js.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# [M] @moq/publish encodes while enabled

## Goal

`@moq/publish` audio and video encoders run whenever their rendition is
enabled, with or without a subscriber. Their groups sit in the track's cache
for a late SUBSCRIBE or FETCH to reach within its `maxDelay`. An `onDemand`
option restores today's gate.

## Plan

Decided 2026-10-28 (maintainer); see the [epic](/quest/m1/eager-encode/README.md).

- The root gate: `js/publish/src/broadcast.ts:413-462` sets `rendition.track`
only while `served.demand().wanted` is defined. Both encoders key off it
(`video/encoder.ts:341-349`, `audio/encoder.ts:401-416` and `:686-688`).
- Losing demand no longer ends an epoch (`#cut()` on demand loss). Disabling
still does.
- Video still answers a join that wants a newer group with a fresh keyframe
(`demand.wanted`).
- `onDemand` is one option on the broadcast, applying to every rendition.
`doc/lib/js/publish.md:146` describes both modes.

Tests: an enabled encoder with no subscriber writes groups. A subscriber that
arrives later with a covering `maxDelay` receives the earlier groups.
`onDemand` keeps today's behavior.

Public API: default behavior change, plus an additive `onDemand` option.
Wire: none.
37 changes: 37 additions & 0 deletions quest/m1/eager-encode/raw-audio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# [M] Audio encodes on demand from a raw backbuffer

## Goal

With no subscriber, the audio publishers in JS and Rust keep captured raw
audio up to the track's `maxAge` instead of encoding it. When demand arrives,
they encode that backlog and continue live, so a late subscriber can still
fetch the recent past within its `maxDelay`. This becomes audio's default.
Video stays always-on.

## Plan

Decided 2026-10-28 (maintainer): audio is cheap to keep raw (48 kHz stereo
f32 is ~384 KB per second retained), unlike video. So audio gets back the CPU
on-demand saved, without losing the past.

- The capture keeps running, so the microphone stays open as under always-on.
- The backlog encodes as live would have: the same frame size, the same
timestamps, one group per frame as today. Encoded groups land in the cache
in order, ahead of the live edge.
- A track with no `maxAge` keeps a fixed cap, chosen and justified in the PR.
- Leaving demand stops encoding and resumes buffering. The epoch continues, so
the past stays addressable.
- JS (`js/publish/src/audio/encoder.ts`) and Rust (`rs/moq-audio` capture)
both switch. The always-on default from their sibling quests becomes the
opt-out.

Tests: no subscriber for a few seconds, then a subscriber with `maxDelay`
covering the last second receives that second, timestamps matching capture
time. Memory stays bounded by `maxAge` or the cap.

Public API: default behavior change for audio. Wire: none.

## Required

- [@moq/publish encodes while enabled](/quest/m1/eager-encode/js.md) - the JS always-on path this switches from
- [moq-audio captures while enabled](/quest/m1/eager-encode/audio.md) - the Rust always-on path this switches from
24 changes: 24 additions & 0 deletions quest/m1/eager-encode/video.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# [M] moq-video captures and encodes while enabled

## Goal

`moq-video`'s capture publisher keeps the camera open and encodes while
enabled, whatever the demand. An on-demand option on the capture config
restores today's close-between-viewers behavior.

## Plan

Decided 2026-10-28 (maintainer); see the [epic](/quest/m1/eager-encode/README.md).

- The gate: `rs/moq-video/src/encode/producer.rs:3-13` (the module doc's
"strictly on demand"), `:525` (`publish_capture`) and `:660-700`
(`until_wanted`/`wait_capture`).
- The demand-driven PipeWire reopen (`capture/pipewire.rs:14,157,304,866`)
serves only the opt-in.
- Update the notes at `capture/mod.rs:116`, `lib.rs:29` and `encode/mod.rs:19`,
and `doc/lib/rs/moq-video.md:30`.

Tests: with no subscriber, the capture publisher writes groups. The opt-in
keeps the camera closed until demand arrives.

Public API: default behavior change, plus an additive option. Wire: none.
63 changes: 63 additions & 0 deletions quest/m1/ietf-js-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# [M] The JS moq-transport subscriber joins at the current group

## Goal

A late JS subscriber behind a spec-strict relay (such as moqx) receives the
group in progress whole, as the Rust subscriber does. A catalog or snapshot
written once arrives, and the first media group plays from its head. It works
on draft 18 and on draft 22.

## Plan

Decided 2026-10-28 (maintainer), from [#7088](https://github.com/moq-dev/moq/issues/7088):

- **Mirror Rust exactly.** On draft 18, SUBSCRIBE with Largest Object. After
a SUBSCRIBE_OK with Largest `{G, k}`, send a Relative Joining FETCH
(offset 0) as its own request, outside PUBLISH_DONE's Stream Count, and
stitch the head and the live tail into one group producer. On draft 22,
`relative(1)` carries the matching fill. Today it sends none, so the same
gap exists there.
- **No join knob.** Every subscription joins at the current group. Rejected:
`join: "current" | "next"` and @moq/watch joining media at the next group.
[Fill refill](/quest/m2/ietf-fill-refill.md) already rejected the next group
("costs a GOP of startup"). Lite and Rust have no such anchor, and how far
behind live to play is the player's choice through `maxDelay`. A "next"
anchor, if ever wanted, belongs in
[JS ranges](/quest/m1/subscribe-ranges/js.md) shared with Rust and lite.
- **No standalone-FETCH fallback.** A relay that refuses the joining FETCH
for a lone subscriber (moqx: "Session has no active subscribe", reported as
[openmoq/moqx#883](https://github.com/openmoq/moqx/issues/883)) has a bug of
its own. A refusal costs only that group's head, as in Rust
([#6407](https://github.com/moq-dev/moq/pull/6407)).
- **No FETCH_OK deadline**, matching Rust: the joining FETCH lives as long as
the subscription. FILL_TIMEOUT is a separate publisher-side bound.
- **Rust's strictness on duplicates.** A whole group from object 0 that
arrives before the head wins and the FETCH is discarded. One that arrives
after the head is refused.
- **Implementation:** take over [#7089](https://github.com/moq-dev/moq/pull/7089)
(michaelstuurman, maintainer edits allowed). Trim it on its branch: drop
`Track.Join`, `Subscription.join` and the @moq/watch `MEDIA_JOIN` change,
drop the standalone fallback and the 10 s per-request deadline, and add the
draft-22 fill. The author keeps credit, and the review history stays with
the PR.

Update the notes this makes stale: [IETF fill timeout](/quest/m1/ietf-fill-timeout.md)'s
"JS sends no IETF FETCH", and the interop README's claim that a JS subscriber
requests neither a joining FETCH nor a fill.

Tests: the unit tests in #7089 minus the knob, plus a draft-22 fill. The
interop late-join lane passes with a JS subscriber against a strict publisher
(a lane that refuses to replay the group). `just ci` runs green, since #7089
has not yet run CI.

Public API: none. Wire: the JS subscriber sends Largest Object and a joining
FETCH on draft 18, and a fill on draft 22.

## Closes

- [#7088](https://github.com/moq-dev/moq/issues/7088) - a late draft-18 JS subscriber never receives a catalog written once

## Related

- [JS publisher answers a late join](/quest/m2/ietf-js-joining-fetch.md) - the publisher half after a quick finish
- [Headless moq-transport groups](/quest/m2/ietf-headless-group.md) - what a subscriber does with a tail whose head never comes
Loading
Loading