core/docs/audio_pipeline_overview.md
Shay 83de943cdd docs(adr-0183): stub ADR for the lawful audio→lexeme path
Records the fork for getting words from audio WITHOUT a serving-time learned
model: (A) words-as-text, or (B) deterministic formant/phonetic decode + taught
vocabulary. Status: Proposed (stub) — deferred, not a committed design. Captures
the problem, the 0-param/decode-not-borrow/refuse-over-fabricate/reviewed-growth
constraints, and the open questions a full ADR must answer. Exists so the
serving path doesn't silently reach for Whisper. Cross-linked from
docs/audio_pipeline_overview.md §9.
2026-05-29 14:02:40 -07:00

19 KiB
Raw Permalink Blame History

Audio Section — Pipeline Overview & Primer

A from-the-ground-up reference for CORE's audio modality: how a waveform becomes a lawful Cl(4,1) versor, what every spec means, and exactly where (and why) learned models like Whisper are — and are not — involved.

Audience: anyone re-familiarising with audio/DSP or onboarding to the audio section. No one knows everything; this is the single greppable source of truth.

Authoritative sources this summarises (read these for the binding contract):

All numbers below are pulled from the code as of the ADR-0181 PR-2…PR-6 stack (merged to main). If you change a constant, update this doc in the same PR.


0. The one big idea

Most systems turn audio into an embedding (an opaque vector from a neural net). CORE refuses that. It treats audio like a compiler treats source code: raw waveform → measured acoustic facts → a typed intermediate representation (IR) → a lawful geometric object (a 32-dim Cl(4,1) versor). Every step is deterministic and checksummed, so the same bytes always produce the same result, bit-for-bit. Learned models may only annotate, never define.

flowchart LR
    W[waveform] --> C[canonicalize<br/>mono 24kHz f32]
    C --> F[frame grid<br/>20ms / 10ms]
    F --> L[acoustic lexer<br/>energy / voicing / pitch]
    L --> P[parser → typed AudioIR<br/>speech / pause / prosody / turn]
    P --> O[operators → rotors]
    O --> V[(32,) versor<br/>SUBSTRATE]
    W -. optional .-> T[Whisper / NeMo<br/>transcript]
    T -. typed label .-> P
    P -.-> AN[content.* anchors<br/>EVIDENCE — never touches versor]

1. Audio file fundamentals (the refresher)

A digital audio file is just amplitude samples over time. The specs that define one:

Property What it means CORE's canonical choice
Sample rate samples per second (Hz) 24,000 Hz (CANONICAL_SAMPLE_RATE)
Channels mono (1), stereo (2), … mono (multi-channel is averaged down)
Sample format / bit depth how each sample is stored (int16, float32…) float32, range ~[1, 1]
Encoding PCM (raw) vs compressed (MP3/AAC) PCM-equivalent float array
  • Nyquist limit: a 24 kHz sample rate represents frequencies up to 12 kHz (half the rate). Speech energy is mostly below 8 kHz, so 24 kHz comfortably captures speech + sibilance. (Teacher ASR gets a derived 16 kHz stream because models like Whisper expect 16 kHz.)
  • Why no .wav files in the repo: tests synthesize signals from parameter specs (evals/audio_sensorium/fixtures.json) instead of committing binary blobs. A spec like {tone, 150 Hz, 300 ms} is diffable and greppable; a .wav is opaque. The signal is a pure function of the spec, so "what's pinned is exactly what's tested." numpy PCG64 RNG + cast-to-float32-at-the-boundary makes it bit-reproducible across machines.

2. Stage 1 — Canonicalize (canonical.py)

Turns whatever arrived into the one canonical form everything else assumes:

  1. Downmix to mono — average across channels (handles (N,), (N,C), (C,N); channel axis = the smaller dimension).
  2. Resample to 24 kHz if needed — using a pinned polyphase FIR filter from the pack (not an ad-hoc resampler — determinism). Same-rate input is an exact passthrough.
  3. Hash twice for provenance:
    • source_sha256 = hash of the original bytes as received.
    • canonical_sha256 = hash of the canonical float32 image.

Output is an AudioSignal (samples + sample_rate + start/end ms + the two hashes). No raw PCM travels further than this in the trace — only hashes.

FIR = Finite Impulse Response filter; polyphase = an efficient way to resample by rational ratios. "Pinned" = the exact filter taps are frozen pack data so resampling is byte-identical everywhere.


3. Stage 2 — Frame grid (frames.py)

