kithara play - zvuk/kithara GitHub Wiki
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::load is the one public way to put a track on the worker, 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
stage that runs it is kithara-render's WarpSource, which the worker builds
for each track around that track's renderer and effects. The Warp
wrapper is resident even with synchronization off, so passthrough never selects a
second implementation. Final-ring admission is
rendered readiness, not proof that a device callback presented those frames.
Resource::new remains the ordinary resource API. A caller that needs a
concrete stream configuration, including independently controlled source and
decoded-audio cancellation, can use Resource::from_stream_audio. It routes
through the same PlayWorker::load owner as both ordinary File/HLS branches
and retains the registration's priority, resident render lane, publisher and
Warp rate. Resource::from_reader is the fixed-rate custom-reader path; it
does not attach those worker controls merely because its reader was registered.
This lower-level construction leaves token lifetime with the caller and omits
the outer resource cancel guard, staging recipe and prepared beat grid. A
preload failure is logged while construction returns the resource. A caller
whose operation requires successful preload must call Resource::preload
and handle its result before admitting the resource.
Registered-resource construction contract source:
revision d703c73bbd01.
A load holds its worker slot before it opens anything. load reserves the
slot first, so a load past PlayWorkerConfig::capacity is refused with
LoadRefusal::Capacity and opens no source. Every other failure is
LoadRefusal::Open: the source or decoder failed, the load was cancelled, or
the worker stopped. A failed open drops the held slot, which frees it. The open
itself runs on the caller's async task, and only then does the track's decoder
node start in the held slot, so the worker thread never waits on a source open.
Each load opens its track's source once: kithara-audio
fires the source_opened probe where a track's stream opens, a point no
decoder rebuild reaches, and tests/crates/play/tests/worker_load.rs pins one
open per load and none for a refused load.
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).
A failed source follows the same producer chain with its audio-owned
TrackFailureKind. DecoderNode writes one decode-epoch-tagged PCM failure
marker and finishes the node, without publishing natural EOF for that failure.
The player drains already-buffered audio before reporting a failed stop.
PlaybackFault::Source preserves the classification in
TrackPlaybackStopReason::Failed and PlayerEvent::ItemDidFail; output-rate
and output-range faults remain distinct play-owned variants. The stop names
the same item id and source as the failing track. Formatting a queue-facing
diagnostic belongs to the off-RT consumer of that event.
Terminal-failure contract source: revision 39d93064ec16.
A track's speed changes on a frame of its own output, through its render
lane. Every track the worker opens gets a kithara-render
lane, and the worker hands the player end, a kithara-command sender of
LaneProtocol, back with the loaded track. The lane applies each LaneCommand
before the render quantum that starts on the LaneFrame the command is due at.
A renderer takes a new speed, keylock, or backend only when its lane executes a
command; there is no shared speed value for it to poll. How a renderer moves
onto a new speed or engine at that frame belongs to kithara-render and
kithara-warp.
TrackLanes in PlayerCore holds the lane of every track sent to the
processor, oldest first. A lane is held once the slot's command ring accepts its
LoadTrack, before the processor executes it. At that moment it is sent the
current speed for its next block, and it is released when the processor reports
its item Unloaded. The track's renderer was built when the track was prepared,
and the track may have preloaded since, so that first send keeps a set_rate
made in between. ItemQueue::take_for_load takes the sender off the resource
before the resource goes to the processor, so the audio thread never holds a
lane end. For each lane the player keeps a Live<TrackSettings, LaneProtocol>.
A change goes out as one batch and enters that lane's settings only once the
lane's receipt says it applied. Receipts settle before the next send to the
lane, and a rejected change is dropped with a warn.
TrackSettings holds what a track plays with that can change while it plays.
speed exists in every build. keylock and backend, and the
SetKeylock/SetBackend lane commands, exist only with a stretch-* feature,
like the matching WarpConfig fields. keylock is a
request: the renderer engages a keylock engine only on a backend that has one.
The player's one live change is speed. set_rate clamps the request to
MIN_SPEED (0.05, a kithara-warp constant this crate re-exports; a NaN
request lands there too) and checks it. A speed the check refuses changes
nothing and only warns; after the clamp, only positive infinity can fail.
Under one lock, set_rate then makes the speed the one the next track starts
at and sends it to every held lane for its next block (When::Next). The
player sends no timed change. Because of that lock, a track taken at the same
moment either is already held and receives the speed, or starts at it.
set_default_rate runs the same set_rate, and Queue delegates both.
Keylock and backend are construction values. Every track starts on the ones in
PlayerConfig.warp, at the speed the player last asked for and never at that
config's own speed, through TrackSettings::warp. The player sends no keylock
or backend change. WarpConfig's speed, keylock, and backend are where a
renderer starts, not document keys; the player's first speed is its
default_rate.
PlayWorkerConfig::lane_capacity sizes every lane's channel. It defaults to
128 and is a key of kithara-app's play_worker: document section. A lane
executes only while it renders, so a paused or backpressured deck keeps every
change sent to it in flight. The player does not merge queued changes, so the
capacity bounds how many speed changes a lane that is not rendering can take.
When a lane's channel is full, set_rate warns for that lane and goes on. That
lane keeps the last speed it accepted until a later set_rate reaches it,
while the other lanes and the media clock take the new speed.
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. The effective rate is the rate the processor applied from
PlayerCmd::SetRate, the media clock's rate, not a speed the renderer
confirmed. A lane that refused a change because its channel was full renders
its last accepted speed until a later set_rate reaches it, and only a warn
reports that.
The media clock does not read a shared speed either. It follows
PlayerCmd::SetRate, which set_rate sends to the processor after the lanes; a
SetRate the slot's command ring refuses is a warn, and the clock keeps its
old rate. The processor applies the rate to every loaded track, and a track it
loads later takes the processor's rate. play and select_item_with_crossfade
(which select_item calls) also send the current speed to their slot. Each
PlayerTrack then advances its media clock at that rate every block. The clock
moves when the processor takes the command. The lane renders the new speed
behind the frames already in the output ring, so what the device plays changes
later than the clock does.
WarpRenderer runs at ratio = 1/speed; key-lock off (the constructed default)
gives pitch = speed, vinyl-style. Without a stretch backend the same Warp
slot stays in the chain as an exact identity renderer: it ignores every speed
its lane is sent, its resource reports the fixed rate 1.0, and
TrackSettings::warp hands it the deck's WarpConfig unchanged. 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.
The PlayWorker dispatcher thread refills every deck's output ring, and a ring
drains while that thread waits for a CPU. On a busy machine an ordinary thread
can stay runnable without a CPU for longer than the ring lasts: in zvuk/kithara#606
the worker waited 327โ366 ms in one run-queue stretch against a ring of about
261 ms, with no logic stall. So PlayWorker builds its dispatcher with
ThreadClass::AudioFeed (kithara-platform,
kithara-worker).
Whether the OS grants it is the deployment's policy. When it refuses,
PlaybackObserver logs one warning and playback continues at the default
class; that is the designed degraded mode, and a busy machine can then starve
the rings again. A Linux deployment grants the class by giving the process
RLIMIT_NICE of at least 36 (for example docker run --ulimit nice=40:40, the
limit Android's init grants its processes) or CAP_SYS_NICE. The CI runner
containers carry --ulimit nice=40:40 (zvuk/kithara#610). Contract: source
revision deaafda65.
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 Firewheel fader volumes
(SessionDuckingMode::volume()): unity, Volume::Linear(0.4) and
Volume::Linear(0.2). The fader taper squares the linear value, so the session
output sits at amplitudes 1.0 / 0.16 / 0.04 (about โ16 dB and โ28 dB). The
Host master node smooths and applies volume().amp(); the taper is the only
volume-to-amplitude conversion. Ducking contract: source revision 99639ee0b. The live value is a Host setting: Cmd::SetSessionDucking changes that
one setting for the next block.
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::insert gives the Player
a SessionBinding holding the Host settings' sample rate at that moment, and
the Player refuses the binding, and any later registration, when its own rate
differs. Register and start commands carry no rate; the session starts its
output at the rate its settings name. 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. PlayerRuntime
declares phase before core, and PlayerCore declares engine and items
before config, so the engine's registered tracks and the undelivered resources
(which hold worker references) release before the Player's PlayWorker clone in
config.worker. Because that clone goes last, 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 and DecoderNode::tick; the master EQ
processor belongs to kithara-effects, and the session limiter and mix tap
processors 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 session transport is the sole owner of when the session beat-to-frame
relation changes. It moves the relation only on the frame a tempo change
applies on. A stream restart drops the transport snapshot because the frame
axis restarted, and the first block on the new axis re-anchors the preserved
beat at the settings' tempo 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 no measured rate until the session has measured an
output, and none again while a restart is pending, for example after a
sample-rate change. The requested rate beside it comes from the Host settings
and 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 a wasm32
build without resample-rubato or resample-glide. Decode, resampler,
HTTP-client, and TLS features forward to a backend in a lower crate; none add
behaviour here. The stretch features forward to kithara-warp and
kithara-render and also gate the keylock and backend track settings here, so
the crate builds without any of them. .config/just/deps.just and the wasm
check in .config/just/platform.just build it that way: speed is then the only
track setting, and every track renders on the identity renderer. 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. - A load opens its track's source at most once, and only after its worker slot is held.
- Once a track's renderer is built, a speed change reaches it only through its
render lane, and its media clock only through
PlayerCmd::SetRate. -
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.