kithara host - zvuk/kithara GitHub Wiki

kithara-host

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.

Ownership

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.

Offline rendering

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.

Runtime boundary

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.

Route and sample-rate invariant

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.

Host settings and session transport

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 is PlayError::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.

Output taps and metronome

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.

⚠️ **GitHub.com Fallback** ⚠️