Audio is non-stationary (it changes constantly), so we analyse it in short overlapping chunks called frames:

  • Window = 20 ms (FRAME_MS) → 480 samples at 24 kHz.
  • Hop = 10 ms (HOP_MS) → 240 samples → 50% overlap between frames.
  • The last partial frame is zero-padded to a full window, so the grid is a pure function of (length, rate, frame_ms, hop_ms).

A "hop" is the time unit the rest of the pipeline counts in: hop index i = the chunk starting at i × 10 ms. So 300 ms of audio ≈ 30 hops.


4. Stage 3 — Acoustic lexer (lexer.py) — the actual audio features

This is where DSP happens. For each frame it measures facts and quantizes them (so the token stream hashes deterministically — "quantize before semantics"). Per hop it emits one primary classification token plus descriptors:

4a. Energy (loudness)

log_energy_db = 20·log10(RMS) — RMS is root-mean-square amplitude. Quantized to 1 dB integer bins. Frames quieter than 55 dB (SILENCE_DB) → silence.

4b. Voicing — voiced vs unvoiced

Uses Zero-Crossing Rate (ZCR) = fraction of adjacent samples where the signal flips sign. Vowels (voiced) are quasi-periodic → low ZCR; fricatives/noise → high ZCR.

  • voiced if ZCR ≤ 0.20 (VOICED_ZCR_MAX) and dB ≥ 45 (VOICED_MIN_DB).
  • Otherwise (loud but noisy) → unvoiced, and it records a spectral centroid bin (16 bins) = the "center of mass" of the spectrum (bright vs dull), from a Hanning-windowed FFT.

4c. Pitch (F0) — only for voiced frames

Estimates fundamental frequency via autocorrelation (pYIN-style: find the lag where the signal best correlates with a delayed copy of itself; that lag = the pitch period).

  • Search range 50500 Hz (F0_MIN/MAX_HZ) — the human voice range.
  • Pitch in cents, not Hz: cents = 1200·log2(Hz / 55), referenced to 55 Hz (note A1), quantized to 25-cent bins. Cents are a log scale — equal musical intervals are equal cent-distances, which is how prosody works perceptually.
  • Keeps the top 2 candidates as (cents_q, prob_q) pairs, prob_q ∈ 0255 = peak strength/confidence.

Net: each hop becomes integer tokens like energy_bin(9), voiced(dB, zcr), pitch_candidates(cents_q, prob_q, …).


5. Stage 4 — Parser → typed AudioIR (parser.py)

Collapses runs of like frames into typed spans/events (never per-frame noise). Six event families:

Family Built from Example event types
speech_spans runs of voiced speech.voiced
pause_spans runs of silence pause.short, pause.long (≥ 30 hops = 300 ms)
prosody_arcs F0 slope / energy delta over a voiced span prosody.rise, prosody.fall, prosody.emphasis (≥ 6 dB swing)
turn_events a long pause turn.boundary
non_speech_events runs of unvoiced nonspeech.noise
content_anchors teacher hints only (PR-6) content.transcript, …

Prosody logic: compare F0 at the end vs start of a voiced span — rising ≥ 1 cent-bin → prosody.rise (question-like), falling → prosody.fall (statement-like). The whole IR is hashed → ir_sha256.


6. Stage 5 — Operators → rotors → versor (operators.py, compiler.py)

Each event type maps to a declared rotor (a rotation in the geometric algebra), not an opaque vector:

  • v1 uses elliptic bivector rotors only — 6 planes in Cl(4,1) (blade indices 6,7,8,10,11,13) that square to 1, giving the well-behaved R = cos(θ/2) + B·sin(θ/2). This guarantees the composition is always a unit versor (versor_condition < 1e-6 without weakening the threshold).
  • The angle θ_q is an integer = base_theta_q + Σ(gain × quantized_attr), clipped. E.g. prosody.rise (plane 10, base 64) adds 3 × slope_q. THETA_STEP = π/512 (1024 steps span the circle).
  • compile_events folds the canonically-ordered events into one versor by repeated geometric product + unitize. Events with no operator (like content.* teacher hints) are skipped — that's why teachers can't change the versor.
  • Result = a (32,) float32 multivector — the single object that crosses into CORE's field/vault, exactly like a text token does.

The operator table (the B_* aliases) is the audio "phonology": B_PAUSE_LONG, B_SPEECH, B_PITCH_RISE, B_PITCH_FALL, B_EMPHASIS, B_TURN, B_NOISE.


7. The checksum chain & merge key (checksum.py)

Every link is content-addressed:

