kithara play - zvuk/kithara GitHub Wiki

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.

Planes & Ownership

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.

Buffer Pool Ownership

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.

Domain Policies

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.

Per-track producer chain

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.

Tempo & Key-Lock

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.

Engine Load

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.

Worker Thread Class

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.

Configuration document

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.

Live Equalizer Layout

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).

Events

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.

Queue Auto-Advance

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).

Engine Lifecycle

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.

Cancel Hierarchy

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.

Real-Time Audio Thread

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 in process_notifications. A full command ring surfaces as PlayError::SlotChannelFull, never as a block.
  • Render scratch is sized in new_stream, off the audio thread, and render_audio clamps its block to it, so a host exceeding its declared max_block_frames loses 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.rs derives 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, and TrackFade::play snaps only once its mix has settled, so a fade in flight keeps its ramp.
  • Facts leave the audio thread through PlayerNotification, rates and faults through PlaybackShared::metrics().
  • Memory ordering. Only playing and seek_epoch decide whether the audio thread acts, and only they carry SeqCst. Everything else is Relaxed, because RtMetrics counters are monotonic deltas and PlaybackSnapshot'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.

Seek Ownership

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_end holds while the track's epoch differs from the published one, costing blocks of silence, never signal.
  • apply_seek re-bases every loaded track - including ones the seek does not move, and a Finished-at-EOF track back to life - and a track planted later starts at the published epoch, so none can be born behind.
  • dispatch_notification drops a stale Eof. The comparison is !=, not <: epochs wrap, and withdrawal legally steps the published value back. Stop and Failed are never fenced - a broken source stays broken across a seek.
  • PlaybackShared::withdraw_seek_epoch is 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.

Session Actuator

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 Mixing

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.

Session Mix Tap

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 DisableMixTap then a fresh EnableMixTap.
  • 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 is Observer::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.

Route Changes

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.

Feature Flags

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.

Current Item

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.

Invariants

  • 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() returns None while duration is unknown; the shared atomic's 0.0 conflates "unknown" with "empty track", so callers must not read it directly.

Testing And Integration

The offline render backend for deterministic engine and player tests lives in kithara-integration-tests::offline, not here.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