Skip to content

kithara app

Pavel Litvinenko edited this page Sep 8, 2026 · 1 revision

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.

Buffer-pool ownership

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.

Recording asset adapter

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.

Broadcast service

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.

UI host

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.

Where the UI package is read from

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.

Reads and host-owned view state

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.

Track analysis

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.

Configuration document

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.

Crate sections

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.

Clone this wiki locally