Repository navigation
kithara render
Documentation reviewed from source revision 41918f1c9. This records the documented contract at that revision; it is not a new runtime validation. API and usage · All crates.
This crate owns the producer-side render stage of one track and the render
lane that changes it on a frame. WarpSource is the only Warp/effect stage
before the play output ring: it steps a decoded AudioSource through the
track's WarpRenderer and its effect chain one render quantum at a time. It is
itself an AudioSource, so the play worker steps it like any decoded source.
The lane is LaneProtocol over a kithara-command channel: the stage holds
the Inbox, executes LaneCommand batches on frames of its own output, and
answers each batch with a receipt.
Neighbours own the rest:
-
kithara-warpowns the renderer: speed evaluation (SpeedCurve), engine selection and keylock, the re-prime and tail fade that follow a change, and the quantum geometry that honours an output cap. -
kithara-effectsownsAudioEffect,apply_effects, and theEffectDrainthat flushes effect tails. -
kithara-commandowns batch numbering and ordering, the late and stale verdicts, receipts, and the credits that bound a channel. -
kithara-audioandkithara-streamown the decoded source, its decode epoch, seek observation (SeekObserve), and discontinuity revisions. -
kithara-play owns what a lane is told and when.
PlayWorker::loadbuilds one stage and one lane channel per loaded track, sized byPlayWorkerConfig::lane_capacity(128 by default), and the player holds the lane'sSender.
The crate depends on the engine layer (kithara-audio, kithara-effects,
kithara-warp with its render feature) and on kithara-command,
kithara-bufpool, kithara-signal, kithara-stream, and kithara-platform.
It depends on nothing in the player. .config/arch/direction.toml places it in
the sync layer, above engine and below playback, so kithara-play may
depend on it and no engine crate may.
Decoder, HTTP, and TLS features forward to kithara-audio, which does not
build without a decoder and an HTTP backend; stretch features forward to
kithara-warp. The defaults match kithara-play's decoder, HTTP, TLS, and
stretch defaults, and the workspace dependency turns default features off, so
the player's choice wins.
LaneCommand::SetKeylock and LaneCommand::SetBackend are available in
every backend build, as are WarpConfig's keylock and backend. At least one
backend feature is required, including explicit stretch-identity; backend
capabilities determine whether rate changes and keylock apply. The native
Signalsmith default is preserved. Backend selection does not change the lane
command or configuration surface.
LaneFrame counts the frames the stage has emitted, after the effect chain,
from the first frame it emitted. Every Data chunk it returns advances the
cursor by its frame count, renderer and effect tails included; end and failure
markers pass through without advancing it. A seek or source discontinuity does
not rewind it: the axis is the track's output, not its source position. A
renderer reset after a seek clears DSP state but keeps the speed, keylock, and
backend the lane last set.
The output cap described below applies to the renderer's output, while the cursor counts what leaves the effects. The two agree while every effect returns each chunk it takes with the same frame count.
The stage executes batches only at quantum boundaries. Before it prepares a
quantum, it drains the inbox and applies, in order, every batch due at the
cursor: those sent for When::Next and those for When::At the cursor's
frame. It treats the cursor as a one-frame block (next_due(cursor, 1)), so a
batch for a later frame waits, and a batch for an earlier frame is answered
Late on the way. Batches due together apply in the channel's order, by moment
and then in send order, before the quantum is planned, so an engine batch and a
speed batch on one frame start the new engine at the new speed
(an_engine_and_a_speed_batch_on_one_frame_engage_the_new_engine).
It then prepares the quantum with an output cap: Inbox::frames_until_due
from the cursor to the earliest batch still waiting, or no cap when none
waits. The renderer never renders a quantum at its own speed past that cap. It
lands exactly on the cap once the cap fits in one quantum and the source at hand
covers it, so a quantum starts on the batch's frame and the batch applies there
from any speed (a_speed_batch_applies_on_its_lane_frame,
a_speed_batch_lands_on_its_frame_from_any_speed). Lane commands drop any
quantum the renderer planned before them, so the quantum that starts on a
batch's frame is planned under it. A quantum that follows a warp plan installed
in the renderer's plan slot keeps the plan's geometry and ignores the cap.
The pending-input and quantum scheduler is common to Identity and DSP backends.
Identity uses equal source/output spans and preserves PCM despite requested
speed/keylock changes. A scheduled At inside an unprepared decoded chunk caps
the preceding quantum and applies at its boundary, including when Identity is
selected. The stage retains the decoded suffix for the next quantum rather than
emitting the whole chunk and answering the scheduled batch late. A keylock
request is effective only on a capable backend.
An executed batch is always applied: Outcome::Applied { at, data: () }, where
at is the batch's frame, or for When::Next the cursor it applied at, the
first frame of the quantum it starts. The lane answers one rejection,
Rejection::Late, for a batch whose frame lies before the cursor when the
stage next executes. A late batch changes nothing. That covers:
- a frame the stage already emitted
(
a_batch_for_a_rendered_lane_frame_comes_back_late); - a frame within a quantum the stage already prepared, its first frame included, while that quantum waits for source input or renders. The lane's frontier is the start of the quantum it committed, and a later batch does not split it, so the receipt says so rather than applying the batch off its frame.
A sender that names When::At therefore leads the lane by at least one
quantum. A When::Next batch that arrives while a quantum is prepared applies
at the start of the following one. The player sends When::Next only.
LaneProtocol has no target (Target = Infallible) and no refusal
(Refusal = Infallible). Lane commands set parameters and shift no time, so
each batch carries an empty basis and is never Stale or Refused.
Each chunk rendered toward a SetSpeed target carries that batch's Seq as
render_revision, and chunks rendered at the construction speed carry 0. The
samples keep the change they render through the output ring.
The renderer takes its first speed, keylock, and backend from WarpConfig's
construction values; from then on only the lane changes them, through
SetSpeed, SetKeylock, and SetBackend. A varispeed change continues the
source at the new step from its frame, with no jump
(a_speed_change_continues_the_source_at_the_new_step). On a running engine
with latency, a speed change retires the old engine's tail into a crossfade and
starts a fresh engine from the source history at the frame; a keylock or
backend change on a running engine does the same with the new engine. From
the frame on, the lane renders what an engine started there renders
(a_keylock_speed_change_renders_on_as_an_engine_started_on_its_frame,
an_engine_batch_renders_on_as_its_engine_started_on_its_frame), and a second
change inside the fade fades from what sounds
(a_speed_change_within_a_tail_fade_fades_from_what_sounds). Where no engine
runs, the renderer refuses to prepare a quantum that would engage an engine
other than the requested one, so the engine is rebuilt in the deferred phase
before it activates.
A keylock request on a backend without keylock runs that backend without it,
and the request stays. A return to unity on a running keylock engine is the
exception to the re-prime: the old tail drains as output before passthrough, so
that change sounds about one engine latency after the frame its receipt names.
The engine mechanics are kithara-warp's.
An Identity/native backend change uses this same lane boundary. Pending input and its consumed-frame offset remain owned by the stage until every decoded suffix is rendered. A native-to-Identity switch first finishes the native transition tail, then Identity resumes the retained source suffix at unity. Switching back prepares the selected RATE backend under the retained controls.
No batch executes while the stage drains.
- While the renderer drains a transition (
transition_pending: a re-prime, an engine switch, or a return to unity), the stage only flushes it. Output emitted meanwhile passes the effects and advances the cursor, so a frame that passes during the drain comes backLateafterwards. - At source EOF the stage flushes the renderer's tail, then each effect's tail
through
EffectDrain, then answers oneEof(every_effect_tail_precedes_the_single_eof). ThatEofrepeats for the decode epoch without pulling the source again; a seek or a new discontinuity revision reopens the stage (exhausted_drain_stays_terminal_for_the_decode_epoch). A batch due during the EOF drain is neither executed nor answered until a reopened stage prepares its next quantum, which answers itLateif its frame passed. A stage dropped at unload answers nothing still waiting.
Every backend uses the common pending-input and quantum lifecycle. The stage holds one decoded chunk with its consumed-frame offset. When a quantum needs a prefix or several decoded chunks, it copies only the required frames into stage-owned pooled storage. A whole decoded chunk that matches the prepared quantum renders through its original buffer. Identity bypasses elastic DSP and preserves the buffer it receives, including when that buffer is a staged prefix. With no split required, an Identity chunk retains the original decoded pointer and sample bits despite initial/live controls.
Pending input survives an engine switch and resumes on the replacement engine
(projected_backend_switch_keeps_pending_input_during_resident_output,
scheduled_identity_native_changes_preserve_the_decoded_suffix). Quantum
scheduling is independent of backend features; capability checks determine
whether the renderer needs elastic staging.
Two phases split the work. step_track, which DecoderNode::tick in
kithara-play calls inside its forbid-blocking region, stages, renders,
flushes, and emits. Due batches execute before the next quantum is prepared,
including a quantum from the held decoded suffix. prepare_deferred, which the worker calls from Task::recycle outside
the tick, returns a retired decoded chunk to its source, prepares the source,
services the renderer (WarpRenderer::prepare, where an engine is built),
executes the batches due at the cursor and prepares the next staged quantum,
and services each effect. Effects are serviced after the source prepares and before it finishes
(deferred_shell_services_effects_between_source_phases).
Each input the stage holds keeps the decode epoch it came from. step_track
first adopts a new source discontinuity revision, which discards staged input
and resets the renderer and effects. It then discards pending input, a staged
quantum, or a drain whose epoch no longer matches both the live seek epoch and
the source's decode epoch, and resets the renderer and effects before
new-epoch audio is admitted. A seek that leaves held state stale and brings a
new revision resets them once
(seek_cancels_stale_tail_and_resets_effects_once). A spec change under the
same revision refreshes the spec without a reset
(discontinuity_refreshes_spec_without_resetting_same_revision).
A failed quantum stays failed: step_track answers
Failed(TrackFailureKind::Render) without pulling the source until a new
discontinuity revision or a stale epoch discards the staged input. A renderer
that accepts no input, such as one whose engine the pool could not build, also
reports that render classification before pulling the source
(unavailable_warp_target_fails_before_pulling_source). A rejected quantum
keeps both buffers it owns, the decoded chunk and the staged copy, instead of
freeing them in the step
(rejected_staged_quantum_retains_both_owning_buffers). An upstream
TrackStep::Failed(failure) passes through unchanged: the render adapter owns
its local rejection cause, not the source's decode or recreation cause.
Terminal-failure contract source: revision 39d93064ec16.
An emitted chunk carries a SourceEnd whenever the renderer reports a rendered
source end: that end less the frames the effects still hold, so the frontier names only source
audio that has left the stage
(buffered_frame_changing_effect_tracks_live_and_flush_frontiers).
entry_position reports where a renderer that enters a warp plan starts
decoding, as a position in the source's own timeline; kithara-play seeks a
staged lane's source there before it starts the lane.
Executing a batch allocates nothing. LaneCommand is Copy; executing one
writes the renderer's requested speed, keylock, or backend and drops its
planned quantum. Draining, judging, and answering use only the schedule and
lock-free rings kithara-command reserves when the channel is built
(draining_judging_and_answering_never_allocate). A receipt carries the batch
back to the sender, so its vectors are freed on the player's side. Engine
builds run in WarpRenderer::prepare, which the stage calls only from
prepare_deferred.
- A command joins
LaneCommandonly once the lane applies it on its frame. A command applied anywhere else would make the receipt'sApplied { at }false. - The executor sizes its own block.
Inbox::frames_until_dueexists so the lane ends a quantum at the next batch rather than applying inside a fixed block. -
SetSpeeddrops a planned quantum, so aWhen::Nextreceipt names the frame the new speed starts on, not a frame one quantum before it. - A frame inside a prepared quantum comes back
Laterather than applying late: the receipt stays truthful, and the cost is that anAtsender leads the lane by one quantum. - The renderer lands a capped quantum exactly on the cap: once the cap fits one quantum and the source covers it, it asks for the fewest source frames whose stretched output reaches the cap, forces the last request's output to it, and carries the rest. Without that, a one-frame cap at a speed other than 1 exceeded the renderer's source block limit and failed the track.
- The renderer refuses a quantum that would engage an outdated engine. Without that, an engine batch and a speed batch on one frame of a lane at unity engaged the old engine for one quantum and then crossfaded, off from an engine started on the frame.
- A return to unity keeps draining the old tail as output instead of taking a re-prime path of its own; the cost is that change sounding about one engine latency late.
- Identity uses the same pending/quantum scheduler, so an
Atboundary splits an unprepared decoded chunk and backend changes preserve its remaining suffix. - The lane is concrete over
WarpSource, with no trait seam: its tests drive the real stage through a real channel.
just test run --lane=render runs the crate's suite; the Bungee cases need
--features stretch-bungee. The crate has a lane of its own because it
declares neither the flash nor the no-block toggle, and cargo refuses a run
that passes a feature no selected package declares; the render lane leaves
both toggles off and adds no network backend. Frame exactness is pinned at the render level: each alignment test
compares the lane's output, once the old tail has faded, with an engine started
at the change's source frame, and requires zero offset and a correlation above
0.95.