-
Notifications
You must be signed in to change notification settings - Fork 3
kithara play
Documentation reviewed from source revision 19ca073f2. This records the documented contract at that revision; it is not a new runtime validation. API and usage · All crates.
The README is the overview and the module tree is the map; repo-wide rules stay
in AGENTS.md. This file carries only what neither the sources nor a named test
can state for itself.
player, engine, and the lower session protocol are deliberate orchestration
planes - their entry files bind API state, RT controls, and session commands -
so .config/arch/thresholds.toml raises module_fan_out to 9 for this crate.
Do not add re-export hops solely to lower that count.
The application composition root declares one closed schema with
kithara_bufpool::pool_schema! and builds one PoolRegion<S>. Player,
resource, worker, Warp, and session registration propagate that one schema
instead of separate byte and sample pools; HasPool<K> makes an unregistered
pool a compile error at the root. Budget semantics belong to
kithara-bufpool contracts. The master EQ is the one deliberate
degraded mode: a failed control-thread scratch allocation leaves
MasterEqProcessor a transparent bypass with a warn - a degraded mode, not a
second allocation path.
policy is the orchestration-level owner of domain matching - not
kithara-assets, kithara-platform, or kithara-drm. Host matching is
case-insensitive and first-match-wins over ordered rules; application-declared
query keys stay case-sensitive. QueryIdentityLayout combines an existing
AssetSource::Remote discriminator with the query identity rather than replacing
it. The DRM KeyProcessorRegistry is opaque, so a caller that also needs
playlist or segment headers keeps the same immutable Arc<DomainKeyPolicy> and
asks it via resource_headers - never a second policy source of truth.
tests/asset_policy.rs and tests/drm_policy.rs pin which query pairs fragment
the cache, wildcard and ordering precedence, and header merge order.
PlayWorker::open is the sole supported production composition path, and no
workspace production code can build a second playback scheduling path:
PlayWorker derives its dispatcher from kithara-worker, while kithara-audio
exposes only the prepared source and wake contracts. One chain, one final
output path:
decoded source -> WarpRenderer -> custom effects -> final output ring. The Warp
wrapper is resident even with synchronization off, so passthrough never selects a
second implementation. Sync is not yet wired end to end; final-ring admission is
rendered readiness, not proof that a device callback presented those frames.
At true source EOF WarpSource drains the Warp tail through the effect chain
before EffectDrain flushes the effects, preserving multi-pull tails.
DecoderNode then publishes the decode-epoch-tagged AudioEvent::EndOfStream.
That raw event is not a user-visible end of playback.
PlayerEvent::ItemDidPlayToEnd is the sole path to FFI DidReachEnd, and only
after the player fences the terminal marker against the live seek epoch (see Seek
Ownership). Seek and source-discontinuity revision changes reset the chain before
new-generation audio is admitted. Every play-owned DSP stage guards its input
with sanitize_sample (an output-only guard would leak the bypass and silence
fast paths); IsolatorEq also flushes denormal IIR state only once both input
and output fall below f32::MIN_POSITIVE, so unity and bypass stay bit-exact
(a_decaying_tail_never_leaks_denormals).
kithara_warp::StretchControls (one Arc per deck, in
PlayerConfig.timestretch) owns the requested playback target, is seeded before
a track loads, and threads unchanged into the resident Warp<S>. Queue
delegates the target to the player; key-lock and backend are set on the same
handle. There is no second cached target.
Target and effective rate are different surfaces and cannot silently diverge.
The leading track publishes the deck's effective rate through PlaybackShared;
Player::rate() and PlayerEvent::RateChanged expose only that, so a fixed or
backendless resource reports 1.0, and a paused deck or one with no leading
track reports 0.0.
WarpRenderer runs at ratio = 1/speed; key-lock off (the constructed default)
gives pitch = speed, vinyl-style. Speed, key-lock, and backend apply live
mid-track with no reload, and each PlayerTrack re-reads the rate its resource
applies every render block, so a runtime change moves its media clock as well as
its DSP. Without a backend - every wasm build - the same Warp slot stays in the
chain as an exact identity renderer: output stays at 1.0. A kithara-warp
region boundary preserves backend history and is not a source discontinuity, and
stretch changes output frame count, never AudioSpec.sample_rate.
PlayerCore creates one address-stable EngineLoad meter and passes the same
Arc through TrackConfig into DecoderNode. Neither AudioConfig nor
PreparedAudioLane carries it, so there is no second route for load to reach a
node.
PlayerConfig<S> is this crate's one player configuration, tunables and
per-call wiring together. #[derive(Patch)] generates PlayerConfigPatch,
what a document's player: section may say: gapless_mode,
crossfade_duration, default_rate, max_slots, response_budget_frames, and
warp, which nests WarpConfigPatch. max_slots is the engine's:
PlayerConfig owns it and hands it to EngineConfig::builder, so one value
stands behind both and EngineConfig needs no patch of its own.
auto_advance_enabled, prefetch_duration, block_on_underrun, eq_layout
and sample_rate are skipped, and so refused by name rather than dropped. Each
names the owner that overwrites it in its own field doc, and document_tests
pins the refusal.
ResourceConfig<S, B> is where a document's HLS, file, and audio values wait
for the track that will use them, as HlsConfigPatch, FileConfigPatch and
AudioConfigPatch rather than built configurations: an HlsConfig needs a URL
and a store, a FileConfig a source, so neither can exist before a track does.
resource/build.rs builds the real configuration and applies the patch onto it,
so a knob either crate adds later needs no edit here. It then overwrites
extension on the file branch: the per-call hint and the source's own
extension name this very track, so either outranks a blanket file.extension.
There is no resource: section — Document's deny_unknown_fields refuses one
(a_resource_section_is_rejected) — and the three patches arrive from
kithara-app's audio:, hls: and file: sections through
sources::build_resource_config, the only construction site a document reaches.
PlayerImpl::set_eq_layout replaces a running player's master EQ, and the
session graph is the actuator: it builds the replacement on the control thread,
reconnects every slot through it, removes the old EQ, and submits one graph
update. The audio thread never allocates, locks, or rebuilds filters for a layout
change. SharedEq is the control-plane gain mirror shared by session and slot
handles - no audio processor reads it; the DSP takes gains from the session's
node event queue. Replacement swaps the whole band array behind the ArcSwap
every handle clone points at (a_handle_clone_sees_the_replacement_band_array).
The crate owns its playback, engine, session, DJ, route, port, and timing vocabulary. These types are published through the shared kithara-events bus; SlotId and TrackId remain bus identities owned by that crate.
One kithara_events::EventBus per player: player.subscribe() and
engine.subscribe() return receivers on the same bus. SessionDuckingMode is
owned here and maps Off / Soft / Hard to session-output gains 1.0 / 0.4
/ 0.2.
A slot is a processor holding an arena of items, not one item.
process_notifications drains every active slot, so a bare start or stop says
nothing about what the listener is hearing, and only this crate can say. Hence
PlaybackStarted, ItemDidPlayToEnd, and ItemDidFail carry an ItemRole
holding the TrackRef inside the role: a consumer cannot reach the identity
without first declaring which item it has. Leading alone drives auto-advance;
Outgoing is the half promoted over inside the current slot, which slot identity
alone would misread as leading; Background is a slot the phase no longer holds,
reaching its own end seconds into the current item. The five cases are pinned in
player/flow/notify.rs. Within TrackRef, only id answers "which entry":
slot is an arena and src names a rendered resource two entries may share.
arm_next is idempotent per index and unloads a differently-armed one;
commit_next is for cf>0 only, because the audio thread stitches cf=0
internally. PlayerConfig::auto_advance_enabled (default true) applies a
built-in linear policy. kithara-queue::Queue disables that policy and reacts
to PlayerEvent::HandoverRequested through select_item_with_crossfade; it
never calls arm_next or commit_next. HandoverRequested carries ItemRole
for the same reason ItemDidPlayToEnd does: it is minted by the track that is
running out while any number of others render, so a consumer that has already
advanced past that track must be able to tell the request is not about the one it
now holds. select_item_with_crossfade fails
PlayError::ItemConsumed before any bookkeeping when the target index is
neither armed, nor the announced current item, nor still holding a resource - the
UI must not drift from the audio
(select_item_on_consumed_slot_errors_without_bookkeeping).
PlayerConfig.sample_rate is mandatory and has no playback-layer default. It
is the Player's initial session-rate contract; Host attachment queries the
canonical dispatcher carried by SessionBinding, and that dispatcher is the
sole rate source for validation and register/start commands. Tests pass their
fixture rate explicitly.
EngineImpl::start is atomic single-start: an internal start_lock
serializes the running check-then-act, and running flips only after
session.start_player fully succeeds. ensure_engine_started treats
EngineAlreadyRunning as success, so a concurrent start is idempotent rather
than a session desync. stop_player releases the output device once no player is
started; the next start_player builds a fresh context.
Drop order is load-bearing and invisible in any one file. PlayerImpl
declares phase before core, and PlayerCore declares items before
engine, so undelivered resources (which hold worker references) release before
the engine and resource registrations. worker is declared after those owners,
so the final PlayWorker reference cannot shut its thread down while a track
still holds a lease; likewise the dispatcher precedes the base-worker clone, and
each lease's task handle precedes its worker clone.
Drop does not take the admission gate. close serialises against admitted
operations; invalidate, which PlayerImpl::drop calls, deliberately does not
(drop_does_not_wait_for_an_admitted_operation). A player is dropped by whoever
last owns it, including a session dispatcher unwinding its own state, and an
admitted operation can be parked on a reply from that same dispatcher - taking
the gate there closes the cycle and the session never finishes shutting down.
Neither teardown step needs it: closing stores an atomic and cancelling fires a
token, and a cancel exists to interrupt an admitted operation rather than queue
behind one.
docs/guides/cancel-policy.md owns the typed propagate-down tree, the
CancelScope seam, and the cancel_root_sites allowlist (kithara-play is not on
it). Consumer crates mint a root and pass a child in PlayerConfig.cancel, while
None makes a fresh root; the same token goes to EngineConfig.cancel, and the
playback dispatcher has its own explicit lifetime in PlayWorkerConfig.cancel.
prepare_config gives every track a .child() of the player token
(prepare_config_per_track_cancel_is_child_of_player_master), and
PlayerImpl::drop cancels its own subtree, never the potentially foreign parent
it was handed.
Resource holds a CancelGuard around the per-track token, declared before
inner, so a mid-session unload tears down the whole track subtree (stream and
Audio) before the reader drops. The From<Resource> reader unwrap disarms that
guard, because the live reader then outlives the wrapper and rides the
kithara-analysis run-scope cancel instead.
docs/guides/performance/realtime.md owns the general callback rules. The
play-owned #[kithara::rtsan_forbid_blocking] sites are
PlayerNodeProcessor::process, MasterEqProcessor::process, and
DecoderNode::tick; the session limiter and mix tap processors belong to
kithara-host. Specific to this crate:
-
Nothing is freed on the audio thread. Finished or evicted tracks go to the
bounded trash ring from
bridge::slot_channels, drained by the main thread inprocess_notifications. A full command ring surfaces asPlayError::SlotChannelFull, never as a block. - Render scratch is sized in
new_stream, off the audio thread, andrender_audioclamps its block to it, so a host exceeding its declaredmax_block_framesloses that block's tail rather than allocating. One block is the whole budget, shared by every processor in the graph;tests/benches/rt_block_budget.rsderives it and measures this node's share. -
Pause and resume are ramps. The flag flips at once for
Player::is_playing()while output ramps to zero, and the media clock advances by the ramp's length. A processor's first block adopts the transport state rather than fading into it, andTrackFade::playsnaps only once its mix has settled, so a fade in flight keeps its ramp. - Facts leave the audio thread through
PlayerNotification, rates and faults throughPlaybackShared::metrics(). -
Memory ordering. Only
playingandseek_epochdecide whether the audio thread acts, and only they carrySeqCst. Everything else isRelaxed, becauseRtMetricscounters are monotonic deltas andPlaybackSnapshot's scalars are independent readouts that need not agree across a block.
Verification is gated by --cfg rtsan, so stable and production builds are
byte-identical. A whole test body counts as a nonblocking region only in the
no-block lane, where .config/rtsan/async-suppressions.txt narrows the check
to genuine waits; the decoder lanes check the product's forbid regions alone.
A seek is split in two, because beginning one publishes an event and wakes
the playback worker - both lock-taking, both forbidden on the device callback.
EngineImpl::begin_slot_seek begins it on the control thread;
PlayerResource::reset_for_seek re-bases on the audio thread through the
reader's lock-free sync_seek. The audio half can neither block nor fail, so
there is no seek-failure signal. A seek handle is bound in send_slot_cmd once
its resource is accepted by the audio-thread channel, and released by exact item
and resource identity when that resource returns through the trash channel, so
replacing one item or reusing a URL cannot detach a different resident track.
A published seek outranks a natural end. seek_seconds publishes the next
epoch on PlaybackShared before sending the matching command, so a render
block can drain the feeder in that window; ending the track there would hand the
queue an ItemDidPlayToEnd it answers by auto-advancing out from under the
accepted seek. Four sites carry the fence, and their own doc comments carry the
derivation:
-
handle_natural_endholds while the track's epoch differs from the published one, costing blocks of silence, never signal. -
apply_seekre-bases every loaded track - including ones the seek does not move, and aFinished-at-EOF track back to life - and a track planted later starts at the published epoch, so none can be born behind. -
dispatch_notificationdrops a staleEof. The comparison is!=, not<: epochs wrap, and withdrawal legally steps the published value back.StopandFailedare never fenced - a broken source stays broken across a seek. -
PlaybackShared::withdraw_seek_epochis a compare-exchange, so a failed send cannot strand its own seek and a newer seek makes withdrawal a no-op (withdrawing_an_overtaken_epoch_leaves_the_newer_seek_published).
PlayerResource::scratch_frames is a per-channel frame count shared by
write_len / write_pos / the read range, sized once off the audio thread and
never re-zeroed, so every mixing path must fill its whole window - an
underrun zero-fills rather than returning short.
rt::TrackSlots::insert hands a rejected newcomer back rather than freeing a
PlayerTrack on the audio thread. When the arena's last track ends at natural EOF the processor
keeps it resident but inert so a later in-range seek can revive it; tracks
finished by stop or a faded-out crossfade are discarded. Off-core worker work
(pooled-buffer free, event flush, parking, symphonia allocation) belongs to the
scheduler shell, and cross-thread wakes reached on the core are armed lock-free
and delivered by the shell, never on the forbid path.
kithara-play owns the lower object-safe SessionDispatcher protocol used by
one Player; kithara-host owns every concrete native or web session, the
Firewheel graph, and the root synchronization group; kithara-warp owns musical
coordinates and the BeatGrid, WarpMap, and SyncGroup protocols. No concrete
production session constructor lives in this crate. A Player
is constructed unbound and receives an opaque one-shot SessionBinding only
through Host::insert; decorators may only delegate it to their resident Player.
TrackBinding captures an owner-published BeatGridSnapshot; it neither creates
a grid identity nor converts analysis facts into a second representation.
SessionDispatcher::consumer_wake_mode is a required object-safe capability,
not a trait default, so a wrapper cannot silently erase an off-RT capability by
omission. ConfigPrep copies it into ResourceConfig::consumer_wake_mode,
so a player-managed resource has no second source of wake policy. An unset field
identifies a direct Resource reader and resolves to ImmediateOffRt while its
config becomes an AudioConfig. The
Host's TransportCommitState is the sole owner of when the session
beat-to-frame relation changes: a stream restart drops the transport snapshot
because the frame axis restarted, and the first block on the new axis reanchors
the preserved beat before publishing another.
Session-input gain has two distinct owners. Each EngineImpl owns its
desired input level (master_volume); the session SessionState owns the
applied graph gain. Production crosses that boundary only through
Host::apply_mix, which validates the whole vector before mutating anything:
omitted players are unchanged, one invalid entry voids the whole batch, and
desired levels commit only after dispatch succeeds. There is no singular
per-player gain command. engine/mix.rs pins the validation
(apply_rejects_duplicate_player_without_mutation and siblings).
Session-input levels are linear amplitudes, so sub-ceiling session output is
the exact weighted sum of level times signal. Firewheel's Volume::Linear is a
fader taper - it squares its argument - so master_gain converts a level to that
taper before the VolumeNode; feeding a level in directly would land 0.5 at
0.25 amplitude. Pure stereo gain stages never use a pan node, because a
centered equal-power pan attenuates both channels. Slot/content volume and
session ducking keep their own taper.
Cmd::SetPlayerMasterVolumes and the matching session-handle method compile only
for tests or the probe feature, keeping deterministic offline render tests on
the lower SessionDispatcher; it is not a second production path. The
crossfader is likewise pure Host policy, not state: consumers fold trim,
mute, crossfader gain, and group master into each member level before calling
Host::apply_mix, and group master is folded per member rather than stored
process-wide, so one logical group cannot change another's master.
The tap is a Host-owned zero-output sink on the session limiter, reading the buffer the device receives. Three facts bind a consumer:
-
The drop counter is in samples, monotonic and
Relaxed- it orders against nothing in the ring, so a delta locates its gap no more precisely than the window drained around it. - A stream restart keeps the tap running, but a restart landing on a different
device rate ends the feed, because the ring carries bare samples and a
consumer holding it would read the new rate as the old one; the way back on air
is
DisableMixTapthen a freshEnableMixTap. - The node cannot resign on its own, because releasing the producer there would
free memory on the audio thread, so a consumer that stops reading sends
DisableMixTap. End of feed isObserver::write_is_held() == false.
Cmd::QuerySampleRate reports None until the session has measured an output;
sample_rate_hint is stream-creation input, never an observed rate.
PlayerNodeProcessor::new_stream is the host-rate bridge: it updates the shared
sample rate and, only when the numeric rate actually changed, propagates it
to every loaded resource, which then enter the existing decoder recreate path
with RecreateCause::RouteChange, preserving position and gapless state.
Equal-rate notifications refresh host state and do not recreate.
ResourceConfig.decoder is the only resource-level owner of decoder construction
settings - backend selection, gapless mode, resampling. An unset resampler field
resolves to B::default() in kithara-audio, which owns that fallback; a
platform backend such as Apple AudioConverter is injected through
ResourceConfig.decoder, never through separate resampler fields.
Cargo.toml owns the list and defaults. src/guard.rs hard-fails misconfigured
builds - wasm32 without resample-rubato or resample-glide, non-wasm without
stretch-signalsmith or stretch-bungee. Decode, resampler, stretch,
HTTP-client, and TLS features forward to a backend in a lower crate; none add
behaviour here. File and HLS pipelines are unconditional:
this crate always links kithara-file, kithara-hls, kithara-abr,
kithara-assets, kithara-net.
PR #354 adds item: Option<TrackId> to
PlayerEvent::CurrentItemChanged. Playlist retains the item identity when its
resource is transferred to the processor; clearing or removing the slot removes
that identity, and replacement installs the replacement identity. Publication
stamps the selected identity under the playlist lock and sends after releasing it.
The FFI bridge updates its current-item routing identity from this player event,
not from QueueEvent::CurrentTrackChanged.
PlayerEvent::CurrentItemChanged means the identity of the current item
changed - not merely that playback (re)started. A bare play() resuming the
already-current item must not emit it: consumers treat it as a track switch and
do real work such as re-analysing the waveform. Playlist owns the current index
and the announce-dedup state, and ItemQueue::announce_current_item is the sole
publisher; a mutation that can
change identity under a reused index resets the dedup state so the next play()
re-announces. play() announces only when the item actually loaded, since an
empty slot means the load is still in flight and announcing would send the
arriving resource down the reselecting-current path, never to be enqueued.
- Audio-thread
process()is allocation-, free-, and lock-free. -
duration_seconds()returnsNonewhile duration is unknown; the shared atomic's0.0conflates "unknown" with "empty track", so callers must not read it directly.
The offline render backend for deterministic engine and player tests lives in
kithara-integration-tests::offline, not here.