Skip to content

kithara ffi

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

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.

Target split

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.

Device feature sets

Both device flows drop default features, so symphonia is absent and the hardware backend is the sole decoder on-device.

  • xtask apple builds frameworks with uniffi,apple,dev,stretch-signalsmith. The crate-local apple feature forwards kithara/apple-fused-src (plus apple-net and the matching kithara-play / kithara-queue fused-SRC features), so Apple AudioToolbox decodes directly to the host rate through decoder-embedded resampler placement. That set intentionally enables neither resample-rubato, analysis-beat, nor analysis-waveform.
  • xtask android builds release JNI libraries with uniffi,android,stretch-signalsmith (debug adds dev,test). The facade android feature keeps the fixed-ratio rubato stage (resample-rubato) and analysis-beat enabled; Android does not use the Apple fused-SRC path.

Cache ownership and layouts

The native cache object graph is owned in Rust:

  • FfiAssetLayoutRegistry is a shareable UniFFI object holding the Rust AssetLayoutRegistry behind 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 foreign Drop may re-enter register without deadlocking.
  • query_identity_layout(rules) takes ordered FfiCacheIdentityRule records (domain patterns plus application-defined query parameter names) and returns a Rust-owned layout. Callers install it with the ordinary register method for file, HLS, or both; no foreign callback participates in its cache-key derivation.
  • FfiAssetStore::new(root, registry) snapshots the registry and builds one AssetStore. Construction returns Result; an invalid pool configuration is reported as FfiError::Internal instead of panicking. Neither FfiAssetStore nor FfiPlayerConfig has a production Default implementation, so native callers always pass the shared store explicitly. root = None preserves the platform StorageBackend default; a supplied root selects that outer disk directory without changing paths inside an asset root.
  • FfiAssetStore also owns the PoolRegion<FfiPools> shared by cache, network, decode, and playback, plus a store-specific CancelScope. Dropping the last foreign Arc<FfiAssetStore> cancels that store subtree. Player cancellation is a separate CancelToken root and does not redefine the shared store lifetime.
  • FfiPlayerConfig.store: Arc<FfiAssetStore> is the only asset/cache field on the player configuration. NativeInner retains that object, clones its pool facade, and clones its inner AssetStore handle 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 layout callbacks

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.

Web target

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.

Track analysis

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.

Clone this wiki locally