Skip to content

kithara analysis

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

kithara-analysis

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.

Contracts and invariants for progressive source-signal analysis. The README owns the overview, features, and type inventory.

Ownership

This crate owns the reusable per-track analysis engine, generic over B: ResamplerBackend. It consumes decoded source ranges through kithara-audio's AudioReader and neutral AudioObserver contracts. Decoder lifecycle, source readiness, seek/session state, and source discontinuity publication remain in kithara-audio; playback scheduling and effects remain in kithara-play; map/synchronization semantics remain in kithara-warp.

The crate owns analysis state, scheduling, waveform/beat DSP, and pure versioned bytes. It does not own AssetStore I/O, cache-key policy, or eviction: the application persists and reads the composite TrackAnalysis bytes through the caller-owned Vec<u8> writer.

BeatArtifact is identity-free detector output, not a live beat grid. The only live grid contract is kithara-warp::BeatGrid, whose owning Track/Player supplies identity and revision. This crate does not fabricate a BeatGridSnapshot: the current detector artifact has confidence and provenance, but no calibrated maximum frame error suitable for FrameUncertainty. A future adapter must consume that real fact rather than substitute zero or reinterpret confidence as timing error.

Track analysis

AnalyzerBuilder<B, S> is the public selector. new(PoolRegion<S>) requires the caller to inject its typed region, and S: HasPool<f32> makes sample-pool registration a compile-time requirement. with_waveform, with_beat - which requires B: Default — and with_beat_config select analyzers, and is_empty() lets callers skip scheduling a pass entirely. TrackAnalyzers<B, S> is the crate-private per-track set; it retains one clone of the facade because later waveform windows and beat runs acquire guards as coverage arrives. Each analyzer is fed every decoded chunk once.

TrackAnalysis is the public snapshot: caller token, revision, rate axis, extent, coverage, per-artifact fingerprint, waveform, and beat. It is self-contained by contract: a consumer holding only a snapshot can render the waveform, place markers on the source timeline, and tell how much of the track it is based on. source_frames() is the denominator that turns a BeatArtifact frame into a fraction: the extent when known, coverage.frontier() otherwise. The frontier is the exclusive end of the highest covered range, not the sum of covered frames, so sparse out-of-order coverage keeps its source position.

A pass publishes many times. TrackAnalyzers::snapshot leaves the pass able to accept further ranges and bumps a strictly increasing revision, so a consumer discards anything that does not outrank what it holds. AnalysisTask publishes every PUBLISH_SECONDS of newly covered source, keyed to decoded frames rather than wall-clock time, once more when the reading ends and before the trailing detection, and once more after it. Only that last publication is settled; the one before it is resumable, and a restart resumes the detection rather than the reading. BeatState is Final only on a settled publication in which the beat pass took every range the pass covered.

Identity is an opaque AnalysisToken the caller opens the pass with; this crate echoes it and never interprets it. AnalysisFingerprint carries the beat tag and the waveform tag separately, so a waveform resolution change cannot invalidate stored beat results. The rate axis is named when the pass opens, not discovered from the first chunk: AnalysisWorker::analyze takes it, the caller opens its reader onto the same one, and a range on another axis is refused rather than redefining what a frame number means. Analyzers are built lazily by whichever range arrives first, so a pass that covers nothing allocates nothing. A checked scratch-allocation failure ends that pass and closes its result channel without publishing a value. A slot that fails closes itself alone, releasing what it held, and the pass reads on with that artifact absent from what it publishes.

Coverage is the canonical record of which source ranges a pass has observed, kept as sorted, disjoint, non-adjacent runs; TrackAnalyzers owns it and every consumer reads it, there is no second copy. A chunk's range comes from AudioChunkInfo::frame_offset and frames, so position never depends on arrival order. TrackAnalysis::missing is derived from that same record rather than kept beside it; its horizon is the extent once known and the covered frontier until then, the same rule source_frames uses.

Decode scheduling

AnalysisTask does not read its reader in order. It picks the middle of the largest uncovered range. That becomes binary subdivision on an uncovered track, so an early snapshot describes the whole track rather than its opening, and refills holes when playback has mostly covered the track; a range another producer covered is never decoded again.

  • One extent. A pass has one Extent, owned by AnalysisTask and handed to every consumer: the schedule, TrackAnalyzers::ingest, each snapshot. It is the largest length the source claimed, bounded by the smallest it proved, and never below the audio the pass was given.
  • Run bounds. A run decodes one schedule chunk before another position is chosen. It ends at covered audio or the extent. It starts from the first decoded chunk, not landed_at: a seek is begun rather than completed when it answers, so the decoder resumes at its own boundary.
  • End and retirement. A pass ends when its extent is covered or every uncovered position proved unreachable. A run that decoded nothing new is never chosen again, preventing a coarse-seeking source from being asked forever. The cost is explicit: retiring the middle of a gap drops that gap from the schedule, and it remains reported as missing.
  • Decode error. It ends the pass without discarding it: delivered ranges are published and the rest reported missing.

