-
Notifications
You must be signed in to change notification settings - Fork 3
kithara app
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.
pools::AppPools is the desktop composition schema. main builds one
PoolRegion<AppPools> and gives the same facade to the asset store, HTTP
client, playback worker, queues, and analysis cache. Its u8 and f32 slots
compete under one 256 MiB hard cap; startup allocation is declared in the
schema configuration rather than warmed later by a component.
AssetPartSink is the application composition seam between storage-neutral
kithara-record and the canonical AssetStore. It acquires one phase-typed
writer, maps random-access container writes directly to that writer, and
publishes only through consuming commit(final_len). Abort first closes the
writer and then removes that exact relative resource through AssetStore's
canonical deletion channel; a cancelled or failed recording must not leave an
active partial asset. Encoding and recording crates never receive a filesystem
path or an AssetStore.
The crate owns only the service wiring; main builds the complete BroadcastConfig with the app's shared worker, pools, and cancellation parent. Packaging, the bounded intake, and the origin belong to kithara-broadcast. A request
stays Requested until the Host exposes its measured output rate, which replaces only the configured sample
rate before BroadcastOutput is installed in the single Host OutputGroup. App-root cancellation ends the origin and encoder; stopping the
running phase releases the output group before the encoder drains.
Stopping blocks - it closes the bounded intake and waits for the encoder tail - so the toggle moves the handle
into an iced task and marks the service Stopping; only that task's completion message makes it Off. The GUI
tick polls BroadcastHandle::status, so an output released by a device-rate change reaches Off the same way.
The UI is a compiled kithara-ui document set and gui::ui is the host side of it. Registry declares every
endpoint the documents may bind, AppUi::new compiles both layout documents against it and returns
UiDocError, and a unit test compiles both, so a compile failure is a build defect rather than a runtime
condition. compile_ui merges builtin::text_doc() with assets/ui/app-en.ktext.ron before every compile;
that catalog holds only the window-manager menu words canon has no key for.
AppConfig.ui_package names the folder holding the UI package. Three sources name it, most specific first:
--ui-package on the command line, then the document's app.ui_package, then assets/ui beside the executable,
which is where a release lays its documents out. main seeds the builder with the last, lets the merge write the
middle, and applies the flag afterwards, so a document key never overrides the path a person just typed. AppUi::new
reads that folder over what the build embeds, so changing a document on disk changes the interface at the next
start without a rebuild.
A path that does not exist means no package was laid out and the build's own documents draw; that is what a
developer running from a build directory sees. Anything else that stops the folder being read - a permission,
a manifest that fails the kithara-ui contract - stops the application rather than quietly drawing the
built-in one. This is the one place the application accepts a missing input as an answer, and it is a
user-facing default rather than a state-resolution fallback: the package is optional configuration, and its
absence is not evidence of a broken contract.
gui::ui::package::Package is the single owner of one loaded package: the resolver it is read through, the
screens it answered for, and the skin and catalog it dresses them in. Both hosts read from that one value -
the iced host paints with Package::skin, and the retained host builds its window Config from the same
resolver and catalog rather than loading a second copy. Two packages drawing one application is the failure
this shape exists to prevent.
The application asks the package for deck-single and deck-dual by role, and Package resolves both once.
A manifest may also name a skin document and a caption catalog; naming a skin is what lets a package change
how the application looks without a rebuild. A manifest that names neither wears the built-in skin and the
built-in words, which is a package carrying pages and nothing else - declared optionality, not a fallback.
Package::REQUIRED is the whole of what a package must answer for, checked once each screen compiles:
-
deck-a/play- the only path that starts and stops playback. A screen without it draws a player that cannot play. -
deck-a/wave- the only path that moves the position within a track. A screen without it can start a track and never move inside it.
Everything else a screen offers is the package's own business. The minimum is checked rather than assumed because a screen missing a path still compiles and still draws; only the paths it answers on say whether the application can reach it, and a press that lands nowhere reads as a dead button rather than as a package defect.
ReadRoot::new is the one place the app state is cut into domains; each node below it holds one slice and
answers only its own addresses, so no type carries the whole vocabulary, and Walk turns the renderer's flat
endpoint key into a walk over it. A binding scope (@deck=a) selects an instance rather than naming a path
segment: the node owning the instances spends it. ViewCache owns what the renderer borrows but the model does
not hold: converted waveform columns, formatted strings, per-deck zoom and quality-menu flag, collapsed modules,
the hovered and focused deck, and the deck layout. Smaller views sit beside them, one owner each:
MenuState (which menu group is open), Modules (which pane the menu switched off), WindowState (what the
single window reports), LibraryView (the library's own query and scope) and StageView (the tempo-map window
edges and the visualisation preset, answered by TempoNode/VisNode). A view is read through ReadRoot and
written only by ui::events; nothing else holds a second copy.
AppUi carries the compiled document set and a Clock. The clock is what answers ui.clock.seconds, so a frame is
reproducible from the state that produced it: update steps it once per tick and both hosts read the same value, rather
than each sampling a wall clock of its own.
Progressive source analysis derives the coloured waveform and an optional beat grid / BPM estimate from decoded
ranges. AnalysisService is the one owner of analysis values: the TrackAnalysisRunner (one pass in flight),
the two-tier TrackAnalysisCache, the AnalysisPersistence client, and one entry per analysed resource. The GUI
frontend creates both once and hands a cloneable AnalysisHandle to every deck StateController. Requests arrive on one channel; values leave on one watch channel per entry.
Nothing else writes an analysis value. Two identities stay separate: TrackId (session-scoped, never persisted)
names the track a request is about and where a pass hands its producer (attach_observer); AnalysisTarget
(the track's AssetStore plus the ResourceKey from ResourceConfig::asset_key) is the entry and cache
identity, and is_same compares key and store.
subscribe(queue, track_id, source, axis) resolves the target, points the entry at the requester, seeds it from
memory then disk for that axis, and returns its receiver; a source with no resource or a rejected key gets a
closed receiver. warm(queue, track_ids, axis) puts a library list in line behind
every held entry without reading the cache and leaves a held entry's requester alone; the seed is read when the
pass opens. Readiness has one rule, settled_for: the pass is settled and every artifact the configuration's
fingerprint expects is present. A gap the source cannot deliver, the head an encoder's priming leaves in front
of a track, stays in missing() for drawing and keeps nothing from being done. An entry that is not settled is
scheduled; a resumable seed resumes, any other opens a fresh pass above the revision the entry holds, so
revisions stay monotonic per token across passes. A checkpoint the runner rejects is no seed: the pass opens
fresh. An entry is done (Stage::Ended on that axis) only when its pass closed on a settled value; a close
without a value or on an unsettled one leaves it Idle, and the next subscribe or warm schedules it again. Queued means in line; with
no analyzer compiled in nothing is queued. The player's current track takes no part in this path.
The runner serves held entries (a live receiver) before warm ones, each in request order. A subscribe whose
entry is not the running one ends a background pass (runner.clear()) and puts it back in line; a pass for a
held entry is never ended by another held entry, which waits. Every request names the engine rate axis; a pass
on another axis is ended and its entry put back in line.
A deck observes the track it shows, UiState::current_track_index, whether or not it plays. On TrackAdded /
TrackRemoved the listener keeps that index naming a track: the player's current item, else the track shown
while the list still has it, else the first. It re-subscribes on QueueEvent::CurrentTrackChanged,
EngineEvent::Started, SessionEvent::RouteChanged, TrackAdded and TrackRemoved, warms the list on the last
two, and drops its receiver before it asks for the next track. A lagged event bus is a resync: the list and the
current index are read from the queue, then the deck follows and warms again.
Every revision a pass publishes goes to the entry's sender, cache.put, and persistence.try_store; a full
persistence queue drops only that intermediate write. When the pass's channel closes, its last value is cached
and sent, and the service awaits persistence.store before the runner takes the next entry. The cache tiers are
the store: an entry no deck holds drops its value when its run ends and is seeded again on the next subscribe.
The deck listener mirrors its receiver into UiState::analysis when the (token, revision) differs from what
is shown; DeckCache::refresh_wave redraws on the same pair.
The memory tier holds Consts::MAX_MEM_ENTRIES (64) in insertion order; evicted entries are served from disk. An analysis with neither waveform nor beat grid is memoized in neither tier. Disk reads probe
AssetStore::resource_state first: opening a missing key would create it. The disk tier stores one
progressive AnalysisFile per track in the track's asset scope (analysis/track.analysis), so it is evicted,
moved and deleted with the audio bytes. Its header and completion index identify covered chunks; each
committed generation replaces the payload. Restore validates fingerprint, source rate, extent and chunk duration
before resuming only missing ranges.
The cache and the persistence actor each reuse one checked-out byte scratch buffer. Capacity up to 2 MiB
remains charged as fixed working memory between operations; normalize releases larger allocations after
each operation. These guards are active rather than idle pool inventory, so region pressure does not reclaim
them.
Invalidation has two levers: the composite codec version in kithara-analysis, bumped whenever its framing or
the waveform / beat-grid encodings change, and analysis_fingerprint, written into every blob so a
configuration mismatch is a miss and waveform_max_buckets or beat-analysis tuning re-analyse on their own.
The identity is the source location, not the bytes: a file overwritten in place keeps its entry until the
version is bumped, acceptable for a library of stable files.
Two layers: build.rs embeds crates/kithara-app/app.yaml verbatim and the application parses that same text at
startup, so the build decides nothing about what a field means, and a kithara.yaml beside the executable — or the
one --config <path> names — is laid over it. document::merge and its tests own what merging does to each node.
app.yaml must never name ui:, draw_pool:, or any future #[cfg(feature = "gui")] section: a lib-only
build's Document declares no such field, and the integration suites load this exact document.
The pipeline is merge → expand → type, and that order keeps secrets out of the logs. Both earlier steps work on the
untyped tree, so a schema failure is reported from the pre-expansion one, naming $SECRET rather
than the value behind it, and Config keeps that tree for Config::dump (--dump-config) and its Debug. The
typed tree holds resolved secrets and is therefore deserialize-only: a Serialize anywhere in it is a leak.
Secret values stay outside the tracked configuration. Typed configuration contains resolved values and must not be serialized or logged. Public configuration examples must contain placeholders only.
Every section names the crate that owns the setting and carries that crate's own patch type, so a value is spelled
once, in the crate that defines it; document::schema is the list, and a list repeated here would only rot. Which
knobs a section may name, and the argument for each left out, is the owning crate's contract. The patches travel
whole — AppConfig carries hls, file and audio to ResourceConfig, and main stops the store builder one
step short with into_config() to patch assets_store — so a knob those crates add later needs no edit here.
pools is the one section no crate patch can carry: pool_schema! leaves no shared region type to derive on, and
kithara-bufpool owns that argument.
app also carries the audio session. HostConfig is a session mode, Realtime or Offline, not a configuration
struct: it has no patch, and a document cannot flip a running application between them. app.sample_rate and
app.output_block_frames reach the Host builder in main, both optional; the rate stays spelled once because
Deck::build reads it back off Host::requested_sample_rate.