Python SDK changelog
The Python packages release together at one version: caprid-relay, caprid-moq-core and caprid-moq-media. Before
1.0, a minor version can break the API; each section says what changed and what to do about it. How to cut a release
is in docs/releasing.md.
caprid-relay,caprid-moq-media: track replacement for barge-in, as in the TypeScript SDK.caprid_relay.media.ReplaceableTrack(publisher, track=..., catalog_service=..., build_catalog=...)publishes a track whosereplace()registersaudio-2,audio-3, …, lists it in the catalog in the old one’s place, drops what the old track has not sent and closes it; frames written to a replacement wait for the relay’s first subscription (demand_wait, 2 s).moq_media.find_replacement_track(current, tracks)finds the replacement in a newer catalog.caprid-relay,caprid-moq-core:MessageParameters.max_age(milliseconds, 0 to 2^53-1) is the MoQ Timestamp extension’s subscriber max age (parameter0x915C6, as in the TypeScript SDK): a publisher that honors it skips a group once a newer group has superseded it for that long. A session sends it on SUBSCRIBE and REQUEST_UPDATE only to a relay whose SETUP declared the extension’sSETUP_OPTION.MAX_AGE(0x915C4), never on FETCH, TRACK_STATUS or SUBSCRIBE_NAMESPACE, and leaves it off otherwise: moq-dev main declares it, moq-relay 0.17.x does not.RelaySubscriber.max_age_supportedandMoqtSession.max_age_supportedreport whether the relay did. Nothing changes for an application that sets nomax_age.caprid-relay:urlis optional on every connect call:connect_relay_subscriber,connect_relay_publisher,connect_supervised_subscriberandconnect_supervised_publisher. Left out, the connection dialsDEFAULT_ENDPOINTrooted at the token’srootclaim, which it reads without verifying the token: the URLroom_endpointwould build from that root (#374). A quickstart is nowconnect_supervised_subscriber(get_token=...), with namespaces relative to the token’s root.get_tokenis then required. A token that is not a JWT, has no stringrootclaim, or has a rootroom_endpointrefuses raisesTypeErrororValueErrorbefore the transport is used. A supervisor keeps the endpoint its first connection dialed for every reconnect and rotation. A call that passesurlis unchanged.caprid-relay: a token’s claims are read only from a JWT payload in unpadded base64url that is strict JSON, as in TypeScript. A payload with padding or another character, or withNaNorInfinity, has no claims, so itsexpires_atisNoneinstead of itsexp. The relay refuses such a token anyway.
caprid-relay:room_endpoint(room)rootsDEFAULT_ENDPOINT, the hosted Relayhttps://relay.mux.global, so a quickstart passes only the token’s root.room_endpoint(endpoint, room)works as before. Both arguments are now positional-only: a call that names them, such asroom_endpoint(endpoint=..., room=...), must pass them in order instead.caprid-relay,caprid-moq-core: a publisher connection answers the relay’s TRACK_STATUS (draft-22 §9.13) for its tracks instead of refusing it withNOT_SUPPORTED: TRACK_STATUS_OK with the track’s properties and, once the track has written an object, LARGEST_OBJECT; an unknown or closed track getsDOES_NOT_EXIST, as a SUBSCRIBE does. moq-relaymainasks a publisher for a track’s status before it subscribes on behalf of another relay, and a track whose publisher refuses does not cross two relays there. Nothing changes for an application. Incaprid-moq-core,RegisteredTrack.object_published(location)records what a TRACK_STATUS_OK reports as LARGEST_OBJECT, andencode_request_oktakestrack_properties.caprid-moq-core: a REQUEST_UPDATE carryingFILL_PARAMETERSis acknowledged instead of ending the subscription withUPDATE_FAILED, as in TypeScript 0.1.1. The publisher reports no Largest Object, so the fill is always empty and no fill fetch stream opens. moq-relay sends no REQUEST_UPDATE, so the relay path is unchanged.
caprid-relay:connect_relay_publisherandconnect_relay_subscriberover native QUIC through aioquic, and thecaprid_relay.mediaframe writer and catalog service.caprid_relay.room_endpoint(endpoint, room)builds the recommended room-rooted endpoint, the endpoint with the token’s root appended to its path. It keeps the query and encodes nothing: moq-relay reads the path without percent-decoding it, so a field that would need encoding (a space,%,/,*, non-ASCII,.or..) raisesValueError, and that room is reached from the bare host.caprid_relay.media.media_track_properties("audio" | "video", ...)gives a media track a default subgroup delivery timeout, so a stall costs stale frames instead of lasting latency: 500 ms for audio, and for video the target latency less the keyframe interval, never less than one interval (one interval by default). Pass its result toregister_trackbesideloc_track_properties(); video needskeyframe_interval_us. The Video.js streaming example uses it. It also sets a default publisher priority (0x0E) that puts audio ahead of video, 16 for audio and 96 for video, whichpublisher_priorityreplaces. moq-relay 0.17 passes it to viewers but orders delivery by their subscriber priority alone, so it changes no delivery there yet.caprid_relay.media.media_subscribe_parameters("audio" | "video", overrides=None)gives a viewer’s subscription the matching subscriber priority, 16 for audio and 96 for video, the one moq-relay 0.17 orders delivery by. Pass it asparameters=tosubscribe,wantorswitch_to;overridesadds other parameters, and itssubscriber_priorityreplaces the default. The Video.js example’s--verifyviewer uses it.open_group(payload, properties=...)opens a group with its first object, so that object’s delivery timeout counts the wait for demand, settling room and the stream; a bareopen_group()has no object to time. The frame writer opens every group this way, so a frame whose open fails reports nogroup_id, andgroupscounts only opens that returned a group.moq_media.AUDIO_ONLY_TARGET_LATENCY_US(500 ms), the same constant as TypeScript’s: pass it asPlayoutClock(target_latency_us=AUDIO_ONLY_TARGET_LATENCY_US)for a broadcast with no video. Without it the clock keeps its 2 s default, since no keyframe interval is ever reported, and a frame sequencer waits 2 s of silence before it resyncs. The clock’s behaviour is unchanged.- Transcripts:
moq_media.build_transcript_segmentandparse_transcript_segmentwrite and read the transcript track’s segments (TRANSCRIPT_TRACK,"transcript"): the text of a talker’s audio fromfrom_us, sent in interim and final versions that share aseq.Transcriptkeeps a listener’s segments, retracts what an interrupt cut at the playout position, and gives the lines heard by a position. The voice bot example publishes the text of its replies:Respondmay yieldstrbefore the audio it is the text of. - Interrupts:
moq_media.build_interruptandparse_interruptwrite and read the interrupt track’s messages (INTERRUPT_TRACK,"interrupt"), which tell a listener to drop audio a talker already sent, up tountil_us. A new parrot voice bot example (examples/voice_bot) decodes a browser user’s microphone withOpusDecoder, answers withOpusEncoderafter a pause, and sends an interrupt when talked over; itsRespondfunction is where STT, a model and TTS plug in. - Opus for voice bots:
moq_media.opus.OpusEncoderandOpusDecoderon PyAV’s libopus, installed with theopusextra (caprid-moq-media[opus]). The encoder turns s16 PCM into 20 ms packets stamped on the broadcast timeline, in runs thatflush()ends; the decoder returns s16 PCM at the rate you ask for.moq_media.opus_packet_duration_usreads a packet’s duration from its TOC byte. The Video.js streaming example’s audio encoder is built onOpusEncoder. caprid-moq-core(imported asmoq_core): the transport-independent MOQT sessions the SDK is built on.ConnectingTransport.framingaccepts"websocket", a factory carrying MOQT on QMux over WebSocket, which sends the same SETUP as"webtransport". The package ships no such factory.- Reading frames in decode order within a latency budget.
moq_media.FrameSequencer(clock, grouping=)orders one track’s objects across group streams, waits for a late group only while it can still matter, skips to the next keyframe past the deadline, drops late audio, and marks eachgap,resyncorrestartwhere a decoder resets.caprid_relay.media.FrameReader(sequencer)drives it from a subscription’s or a want’s handlers and is iterated for frames. The playout clock holds the budget:target_latency_us, by default twice the longest recent keyframe interval (report_keyframe_interval), 2 s before one is known; wall pacing now re-anchors on a late frame only beyond it, instead of beyondmax_video_delay_us. The Video.js streaming example’s keyframe interval (--gop) now defaults to 1 s. - Frame grouping plays every frame of a group that holds several, one per object, as
open_group()writes them: the sequencer’s position is a group and an object, so a group’s later objects are no longer dropped as behind its first, and an object skipped inside a group is agap. Groups of one frame, as the frame writer sends audio, behave as before. - MOQT draft-22 by default: connections offer
moqt-22, thenmoqt-20as a fallback, and speak whichever the relay selects. The drafts differ on the wire only in the LOCATION_FILTER parameter, whichmoq_corenow encodes for the negotiated draft: its encoders and decoders that carry message parameters take a trailingprotocolargument (defaultmoqt-22), and it exportsMOQT_PROTOCOL_IDSandMoqtProtocolId.MOQT_PROTOCOL_IDis nowmoqt-22. moq-relay 0.17.0 frames LOCATION_FILTER the draft-20 way onmoqt-22, so a Next Object filter closes the session there; use a relative start of 0. caprid-relay:subscriber.subscribe_switchable(namespace, track, handlers)moves one set of handlers between tracks make-before-break.switch_to(track, parameters=..., until=..., drain=..., cancellation=...)subscribes to the new track while the old one keeps delivering, cuts over on the first objectuntilaccepts, reportson_switched, and can keep the old subscription open briefly for groups still in flight. It is how to change a track, filter or priority, whichupdate()cannot do against moq-relay.caprid-relay: a supervised want moves make-before-break. A newer generation, or wanting the key again with another track or parameters, opens the replacement while the current subscription keeps delivering, and cuts over on the first objectuntilaccepts.wantgainsuntil=anddrain=(seconds), as onswitch_to, andWantHandlersgainson_switched, which receives aWantSwitchedInfo(from_namespace,from_track,to_namespace,to_track,subscribed). A generation move no longer reportson_restarted("generation", ...); handleon_switchedinstead, where decoders reset. Wanting a key again with onlystale_afterchanged now swaps it without resubscribing. Another namespace orgenerationsstill replaces the want.caprid-moq-media(imported asmoq_media): LOC and MSF format helpers.build_msf_catalogleavesisCompleteout, since a live broadcast is not complete (msf-01 §5.1.3), andbuild_msf_end_catalogbuilds the §11.3 catalog that ends a broadcast:isComplete: trueand no tracks.caprid-moq-media: one timeline, and a playout clock for audio/video sync.BroadcastClockstamps every track in microseconds since the Unix epoch, so broadcasts line up with each other (from_source_us, a calibratedsource_timelinefor a clock with its own origin and rate, andsample_timelinefor sample counters).PlayoutClocktakes audio reports and answersdue(timestamp_us)with aPlayoutDecisionto wait, show, or drop, falling back to wall-clock pacing without audio.build_msf_catalogputs video and audio in one render group (renderGroup: 1, msf-01 §5.2.11), andparse_msf_catalogreturnsrender_group.caprid-moq-media:build_msf_catalogdescribes any number of video and audio tracks.mediatakesVideoTrackInfoandAudioTrackInfoentries, which gainrender_group(default 1) andalt_group(msf-01 §5.2.12) for simulcast layers; members of an alternate group must share a role and a render group.videoandaudioare unchanged.parse_msf_catalogreturnsalt_group, andgroup_msf_trackslists the sets of tracks a viewer picks one of. The module-levelmoq_media.catalog.MEDIA_RENDER_GROUPconstant is gone; the default isrender_group’s.caprid-moq-media: format helpers for msf-01’s Media Timeline track, which maps media time to wall-clock time.media_timeline_entryturns a timeline timestamp and a group into aMediaTimelineEntry,build_media_timelineandparse_media_timelinewrite and read a timeline object, andbuild_msf_catalogannounces the track throughmedia_timeline=MediaTimelineTrackInfo()(packagingandrolemediatimeline,dependson the video and audio tracks by default).parse_msf_catalogreturnsdependsandmimetype.MediaTimeline(max_entries=512)keeps one broadcast’s records (oldest dropped) and returns the document to send for each new entry.caprid-relay:caprid_relay.media.DocumentServiceserves a state-shaped track whose document the application replaces, such as the media timeline: the latest document on every relay subscription and on everyupdate()while the relay is subscribed. The Video.js streaming example publishes the media timeline with it, and--verifychecks the records against the video received.caprid-relay:caprid_relay.media.FrameWriterdrops rather than queues, and says when it needs a keyframe. Concurrentwrite()calls are now served one at a time in call order, and the frames waiting are a backlog of at mostmax_backlog(default 8, or 25 withgrouping="frame";writer.backlogcounts them). A frame arriving at a full backlog drops the backlog and the group in progress under keyframe grouping, and the oldest waiting frame under frame grouping; such frames report the newdroppedoutcome. Every relay demand change now resets the open group before the next frame.writer.keyframe_neededand theon_keyframe_neededcallback say when the next frame must be a keyframe, so an encoder can force one. The writer watches demand from its creation, so create it with an event loop running;close()reports waiting frames asdropped. A caller that awaits each write sees no difference beyond the demand-change reset.caprid-moq-media: capture times on one epoch, and a Timescale-aware reader. Source timelines take a settablecapture_latency_us, subtracted from every timestamp, for the input delay no device reports.loc_timestamp_usreads an object’s LOC Timestamp in microseconds, applying the object’s or the track’s Timescale.caprid-relay:RelaySubscriber.namespace_events(prefix, capacity=..., cancellation=...)discovers which namespaces under a prefix are announced, as a bounded iterator ofNamespaceEvents (announced/unannounced). The subscriber’s SETUP now sends the MoQ Solicit option, so the relay advertises namespaces only on that request.caprid-moq-core:MoqtSession.subscribe_namespace(SUBSCRIBE_NAMESPACE with NAMESPACE / NAMESPACE_DONE) andSETUP_OPTION.SOLICIT.caprid-relay: opt-in supervised connections, as in the TypeScript SDK.connect_supervised_publisherreconnects with backoff, serving the root namespace plus a fresh generation field per connection, and rebinds the same track writers to every generation; a write between generations raises the newRECONNECTINGcode and is never replayed.connect_supervised_subscriberkeeps tracks subscribed throughwant(key, namespace, track, WantHandlers(...)): it retries the announce race, resubscribes after a done, a rejection or a stale track, moves to replacement connections, follows a supervised publisher’s newest generation through discovery, and reportson_restarted. The primitives are unchanged. The examples’ sharedannounce_race.pyis retired: the demo, the Video.js example’s--verifyviewer and the live smoke test subscribe throughwant.caprid-relay: sessions end at the token’s expiry, and the supervisors now rotate before it, as in the TypeScript SDK.get_tokenmay return aRelayCredential(token, an awareexpires_at); every connection exposesexpires_at, from that or the JWT’sexpclaim. Both supervisors takerotate_before(seconds, default 60) androtate(), and replace their connection make-before-break: the publisher moves each track to the next generation once it has demand there, and every want moves its subscription to the new connection as a move does. A want whose target is unchanged cuts over on the first object the old subscription did not deliver, with no callback, so nothing is lost or repeated.