core/sensorium/audio/parser.py
Shay f017785a6d
feat(adr-0181-p6): audio teacher/shadow lanes — typed hints, never substrate (#479)
Implements ADR-0181 PR-6 (eval-plan §4): teachers label or align; they never
define the substrate and never fold embeddings into the versor path.

- sensorium/audio/teachers.py:
  - TeacherHint: typed, versioned, checksummed annotation (no raw embeddings).
  - AudioTeacher protocol (pure on the signal).
  - attach_teacher_hints: the ONLY admission path — appends content.* anchors to
    the IR's content_anchors (immutable, recomputes ir_sha256). content.* is not
    an operator key, so compile_events skips it: versor + projection_sha256 stay
    byte-identical; only the ir leg of the merge_key moves (evidence recorded).
  - KNOWN_TEACHER_LANES (whisper/nemo/clap/encodec): declared + gated behind
    optional extras; load_teacher import-guards and fails loudly (never a silent
    fallback). StubTranscriptTeacher is the deterministic reference instance.
- parser.py: extract _ir_payload + ir_sha256_of (DRY single source of truth for
  ir_sha256; byte-identical to parse() output — regression-guarded).
- pyproject.toml: audio-whisper/nemo/clap/encodec optional extras (never
  runtime-required).

16 failable proof tests in tests/test_audio_teachers.py. Load-bearing:
test_teacher_hint_does_not_change_versor. Mutation-verified — giving a teacher
anchor an operator event_type (folding it into the versor) fails the
versor-invariance proof; reverted, all pass.

Additive only (ADR-0013): no core layer touched. Audio suite 57/57; eval-gate
ir_sha256 pins unchanged by the parser refactor; architectural invariants 40/40.
Real model adapters are deferred until extras+weights are present; this PR ships
the policy, the typed-hint contract, and the shadow-only guarantee.
2026-05-29 13:36:33 -07:00

147 lines
5.6 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 = _ir_payload(
speech_spans, pause_spans, prosody_arcs, turn_events, non_speech_events, ()
)
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),
}
def _ir_payload(speech, pause, prosody, turn, non_speech, content_anchor) -> dict:
"""Canonical JSON-serialisable IR image — the single source of truth for
``ir_sha256`` so a hint-augmented IR (PR-6) hashes by the same rule."""
return {
"speech": [_ev(e) for e in speech],
"pause": [_ev(e) for e in pause],
"prosody": [_ev(e) for e in prosody],
"turn": [_ev(e) for e in turn],
"non_speech": [_ev(e) for e in non_speech],
"content_anchor": [_ev(e) for e in content_anchor],
}
def ir_sha256_of(ir: AudioIR) -> str:
"""Recompute ``ir_sha256`` from an AudioIR's events. Byte-identical to what
``parse`` stored for an un-augmented IR (regression-guarded in tests); the
teacher-hint admission path (`sensorium.audio.teachers`) uses it to re-hash
an IR after appending content anchors."""
return sha256_json(
_ir_payload(
ir.speech_spans, ir.pause_spans, ir.prosody_arcs,
ir.turn_events, ir.non_speech_events, ir.content_anchors,
)
)