core/sensorium/audio/lexer.py
Shay 4d59e66043
feat(adr-0181-p2): deterministic audio compiler substrate (sensorium/audio) (#466)
PR-2 of ADR-0181. Lands the deterministic substrate only — no pack artifacts
(PR-3), no evals (PR-4), no Delta-CRDT wiring (PR-5), no teachers (PR-6).

Pipeline (spec §1): canonical signal → frame grid → acoustic lexer →
typed AudioIR → canonical event ordering → elliptic rotor lowering →
versor composition → AudioCompilationUnit (the future CRDT delta).

Modules (sensorium/audio/):
- types.py      frozen AudioSignal/Token/Event/IR + AudioCompilationUnit.merge_key
- checksum.py   layered sha256 chain (source→canonical→token→ir→manifest→projection)
- resample.py   pure-numpy polyphase FIR (scipy absent; FIR taps are a PR-3 artifact)
- canonical.py  mono/24kHz canonicalisation + provenance hashes
- frames.py     20ms/10ms deterministic frame grid (zero-padded tail)
- lexer.py      quantized per-frame descriptors (energy/voicing/zcr/centroid/pYIN-style F0)
- parser.py     runs → typed spans/events (pause/speech/prosody/turn/non-speech)
- operators.py  elliptic-ONLY rotor registry; planes {6,7,8,10,11,13} square to -1
- compiler.py   compile_events serialization barrier (ADR-0181 §2.1) + checksum chain
- trace.py      trace-safe audio evidence (no PCM)

Correctness: v1 restricts to the six elliptic grade-2 planes (e_i e_j, i,j∈{1..4})
so every rotor and every composition is a unit versor — versor_condition < 1e-6
holds without weakening the threshold (CLAUDE.md §Non-Negotiable Field Invariant).
Non-elliptic blades (those touching e5) are rejected at OperatorSpec construction.

Tests (tests/test_audio_compiler.py, 13 passed): A-1 determinism, A-4 serialization
barrier order-sensitivity, A-5 versor condition, A-6 trace hygiene, IR-replay,
shape/dtype, elliptic-plane lawfulness. Smoke 67 + arch-invariants 40 green.

No core mutation: ingest/field/generate/vault/vocab untouched (ADR-0013).
unitize_versor is the only normalization, algebra-owned (CLAUDE.md §Normalization).
2026-05-29 10:50:28 -07:00

115 lines
4.1 KiB
Python

"""
sensorium/audio/lexer.py — acoustic lexer (spec §4).
Operates on measured facts, not semantic guesses. Each frame yields quantized
descriptors so the token stream hashes deterministically (spec §7: quantize
before semantics). Emits AudioTokens in canonical hop order.
Quantization regime (frozen here for v1):
- log energy : 1 dB bins (int dB)
- F0 : 25-cent bins, referenced to 55 Hz (A1)
- confidences : uint8 (0..255)
- spectral : fixed ordinal centroid bins
All thresholds are module constants so the lexer is a pure function of the
frame matrix.
"""
from __future__ import annotations
import math
import numpy as np
from sensorium.audio.types import AudioToken
EPS = 1e-12
# Quantization / classification constants (v1, frozen).
SILENCE_DB = -55 # frames quieter than this are silence
VOICED_ZCR_MAX = 0.20 # voiced frames have low zero-crossing rate
VOICED_MIN_DB = -45 # and enough energy
F0_REF_HZ = 55.0 # A1 reference for cents
CENTS_BIN = 25 # 25-cent quantization
F0_MIN_HZ = 50.0
F0_MAX_HZ = 500.0
N_CENTROID_BINS = 16
MAX_PITCH_CANDIDATES = 2
def _log_energy_db(frame: np.ndarray) -> float:
rms = math.sqrt(float(np.mean(frame * frame)) + EPS)
return 20.0 * math.log10(rms + EPS)
def _zero_crossing_rate(frame: np.ndarray) -> float:
signs = np.signbit(frame)
return float(np.count_nonzero(signs[1:] != signs[:-1])) / max(1, frame.size - 1)
def _spectral_centroid_bin(frame: np.ndarray) -> int:
mag = np.abs(np.fft.rfft(frame * np.hanning(frame.size)))
total = float(mag.sum()) + EPS
bins = np.arange(mag.size, dtype=np.float64)
centroid = float((bins * mag).sum()) / total / max(1, mag.size - 1) # 0..1
return int(min(N_CENTROID_BINS - 1, round(centroid * (N_CENTROID_BINS - 1))))
def _hz_to_cents_q(hz: float) -> int:
cents = 1200.0 * math.log2(max(hz, EPS) / F0_REF_HZ)
return int(round(cents / CENTS_BIN))
def _pitch_candidates_q(frame: np.ndarray, sample_rate: int) -> tuple[int, ...]:
"""pYIN-style: keep the top autocorrelation peaks (cents_q, prob_q) pairs,
*before* any Viterbi smoothing (spec §4)."""
n = frame.size
ac = np.correlate(frame, frame, mode="full")[n - 1:]
if ac[0] <= EPS:
return ()
ac = ac / ac[0]
lag_min = max(1, int(sample_rate / F0_MAX_HZ))
lag_max = min(n - 1, int(sample_rate / F0_MIN_HZ))
if lag_max <= lag_min:
return ()
window = ac[lag_min:lag_max]
# local maxima
peaks = [
lag_min + i
for i in range(1, window.size - 1)
if window[i] > window[i - 1] and window[i] >= window[i + 1] and window[i] > 0.3
]
peaks.sort(key=lambda lag: (-float(ac[lag]), lag))
out: list[int] = []
for lag in peaks[:MAX_PITCH_CANDIDATES]:
hz = sample_rate / lag
prob_q = int(min(255, max(0, round(float(ac[lag]) * 255))))
out.extend((_hz_to_cents_q(hz), prob_q))
return tuple(out)
def lex(frames: np.ndarray, sample_rate: int) -> tuple[AudioToken, ...]:
"""Lower a frame matrix into a canonical-ordered tuple of AudioTokens.
One primary classification token per hop (silence / voiced / unvoiced),
plus an energy_bin token and, for voiced frames, a pitch_candidates token.
"""
tokens: list[AudioToken] = []
for i, frame in enumerate(frames):
db = _log_energy_db(frame)
db_q = int(round(db))
zcr = _zero_crossing_rate(frame)
tokens.append(AudioToken("energy_bin", i, i + 1, (db_q,)))
if db_q <= SILENCE_DB:
tokens.append(AudioToken("silence", i, i + 1, (db_q,)))
continue
if zcr <= VOICED_ZCR_MAX and db_q >= VOICED_MIN_DB:
tokens.append(AudioToken("voiced", i, i + 1, (db_q, int(round(zcr * 255)))))
cands = _pitch_candidates_q(frame, sample_rate)
if cands:
tokens.append(AudioToken("pitch_candidates", i, i + 1, cands))
else:
centroid_q = _spectral_centroid_bin(frame)
tokens.append(AudioToken("unvoiced", i, i + 1, (db_q, centroid_q)))
return tuple(tokens)