source_sha256 → canonical_sha256 → token_stream_sha256 → ir_sha256
              → pack_manifest_sha256 → projection_sha256

The merge key = (canonical_sha256, ir_sha256, projection_sha256) — what the Delta-CRDT (arena.py, PR-5) uses to dedup/order. Teacher hints move only the ir_sha256 leg (evidence), never projection_sha256 (substrate).


8. Concurrency — the Delta-CRDT arena (arena.py, PR-5)

Each compiled chunk is one AudioCompilationUnit (the delta). Units accumulate in a thread-local, share-nothing AudioArena; the merge kernel folds arena snapshots into one content-addressed, deduplicated, totally-ordered set keyed by merge_key. The merge is permutation- and duplicate-invariant, so hash(Sequential_Ingest) == hash(Concurrent_CRDT_Ingest) — the proof obligation of ADR-0180. The Python layer mirrors the Rust LocalArena/SemilatticeDelta/merge_kernel (core-rs/src/vault.rs) so they stay in parity when the binding lands.


9. Where learned models fit — and where they emphatically do not

This is the most-asked question, so it gets its own section.

Three things a model could hand you

What Used in CORE? Where it comes from
Embeddings (opaque latent vectors) Never — explicitly rejected the whole "no embedding bridge" rejection (CLAP/EnCodec as substrate)
Audio specs (pitch, energy, voicing, pauses, prosody, turns) Yes CORE's own deterministic DSP compiler (lexer.py/parser.py) — not any learned model
Text transcript (the words) (intended, as evidence) Whisper / NeMo — admitted as a typed label

Concretely

  • The audio specs do NOT come from Whisper. Every acoustic fact — framing, energy, ZCR voicing, F0/pitch, spectral centroid, pause/turn detection — is measured by CORE's own lawful DSP. Whisper has zero involvement. That's the native substrate, and it stands alone.
  • Whisper's only intended job is audio → words. It would emit a text transcript (+ timestamps/language ID), attached to a time span as a content.transcript anchor — a lexical label / evidence in the IR. It fills the one gap DSP can't: what words were said, vs how they were said.
  • We take Whisper's discrete text output, never its internals. Whisper is itself a neural net full of embeddings — but CORE ingests only its emitted string + timestamps (a typed, checksummed hint), never its latent vectors. A word like "home" as a label, not a 768-dim vector.
  • CLAP is the one to watch. Its natural output is embeddings + audio-text alignment. The eval plan admits CLAP only for coarse text labels ("laughter", "alarm") and rejects its embeddings. The rule across all teachers: words/labels in, vectors never.

The two lanes never mix

waveform ─┬─► CORE DSP compiler ──► AudioIR specs (pitch/energy/pauses/turns) ──► versor   [SUBSTRATE]
          │
          └─► Whisper ──► "are you coming home?" + timestamps ──► content.transcript label  [EVIDENCE]
                                                                  (never touches the versor)

Teacher policy, verbatim (eval-plan §4)

Use teachers to label or align.
Never let teachers define the substrate.
Never fold teacher embeddings directly into the main versor path.
Only admit teacher outputs through typed, versioned, checksumed hints.

Current status (important)

As of the merged PR-6: the teacher lanes are declared, gated behind optional extras, and inert. load_teacher("whisper") raises TeacherUnavailableno real model is wired or imported. The only working teacher is the deterministic StubTranscriptTeacher (no weights), used to prove the contract. The structural guarantee — a teacher hint leaves the versor/projection_sha256 byte-identical — is enforced by a failable test (tests/test_audio_teachers.py::test_teacher_hint_does_not_change_versor).

The real doctrinal commitment is not admitting a teacher; it's whenever someone builds the consumer that reads teacher hints into comprehension. No such consumer exists yet. That is the PR to scrutinise hard.

Teacher = bootstrap scaffolding; the serving path stays Whisper-free

A teacher is scaffolding for the teaching phase, not a production component. This is not ML distillation: CORE has no weights, so Whisper does not train a "student." Its only job is to propose transcripts → a human reviews them → they become taught associations (acoustic-pattern ↔ lexeme) in curated packs. Then the engine decodes audio and recalls against what it learned — and Whisper is gone.

Serving rule: the production/serving path must never call a teacher. Teachers are admitted only on the teaching side (reviewed, evidence-only). The day someone proposes a teacher in the serving path is the day to say no.

