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.
147 lines
5.6 KiB
Python
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,
|
|
)
|
|
)
|