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).
126 lines
4.7 KiB
Python
126 lines
4.7 KiB
Python
"""
|
|
sensorium/audio/parser.py — typed AudioIR parser (spec §5).
|
|
|
|
Promotes the lexer's per-frame tokens into typed spans and events. The IR is
|
|
built from runs of like frames, never from individual mel/frame values. Output
|
|
event types match the operator registry keys so every event lowers to a rotor.
|
|
|
|
Determinism: every numeric attr is a quantized int; events are emitted in a
|
|
stable per-category order; ``ir_sha256`` hashes the canonical serialization.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sensorium.audio.checksum import sha256_json
|
|
from sensorium.audio.types import AudioIR, AudioToken, AuditoryEvent
|
|
|
|
LONG_PAUSE_HOPS = 30 # >= 300 ms (10 ms hop) is a long pause / turn
|
|
SLOPE_CENTS_THRESH = 1 # min |Δcents_q| to call a contour rise/fall
|
|
EMPHASIS_DB_THRESH = 6 # min intra-span energy delta (dB) for emphasis
|
|
|
|
|
|
def _runs(kinds: list[str | None]) -> list[tuple[str, int, int]]:
|
|
"""Collapse a per-hop primary-kind list into (kind, start_hop, end_hop)."""
|
|
runs: list[tuple[str, int, int]] = []
|
|
i = 0
|
|
n = len(kinds)
|
|
while i < n:
|
|
k = kinds[i]
|
|
if k is None:
|
|
i += 1
|
|
continue
|
|
j = i
|
|
while j < n and kinds[j] == k:
|
|
j += 1
|
|
runs.append((k, i, j))
|
|
i = j
|
|
return runs
|
|
|
|
|
|
def parse(tokens: tuple[AudioToken, ...], n_hops: int) -> AudioIR:
|
|
primary: list[str | None] = [None] * n_hops
|
|
energy_db: dict[int, int] = {}
|
|
pitch_cents: dict[int, int] = {}
|
|
|
|
for tok in tokens:
|
|
h = tok.start_hop
|
|
if tok.kind == "energy_bin":
|
|
energy_db[h] = tok.value_q[0]
|
|
elif tok.kind in ("silence", "voiced", "unvoiced"):
|
|
primary[h] = tok.kind
|
|
elif tok.kind == "pitch_candidates" and tok.value_q:
|
|
pitch_cents[h] = tok.value_q[0] # top candidate's cents_q
|
|
|
|
speech_spans: list[AuditoryEvent] = []
|
|
pause_spans: list[AuditoryEvent] = []
|
|
prosody_arcs: list[AuditoryEvent] = []
|
|
turn_events: list[AuditoryEvent] = []
|
|
non_speech_events: list[AuditoryEvent] = []
|
|
|
|
for kind, start, end in _runs(primary):
|
|
dur = end - start
|
|
if kind == "silence":
|
|
is_long = dur >= LONG_PAUSE_HOPS
|
|
etype = "pause.long" if is_long else "pause.short"
|
|
pause_spans.append(AuditoryEvent(etype, start, end, (("dur_hops", dur),), ()))
|
|
if is_long:
|
|
turn_events.append(
|
|
AuditoryEvent("turn.boundary", start, end, (("boundary_q", dur),), ())
|
|
)
|
|
elif kind == "voiced":
|
|
speech_spans.append(
|
|
AuditoryEvent("speech.voiced", start, end, (("dur_hops", dur),), ())
|
|
)
|
|
# Prosody arc from the final-contour F0 slope over the span.
|
|
cents = [pitch_cents[h] for h in range(start, end) if h in pitch_cents]
|
|
if len(cents) >= 2:
|
|
slope = cents[-1] - cents[0]
|
|
if slope >= SLOPE_CENTS_THRESH:
|
|
prosody_arcs.append(
|
|
AuditoryEvent("prosody.rise", start, end, (("slope_q", slope),), ())
|
|
)
|
|
elif slope <= -SLOPE_CENTS_THRESH:
|
|
prosody_arcs.append(
|
|
AuditoryEvent("prosody.fall", start, end, (("slope_q", -slope),), ())
|
|
)
|
|
# Emphasis from intra-span energy delta.
|
|
dbs = [energy_db[h] for h in range(start, end) if h in energy_db]
|
|
if dbs and (max(dbs) - min(dbs)) >= EMPHASIS_DB_THRESH:
|
|
prosody_arcs.append(
|
|
AuditoryEvent(
|
|
"prosody.emphasis", start, end,
|
|
(("delta_db_q", max(dbs) - min(dbs)),), (),
|
|
)
|
|
)
|
|
elif kind == "unvoiced":
|
|
non_speech_events.append(
|
|
AuditoryEvent("nonspeech.noise", start, end, (("noise_q", dur),), ())
|
|
)
|
|
|
|
ir_payload = {
|
|
"speech": [_ev(e) for e in speech_spans],
|
|
"pause": [_ev(e) for e in pause_spans],
|
|
"prosody": [_ev(e) for e in prosody_arcs],
|
|
"turn": [_ev(e) for e in turn_events],
|
|
"non_speech": [_ev(e) for e in non_speech_events],
|
|
"content_anchor": [],
|
|
}
|
|
return AudioIR(
|
|
speech_spans=tuple(speech_spans),
|
|
pause_spans=tuple(pause_spans),
|
|
prosody_arcs=tuple(prosody_arcs),
|
|
turn_events=tuple(turn_events),
|
|
non_speech_events=tuple(non_speech_events),
|
|
content_anchors=(),
|
|
ir_sha256=sha256_json(ir_payload),
|
|
)
|
|
|
|
|
|
def _ev(e: AuditoryEvent) -> dict:
|
|
return {
|
|
"event_type": e.event_type,
|
|
"start_hop": e.start_hop,
|
|
"end_hop": e.end_hop,
|
|
"attrs": [list(a) for a in e.attrs],
|
|
"evidence_ids": list(e.evidence_ids),
|
|
}
|