Skip to content

kithara render

Pavel Litvinenko edited this page Oct 6, 2026 · 3 revisions

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.

Ownership

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-warp owns 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-effects owns AudioEffect, apply_effects, and the EffectDrain that flushes effect tails.
  • kithara-command owns batch numbering and ordering, the late and stale verdicts, receipts, and the credits that bound a channel.
  • kithara-audio and kithara-stream own the decoded source, its decode epoch, seek observation (SeekObserve), and discontinuity revisions.
  • kithara-play owns what a lane is told and when. PlayWorker::load builds one stage and one lane channel per loaded track, sized by PlayWorkerConfig::lane_capacity (128 by default), and the player holds the lane's Sender.

Dependency boundary

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.

Lane axis

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.

When a batch executes

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.

Receipts

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.

Speed and engine changes

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.

Drains

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 back Late afterwards.
  • At source EOF the stage flushes the renderer's tail, then each effect's tail through EffectDrain, then answers one Eof (every_effect_tail_precedes_the_single_eof). That Eof repeats 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 it Late if its frame passed. A stage dropped at unload answers nothing still waiting.

Stage lifecycle

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.

Real-time constraints

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.

Decisions

  • A command joins LaneCommand only once the lane applies it on its frame. A command applied anywhere else would make the receipt's Applied { at } false.
  • The executor sizes its own block. Inbox::frames_until_due exists so the lane ends a quantum at the next batch rather than applying inside a fixed block.
  • SetSpeed drops a planned quantum, so a When::Next receipt names the frame the new speed starts on, not a frame one quantum before it.
  • A frame inside a prepared quantum comes back Late rather than applying late: the receipt stays truthful, and the cost is that an At sender 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 At boundary 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.

Testing

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.

Clone this wiki locally