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).
47 lines
1.4 KiB
Python
47 lines
1.4 KiB
Python
"""
|
|
sensorium/audio/frames.py — fixed frame grid (spec §4).
|
|
|
|
Default 20 ms window / 10 ms hop. Deterministic: the last partial frame is
|
|
zero-padded to the full window length so the grid is a pure function of
|
|
(signal length, sample_rate, frame_ms, hop_ms).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
FRAME_MS = 20
|
|
HOP_MS = 10
|
|
|
|
|
|
def frame_signal(
|
|
samples: np.ndarray,
|
|
sample_rate: int,
|
|
*,
|
|
frame_ms: int = FRAME_MS,
|
|
hop_ms: int = HOP_MS,
|
|
) -> np.ndarray:
|
|
"""Return a (n_frames, frame_len) float64 matrix of zero-padded frames.
|
|
|
|
The number of frames is ``ceil((n - frame_len)/hop) + 1`` for n >=
|
|
frame_len, else 1 (a single zero-padded frame). Hop index i spans samples
|
|
[i*hop, i*hop+frame_len).
|
|
"""
|
|
x = np.asarray(samples, dtype=np.float64)
|
|
frame_len = max(1, int(round(sample_rate * frame_ms / 1000)))
|
|
hop_len = max(1, int(round(sample_rate * hop_ms / 1000)))
|
|
|
|
if x.size <= frame_len:
|
|
n_frames = 1
|
|
else:
|
|
n_frames = (x.size - frame_len) // hop_len + 1
|
|
# Cover the tail with one more zero-padded frame when it spills over.
|
|
if (n_frames - 1) * hop_len + frame_len < x.size:
|
|
n_frames += 1
|
|
|
|
out = np.zeros((n_frames, frame_len), dtype=np.float64)
|
|
for i in range(n_frames):
|
|
start = i * hop_len
|
|
chunk = x[start:start + frame_len]
|
|
out[i, : chunk.size] = chunk
|
|
return out
|