-
Notifications
You must be signed in to change notification settings - Fork 3
kithara ffi
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.
src/lib.rs is the single structural target boundary: core and player are shared, native is
gated on non-wasm and web on wasm. The arch.no-target-os-outside-platform ast-grep rule exempts
src/lib.rs for exactly that split; narrower platform gates (mod android, mod android_test) live
one level down in src/native/mod.rs. android_test additionally requires the crate-local test
feature and must never ship in a release AAR. FfiAssetLayoutRegistry, FfiAssetStore, and
FfiPlayerConfig are native-only, so the shared AudioPlayer facade has a native-only
new(FfiPlayerConfig) constructor; the wasm surface constructs the same facade with no config.
Both device flows drop default features, so symphonia is absent and the hardware backend is the
sole decoder on-device.
-
xtask applebuilds frameworks withuniffi,apple,dev,stretch-signalsmith. The crate-localapplefeature forwardskithara/apple-fused-src(plusapple-netand the matchingkithara-play/kithara-queuefused-SRC features), so Apple AudioToolbox decodes directly to the host rate through decoder-embedded resampler placement. That set intentionally enables neitherresample-rubato,analysis-beat, noranalysis-waveform. -
xtask androidbuilds release JNI libraries withuniffi,android,stretch-signalsmith(debug addsdev,test). The facadeandroidfeature keeps the fixed-ratio rubato stage (resample-rubato) andanalysis-beatenabled; Android does not use the Apple fused-SRC path.
The native cache object graph is owned in Rust:
-
FfiAssetLayoutRegistryis a shareable UniFFI object holding the RustAssetLayoutRegistrybehind a mutex.register(target, layout)replaces the layout for the file or HLS target; targets are independent and the latest registration for one target is the registry's current value. A replaced foreign layout is dropped after the lock is released, so a foreignDropmay re-enterregisterwithout deadlocking. -
query_identity_layout(rules)takes orderedFfiCacheIdentityRulerecords (domain patterns plus application-defined query parameter names) and returns a Rust-owned layout. Callers install it with the ordinaryregistermethod for file, HLS, or both; no foreign callback participates in its cache-key derivation. -
FfiAssetStore::new(root, registry)snapshots the registry and builds oneAssetStore. Construction returnsResult; an invalid pool configuration is reported asFfiError::Internalinstead of panicking. NeitherFfiAssetStorenorFfiPlayerConfighas a productionDefaultimplementation, so native callers always pass the shared store explicitly.root = Nonepreserves the platformStorageBackenddefault; a supplied root selects that outer disk directory without changing paths inside an asset root. -
FfiAssetStorealso owns thePoolRegion<FfiPools>shared by cache, network, decode, and playback, plus a store-specificCancelScope. Dropping the last foreignArc<FfiAssetStore>cancels that store subtree. Player cancellation is a separateCancelTokenroot and does not redefine the shared store lifetime. -
FfiPlayerConfig.store: Arc<FfiAssetStore>is the only asset/cache field on the player configuration.NativeInnerretains that object, clones its pool facade, and clones its innerAssetStorehandle into the queue and every resource, so one FFI store can back multiple players.
Registry mutation and store configuration are separate lifetimes. Registering or replacing a layout
after a store exists does not alter that store; only a later FfiAssetStore::new observes the new
snapshot. A store retains the foreign callbacks captured by its snapshot even if the registry or the
caller's original callback reference is released. Generated bindings expose the Rust registry and
store objects, and platform adapters only retain and forward them.
Foreign root and path callbacks receive complete owned FFI values. root is invoked once per
asset-scope construction and path once per resource-key construction; repeating scope or key
construction invokes the corresponding callback again. After a key is minted, cloning the scope and
all acquire, open, read, write, seek, state, availability, demand, and eviction operations stay in
Rust and never cross the FFI callback boundary.
Callbacks must be deterministic, fast, non-blocking, non-throwing, and safe on background threads.
Invalid output fails scope or key creation; it is neither sanitized nor replaced with the default
layout. The exact component rules live on the FfiAssetLayout trait doc in src/core/layout.rs. A
URL resource carries the full URL, so a custom delegate must preserve any required query identity
without writing query text, credentials, or other secrets into a path. The default layout uses a
bounded query fingerprint and ignores fragments.
An omitted target registration uses DefaultLayout — the normal default, not a compatibility
fallback. Its disk mapping: direct-file bytes at track/track.<ext>, HLS URL resources mirrored
below track/ by authority and path with a query fingerprint when needed, and the named track
analysis artifact at analysis/track.analysis. Changing the outer store root does not change those
relative paths.
The browser surface is the cross-platform AudioPlayer facade (src/player/facade.rs) with a
#[wasm_bindgen] impl in src/web/surface.rs: JS constructs one new AudioPlayer(), drives the
queue through append / insert / selectItem, transport through play / pause / seek, and
receives structured events through setObserver / setItemObserver. Generated TypeScript
definitions ship with the wasm-bindgen output.
The main-thread bridge builds one PoolRegion<FfiPools> and passes that same facade to the
WebCodecs probe and the Web Worker. The worker owns the Queue and builds its in-memory
AssetStore (StorageBackend::Memory) from that facade, using the default layout registry;
native foreign layout callbacks are not bridged into JavaScript. Host::insert moves only the
Queue's sendable synchronization state and current desired level to the main-thread Host; the
remote Worker Host retains the resident Queue and every reader or JS handle. The main-thread
WasmInner owns the WorkerCmd channel plus a local cache so the infallible facade getters can
answer without a round trip. Wasm builds use the web-audio backend and link no stretch backend;
playback rate is retained as main-thread control state only - no WorkerCmd carries it - so PCM
speed stays 1.0 until a wasm-capable stretch backend exists.
When the worker command channel closes, teardown attempts Host removal once. A permanent removal error is not retried: the Host reports the invariant failure and retains the still-attached Queue runtime rather than dropping Worker-owned resources behind the main-thread topology.
Wasm does link a resampler: the wasm32 dependency arm re-adds resample-rubato on top of
default-features = false. A web-audio context runs at the one rate the browser gives it, and any
track whose own rate differs needs a fixed-ratio stage to reach it. Without the feature
PlaybackResamplerBackend resolves to NoResamplerBackend and such a track fails to open at all.
The Apple device set above can drop rubato because AudioToolbox decodes straight to the host rate;
the browser offers no equivalent.
The wasm arm enables analysis-waveform and beat-dsp, so the browser computes the same
waveform and beat grid the desktop app does, through the same kithara-analysis pass. analyze
starts a pass for a queued track and setAnalysisObserver registers the one callback every
publication reaches, as a plain object:
{ trackId, revision, settled, sampleRate, sourceFrames, waveform, beats, downbeats, bpm, beatFinal }waveform is a Float32Array of low, mid, high per bucket; beats and downbeats are
Float64Array in seconds on sampleRate. The typed arrays are allocated on the JS side, so nothing
crosses as a view into wasm memory. Readiness is settled, which says the pass ran out of positions
the source can deliver, never completeness of coverage. The route tag the worker stamps on the
message is stripped before the callback sees it, so the delivered object carries those ten fields
alone. The DSP beat backend reports beats, not downbeats. This surface is wasm-only; the UniFFI
surface carries no analysis.
AnalysisRuns (src/web/analysis/runs.rs) is the engine worker's analysis owner: one
AnalysisWorker and the cancel token of the single live pass per track. Calling analyze again
for a track cancels its previous pass and begins a new revision sequence for that track; the
cancelled pass publishes nothing further. Remove, Replace and RemoveAll cancel the pass of
the track they drop, and a pass that ends on its own releases its own slot. The queue owns the
track's source: the pass opens the very ResourceConfig playback holds, or, for a track queued by
URL, the configuration build_config (src/web/worker.rs) derives from it, the same one an
insertion builds. Once that reader is open the pass's producer is attached to the queue so
playback decode feeds it; the per-track observer slot is latest-wins
(kithara-audio::AudioObserverSlot), so a restarted pass replaces the producer of the one it
cancelled.
Per-pass cancel tokens are children of the AnalysisWorker's own scope, and that scope has no
parent: the engine worker holds no master cancel to derive it from. Publications travel to the
main thread over the BroadcastChannel that already carries player and item events, tagged with
the analysis scope, and Routes (src/web/observer/router.rs) fans all three out from one
listener.