A source that reports no duration has no middle to seek to, so the task decodes it in order and never repositions the reader. This is a degraded mode with no answer for a live stream, not a fallback over a missing correct answer.

Producer ingest

producer/ lets a component that must not be slowed down contribute ranges it has already decoded, so a playing track is not decoded a second time. AnalysisProducer names its pass once when analyze hands it back, so offering costs no lookup and two producers never contend; a track with no open pass has no handle at all.

offer downmixes to mono by the channel mean and copies into a bounded transport allocated when the pass opens: a sample ring plus a ring of (start, frames) descriptors. It never blocks, never allocates, and never retains the caller's buffer, so the caller may recycle its buffer as soon as it returns. A range that does not fit is refused whole and reported untaken; it stays uncovered, missing, and eligible to be produced again. Outlet is deliberately not this transport: its one-slot overflow cannot report the first refusal.

The rtsan lane calls the taken offer path inside a forbid-blocking region, where RTSan aborts on a malloc, free, lock, or syscall. A pass ending drops the reading half; the next offer reports the pass closed and the caller lets its handle go. AnalysisProducer implements the neutral AudioObserver contract, so playback can attach it without kithara-audio, kithara-play, or kithara-queue knowing which analyzer consumes decoded ranges. Rejection is best-effort and never changes playback.

The worker drains the transport on its own tick, where DSP is allowed, and folds one block per descriptor. A descriptor continuing a run is resampled through that run's own MonoStream, so the audio a producer contributes reaches the detector the same whatever its chunk boundaries are.

BeatAnalysisConfig<B> is the one configuration struct of the analyzer: the caller-owned resampler backend and the backend-independent beat tunables live in it together. A configuration document reaches the tunables through BeatAnalysisConfigPatch, generated by #[derive(Patch)]; the backend itself carries #[patch(skip)]. Defaults are 1024-frame mono resampler blocks, 22 050 Hz detector input, 30-second detector windows with 2 seconds of overlap, and ResamplerQuality::High. Behind beat-nn the config also carries the detector's BeatConfig peak-picking policy: that field, its patch's nested beat field, and the beat: document key exist only under that feature, and the policy is printed by the hand-written Debug so cache_tag() cannot hand a moved policy the grid cached for the frozen parity values. A beat-dsp build also carries the Tempo the signal detector searches, 48..=185 BPM around 120 by default; it reaches the cache tag, so a grid searched over one band is never served for another. A document patches it key by key under tempo:, and Tempo judges the merged policy as a whole before it is committed, so a band the comb never scores is refused under its own key rather than searched. The refusal is declared by the struct (#[patch(fallible)]), not read off the fields, so apply keeps one signature whichever detector the build selects: without beat-dsp BeatAnalysisConfigPatchError is simply uninhabited. The analyzer never stores whole-track source PCM: it downmixes to mono and keeps covered spans at detector rate in buffers borrowed from the caller's typed region.

The contiguous run, not the pass, owns the sequential MonoStream. A range decoded later cannot be pushed through the stream that produced an earlier one: audio continuing a run goes through that run's stream, so the seam carries no step for an onset detector to read as a beat, and a run the arriving audio does not continue flushes its stream into its mono. Every join is pinned to its implied detector frame, so rounding cannot accumulate into marker drift. Detector windows are fixed spans of the absolute detector-rate timeline and are detected once when complete, regardless of arrival order. Markers therefore agree across arrival orders within the resampler's splice tolerance.

A run reaching detector_min_window_seconds is detected immediately, then re-detected when its full window fills. Once the extent is known, the artifact is spread across it at its own tempo while retaining detected marker positions. Run mono comes from sample guards acquired through TrackAnalyzers; the logical run set opens at most four runs of its own while every physical allocation still competes under the region-wide hard byte budget. That cap bounds memory as much as detection: each run holds the resampler its audio continues, so a source arriving in scattered fragments cannot spend the region on resamplers. Audio past that hold is turned down rather than given up: it stays outside the beat coverage, and the pass reads it again once the detector frees room. Downmix and grid-cleanup scratch stay as guards for the pass lifetime; no lower component constructs or stores another pool facade.

AnalysisWorker owns one kithara-worker dispatcher and admits every pass as a separate AnalysisNode<B, S> task. AnalysisWorkerConfig carries the analyzer builder, parent cancellation, optional shared base Worker, dispatcher capacity and budgets, per-pass priority, and per-pass compute budget. Without a supplied base it creates and retains a standalone base worker. With a supplied base, analysis cancellation is OR-composed only onto its dispatcher, so it cannot cancel sibling domain dispatchers. The backend and pool-schema types are consumed by new and stay inside the task factory rather than leaking into the handle.

