Skip to content

SDK vocabulary

Every Relay SDK spells the same concepts with the same words, in that language’s casing. This document is the record of those words: what each public name means, what it must not be read to mean, and why it was chosen over the alternatives. behavior.md says what must be true; the language READMEs show the API; this file fixes the nouns and verbs both use.

Rename a concept here first, then in every SDK in the same change. A name that exists in one SDK without a row here is a bug.

Concept TypeScript (@caprid/relay) Python (caprid_relay)
Open a connection that can only subscribe connectRelaySubscriber(options) returns RelaySubscriber connect_relay_subscriber(url, ...) returns RelaySubscriber
Open a connection that can only publish one namespace connectRelayPublisher(options) returns RelayPublisher connect_relay_publisher(url, namespace=...) returns RelayPublisher
Ask the relay for a track’s objects subscriber.subscribe(options, handlers) returns RelaySubscription subscriber.subscribe(namespace, track, handlers, parameters=...) returns RelaySubscription
Await the relay’s answer to a subscription subscription.subscribed resolves SubscribeOk await subscription.subscribed returns SubscribeOk
Learn that a subscription ended, and how subscription.ended resolves SubscriptionEnd await subscription.ended returns SubscriptionEnd
The type of subscribed, ended, announced and a connection’s closed Promise<T> RelayAwaitable[T]: awaitable by any number of isolated waiters, with done(), result(), exception() and add_done_callback(); not a cancellable Future
Events on one subscription RelaySubscriptionHandlers RelaySubscriptionHandlers
The relay accepted the subscription onSubscribed(info) on_subscribed(info)
The relay rejected it, or the request itself failed before acceptance onError(error) on_error(error)
An object arrived onObject(object) on_object(object)
A subgroup stream ended cleanly or was reset onSubgroupEnd(info), onSubgroupReset(info) on_subgroup_end(info), on_subgroup_reset(info)
The publisher said it is finished with the track onPublisherDone(info) on_publisher_done(info)
Protocol notifications on the request stream onUpdateOk(), onStateNotify(parameters), onGoaway(goaway) on_update_ok(), on_state_notify(parameters), on_goaway(goaway)
Subscribe through a handle that can move to another track make-before-break subscriber.subscribeSwitchable(options, SwitchableHandlers) returns SwitchableSubscription subscriber.subscribe_switchable(namespace, track, SwitchableHandlers, parameters=...) returns SwitchableSubscription
Move it, and learn of the cutover switchable.switchTo(track, SwitchOptions) (parameters, until, drainMs, signal) resolves SwitchedInfo (from, to, subscribed); onSwitched(info); switchable.track, switchable.pending switchable.switch_to(track, parameters=..., until=..., drain=..., cancellation=...) returns RelayAwaitable[SwitchedInfo] (from_track, to_track, subscribed); on_switched(info); switchable.track, switchable.pending
Learn which namespaces under a prefix are announced subscriber.namespaceEvents({ prefix }) (NamespaceEventOptions) returns NamespaceEvents, yielding NamespaceEvent subscriber.namespace_events(prefix) returns NamespaceEvents, yielding NamespaceEvent
A namespace was announced, or its announce withdrawn { type: 'announced' | 'unannounced', namespace } NamespaceEvent(type='announced' | 'unannounced', namespace=...)
Register a track this publisher will serve publisher.registerTrack(options) (RegisterTrackOptions, each track property a PublishProperty) returns RelayTrackWriter publisher.register_track(track, ...) returns RelayTrackWriter
Await the namespace announce reaching the wire publisher.announced resolves void await publisher.announced returns None
Send one object as its own group writer.write(bytes, options) await writer.write(payload, properties=...)
Send one UTF-8 text object writer.writeText(text, options) await writer.write_text(text, properties=...)
Send one JSON object writer.writeJSON(value, options) await writer.write_json(value, properties=...)
Read an object as UTF-8 text or JSON readText(object), readJSON(object) read_text(obj), read_json(obj)
SDK failure with a stable code and operation phase RelayError, RelayErrorCode, RelayErrorPhase RelayError, RelayErrorCode, RelayErrorPhase
Send several objects in one group writer.openGroup() returns RelayGroupWriter; openGroup({ payload, properties }) opens it with object 0 await writer.open_group() returns RelayGroupWriter; open_group(payload, properties=...) opens it with object 0
Does the relay currently want this track writer.hasDemand writer.has_demand
Follow every change in relay demand writer.demandChanges() writer.demand_changes()
The track ended and cannot be written again writer.closed writer.closed
Borrowed protocol-level track access for integrations subscriber.trackSource (a MoqTrackSource) subscriber.track_source (a MoqTrackSource)
The connection ended, and why connection.closed resolves { error? } await connection.closed returns Closed(error=...)
Cancel a connection’s whole lifetime from outside AbortSignal (platform), given as signal CancellationToken, given as cancellation=; Cancelled is the reason it raises when cancel() was given none
Options every connection takes: endpoint, token provider, lifetime cancellation, connection deadline, transport ConnectRelayOptions (url, getToken, signal, connectTimeoutMs, createTransport) the shared keyword arguments of both entry points (url, get_token, cancellation, connect_timeout, create_transport)
A token with the time the relay stops honoring it, which a token provider may return RelayCredential (token, expiresAt: a Date) RelayCredential (token, expires_at: an aware datetime)
When the relay will end a connection for its credential’s expiry connection.expiresAt (Date | undefined) connection.expires_at (datetime | None)
A subscriber’s max age on its SUBSCRIBE (the MoQ Timestamp extension’s MAX_AGE, 0x915C6, in milliseconds), and whether the relay declared it so the SDK sends it parameters.maxAge (MessageParameters); subscriber.maxAgeSupported (boolean) parameters.max_age (MessageParameters); subscriber.max_age_supported (bool)
Options only a subscriber connection takes ConnectRelaySubscriberOptions: the shared options plus requestTimeoutMs connect_relay_subscriber’s shared keyword arguments plus request_timeout
Options only a publisher connection takes ConnectRelayPublisherOptions: the shared options plus namespace; no request deadline connect_relay_publisher’s shared keyword arguments plus namespace; no request deadline
Connect from Node over native QUIC, and the trust options that takes @caprid/relay/node: the same exports, its connect functions taking RelayNodeOptions<T>: RelayNodeTrustOptions (ca, serverCertificateHashes, each hash a RelayCertificateHash) or createTransport, not both not applicable: the Python SDK has its own QUIC stack
Supply the transport createTransport(url, protocols) returns { transport, ready, framing? } (RelayTransportFactory); browser WebTransport by default create_transport(url, protocols) returns ConnectingTransport(transport, ready, framing="webtransport") (RelayTransportFactory); caprid_relay.quic.connect_quic, the aioquic native QUIC factory, by default
A transport factory that speaks native QUIC: MOQT on QUIC, ALPN moqt-22 then moqt-20, PATH and AUTHORITY in SETUP nativeQuicTransport(options) from @caprid/relay-quic caprid_relay.quic.connect_quic(url, protocols, configuration=...), the default create_transport
How many unidirectional streams the relay may keep open to a native QUIC connection at once (the MAX_STREAMS credit granted) nativeQuicTransport({ anticipatedConcurrentIncomingUnidirectionalStreams }) from @caprid/relay-quic: 1000 by default, an integer from 1 to 65535 not applicable: aioquic raises the limit itself as streams are used (128, doubled whenever more than half is used)
Write a qlog trace per native QUIC connection nativeQuicTransport({ qlogDir }) from @caprid/relay-quic, or RELAY_QUIC_QLOG_DIR not yet (TypeScript only)
A transport factory that speaks QMux over WebSocket: subprotocols qmux-01.moqt-22 then qmux-01.moqt-20, path and token in the upgrade URL, framing: 'websocket' createWebSocketTransport() from @caprid/relay-websocket not applicable: the Python SDK ships no WebSocket adapter (#321)
WebTransport first, racing WebSocket after a head start, and its options withWebSocketFallback(options) from @caprid/relay-websocket: WebSocketFallbackOptions (webSocketDelayMs, default 250; onFallback) not applicable
Consume one subscription as an async iterator subscriber.subscribeEvents(options, buffer?) (buffer: SubscriptionEventOptions) returns SubscriptionEvents subscriber.subscribe_events(namespace, track, ...) returns SubscriptionEvents
One event from the events iterator SubscriptionEvent, discriminated by type SubscriptionEvent, the union of SubscribedEvent, ObjectEvent, PublisherDoneEvent, SubgroupEndEvent, SubgroupResetEvent, StateNotifyEvent, GoawayEvent and UpdateOkEvent, each also carrying type
Protocol types borrowed from the core Goaway, KeyValuePair, LocationFilter, MessageParameters, MoqTrackSource, MoqtObject, MoqtTransport, PublishDone, RequestError, SubscribeOk, Subscription, SubscriptionHandlers, re-exported as types the same names re-exported from moq_core, plus Closed; every other core name is imported from moq_core
Open a publisher that reconnects, one generation per connection connectSupervisedPublisher(options) (ConnectSupervisedPublisherOptions) returns SupervisedPublisher connect_supervised_publisher(url, namespace=...) returns SupervisedPublisher
Open a subscriber that reconnects and keeps tracks subscribed connectSupervisedSubscriber(options) (ConnectSupervisedSubscriberOptions) returns SupervisedSubscriber connect_supervised_subscriber(url, ...) returns SupervisedSubscriber
One supervised publisher connection and the namespace it serves publisher.generation is a PublisherGeneration (generation, namespace); onGeneration(info) publisher.generation is a PublisherGeneration (generation, namespace); on_generation(info)
Where a supervisor stands SupervisedStatus: 'connecting' | 'live' | 'reconnecting' | 'closed', reported to onStatus(status, error) SupervisedStatus, the same strings, to on_status(status, error)
Keep a track subscribed until told otherwise subscriber.want(key, WantOptions, WantHandlers), subscriber.unwant(key) subscriber.want(key, namespace, track, WantHandlers, ...), subscriber.unwant(key)
Follow a supervised publisher’s newest generation generations: true on WantOptions generations=True
Resubscribe a track that went silent staleAfterMs stale_after (seconds)
Replace a want’s subscription at the live edge because it delivers too late subscriber.catchUp(key); onRestarted reason 'catch-up' not yet (TypeScript only)
A want replaced its subscription; drop state that assumed continuity onRestarted({ reason, namespace }), RestartReason; onSubscribed(info, { namespace }) on_restarted(reason, namespace), RestartReason; on_subscribed(info, namespace)
A want moved make-before-break (a newer generation, or another track or parameters) until and drainMs on WantOptions; onSwitched(info) with WantSwitchedInfo (from, to, each a WantTarget of namespace and track; subscribed) until= and drain= (seconds) on want; on_switched(info) with WantSwitchedInfo (from_namespace, from_track, to_namespace, to_track, subscribed)
Replace a supervisor’s connection make-before-break before its credential expires rotateBeforeMs (default 60 000); publisher.rotate(), subscriber.rotate() rotate_before (seconds, default 60); publisher.rotate(), subscriber.rotate()
Backoff bounds between attempts SupervisionBackoff (initialDelayMs, maxDelayMs), given as reconnect or retry SupervisionBackoff (initial_delay, max_delay, seconds), as reconnect= or retry=
Build a room-rooted endpoint: the endpoint with a room’s fields appended to its path roomEndpoint(room) or roomEndpoint(endpoint, room) returns a URL string room_endpoint(room) or room_endpoint(endpoint, room) returns a URL str
The hosted Relay endpoint pinned in spec/protocol.json, which roomEndpoint roots without an endpoint DEFAULT_ENDPOINT DEFAULT_ENDPOINT

Casing is the only difference between languages: camelCase in TypeScript, snake_case in Python. Boolean state is a property (hasDemand, closed); observing change over time is a method that returns an async iterator (demandChanges()); everything that sends a request or bytes is a verb.

Connection roles: connectRelaySubscriber, connectRelayPublisher

Section titled “Connection roles: connectRelaySubscriber, connectRelayPublisher”

A Relay connection has one role. A subscriber connection can subscribe, fetch, and query track status; it cannot publish. A publisher connection announces one namespace and serves tracks in it; it cannot subscribe. Each uses its own transport and its own token, and the application owns both connections’ shutdown.

connectRelay was the subscriber entry point. Next to connectRelayPublisher it read as the general or bidirectional way in, and its result type RelayConnection said nothing about what the connection could do. The names now carry the role: connectRelaySubscriber returns a RelaySubscriber, connectRelayPublisher returns a RelayPublisher, and a reader who sees either knows which token it needs and which operations it has.

Not adopted: keeping connectRelay as the short, default entry point (subscribing is not more fundamental than publishing, and the asymmetry is what caused the confusion); RelayConnection as a shared base type (the two roles share a lifetime implementation, not an API).

The connection options follow the same split (issue #72). ConnectRelayOptions is what both roles take: endpoint, token provider, lifetime signal, connection deadline, and transport. Each role’s type extends it with what only that role uses. ConnectRelaySubscriberOptions adds requestTimeoutMs, the deadline for a subscription’s initial response. ConnectRelayPublisherOptions adds namespace and no request deadline, because a publisher sends no request that deadline would bound; each track’s writeTimeoutMs bounds waiting for demand and writing. A requestTimeoutMs given to a publisher is a compile-time error and is ignored at runtime, as is any other key the call does not take.

Not adopted: renaming the shared type RelayConnectionOptions (that is the shared-base name the role decision above set aside, and the two roles share a lifetime, not an API); keeping requestTimeoutMs on the shared type (a publisher would advertise, and validate, a deadline that changes nothing); rejecting a stray requestTimeoutMs on a publisher at runtime (the SDK checks the options a call uses, not the absence of others).

Track registration is not sending: registerTrack, write

Section titled “Track registration is not sending: registerTrack, write”

Publishing follows the relay’s announce-and-serve flow. registerTrack adds a track to the set this publisher connection will serve and returns its writer. Nothing is sent: the relay learns about the track only when a subscriber asks for it, and the first bytes leave when the application calls write() or opens a group, which first waits for that relay demand and then sends.

The method used to be publish(). const events = publisher.publish({ track: 'events' }) reads as an act of publishing, so a reader expects bytes to move and is surprised that the real sending verb is events.write(). The original review comment on this repository made exactly that point. registerTrack is the verb behavior.md already uses (“Registering a track announces it”), it is the verb the generic core uses for the same step, and it cannot be misread as transmission.

Not adopted: publishTrack (still the verb that suggests sending); createTrack (implies a resource is created on the relay, but the relay holds no track until a subscriber requests it); openTrack (nothing opens on the wire until a write, and openGroup does open a stream, so the two would suggest the same thing and mean different things).

The TypeScript options type follows the method: RegisterTrackOptions (track name, track properties, write deadline). The generic core has a type of the same name for the same step one level down, where the namespace is an explicit field; the product type omits it because the publisher connection already fixes the namespace.

Demand is the relay’s upstream subscription for one track. Demand is present when the relay has subscribed to the track on this publisher connection and is forwarding; while it is present a write proceeds at once, and while it is absent a write waits for it (up to the write deadline). Demand is what the relay asks for on its own behalf; it is not a count of viewers and not a liveness signal:

  • The relay fans one upstream subscription out to every downstream viewer. A second viewer joining produces no new true; later viewers are served from the relay’s cache of the last objects.
  • The relay decides when its upstream subscription ends. moq-relay 0.15 and later cancel it about one round trip after the last viewer leaves; relays before 0.15 held it for about 30 seconds (see relay demand). true does not prove anyone is watching, and false says only that the relay stopped forwarding.
  • A replacement subscription, such as the relay re-subscribing after its own reconnect, is a new relay subscription: demandChanges() yields true again even though hasDemand never became false. A write in flight when that happens fails, because its bytes were addressed to the old subscription; the track stays open.

hasDemand answers “would a write proceed right now”. demandChanges() yields the current state first and then one value per relay subscription change: true when the relay subscribes or replaces its subscription, false when its subscription ends. It finishes when the track ends, however it ended. This is how a state-shaped track such as a catalog re-sends its document on every relay subscription, and the repeated true is deliberate: skipping it would lose the replacement subscription that needs the document again.

The properties used to be bound and bindings(). Those are the core’s words for the alias binding between a track and the relay’s request, which is an implementation detail; at the product level “bound” invited the reading “a viewer is bound to this track”, which is exactly what the value does not mean.

Not adopted: any name containing “subscriber” or “viewer” (subscribers(), hasViewers), because the value is per relay subscription, not per viewer; demand() for the iterator (too close to the property, and a noun does not say that it yields over time).

Publisher completion is not termination: onPublisherDone

Section titled “Publisher completion is not termination: onPublisherDone”

PUBLISH_DONE is a control message from the publisher’s side saying that it has finished with the track. It is delivered on the control path, so objects still in transit on data streams can arrive after it. It does not end the subscription: the request stays open until the application cancels it or the connection ends, and the SDK does not resubscribe on the application’s behalf.

The handler used to be onDone. That reads as “this subscription is done, nothing more will come”, which is wrong in both halves: trailing objects can still come, and the subscription is not over. onPublisherDone names the party that is done. The terminal signal for the subscription itself, settling once when it can no longer deliver, is ended (issue #68, and below). It is not named “drained” or “complete” because the protocol cannot establish that property.

SUBSCRIBE_OK means the relay accepted the request and will forward objects, and its payload carries the track alias, the relay’s parameters, and the track properties as the relay sends them. The handler used to be onOk, the core’s name for the OK response on any request (subscribe, fetch, track status). At the product level it did not say what became ready. onSubscribed does. subscription.subscribed, the awaitable readiness result, settles on the same event, so the two names agree.

onError keeps its name: the relay answered the request with REQUEST_ERROR, because it rejected the subscription or the request itself failed before acceptance (request timeout, or the request stream failing). A REQUEST_ERROR can also arrive after acceptance, when a relay refuses an update(), and it ends the subscription then too (issue #94). moq-relay sends neither REQUEST_OK nor REQUEST_ERROR for an update: it ends the subscription with PUBLISH_DONE instead, so against it onUpdateOk / on_update_ok never fires and an update reaches onPublisherDone / on_publisher_done, not onError (relay interop deviation 4). Connection shutdown and GOAWAY never reach onError: a pending subscription ends through subscribed and ended as terminated by the closure, so a lost connection is never mistaken for a rejection, and after acceptance closure surfaces as subgroup resets and connection closure.

subscriber.trackSource is the same connection seen through the generic MoqTrackSource contract: subscribe, fetch, and trackStatus in protocol terms (namespace tuple, track name, message parameters, and the core’s SubscriptionHandlers with onOk and onDone), plus the closed promise. It exists so an integration such as a player can be handed track access without being handed the connection: the object has no close, and the application that opened the connection keeps shutdown. It lists nothing and discovers nothing. It is not a collection of tracks.

The accessor used to be tracks, which reads as an enumerable collection or a discovery API. trackSource names the contract the object implements.

The core’s names inside that contract are unchanged on purpose. MoqTrackSource and SubscriptionHandlers are the upstream-facing generic interface that the Video.js protocol modules and this repository share; renaming them to product vocabulary would fork the generic contract to fix a product naming problem. The product handler type RelaySubscriptionHandlers maps onto the core’s handlers one to one, and subscriber.subscribe() performs the mapping.

The old names were removed rather than kept as deprecated aliases. When these renames were made, no package had been released and nothing outside this repository imported them, and a deprecated alias would have kept the misleading name in autocomplete next to the one that replaced it. Consumers inside the repository (examples, live smoke tests, the Video.js streaming example) moved in the same change. The packages have been published since 0.1.0. Before 1.0, a minor version can break the API, and the changelogs (TypeScript, Python) say what each rename changed and what to do about it.

One error identity: RelayError, code, phase

Section titled “One error identity: RelayError, code, phase”

Both SDKs raise one RelayError whose code says what happened and whose phase says where, the identity spec/behavior.md tells callers to branch on. The three subscription errors (SubscriptionRequestError, SubscriptionOverflowError, SubscriptionConnectionClosedError) subclass it in both SDKs because they carry payload the code cannot: the relay’s RequestError, or the connection’s failure as the cause.

Not adopted: Python-only exception subclasses per code (RelayClosedError, RelayTimeoutError, RelayNamespaceError, RelayGoawayError, RelayWritePendingError, and the never-raised alias RelayTrackEndedError). They duplicated the code discriminant under names TypeScript did not have, so a caller switching languages met two identities for one failure, and except RelayError as error: if error.code == "WRITE_TIMEOUT" is one line longer than a subclass catch. Also not adopted: a RELAY_ERROR fallback code for a RelayError built without one (code is required in both SDKs), and a transport error that is itself a Relay error (the transport layer’s own exception, a browser’s WebTransportError or Python’s QuicTransportError, is wrapped as TRANSPORT_ERROR at the Relay boundary). Removed without aliases under the policy above; reversible before the first release, so no ADR.

UNAUTHORIZED names a connection whose credentials the relay refused, after the MOQT session termination code the relay closes with (0x2). Both SDKs list it, as both list TRANSPORT_UNAVAILABLE, which only TypeScript raises. Both raise UNAUTHORIZED; TypeScript in a browser only against moq-relay 0.17.0 or later, since older relays’ close never reached the page (#14). Not adopted: AUTHENTICATION_FAILED or FORBIDDEN. The relay closes with the same code for a missing, invalid, expired or out-of-scope token, so one name covers them, and it is the protocol’s. A subscription refused for its grant stays a MOQT RequestError on onError / on_error.

DELIVERY_TIMEOUT names a write whose object outlived the draft’s delivery timeout (draft-22 §5.2) before it reached the transport, after the stream reset code the publisher sends with it (0x2). The term is in CONTEXT.md. Not adopted: reusing WRITE_TIMEOUT, because the write deadline is the SDK’s own bound on one operation and calls for a different response (data that missed a write deadline may still be worth sending; data past its delivery timeout is stale and is not retried as it is), nor OBJECT_EXPIRED or STALE_OBJECT, which name no protocol term. A settling group reset by its subgroup timeout reports nothing, so the code only ever comes from the object timeout. Both SDKs raise it.

subscriber.subscribeEvents() / subscriber.subscribe_events() creates a bounded iterator of object and lifecycle events. The subscribed and publisherDone / publisher_done events use the same vocabulary as the product callbacks. It owns its subscription, never the connection. See the async consumption contract for capacity, cancellation, and termination semantics. Product and core callback objects are validated and snapshotted when subscribing; callbacks remain synchronous, and async consumers use this iterator.

Namespace discovery: namespaceEvents, announced, unannounced

Section titled “Namespace discovery: namespaceEvents, announced, unannounced”

subscriber.namespaceEvents({ prefix }) / subscriber.namespace_events(prefix) is the subscriber’s discovery entry point (behavior.md). It is named after subscribeEvents, because it is the same bounded iterator over one owned request, and after what it yields. The events reuse the publisher’s word: announced means a namespace’s announce reached the relay’s routing table, as publisher.announced means it left the publisher. unannounced is its withdrawal, by a clean close or by the relay noticing a crash.

The iterator ends with the subscription error classes (SubscriptionRequestError, SubscriptionConnectionClosedError, SubscriptionOverflowError): SUBSCRIBE_NAMESPACE is a subscription in the draft’s terms, and a second set of classes with the same meanings would only add names. The prefix is a namespace prefix, matched field by field, never a string prefix.

Not adopted: announcements() (CONTEXT.md keeps “announcement” out because it names the act and the relay’s state interchangeably); watchNamespaces() (a verb, where every other iterator is named for what it yields); subscribeNamespace() (the core’s protocol name, which trackSource already keeps on the protocol side); published / unpublished (the draft’s PUBLISH is a different message).

roomEndpoint(endpoint, room) returns the endpoint with the room’s fields appended to its path, and roomEndpoint(room) does the same for DEFAULT_ENDPOINT, the hosted Relay that most applications use. A connection to that URL names namespaces relative to the room (Subscribing, issue #348). It is a pure function: it builds a string and connects nothing. “Room” is the word the docs already use for the path a token grants, and the result is an endpoint, the same noun as url on every connect call, so the name says what it returns, as mediaTrackProperties does. room is a namespace prefix, so it follows the namespace argument rules, including the 32-field limit.

It encodes nothing. moq-relay reads each path segment as a namespace field without percent-decoding it, so a%20b is the field a%20b, not a b (endpoint paths). An encoded URL would therefore root the connection somewhere else, with no error. For that reason the helper refuses any field that a URL cannot carry unchanged, and that room is reached from the bare host instead.

Not adopted: rootedEndpoint (it names the mechanism, while the docs and the token name the room); endpointFor or withPrefix (they do not say the prefix becomes the path); a room option on connectRelay* (it would change how every connect call reads its namespace, and it would hide the URL that both the relay and the logs see); percent-encoding as #348 proposed (it is wrong on every relay measured; it can be added if the relay starts decoding, without breaking an accepted room); keeping the helper in the examples (its value is the validation, which applications need as much as the examples do); a trailing optional endpoint, roomEndpoint(room, endpoint?) (it would break every existing call, and an endpoint read before its path matches the URL it builds); an options object, roomEndpoint(room, { endpoint }) (one optional value does not need a bag).

A connect call given no url dials DEFAULT_ENDPOINT rooted at the token’s root claim, the URL roomEndpoint builds from that root (token-rooted endpoint, issue #374). It adds no name: url becomes optional. Like the rejected room option, it takes the URL out of application code. It was taken anyway because it removes the step quickstart users get wrong, copying their token’s root into roomEndpoint, and the shortest call stays the recommended room-rooted form, with relative namespaces unchanged. A token that names no usable root fails before dialing with a message that says to pass url, and the relay still verifies the token. Not adopted: defaulting url to the bare DEFAULT_ENDPOINT (the shortest call would be the less recommended endpoint, and room-relative namespaces there fail far from the cause, as in issue #119); the room option (the user would still copy the root in by hand).

Switching tracks: subscribeSwitchable, switchTo, onSwitched

Section titled “Switching tracks: subscribeSwitchable, switchTo, onSwitched”

A switchable subscription is the primitive for changing what a viewer receives (behavior.md). The entry point is a verb, subscribeSwitchable, because it sends a SUBSCRIBE like subscribe; the handle is named for what it can do. switchTo names the act the application asks for; onSwitched names the cutover once it has happened. Not adopted: switchable() (an adjective for a call that sends a request), rendition (a media word for what is, to the SDK, any track), update (REQUEST_UPDATE, which cannot change the track and which moq-relay answers by ending the subscription).

Handlers take the active track’s name as their last argument rather than a context record, so one handler set serves every track. Python spells SwitchedInfo’s fields from_track and to_track because from is a keyword, and gives the drain in seconds (drain=) as it does every duration.

Supervised connections: connectSupervised*, generation, want, onRestarted

Section titled “Supervised connections: connectSupervised*, generation, want, onRestarted”

The supervisors are opt-in policy over the primitives (behavior.md). Their entry points keep the connect verb, because each resolves once a connection is up, and add Supervised instead of an option on connectRelay*: they return different types (a generation and want, which the primitives lack), and one function with two return shapes would hide that. They live in @caprid/relay and caprid_relay themselves, not in a separate package.

Generation names one supervised publisher connection and the namespace field it serves. Each reconnect is a new publisher to the relay, and the word says so: it is not a session, an epoch, or a reconnect count.

want(key, ...) is declarative: it states which track the application wants, keyed by a name the application chooses, and the supervisor owns how that stays true. unwant undoes it. Not adopted: subscribe (the primitive’s verb, which returns one subscription that ends); follow and watch (they suggest observing without receiving data); ensureSubscribed (a verb phrase for a standing wish).

Python’s want takes the namespace and track as arguments, as subscribe does, and its handlers receive the namespace as a second argument (on_subscribed(info, namespace), on_restarted(reason, namespace)), as on_status(status, error) does, rather than through a record type of their own.

Rotation names replacing a supervisor’s connection before its credential expires, with the old one serving until the new one has taken over. It is not a reconnect (nothing was lost) and not a refresh (the relay cannot take a new token on a live session, so the connection itself is replaced). RelayCredential is the token with its expiry, not a second kind of token; expiresAt says when the relay ends the session, which is the fact an application plans around. Not adopted: refreshToken and onTokenExpiring (no token is refreshed in place), renew, cycle, tokenExpiry (the session’s end is what matters, and the grant can end it before exp).

onRestarted tells a decoder that the next object starts a new subscription. It is named for that fact rather than for the cause, which reason carries.

A want’s make-before-break move reuses the switchable’s words: until, drainMs / drain and onSwitched mean what they mean on switchTo, so the two cutovers read the same. A move is not a restart, because nothing was lost in between, so it gets onSwitched and not an onRestarted flag. A want’s record is WantSwitchedInfo rather than SwitchedInfo, because a generation move changes the namespace as well as the track. Not adopted: a want(key).switchTo(...) verb. want is declarative, and wanting the key again with another track already states the change. RECONNECTING names the publisher’s state when a write fails between generations, and keeps it apart from CLOSED, which retires a frame writer.

Subscription readiness and termination: RelaySubscription, subscribed, ended

Section titled “Subscription readiness and termination: RelaySubscription, subscribed, ended”

subscribe() returns a RelaySubscription: the core handle (requestId, update(), cancel()) plus two awaitables. subscribed settles when the relay answers the request: it resolves with the acceptance info onSubscribed receives, and rejects with the same error classes the events iterator raises (SubscriptionRequestError for REQUEST_ERROR before acceptance, including the request timeout; SubscriptionConnectionClosedError when the connection ended first) or with the reason given to cancel(). ended settles once, when the subscription can no longer deliver, and never rejects: its SubscriptionEnd value carries kind (rejected, terminated, cancelled; the type is SubscriptionEndKind), the protocol cause, whether the subscription had been accepted, and the error. That mirrors the connection’s closed, which resolves with the error as data. A connection that ends before the relay answers is a terminated ending with the connection error, never a rejected one, although the core fails the pending request first.

In Python the two are RelayAwaitable[T] rather than bare futures, as are the publisher’s announced and both connections’ closed: a task cancelled while awaiting a future cancels the future itself, and asyncio.wait_for does the same on timeout, so a shared future would let one impatient waiter destroy the signal for every later one. The awaitable isolates each waiter and reads status without allocating. Future was rejected for that reason; Signal and Promise for naming a mechanism rather than the fact the caller awaits. The type was SubscriptionAwaitable until announced and closed joined it; a name that said “subscription” would have been wrong for three of its four uses.

Publisher completion settles neither. onPublisherDone is a control notification; trailing objects can still arrive and the request stays open, so a subscribed or ended that moved on it would promise what the protocol cannot establish.

Not adopted: ready or accepted for readiness (the callback is onSubscribed, and the two must agree); closed, done, drained or complete for termination (closed collides with the connection’s word for a different scope, done with publisher completion, and the last two claim a delivery property the protocol does not give); an ended that rejects (an ending is data about the subscription, not a failure of the caller’s await, and the connection’s closed set the precedent).

connectRelayPublisher announces the namespace once the first track is registered, and the relay acknowledges an announce with nothing: the strongest fact the publisher can have is that the NAMESPACE entry was handed to the transport on a solicitation that covers the namespace. announced settles on that fact, once, with no value. The relay routes subscribes to the namespace about a round trip later, so a subscribe that races the announce is rejected (16 unroutable from moq-relay 0.15.8, 404 before) and the application retries it (see behavior.md); a process that owns both sides awaits announced before subscribing, which removes most of that race. It stays pending while no solicitation covers the namespace, where the first write’s NAMESPACE_NOT_SOLICITED is the diagnostic, and the connection’s closure terminates it if that comes first, as it does subscribed.

It is a property awaitable like closed and subscribed; in Python a RelayAwaitable[None]. Not adopted: announce() as an awaitable verb (announcing is part of connecting and registering, not a step the application takes); ready (the connection is ready long before the announce, and the namespace is never “ready” for a viewer the relay has not routed yet); solicited (the fact the publisher observes is its own announce leaving, not the relay’s request for it); resolving with the namespace (the publisher’s own, publisher.namespace in Python and the caller’s option in TypeScript).

Transport errors by shape: source, QuicTransportError

Section titled “Transport errors by shape: source, QuicTransportError”

A transport’s stream or session failure is classified by its fields, not its class (spec/transport-adapter.md TA-22): source is stream or session, a stream error carries the peer’s code as streamErrorCode / stream_error_code (null / None when unknown, never 0), and a session error that reports the peer’s close carries closeCode and reason / close_code and reason (TA-24). TypeScript reads them with isStreamError from @caprid/moq-core; Python with is_stream_error and is_session_error from moq_core.transport. A peer STOP_SENDING is a stream error raised on a send stream; where it surfaces makes it a stop.

QuicTransportError is the exception caprid_relay.quic raises with that shape, the counterpart of @caprid/relay-quic’s internal NativeQuicSessionError and the core’s MoqtStreamError in one class: the Python core hands its own reset reasons over (StreamReset, with stream_error_code), so it needs no stream error class. It is private (caprid_relay.quic._errors, not in caprid_relay.quic.__all__), as NativeQuicSessionError is: the shape is the contract, and a caller who catches the class instead would be classifying by name. It replaced WebTransportError and its StreamStopped subclass without aliases, under the policy above: the package no longer dials WebTransport (#232), so the old name was a misnomer, and the subclass made a stop a type rather than a place. Not adopted: TransportError, which reads as the TRANSPORT_ERROR code it is wrapped in, and a StreamStopped subclass kept beside the shape.

  • RelayTrackOptions, the subscribe request shape (namespace, track, parameters), and ConnectRelayOptions, the connection options shared by both roles: issue #72 kept the name for the shared shape and added the role types beside it.
  • SubscriptionEnd’s field names kind, cause, accepted, error: plain words for a value most callers only log or branch on once.
  • RelayTrackWriter, RelayGroupWriter, write, openGroup, close, abort: these say what they do.
  • Everything in @caprid/moq-core and moq_core: protocol vocabulary (onOk, onDone, registerTrack, TrackBinding) stays as the drafts and the upstream modules spell it. The product packages re-export the same short list of core names (the “Protocol types borrowed from the core” row; Python adds Closed, which TypeScript spells inline) and nothing else; python/packages/caprid-relay/tests/test_exports.py checks that list against ts/src/index.ts.

Stage 1 media format names. Python exports these from moq_media. TypeScript exports them from @caprid/moq-media. The source-URL composer and the catalog-generation timestamp keep the language-specific names of the original examples, and the table maps them.

Concept TypeScript Python
Video description VideoTrackInfo VideoTrackInfo
Audio description AudioTrackInfo AudioTrackInfo
Data description DataTrackInfo DataTrackInfo
Video or audio entry of the catalog’s media list MediaTrackInfo ({ role: 'video' | 'audio', ... }), MsfCatalogInput.media VideoTrackInfo | AudioTrackInfo, build_msf_catalog(media=...)
Render group / alternate group of a media track (msf-01 §5.2.11, §5.2.12) renderGroup, altGroup render_group, alt_group
Catalog input MsfCatalogInput build_msf_catalog keyword arguments
Build independent catalog bytes buildMsfCatalog build_msf_catalog
Build the end-of-broadcast catalog (msf-01 §11.3) buildMsfEndCatalog / MsfEndCatalogInput build_msf_end_catalog keyword arguments
Read an independent catalog object’s payload parseMsfCatalog(payload, { catalogNamespace }) parse_msf_catalog(payload, catalog_namespace=...)
Catalog as read MsfCatalog MsfCatalog
Track description as read MsfCatalogTrack MsfCatalogTrack
Sets of tracks a viewer picks one of groupMsfTracks / MsfTrackGroup group_msf_tracks / MsfTrackGroup
The track a newer catalog lists in place of one playing (a track replacement) findReplacementTrack(current, tracks) → MsfCatalogTrack | undefined find_replacement_track(current, tracks) → MsfCatalogTrack | None
Reader options ParseMsfCatalogOptions parse_msf_catalog keyword arguments
LOC track properties locTrackProperties loc_track_properties
LOC object properties from microseconds locProperties / LocPropertiesOptions loc_properties keyword arguments
An object’s LOC Timestamp in microseconds locTimestampUs(properties, { trackProperties? }) (LocTimestampOptions) loc_timestamp_us(properties, *, track_properties=())
Compose source URL composeMoqSource / ComposeMoqSourceOptions compose_player_url keyword arguments
Escape UTF-8 component encodeNameComponent encode_name_component
Encode namespace plus track name encodeNamespaceName encode_namespace_name
H.264 configuration record — (Python only) H264Config
Configuration from Annex B — (Python only) H264Config.from_annexb
Configuration bytes / codec string — (Python only) H264Config.avcc / .codec
Configuration’s access-unit conversion — (Python only) H264Config.to_avcc
Split Annex B NALs — (Python only) iter_annexb_nal_units
NAL type — (Python only) nal_unit_type
Extract SPS/PPS — (Python only) extract_parameter_sets
Build avcC — (Python only) build_avcc
Codec string from avcC header avcCodecString avc_codec_string
Convert Annex B access unit — (Python only) annexb_to_avcc

Constants use the same spelling in both languages: LOC_DOCUMENT, MSF_DOCUMENT, MSF_CATALOG_VERSION, MICROSECONDS_PER_SECOND, CATALOG_TRACK, VIDEO_TRACK, AUDIO_TRACK, LOC_TIMESTAMP, LOC_TIMESCALE, LOC_VIDEO_CONFIG, and LOC_AUDIO_CONFIG. Python also retains the example’s TRACK_TIMESCALE_PROPERTIES as a tuple; new code should call loc_track_properties() for fresh properties.

Track fields use camelCase in TypeScript and snake_case in Python (initData / init_data, channelConfig / channel_config, isLive / is_live, the read-only renderGroup / render_group). Catalog generation time is generatedAt / generated_at_ms; completeness is isComplete / is_complete.

The catalog reader mirrors the builder: parse because it reads a document the SDK did not produce, Msf because it reads msf-01’s catalog, not a Relay concept. Its results are reader-side types rather than the builder’s VideoTrackInfo / AudioTrackInfo / DataTrackInfo, because a read track may be any role and any packaging (moq-rs cmaf, legacy) and carries the namespace, packaging, and live status the builder fixes. The TypeScript parser was internal as parseIndependentCatalog (with MoqCatalog / MoqCatalogTrack from Video.js); those names are gone, with no alias. Media publishing-handle and object-to-frame reader names remain provisional.

The TypeScript-only WebCodecs helpers in @caprid/moq-media are videoTrackFromDecoderConfig, audioTrackFromDecoderConfig, chunkToLocFrame, LocFrame, and ChunkToLocFrameOptions. They consume standard decoder configs and native encoded video/audio chunks. The optional @caprid/moq-mediabunny file-input integration exports packetToLocFrame and PacketToLocFrameOptions; it retains compatibility re-exports of the config mappers and LocFrame. Python keeps its PyAV Annex B conversion helpers.

The broadcast and playout clocks of media-layer.md (issue #285), exported from @caprid/moq-media and moq_media. TypeScript uses factories that return interfaces, like the rest of that package; Python uses classes, like CatalogService and FrameWriter.

Concept TypeScript Python
The publisher’s one clock for every track createBroadcastClock(options?) → BroadcastClock (BroadcastClockOptions) BroadcastClock(now_us=None, origin_us=None, origin_epoch_us=None)
The origin on the source clock clock.originUs clock.origin_us
The origin as Unix-epoch microseconds clock.originEpochUs clock.origin_epoch_us
The timeline now: Unix-epoch microseconds clock.now() clock.now_us()
Map a time on the clock’s own source onto the timeline clock.fromSourceUs(t) clock.from_source_us(t)
Map a clock with another origin and rate clock.sourceTimeline({ toleranceUs, captureLatencyUs }) → SourceTimeline (SourceTimelineOptions) clock.source_timeline(tolerance_us=5_000, capture_latency_us=0) → SourceTimeline
A device’s input delay, subtracted from each timestamp timeline.captureLatencyUs (settable) timeline.capture_latency_us (settable)
A measured (source time, clock time) pair timeline.calibrate(s, atUs?) → re-anchored timeline.calibrate(s, at_us=None) → re-anchored
Source time to timestamp; current anchor timeline.timestampUs(s); timeline.anchorSourceUs timeline.timestamp_us(s); timeline.anchor_source_us
Anchor a sample counter clock.sampleTimeline(rate, { firstSampleUs }) → SampleTimeline (SampleTimelineOptions) clock.sample_timeline(rate, first_sample_us=None) → SampleTimeline
Sample index to timestamp timeline.timestampUs(n) timeline.timestamp_us(n)
The player’s clock createPlayoutClock(options?) → PlayoutClock (PlayoutClockOptions) PlayoutClock(now_us=None, late_drop_us=40_000, max_video_delay_us=1_000_000, audio_timeout_us=500_000, target_latency_us=None)
Audio with this timestamp is audible reportAudio(timestampUs, atUs?) report_audio(timestamp_us, at_us=None)
Audio muted or ended stopAudio() stop_audio()
When to show a video frame due(timestampUs) → PlayoutDecision (PlayoutWait / PlayoutShow / PlayoutDrop) due(timestamp_us) → PlayoutDecision(action, offset_us, wait_us)
Decision kinds action: 'wait' | 'show' | 'drop' PlayoutAction: "wait", "show", "drop"
What paces the clock mode: PlayoutMode ('idle', 'audio', 'wall') mode: PlayoutMode
Position now / forget anchors position(), reset() position_us(), reset()
The broadcast’s latency budget: the option, or twice the longest of the last four keyframe intervals targetLatencyUs (option and property) target_latency_us (option and property)
Report the interval between consecutive keyframes reportKeyframeInterval(intervalUs) report_keyframe_interval(interval_us)
The late-drop threshold, read back lateDropUs late_drop_us
How many times the clock was reset, so a frame reader sees the timeline change resets resets
The clock’s local time source now, which a frame reader measures its waits in localNow() local_now_us()
Injected time source now: MicrosecondClock now_us: Callable[[], int]

“Playout”, not “playback”: the clock decides when to present, and does not play anything. Python’s now_us() and position_us() carry the unit because there is no type to say it; TypeScript’s now() and position() follow performance.now(). The issue’s timeline: "shared" catalog field was not added. msf-01 defines no such field, so the builder uses msf-01’s own renderGroup instead.

msf-01 §7’s Media Timeline track, which maps a broadcast’s media time to wall-clock time (media-layer.md, issue #291). Exported from @caprid/moq-media and moq_media.

Concept TypeScript Python
One record: media time, location, wall-clock time MediaTimelineEntry { mediaTimeMs, group, object, wallclockMs } MediaTimelineEntry(media_time_ms, group, object, wallclock_ms)
The record for a timeline timestamp mediaTimelineEntry({ timestampUs, group, object?, wallclockUs? }) (MediaTimelineEntryInput) media_timeline_entry(timestamp_us, group, *, object=0, wallclock_us=None)
Build a timeline object’s bytes buildMediaTimeline(entries) build_media_timeline(entries)
Read a timeline object’s payload parseMediaTimeline(payload) parse_media_timeline(payload)
Announce the track in the catalog MediaTimelineTrackInfo { name?, depends? }, MsfCatalogInput.mediaTimeline MediaTimelineTrackInfo(name=, depends=), build_msf_catalog(media_timeline=)
Catalog fields as read MsfCatalogTrack.depends, .mimetype MsfCatalogTrack.depends, .mimetype
Default track name MEDIA_TIMELINE_TRACK (timeline) MEDIA_TIMELINE_TRACK
One broadcast’s retained entries createMediaTimeline({ maxEntries? }) (MediaTimelineOptions) → MediaTimeline MediaTimeline(max_entries=512)
Record a sent group’s first object; the new document timeline.add({ timestampUs, group, object?, wallclockUs? }) → bytes timeline.add(timestamp_us, group, object=0, wallclock_us=None) → bytes
Retained entries; their document timeline.entries; timeline.document timeline.entries; timeline.document

“Wallclock” is msf-01’s spelling for Unix-epoch time and appears only in the entry field that carries it. The clock’s property says Epoch, because CONTEXT.md keeps “wall clock” for the local monotonic source. depends keeps msf-01’s JSON name rather than Video.js’s dependencies, like the reader’s other fields. MediaTimeline is the glossary’s media timeline: “timeline” alone also names the clock’s source and sample timelines. Its add takes what mediaTimelineEntry takes, and it has no clear(): a supervised publisher’s next generation is a new broadcast with a new timeline (issue #292). Not added: helpers for the §7.4 template, incremental documents, and a reader for MSF_COMPRESSION payloads.

Media subscribing: FrameSequencer, FrameReader

Section titled “Media subscribing: FrameSequencer, FrameReader”

Reading a track as frames in decode order within the playout clock’s target latency (issue #303); its contract is the “Media subscribing” section of behavior.md. The sequencer is format-aware and lives in @caprid/moq-media and moq_media; the reader is transport glue and lives in @caprid/relay/media and caprid_relay.media, which do not depend on the format packages, so it takes the sequencer as an argument.

Concept TypeScript Python
One track’s ordering and deadlines createFrameSequencer({ clock, grouping?, trackProperties? }) → FrameSequencer (FrameSequencerOptions) FrameSequencer(clock, grouping="keyframe", track_properties=())
Feed it a subscription’s events object(o), groupEnded(id), groupReset(id), restart(), trackProperties object(o), group_ended(id), group_reset(id), restart(), track_properties
Take the ready frames and the next wait poll(maxFrames?) → SequencerPoll { frames, waitUs } poll(max_frames=None) → SequencerPoll(frames, wait_us)
One frame in decode order SequencedFrame { groupId, objectId, timestampUs, key, payload, properties, discontinuity?, missingGroups? } SequencedFrame(group_id, object_id, timestamp_us, key, payload, properties, discontinuity)
Groups of which nothing was delivered before this frame missingGroups: MissingGroups { firstGroupId, lastGroupId }, inclusive not yet (TypeScript only)
One track’s delivery policy lateDropUs, reorderWindowUs, catchUpAfterUs on FrameSequencerOptions not yet (TypeScript only)
Hold early frames and release each at its timestamp, off unless used timestampPlayout: true or TimestampPlayoutOptions { horizonUs?, leadUs? } on FrameSequencerOptions (frame grouping; leadUs a number or a function read at every poll) —
The sequencer asks for its subscription to be replaced SequencerPoll.catchUp; the reader’s onCatchUp() on FrameReaderOptions not yet (TypeScript only)
Why a decoder resets before this frame FrameDiscontinuity: 'gap', 'resync', 'restart' FrameDiscontinuity: "gap", "resync", "restart"
Counters stats: FrameSequencerStats { delivered, late, skippedGroups, resyncs, untimed, early } stats: FrameSequencerStats(delivered, late, skipped_groups, resyncs, untimed)
A media track’s subscriber priority, as subscription parameters mediaSubscribeParameters(kind, overrides?) → MessageParameters (MediaKind) media_subscribe_parameters(kind, overrides=None) → MessageParameters (MediaKind)
A subscription’s or a want’s handlers that read frames createFrameReader(sequencer, { onFrame }) → FrameReader (FrameReaderOptions); spread into subscribe or want handlers FrameReader(sequencer); subscription_handlers(on_error=, on_publisher_done=), want_handlers(...); async for frame in reader
What a reader drives FrameReaderSequencer<F> FrameReaderSequencer, FrameReaderPoll (protocols)
Stop reading reader.close() reader.close(), reader.closed

“Sequencer”, not “reorderer” or “jitter buffer”: it puts frames in decode order and decides what is too late, but holds nothing for smoothing, which the audio player’s jitter buffer does. “Reader” pairs with the frame writer. “Target latency” follows msf-01’s catalog field in name only: msf-01 §5.2.8 measures end-to-end wall-clock latency, while this budget is how far behind the playout position a frame may still be delivered, so the catalog field is not read. Not adopted from issue #303: targetLatencyMs (every clock option is in microseconds); the reader taking the clock and grouping itself (the product package cannot import the format package).

“Catch-up”, not “skip”, “flush” or “resync”: a resync re-anchors the clock on late frames and keeps the delay, while a catch-up replaces the subscription so the delay goes away. It is a want’s restart (CONTEXT.md) the application asks for, so it reports through onRestarted. “Reorder window”, not “jitter buffer” (the audio path’s smoothing) or “deadline” (one frame’s): it is how long newer frames wait for an earlier group. lateDropUs on the sequencer keeps the clock option’s name because it is the same limit, for one track. missingGroups names groups, not frames, because a group ID is what a relay can be asked about.

mediaSubscribeParameters says what it returns, like mediaTrackProperties: the parameters that subscribe, want and switchTo take, so it pairs with the publisher’s helper and lives beside it in @caprid/relay/media and caprid_relay.media (#337). It takes the whole parameters as overrides rather than a subscriberPriority option, so a viewer adds a location filter without spreading two objects. Not adopted: reading the publisher’s 0x0E from SUBSCRIBE_OK (the priority is chosen before SUBSCRIBE, and changing it later ends the subscription on moq-relay); a default inside subscribe (the product package knows nothing about media kinds); documentation alone (nothing in the media tier would point a viewer at the one priority the relay acts on).

Audio playout: createAudioPlayer, AudioPlayerInput

Section titled “Audio playout: createAudioPlayer, AudioPlayerInput”

A headless player for decoded audio: WebCodecs decoding, an adaptive jitter buffer in an AudioWorklet, a mix, and reports to the playout clock (issue #314); its design is in media-layer.md. TypeScript only, in @caprid/moq-media: a Python bot has no output device. It takes frames, not a subscription, so a frame reader’s onFrame feeds it and the format package still does not depend on the product package.

Concept TypeScript Python
One player per AudioContext await createAudioPlayer({ context }) → AudioPlayer (AudioPlayerOptions) —
Load what a player waits for ahead of it, both at once await warmAudioPlayer(context, { lossConcealment? }) (WarmAudioPlayerOptions) —
When a join’s steps happened input.joinPhases: AudioInputJoinPhases { addedUs, firstPushUs, firstDecodedUs, firstPlayUs, firstAudibleUs }; player.joinPhases: AudioPlayerJoinPhases { workletLoadedUs, opusLoadedUs } —
Frames kept for a consumer not ready yet, passed on in order createFrameHold() → FrameHold { push(frame), release(consumer), clear(), isReleased, stats } (FrameHoldStats { held, maxHeld, released }) —
The mix, which the application routes player.output (the context’s own gain node type) —
Delay after the output that the context does not report player.outputDelayUs (settable) —
One track’s playback, usually one speaker player.addInput({ clock, decoderConfig, gain, playbackRate, jitterTargetUs, minJitterTargetUs, maxJitterTargetUs, onError, onCutHeard }) → AudioPlayerInput (AudioInputOptions); player.inputs —
What an input calls on its clock; a PlayoutClock qualifies AudioInputClock { targetLatencyUs, localNow(), reportAudio(timestampUs, atUs, stats), stopAudio() } —
Hand it a frame in decode order input.push(frame) (AudioPlayerFrame: timestampUs, payload, discontinuity?; a SequencedFrame qualifies) —
Drop received audio in a timeline range input.cut({ fromUs?, untilUs? }) (AudioCutRange, which a parsed Interrupt is); cut() drops everything held —
How far ahead of the clock an input wants frames: a timestamp-playout sequencer’s lead input.playoutLeadUs (output delay plus jitter target) —
When the latest cut is heard at the speaker input.stats.cutHeardAtUs; onCutHeard(atUs) on AudioInputOptions —
Per-input gain; stop input.gain; input.remove(), input.removed; player.close() —
Held audio played per output frame, 0.5 to 2 input.playbackRate (settable) —
Counters and buffer state input.stats: AudioInputStats { decoded, late, decoderResets, errors, underruns, playing, playStarts, heldUntilUs, dryAtUs, bufferedUs, jitterTargetUs, trimmedUs, concealedUs, synthesizedUs, recoveredUs, cutUs, cutHeardAtUs, reports } —
What the player needs of Web Audio AudioPlayerContext, AudioPlayerNode, AudioPlayerGainNode —
A catalog audio track’s decoder config audioDecoderConfigFromTrack(track) —
An Opus packet’s duration from its TOC byte opusPacketDurationUs(packet) opus_packet_duration_us(packet) (moq_media; see below)
The target latency for a broadcast with no video AUDIO_ONLY_TARGET_LATENCY_US (500,000) AUDIO_ONLY_TARGET_LATENCY_US (500,000; moq_media), passed as PlayoutClock(target_latency_us=...)
Play a node where Chromium’s echo canceller hears it await createEchoCancellationLoopback(context, node, { sinkId, timeoutMs }) → EchoCancellationLoopback { setSinkId, close } (EchoCancellationContext, EchoCancellationLoopbackOptions) —
When a context renders, on performance.now()’s clock createRenderClock({ windowUs }) → RenderClock { observe, best, renderedAtUs } (RenderClockOptions, RenderObservation) —

“Input”, not the issue’s “source”: composeMoqSource, MoqTrackSource and the clock’s source timeline already use “source”, and a mixer’s inputs are what this is. “Jitter buffer” is the glossary’s name for what the audio path holds for smoothing, which the frame reader does not; its options and counters say jitterTarget so they do not read as the clock’s target latency. “Cut”, not “flush” or “interrupt”: it names a timeline range dropped (issue #320’s interruption is one use), and flush reads as WebCodecs’ “finish pending work”. “Loopback” is the word media-layer.md already uses for the WebRTC path. The render clock moved here from the WebCodecs example, which still re-exports it. Not adopted from issue #314: addSource(frameReader, …) (the format package cannot take a frame reader), a player method for the loopback (it is a function over any node, so the v10 renderer can use it too), and a createPlayoutClock({ audioOnly: true }) preset or an automatic audio-only default (the clock is pinned by shared vectors in both SDKs; the constant changes nothing for Python).

playbackRate is Web Audio’s and HTMLMediaElement’s name for the same ratio (issue #329); the Video.js renderer applied its latency nudges to AudioBufferSourceNode.playbackRate before it used the player. AudioInputClock names the members the input calls, rather than Pick<PlayoutClock, …>, because its reportAudio takes a third argument that PlayoutClock does not: the input’s stats, refreshed with the report. Issue #329 asked for { playStarts } there; the whole stats object also gives a clock dryAtUs and heldUntilUs as of that report without a reference to the input, which a clock created before addInput does not have. PlayoutClock stays as it is, pinned by the shared vectors, and still qualifies, since a function that ignores an argument is assignable. playStarts, not reports bumped per start: reports counts reports, and two meanings in one counter would make both harder to read. dryAtUs and heldUntilUs are timeline timestamps, so they end in Us like fromUs and untilUs.

Interrupts: INTERRUPT_TRACK, buildInterrupt, parseInterrupt

Section titled “Interrupts: INTERRUPT_TRACK, buildInterrupt, parseInterrupt”

The talker’s side of a cut (issue #320): one message on a data track that tells listeners to drop audio they already hold. Its design is in media-layer.md. Both format packages.

Concept TypeScript Python
The interrupt track’s default name INTERRUPT_TRACK ('interrupt') INTERRUPT_TRACK ("interrupt")
One interrupt Interrupt { track, fromUs?, untilUs }, an AudioCutRange as it is Interrupt(track=, until_us=, from_us=None)
Build an interrupt object’s payload buildInterrupt({ track?, fromUs?, untilUs }) (InterruptInput) build_interrupt(*, until_us, from_us=None, track=AUDIO_TRACK)
Read one parseInterrupt(payload) parse_interrupt(payload)
List the track in the WebCodecs example’s catalog publishCanvas(..., { data: [{ name: INTERRUPT_TRACK }] }) (example code) —
Hear about an applied interrupt in the example’s listener listenAudio(..., { onInterrupt }) (example code) —
Publish a microphone with no video, as a voice participant publishMicrophone(publisher, namespace, { signal, microphone, data }) (example code) —
Talk to a bot: publish the microphone, play the bot, cut on its interrupts talkToBot(publisher, subscriber, { userNamespace, botNamespace, context, stream, onInterrupt }) (example code) —
A bot that answers after a pause and interrupts itself when talked over — run_bot(publisher, subscriber, user_namespace=, bot_namespace=, respond=, gate=), with Respond, Utterance and EnergyGate (example code)

“Interrupt” is the talker’s act, as the glossary’s entry for a cut already said; the listener’s response stays cut. It is not the frame writer’s “interruption” (a failure that resets a group) or its 'interrupted' outcome, which concern a publisher’s own writes. Not adopted: “flush” (issue #320’s word, which reads as WebCodecs’ “finish pending work” and as the player’s drop-everything), “barge-in” (one cause, the user talking over a bot, not the message), and a seq field (an interrupt only drops audio before its untilUs, so order does not matter; see media-layer.md). The JSON fields are fromUs and untilUs in both languages, the names AudioCutRange already uses, so a TypeScript listener passes a parsed interrupt to cut unchanged. No player method takes interrupts: an input does not know its track’s name, and the one line that matches them belongs to the listener.

Transcripts: TRANSCRIPT_TRACK, buildTranscriptSegment, Transcript

Section titled “Transcripts: TRANSCRIPT_TRACK, buildTranscriptSegment, Transcript”

The text of a talker’s audio, placed on its timeline (issue #320). Its design is in media-layer.md. Both format packages.

Concept TypeScript Python
The transcript track’s default name TRANSCRIPT_TRACK ('transcript') TRANSCRIPT_TRACK ("transcript")
One version of a segment TranscriptSegment { seq, track, fromUs, untilUs?, text, final } TranscriptSegment(seq=, track=, from_us=, until_us=None, text=, final=False)
Build a segment object’s payload buildTranscriptSegment({ seq, track?, fromUs, untilUs?, text, final? }) (TranscriptSegmentInput) build_transcript_segment(*, seq, from_us, text, until_us=None, final=False, track=AUDIO_TRACK)
Read one parseTranscriptSegment(payload) parse_transcript_segment(payload)
A listener’s transcript of one audio track createTranscript() → Transcript Transcript()
Keep a segment’s version transcript.apply(segment) → boolean transcript.apply(segment) → bool
Retract what an interrupt cut, at the playout position transcript.cut(interrupt, clock.position()) (TranscriptCutRange) transcript.cut(until_us=, from_us=None, position_us=clock.position_us)
What has been said by a position; everything held transcript.linesAt(positionUs); transcript.lines → TranscriptLine[] transcript.lines_at(position_us); transcript.lines → list[TranscriptLine]
A segment as held, and whether an interrupt cut it TranscriptLine (a TranscriptSegment with cut) TranscriptLine(..., cut=)
Keep the bot’s transcript in the example’s listener listenAudio(..., { onTranscript }), listener.transcript (example code) —
Give a reply’s text to the example bot — Respond yields str before the audio it is the text of (example code)

“Segment”, as captioning and STT services name a timed piece of text, and “final”, the issue’s word and theirs, for the last version. “Line” is a segment as a listener holds and shows it. “Cut”, the audio player’s word, for what an interrupt does to it. seq names a segment, so its versions find each other; it is not an order across segments, which the timeline gives. Not adopted: “caption” (a rendering, and WebVTT’s word), “utterance” (the example bot’s word for what the user said), isFinal (the wire’s other fields have no is), a separate retraction message (the interrupt already says what was cut), and a player method (the transcript is text, not audio).

PCM to Opus and back for a server-side talker such as a voice bot (issue #320). Python only, in moq_media.opus, on PyAV’s libopus and installed with the opus extra (caprid-moq-media[opus], ADR 0006). A browser has WebCodecs’ AudioEncoder and AudioDecoder, which the TypeScript helpers already take.

Concept TypeScript Python
Encode s16 PCM, or a PyAV frame, into 20 ms packets on the broadcast timeline — (WebCodecs AudioEncoder) OpusEncoder(input_rate=48_000, input_channels=1, channels=1, bitrate=32_000, application="voip"); encoder.encode(pcm, timestamp_us=...) → list[OpusPacket]
libopus tuning — OpusApplication ("voip", "audio", "lowdelay")
End a run of audio, such as an utterance — encoder.flush() → list[OpusPacket]
Where the next sample lands, or nothing between runs — encoder.next_timestamp_us
One encoded packet, which a frame writer takes as a frame — (WebCodecs EncodedAudioChunk) OpusPacket(timestamp_us, payload, duration_us), with key, discontinuity and LOC properties
Decode packets into s16 PCM at a chosen rate — (WebCodecs AudioDecoder) OpusDecoder(rate=48_000, channels=1); decoder.decode(payload) → bytes; decoder.reset()
An Opus packet’s duration from its TOC byte opusPacketDurationUs(packet) opus_packet_duration_us(packet) (moq_media, no PyAV)
Opus’s decode rate; the encoder’s frame length — OPUS_SAMPLE_RATE (48,000), OPUS_FRAME_US (20,000) (moq_media)

“Run”, not “utterance” or “segment”: the encoder knows only contiguous audio, and a bot’s utterance is one use. A run’s first encode takes the timeline timestamp and later calls continue by sample count, because a timestamp per call from a clock that drifts against the sample rate would gap or overlap packets. flush follows PyAV’s and libavcodec’s drain, which is what it does to the encoder; it is not the audio player’s cut, which drops what a listener holds. “Packet” is RFC 6716’s word for what one Opus encode produces, and keeps clear of the frame writer’s Frame. The decoder returns bytes and no timestamps: the caller has each frame’s timestamp, and a resampler’s output length varies from packet to packet.

Media publishing: FrameWriter, CatalogService, mediaTrackProperties

Section titled “Media publishing: FrameWriter, CatalogService, mediaTrackProperties”

The media publishing policy the Video.js example proved, lifted into each product package (issue #84 for Python as caprid_relay.media, issue #263 for TypeScript as the @caprid/relay/media subpath); its contract is the “Media publishing” section of behavior.md. Neither root package exports these names; the media exports are pinned by python/packages/caprid-relay/tests/test_exports.py and by the TypeScript package’s export test. TypeScript uses factories that return interfaces, like @caprid/moq-media; Python uses classes.

Concept TypeScript Python
Serve a state-shaped track: send its document on every relay subscription createCatalogService(track, document, { retryDelayMs }) → CatalogService (CatalogServiceOptions; default 1000) CatalogService(track, document, retry_delay=1.0)
Run the service until cancelled or the track ends await service.serve({ signal }) (ServeOptions) await service.serve()
Documents accepted, and failures that were retried service.sends, service.failures service.sends, service.failures
Serve a state-shaped track whose document the application replaces createDocumentService(track, document?, { retryDelayMs }) → DocumentService (DocumentServiceOptions); serve, sends, failures as above DocumentService(track, document=None, retry_delay=1.0); serve(), sends, failures as above
Replace the document and send it while the relay is subscribed; the latest service.update(document); service.document service.update(document); service.document
A track the publisher replaces mid-stream, listed in the catalog in the old one’s place createReplaceableTrack(publisher, { track, catalog: { service, build }, name?, trackProperties?, writeTimeoutMs?, writer?, demandWaitMs? }) → ReplaceableTrack { track, replacements, closed, write(frame), replace(), close() } (ReplaceableTrackOptions, ReplaceableTrackCatalog); replace() → TrackReplacement { from, to, replacements } ReplaceableTrack(publisher, *, track, catalog_service, build_catalog, name=None, track_properties=None, write_timeout=None, writer_options=None, demand_wait=2.0): track, replacements, closed, await write(frame), await replace() → TrackReplacement(from_track, to_track, replacements), await close()
Map encoded frames onto a track’s groups createFrameWriter(track, { grouping, stale, maxBacklog, onKeyframeNeeded }) → FrameWriter (FrameWriterOptions); grouping: 'frame' for one group per frame (FrameGrouping) FrameWriter(track, grouping="keyframe", stale=None, max_backlog=None, on_keyframe_needed=None); grouping="frame" for one group per frame
Send one frame if the relay wants it writer.write(frame) → Promise<FrameOutcome>, callable without awaiting await writer.write(frame) returns a FrameOutcome; concurrent calls are served in order
What became of a frame FrameOutcome { kind, error?, groupId? }, kind a FrameOutcomeKind: 'sent', 'skipped', 'stale', 'dropped', 'interrupted', 'ended' FrameOutcome with kind (a FrameOutcomeKind: sent, skipped, stale, dropped, interrupted, ended), error, group_id
One encoded frame as the writer sees it Frame { payload, key, properties?, discontinuity? }; @caprid/moq-media’s LocFrame qualifies Frame, a protocol: payload, key, properties, discontinuity
Frames waiting behind the one being written; their bound writer.backlog; maxBacklog (default 8, or 25 under frame grouping) writer.backlog; max_backlog (same defaults)
The next frame sent must be a keyframe; told on each entry writer.keyframeNeeded; onKeyframeNeeded() writer.keyframe_needed; on_keyframe_needed()
FIN the open group and refuse further frames await writer.close() await writer.close()
A media track’s default delivery timeout and publisher priority, as track properties mediaTrackProperties(kind, { keyframeIntervalUs, targetLatencyUs, subgroupTimeoutUs, publisherPriority }) (MediaKind: 'audio', 'video'; MediaTrackPropertiesOptions) media_track_properties(kind, *, keyframe_interval_us=None, target_latency_us=None, subgroup_timeout_us=None, publisher_priority=None) (MediaKind)
The writer ended, and how writer.closed, writer.ended writer.closed, writer.ended
Groups opened and objects accepted writer.groups, writer.objects writer.groups, writer.objects

FrameWriter.write keeps the SDK’s one verb for sending bytes, one tier above RelayTrackWriter.write: it sends when the relay wants the frame, and its return value is what says this write can decline (skip, drop, report) instead of waiting for demand or raising. The failures it reports are data because an encoder loop keeps producing after any of them; catching an exception per frame would be the caller’s whole loop.

Frame is a protocol, not a class. The application’s own encoded-sample type qualifies once it carries the frame’s object properties, so nothing is converted between the encoder and the writer.

dropped is not stale: a drop says the transport fell behind, while staleness is the application’s own clock. “Backlog”, not “queue”: the writer queues nothing it would rather send late, and its bound is what it drops at. “Keyframe needed”, not “keyframe request”: NEW_GROUP_REQUEST is the relay’s request, which the SDKs do not send.

Not adopted from issue #263: createMediaSink and push() (the frame writer already is that concept, and write is the SDK’s one verb for sending); sink.hasDemand (the track has it, and its demandChanges()); sink.attach(writer) (a supervised publisher’s writers outlive its generations, and a move to a new one is a demand change); maxQueue (the bound is the backlog’s).

mediaTrackProperties says what it returns, like locTrackProperties, and what it is for: the media tier’s part of a media track’s properties. “Target latency” is the reader’s name for the same budget (#303), so one setting reads the same on both sides. Not adopted: a timescale option returning the LOC Timescale too (the product package does not depend on @caprid/moq-media, so the caller adds locTrackProperties()); an option on registerTrack (it knows nothing about media); an option on the frame writer (it receives a track already registered, and an object-level 0x06 reaches viewers as a Timestamp); deliveryTimeoutMs (media durations are microseconds, and the budget is what the application knows); an object timeout (see behavior.md).

publisherPriority is draft-22’s name for what 0x0E carries (Default Publisher Priority), so it reads as the protocol value it sets, not as a scheduling promise: on moq-relay 0.17 only the viewer’s subscriber priority orders delivery (#316), which mediaSubscribeParameters sets (#337).

Not adopted: FramePublisher.publish() (“publisher” is the connection role, and publish() was already rejected as the name of a step that does not send); MediaTrackWriter.write_frame() (redundant on a type named for frames); an events iterator for the catalog service (two counters answer what the example’s finished line asks); exceptions for interrupted and ended frames (see above); update() on CatalogService (its document is fixed, and the media timeline, which changes, gets DocumentService instead of a mode). The register_media handle in media-layer.md remains a proposal.