Production is Whisper-free — on one condition. Removing the teacher only leaves the engine able to handle words if, by then, a lawful runtime path carries what the teacher bootstrapped. Two ways that holds:

  • (A) Words arrive as text — audio stays a paralinguistic sense (prosody/turns/affect/coarse phonetics); the what comes through the text modality. Whisper never needed at runtime. Cleanest.
  • (B) The deterministic audio→lexeme decode matured — a formant/phonetic front-end + taught vocabulary lets the engine recognise words itself, lawfully, 0-param. Whisper was just the bootstrap that helped build that vocabulary.

Paths (A)/(B) are the subject of ADR-0183 (stub) — deferred, but on the record so the serving-path boundary isn't crossed silently.

The trap to avoid: teaching with a model does not automatically transfer word-recognition into a 0-param engine the way distillation transfers into a student network. Nothing transfers unless path (A) or (B) actually exists to use what was taught. Remove the teacher with neither in place and the engine is simply deaf to words again — it keeps all of prosody/turns/affect, but loses lexical content.

Hold it in one line: the teacher teaches; the lawful path serves.


10. Specs quick-reference (all from the code)

Spec Value Source
Canonical sample rate 24,000 Hz (Nyquist 12 kHz) canonical.py
Format mono float32 canonical.py
Frame / hop 20 ms (480 smp) / 10 ms (240 smp), 50% overlap frames.py
Silence threshold 55 dB lexer.py
Voiced criteria ZCR ≤ 0.20 and dB ≥ 45 lexer.py
F0 range / ref / bin 50500 Hz / 55 Hz (A1) / 25 cents lexer.py
Spectral centroid bins 16 lexer.py
Long pause / turn ≥ 30 hops (300 ms) parser.py
Emphasis threshold ≥ 6 dB intra-span swing parser.py
Rotor type elliptic bivector, 6 planes (6,7,8,10,11,13) operators.py
θ resolution π/512 per step, 1024 steps operators.py
Output (32,) float32, versor_condition < 1e-6 compiler.py
Merge key (canonical_sha256, ir_sha256, projection_sha256) checksum.py

11. The test fixtures, acoustically (evals/audio_sensorium/)

Signals are synthesized from fixtures.json (no .wav blobs). Three primitives: _tone (sine + optional linear F0 sweep), _silence (zeros), _noise (seeded Gaussian) — all 24 kHz mono float32.

Fixture Synthesis What the lexer/parser extracts
silence_500ms 500 ms zeros 50 hops silencepause.long + turn.boundary
rise_question 300 ms sine, 150 Hz sweeping +90 → 240 Hz, amp 0.5 low ZCR + loud → speech.voiced; rising F0 → prosody.rise
fall_statement 300 ms sine, 230 Hz sweeping 90 → 140 Hz, amp 0.5 speech.voiced + prosody.fall
noise_burst 300 ms Gaussian noise (seed 7, amp 0.3) high ZCR → unvoicednonspeech.noise
speech_then_pause 300 ms 150 Hz tone + 400 ms silence speech.voiced then pause.long + turn.boundary

A 0.5-amplitude sine has RMS ≈ 0.354 → ≈ 9 dB (well above 45) and very low ZCR → reliably "voiced." Gaussian noise crosses zero constantly → high ZCR → "unvoiced." The fixtures are designed so the parser's accuracy is checkable, not just its determinism.

What the tests actually assert

tests/test_audio_*.py (in the PR smoke gate): exact (32,) float32 shape; versor_condition < 1e-6; bit-identical replay; frozen canonical_sha256 / ir_sha256 pins; IR-replay equality; parser accuracy (event_type_counts match the designed parse); cross-platform versor stability within atol=1e-6; trace hygiene (no PCM); gate-closure; sequential==concurrent merge; and teacher-shadow invariance. They prove the path is deterministic, replayable, checksummed, lawfully shaped, and parses the intended structure — they do not test real-world speech, accents, noise robustness, or transcription accuracy (that needs real corpora + the deferred teacher adapters).


12. Mini-glossary

PCM — raw uncompressed samples. RMS — root-mean-square amplitude (loudness). dB — log loudness scale. ZCR — zero-crossing rate (voicing proxy). F0 / pitch — fundamental frequency. cents — log pitch unit (100 cents = 1 semitone). spectral centroid — frequency "center of mass" (brightness). Nyquist — max representable freq = ½ sample rate. frame/hop — short analysis window / step between windows. rotor/versor — geometric- algebra rotation operator / the unit object it produces. FIR — finite impulse response (resampling) filter. Delta-CRDT — conflict-free replicated data type; order-invariant merge.


13. Where to go next