open gives every pass a child of the analysis scope; task admission folds that pass token into the dispatcher's derived task token. Callers may clone it for the reader before submitting the pass. Results arrive on a watch channel: waveform first, then waveform plus beat when configured; on failure or cancellation the sender drops without a value. Every node owns one task FSM. Nodes share one immutable detector runtime; mutable run buffers and detection requests remain per pass, while bounded compute admission prevents a hidden queue. Decode consumes at most one chunk per tick. Registration wakes the dispatcher; there is no sleep, backoff loop, or poll watcher. AnalysisObserver consumes dispatcher events, retains the no-progress watchdog, and classifies returned heavy ticks against a 120-second budget.

Feature seams. There is no single analysis feature. Artifact types are unconditional because analysis and cache keys use them even when a pass is absent. analysis-waveform gates the realfft analyzer and with_waveform; analysis-beat gates the beat path. Without analysis-beat, with_beat() is a compile-time no-op and is_empty() is the runtime signal. beat-backend is the derived feature every detector backend enables, so code that only asks whether this build carries one names it instead of listing the backends. backend::SELECTED_DETECTOR is the single expression of which backend a build uses, and the beat fingerprint tag derives from it.

Waveform

Waveform DSP is synchronous and turns decoded PCM into a Waveform for display. It owns no async, I/O, cancellation, or colour types; those belong to consumers. Tunables live in AnalysisParams.

  • Analysis runs on the decoded source signal, never post-EQ, post-timestretch, or post-resample output. Playback-rate and mixer transforms only remap the time axis and never re-run analysis.
  • The optional AudioObserver sees playback decoder output before effects. Its AudioChunk::meta.spec is authoritative and may reflect decoder-side sample rate conversion; source-rate offline analysis does not consume this feed.
  • Bucket { low, mid, high } holds three independent band heights, normalized on one shared [0, 1] scale. All-zero is silence, never NaN.
  • WaveformAnalyzer::push is position-addressed: it scatters a source block into every touched window and reduces a window only once its own frame set is complete. Blocks may arrive out of order, overlap, or repeat. Waiting windows are capped; evicting one leaves that span uncovered rather than publishing a half-silent window.
  • Buckets use normalized track position [0, 1], never wall-clock seconds. bucketize is the sole mapping and always returns exactly the requested count. snapshot spreads buckets over the known extent, or the highest reduced window while the extent is unknown, without consuming the pass.
  • Crossovers map to FFT bins using the track sample rate. Each Hann window sums low/mid/high energy density (DC zeroed), hops by fft_size / 4, keeps the component-wise loudest window per bucket, applies sqrt and band_gain, and divides all bands by one shared maximum so the loudness tilt survives.

Blob codec

Waveform, BeatArtifact, and composite TrackAnalysis use the crate-internal, domain-agnostic blob module for versioned little-endian bytes. Blob owns an artifact frame — u32 Blob::VERSION then body — while each artifact implements its body. A decode cursor must consume exactly its bytes; trailing data is corruption.

Each artifact owns its VERSION. A mismatch is BlobError::Version; a truncated, mis-sized, or out-of-range body is BlobError::Corrupt. Both are cache misses: the application re-analyzes and overwrites, with no in-place migration. Untrusted length prefixes are bounded by MAX_PREALLOC. BlobError is the public error; Blob, Reader, and Writer stay internal. This codec is pure bytes: kithara-app owns AssetStore reads/writes, cache identity, and policy.

Progressive analysis file

AnalysisFile is the durable, bounded container for one track's progressive TrackAnalysis. kithara-analysis owns only the byte layout, validation, and an ordered AnalysisFileUpdate; it owns no filesystem, AssetStore, writer thread, cancellation, or observability. The application owns those lifecycle boundaries.

  • The fixed header records format version, source rate, stable extent, fixed chunk size, analyzer fingerprints, latest revision/settled state, and the exact payload bounds. A one-byte-per-chunk completion index follows it.
  • Exactly one current v6 TrackAnalysis payload follows the index at a fixed offset. A newer generation replaces that payload and commits its exact new length; it does not retain unread historical snapshots.
  • An update writes initial header/index bytes only for a new resource, writes the complete payload, applies newly completed index bytes, and patches the header last. A replacement update first seeds its fixed header/index prefix from the prior committed generation; old payload bytes are not copied. Publication is the application's successful atomic AssetStore commit at final_len, never an intermediate write. Production USDT observation belongs after that commit and is not a correctness signal.
  • A fixed index requires a known stable extent. Until a worker publication has that extent, AnalysisFileSpec::for_analysis returns UnknownExtent and the snapshot remains memory-only.
  • Restore requires the active analyzer fingerprint. The validated header then supplies the stored axis, extent, and chunk frames; the application must use AnalysisFileSpec::matches_chunk_duration to reject a coherent file created under another configured duration, and validate the axis once current source metadata is available; the stored extent is restored as a claim the reopened reader re-proves.

Clone this wiki locally