kithara host - zvuk/kithara GitHub Wiki
Documentation reviewed from source revision 19ca073f2. The runtime boundary, route and sample-rate, Host settings and session transport, and output taps and metronome sections reflect the commit that merged PR #546, 1edd1e4a6. These are source-derived contracts, not new runtime validation. API and usage · All crates.
The Host owns the session root sync group and its member values, the shared
Firewheel graph, session transport, output limiter, metronome, master and output taps, and
native or web audio backend. It also owns HostSettings, the live sample rate, tempo, metronome and session ducking, and TransportEvent, the committed tempo and failure facts published by that transport. The graph registry is only a projection of Host-owned members;
it must not become a second mutable synchronization topology. On wasm the
main-thread member owns the sendable synchronization state and desired level;
the remote Worker Host retains the Queue runtime and its worker-bound JS
resources until explicit removal.
kithara-play owns one player/deck, its render node, effects, fades, worker,
and playback flow. kithara-warp owns synchronization and warp contracts. The
dependency direction is kithara-host -> kithara-play and
kithara-host -> kithara-warp; neither lower crate may depend on the Host.
Host is the sole composition root for both realtime and offline sessions.
HostConfig<S> directly selects the platform device backend or offline
renderer; there is no nested session config or parallel offline Host type.
The offline mode owns the same root group, Firewheel graph, limiter, transport,
and member insertion path as realtime. Only its backend and scheduler differ.
The backend starts lazily on the first render when no player has started it
yet, so an empty master mix is valid silence rather than an unavailable graph.
Its renderer consumes one absolute finite frame range at a time. It may skip
forward by rendering undisclosed frames, never rewinds an already consumed
timeline, and writes only the requested range to RenderSink in blocks bounded
by the offline HostConfig::max_block_frames. The same config supplies the
composition root's PoolRegion<S>; each block is acquired from its f32 pool
and returned after the sink call, preserving the shared hard budget.
Signal-format mismatch, cancellation, backend failure, and sink failure are
terminal for that request. The composition owner finalizes the sink on success
and drops it on error; Host does not own storage or encoding.
The offline session is one kithara-worker task. Worker, dispatcher, and task
budgets come from the offline Host config; rendering is unpaced by default and
runs as fast as the caller requests. Optional pacing is probe-only and advances
the same canonical cursor, so deterministic finite capture must not combine it
with explicit render requests.
The selected session runtime is the canonical Host actuator. Realtime uses the
platform session owner; offline uses one kithara-worker task. It owns the root
group, member values, Firewheel context, graph commands, and offline cursor.
Closing a player remains a caller-side two-phase operation: close the runtime
first, then detach the member. Calling close from the owner loop could dispatch
back into that same loop and deadlock.
Terminal shutdown first drops the command receiver, then drops session state, then acknowledges the Host. This order disconnects queued callers before Player destruction takes the per-player admission gate, so an admitted command cannot wait on the owner while the owner waits on that command's gate.
A HostOwned endpoint contains the canonical member identity and the player's
cloneable control capability. It does not expose or own the inner Player,
retain players through Arc or Weak, or introduce a second command route.
The wasm remote Host's resident registry only retains Worker-bound runtime
values; membership remains canonical in the main-thread topology. The
immutable RootView publishes the grid, topology, status, stream shape, sample
rate, and the Host settings as the owner last settled them, which the Host's
getters read; it is a read-only projection, never another mutable root.
Worker teardown attempts explicit removal once. Success detaches and releases
the resident; SessionGone ends teardown without a retry because the canonical
owner no longer exists. Any remote Host that still has resident entries when it
drops logs the invariant failure and retains only those players. It neither
retries a potentially permanent failure nor drops runtime state while a
main-thread topology may still reference its member.
Host::insert accepts one fully configured Player or decorator instance. The
instance already owns its stable grid identity. Insertion attaches an opaque
SessionBinding exactly once. Native transfers the instance into the Host
root. Wasm transfers only its synchronization state and current desired level
to the main-thread root, while the remote Worker Host retains the resident
instance. The session graph copies that level when the player registers and
owns later graph actuation. Decorators only delegate the binding and ownership
split to their resident Player. Neither a config builder nor a raw
SessionDispatcher crosses the insertion API.
Native and web dispatch wrap lower Player commands and Host topology commands
in one private envelope. On web, the main-thread Host and its Worker facade use
that same envelope and shared RootView; the Worker is never given a raw
SessionHandle with which to construct an unattached player.
The envelope is typed by the composition root's pool schema. Host<S> accepts
only PlayerControlSource<Schema = S>, and each graph deck retains the same
PoolRegion<S> handle carried by the player's registration command.
On web, one local Host is exclusive per JavaScript thread. It owns the session state and every remote command receiver; TLS retains only a nongeneric active Host flag. Sender and receiver wrappers are capabilities, not owners. Host shutdown drops those receivers before session state so queued reply channels disconnect and a Worker call cannot wait forever. A replacement Host starts with cleared bridge playback observations.
If a command cannot be sent, ownership has not transferred and the original operation is rejected as owner-unavailable. If the sole owner thread stops after accepting an ownership-bearing command, commit state is unknowable. That single post-send boundary deliberately fails fast instead of fabricating a rejection or fallback state.
HostSettings::sample_rate, carried by the selected HostConfig mode, is the
only product default for the initial output sample rate. The realtime variant
also owns the optional native output block override; leaving it unset
preserves the backend default. Each inserted Player is built for an explicit
initial rate. The one-shot SessionBinding carries the canonical Host
dispatcher and the sample rate the Host settings name at insertion. The Player
compares its own rate with that copy, without querying the session, and
refuses a mismatch before it can register or start. Session register/start
commands carry no rate; the session starts its output at the rate its settings
name.
Web warm-up creates the AudioContext at the rate the Host settings name, the
same rate every Player is built for and checked against at insertion. The rate
the device settles on reaches the resampler as the measured rate.
A sample-rate change applies at the owner and never reaches the render graph as
a change. While a stream runs, the owner restarts the output route at the new
rate on the same Firewheel context; before then, the change only names the rate
the next stream starts at. A restart that fails keeps the new rate for the next
one. Because the render graph never sees the change, one configured for a
session frame is refused with PlayError::Untimed. The HostSettingsControl
getter sample_rate() reads the requested rate; Host::output_sample_rate()
reads the rate the output runs at as measured, beside the requested one.
Route changes keep the existing flow: the Host observes the device change and the Player receives the resulting sample-rate update through its current control path. A physical route change and a sample-rate change both restart the output stream on the same Firewheel context; neither rebuilds the graph. The output graph is rebuilt only after an idle teardown, once every deck has stopped. Decode resampling, the Stream-to-decode hook, worker scheduling, fades, and playback semantics are not redesigned by this extraction.
HostSettings is the one type that holds the Host's runtime settings: the
sample rate (44.1 kHz by default), the tempo (120 BPM by default), the
metronome, and session ducking (off by default). HostConfig carries only
their starting values. Once the Host starts, the session owner holds the
settings as the render graph confirmed them, and the session transport holds
the copy it applies. Host implements Configure<HostSettingsChange> by
sending each change to that owner; the derived HostSettingsControl and
MetronomeConfigControl traits give it field getters and next-block setters.
A change of the tempo, the metronome or ducking is a command to the render
graph through the kithara-command queue. It is due at the next block
(When::Next, which every generated setter sends) or at an exact session frame
(When::At, through configure). The session transport applies it on that
frame, inside a block if need be, and answers it with a receipt. The Host's
getters read the settings as the owner last settled them: a render-side change
shows once its receipt arrives, or at once when no render graph runs.
The owner refuses a change before it is queued:
- a change for a frame earlier than the audio clock plus one maximum block is
PlayError::Late; a change for a frame while no stream runs isPlayError::Untimed; - a full queue is
SessionError::HostQueueFull.
The render side refuses changes internally, and no caller matches on its reasons:
- a route restart refuses every change still waiting for a frame on the old stream, because those frames do not exist on the new axis; changes due at the next block wait for the new stream;
- a block that does not start where the last accepted block ended, or that runs at a rate other than the beat anchor's, is refused, and so is every later block of that stream until a route restart. Such a block carries no render context and applies no tempo change; metronome and ducking changes due in it still apply.
The owner sends a refused next-block change again, unless a newer change of the
same setting, also due at the next block, is already in flight. A frame change
the render graph does not apply is dropped and reported as
TransportEvent::Failed on every deck's event bus, with the reason as text; an
unanswered change is only logged. When the last deck stops and the session
drops its render graph, every change still in flight, frame changes included,
folds into the settings, and the next stream starts with them.
The session transport is not a play or seek transport. It runs while the
session renders: on a native session while at least one deck has started; an
offline session also renders on request with no deck, and a web session keeps
its output after the last deck stops. The first
block of a fresh stream anchors session beat 0 at the settings' tempo; after a
route restart, the first block anchors the beat the old stream stopped on.
After that the transport re-anchors the beats only on the frame a tempo change
applies on, and from that frame the tempo turns toward the new value over about
5 ms, so the beat curve bends instead of stepping. A tempo change the render
graph applied and confirmed is announced as TransportEvent::TempoCommitted on
every deck's event bus; a tempo set while no render graph runs, or folded at
teardown, changes the setting without an event.
Each applied change due inside a block starts a span at its frame. The metronome and the master node at the head of the session output render span by span, so a change sounds from its frame rather than from the block start. The master node moves its gain toward each span's ducking along a 62 ms curve.
A deck's render context carries one beat anchor per block, the one the block ends with. With two tempo changes in one block, a deck reads every frame before the last change at the tempo playing on that change's frame, so the frames before the earlier change are read at the wrong tempo. The metronome follows each change from its frame.
The session output runs mix, master (session ducking), limiter, metronome,
device. Two taps expose it: Tap::Master carries the ducked, limited mix
without the click, for broadcast and
recording oracles; Tap::Output carries what the device hears, the limited mix
plus the click. A tap has at most one consumer group:
Host::attach_outputs(tap, group) refuses a second one, and
Host::detach_outputs(tap) removes it.
The metronome is Host-owned and reads the session transport's beat grid. While
enabled and while the session renders, it clicks once on every session beat,
counting four-beat bars from session beat 0; the downbeat sounds higher and
louder. It is off by default. metronome().set_enabled() switches it from the
next block, and configure with a MetronomeConfigChange::Enabled change at
When::At switches it from that session frame. A click that is sounding when
it is switched off finishes. While it is off,
Tap::Output carries the master mix bit for bit. A route restart neither
repeats nor drops a beat.
HostSettings::metronome holds one MetronomeConfig with these fields:
- whether it is enabled;
- the click level as a share of the limiter ceiling (
0 < level <= 1); - the duck depth (
0..=1, zero turns the duck off); - the click's decay (8 to 50 ms);
- the duck's hold (at most 1 s) and release (8 ms to 1 s).
Each bounded field declares its own check (enabled has none), and the derived
kithara_config::CheckedConfig::validated runs them in declaration order. The
builder checks nothing, and Default returns in-bounds values. A Host start
validates the config. It refuses the first out-of-range field with
PlayError::InvalidParameter, which names the field (metronome_level,
metronome_duck, metronome_decay, metronome_hold, metronome_release).
enabled and level are the config's live fields. metronome().set_level()
checks only the level and changes it alone. A level change applies from its
frame: the first click from there on sounds at the accepted level, and so does
every metronome node built after a graph rebuild. A sounding click keeps its own level, and a refused
level keeps the last one. The duck, decay, hold and release are fixed once the
Host starts.
A duck at least as deep as the level keeps the ducked mix plus the click under
the limiter ceiling at every sample. A shallower duck, or none, is a product
option that lets a loud mix plus the click pass the ceiling. Between samples
the duck folds loud mix content near Nyquist over the ceiling; the crate
documents the measured overshoot. Tap::Master is unaffected by either.