Skip to content

kithara waveform

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

kithara-waveform

Documentation reviewed from source revision 4a470dd2c. This records the documented contract at that revision; it is not a new runtime validation. API and usage · All crates.

Contracts and invariants of the three-band display waveform. The README owns the overview and the feature list.

Ownership

This crate owns the waveform value (Bucket, Waveform), its byte codec, its tunables (AnalysisParams), and the position-addressed analyzer that produces it (WaveformAnalyzer, behind the dsp feature). The analyzer is synchronous: it owns no async, I/O, cancellation, scheduling, or colour. A pass, its coverage record, persistence and cache identity belong to kithara-analysis; colour belongs to the painter.

Frame-range coverage types come from kithara-signal, the versioned framing from kithara-blob, pooled buffers from the caller's kithara-bufpool region, and the FFT, band sums and downmix from kithara-dsp.

The model, the codec, the parameters, the resume record and AnalyzerError compile without dsp, so a crate that reads a served or stored waveform (kithara-play) links no analyzer. AnalysisParams names its FFT length as a kithara_dsp::spectrum::FftLen, so kithara-dsp is a dependency either way; dsp turns on its spectrum feature, so a build without the analyzer compiles no FFT.

Model

  • Bucket { low, mid, high } is one display column: three band heights, each in [0, 1] on one scale shared by the whole waveform. All-zero is silence, never NaN. Consumers paint the bands as concentric mirrored bars, low behind, so all three stay visible.
  • Waveform holds its buckets in an Arc<[Bucket]>: a clone shares them. Bucket order is normalized track position [0, 1], never wall-clock seconds; playback rate and mixing remap that axis and never re-run analysis.
  • Waveform::try_from(Vec<Bucket>) is the checked way in for a waveform a caller already holds. It refuses more than MAX_BUCKETS (65 536) buckets with WaveformError::TooLarge, and a band that is not finite or lies outside [0, 1] with WaveformError::Band { index, value }. It admits exactly what the codec admits, so a structure and a blob cannot disagree about what a valid waveform is. Only the analyzer builds a Waveform unchecked, because it normalizes every band before it fills a bucket.

Codec

Waveform::write_to appends a kithara-blob frame to caller-owned storage: the u32 WAVEFORM_BYTES_VERSION, then three little-endian f32 per bucket in the order low, mid, high. Waveform::try_from(&[u8]) returns BlobError::Version for another version and BlobError::Corrupt for a body that is not a whole number of buckets or that try_from(Vec<Bucket>) refuses; a reader treats both as a cache miss. WAVEFORM_BYTES_VERSION moves when the encoding, the analysis parameters, or the bucket resolution consumers ask for changes.

Analyzer

WaveformAnalyzer::new(sample_rate, params, pools) plans a kithara_dsp::spectrum::Fft of params.fft_size() and takes its Spectrum from the caller's region. It returns AnalyzerError::Spectrum when no backend runs that length and AnalyzerError::Pool when the spectrum does not fit the region. A window is N = fft_size frames long, and windows start every hop = N / 4 frames: window k spans [k·hop, k·hop + N) of the source.

Push. push(pools, pcm, channels, at) takes interleaved PCM starting at source frame at:

  • It downmixes every whole frame to mono by the channel mean through kithara_dsp::downmix. Zero channels, or no whole frame, is a no-op.
  • It copies the mono block into every window it overlaps and reduces a window once all of that window's frames are present. Blocks may arrive in any order, twice, or overlapping; a window split across blocks reduces to what the whole window would, and a reduced window is never reduced again.
  • At most 256 windows wait for missing frames. Past that the window opened first is dropped with a debug log, and its span stays unanalysed: frames arriving later never reduce it from the part that survived.
  • The downmix buffer and the waiting windows come from the caller's PoolRegion<S>, S: HasPool<f32>; PoolError when one cannot grow under the region budget.

Reduce. One window becomes three band energies:

  • Fft::forward applies the symmetric Hann window, zero-pads a shorter frame, and writes bins 0..=N/2.
  • A crossover maps to bin ⌊hz / (rate / N)⌋, clamped to N/2 + 1, and the mid/high bin is never below the low/mid bin. Low is bins 1..low_mid, mid low_mid..mid_high, high mid_high..=N/2. The DC bin is left out, so a constant offset never colors the low band, and a crossover at 0 Hz or above Nyquist leaves its band empty.
  • A band's energy is kithara_dsp::sum_squares over the real and imaginary parts of its bins, divided by its bin count, so bands compare as energy density rather than by width.
  • A window whose RMS over the bins past DC, √(Σ|X|² / N), is below energy_floor contributes zero to every band.

Snapshot. snapshot(buckets, extent) folds the reduced windows into a Waveform and leaves the pass able to take further ranges:

  • The window count comes from the extent when it is known, (extent − N) / hop + 1, so bucket boundaries stay put as coverage grows; while it is unknown, from the highest reduced window. A known extent shorter than one window reduces window 0 from the frames it has, zero-padded.
  • A window not reduced yet — a gap, or an evicted window — reads as silence until its frames arrive.
  • The waveform carries min(buckets, windows) buckets: never more columns than analysed windows. Bucket b of B folds windows [b·W/B, (b+1)·W/B) of W, keeping each band's loudest window; this is the only mapping from windows to track position.
  • Each band then takes √ and its band_gain, and every band of every bucket divides by one shared maximum, so the loudness tilt between bands survives, the loudest band of the track is 1, and silence stays all zero.

Defaults. AnalysisParams is a bon builder whose Default is the builder's defaults: fft_size 4096, crossovers at 250 Hz and 2500 Hz, energy_floor 1e-4, band_gain [1.0, 2.5, 12.0]. Music tilts energy toward the low end, so without lifting mid and high the upper bands render as slivers; band_gain is that balance, not a colour, and low stays the dominant hull.

Why fft_size is an FftLen: the analysis FFT runs on vDSP on Apple and on realfft elsewhere, and vDSP builds only f·2ⁿ. A plain usize would accept a length that builds on Linux and fails on Apple; FftLen::new refuses it where the parameters are made.

Resume

write_resume appends a checkpoint of the algorithm, not a result: a served waveform never carries one. It holds every reduced window's band energies in index order, every waiting window's samples, covered frames and opening order, and the opening counter.

  • WaveformResume::decode reads that section from a kithara-blob Reader and returns BlobError::Corrupt for a truncated section, a non-finite energy, an index out of order, a waiting window beside a reduced window of the same index, a waiting window with no samples or no covered frames, or an opening order not below the counter.
  • restore(pools, resume) also refuses more than 256 waiting windows, a waiting window whose length is not this analyzer's window, and covered frames outside a window's span; BlobError::Pool when the samples do not fit the region.
  • A restored pass snapshots what the stopped one would have.

The record type compiles without dsp, so a build without the analyzer still reads the section; kithara-analysis then refuses a record that carries one.

Validation

The analysis lane of just test runs the crate's tests with dsp. Unit tests generate their own signal. tests/reference.rs holds the heights of a full-spectrum mix and a stereo tone within 1e-4 of references recorded before the FFT moved to kithara-dsp, on every backend.

Integration

  • kithara-analysis runs one analyzer per pass behind its analysis-waveform feature, which enables dsp. It builds the analyzer from AnalysisParams::default(), sets the bucket ceiling, feeds decoded source ranges, and stores the resume section in its progress record.
  • kithara-play reads a prepared waveform document through Waveform::try_from(&[u8]) and needs only the model.
  • The kithara facade re-exports the crate as kithara::waveform under its waveform feature; its analysis-waveform feature enables dsp.

Clone this wiki locally