| """Streaming inference loop for the LS-EEND one-frame AXMODEL. |
| |
| The graph is a single frame step with explicit recurrent state. Two things are |
| the caller's responsibility, and getting either wrong costs tens of DER points: |
| |
| 1. **FP32 host accumulation.** The graph returns the *bounded per-frame |
| increment* ``k_t v_t`` (``enc{i}_inc`` / ``dec{i}_inc``), not the updated |
| retention state. The host keeps the running mean in FP32 via |
| ``mean += (inc - mean) / t``. Feeding a quantized state back instead lets |
| quantization error compound through the recurrence (DER 1.95% -> 7.4%). |
| |
| 2. **Warmup gating.** Upstream ``StreamingConv1d`` buffers 19 frames and emits |
| nothing until its 10th call, so the native loop never invokes the decoder for |
| the first ``CONV_DELAY`` frames. Real features are still fed (the encoder |
| state must advance), but ``pred`` is discarded and the decoder state is left |
| untouched. Skipping this diverges the trajectory permanently (DER -> 71.8%). |
| """ |
| from __future__ import annotations |
|
|
| import numpy as np |
|
|
| N_ENC_LAYERS = 4 |
| N_DEC_LAYERS = 2 |
| CONV_DELAY = 9 |
| |
| |
| |
|
|
| INPUT_NAMES = ( |
| ['feat', 'inv_count', 'dec_inv_count', 'conv_cache'] |
| + [f'enc{i}_{x}' for i in range(N_ENC_LAYERS) for x in ('kv', 'conv')] |
| + [f'dec{i}_kv' for i in range(N_DEC_LAYERS)] |
| ) |
| OUTPUT_NAMES = ( |
| ['pred'] |
| + [f'enc{i}_{x}' for i in range(N_ENC_LAYERS) for x in ('inc', 'conv_out')] |
| + ['conv_cache_out'] |
| + [f'dec{i}_inc' for i in range(N_DEC_LAYERS)] |
| ) |
| ENC_MEAN_NAMES = [f'enc{i}_kv' for i in range(N_ENC_LAYERS)] |
| DEC_MEAN_NAMES = [f'dec{i}_kv' for i in range(N_DEC_LAYERS)] |
|
|
|
|
| class StreamingDiarizer: |
| """Frame-synchronous LS-EEND runner over an AXMODEL (or ONNX) session.""" |
|
|
| def __init__(self, model_path, providers=None): |
| self.model_path = str(model_path) |
| self._session, self._backend = self._open(self.model_path, providers) |
| shapes = {i.name: tuple(i.shape) for i in self._session.get_inputs()} |
| missing = [n for n in INPUT_NAMES if n not in shapes] |
| if missing: |
| raise RuntimeError(f'model is missing expected inputs: {missing}') |
| self.shapes = shapes |
| self.slots = int(np.prod([d for d in self._session.get_outputs()[0].shape])) |
| self.reset() |
|
|
| @staticmethod |
| def _open(model_path, providers): |
| if model_path.endswith('.axmodel'): |
| import axengine as axe |
| return axe.InferenceSession( |
| model_path, providers=providers or ['AxEngineExecutionProvider'] |
| ), 'axengine' |
| import onnxruntime as ort |
| return ort.InferenceSession( |
| model_path, providers=providers or ['CPUExecutionProvider'] |
| ), 'onnxruntime' |
|
|
| @property |
| def backend(self): |
| return self._backend |
|
|
| def reset(self): |
| """Clear all recurrent state; call before each new recording.""" |
| self._state = {n: np.zeros(self.shapes[n], dtype=np.float32) for n in INPUT_NAMES} |
| self._enc_t = 0 |
| self._dec_t = 0 |
|
|
| def step(self, frame): |
| """Advance one feature frame. |
| |
| Args: |
| frame: (345,) or (1,1,345) float32 log-mel frame. |
| Returns: |
| (slots,) float32 logits, or None during the conv warmup. |
| """ |
| state = self._state |
| state['feat'] = np.asarray(frame, dtype=np.float32).reshape(self.shapes['feat']) |
| enc_b = 1.0 / (self._enc_t + 1) |
| dec_b = 1.0 / max(self._dec_t + 1, 1) |
| state['inv_count'] = np.full(self.shapes['inv_count'], enc_b, dtype=np.float32) |
| state['dec_inv_count'] = np.full(self.shapes['dec_inv_count'], dec_b, dtype=np.float32) |
|
|
| feed = {n: np.ascontiguousarray(state[n], dtype=np.float32) for n in INPUT_NAMES} |
| out = dict(zip(OUTPUT_NAMES, self._session.run(None, feed))) |
|
|
| |
| for i, name in enumerate(ENC_MEAN_NAMES): |
| mean = state[name] |
| mean *= (1.0 - enc_b) |
| mean += out[f'enc{i}_inc'] * enc_b |
| state[f'enc{i}_conv'] = out[f'enc{i}_conv_out'] |
| state['conv_cache'] = out['conv_cache_out'] |
| self._enc_t += 1 |
|
|
| if self._enc_t <= CONV_DELAY: |
| return None |
|
|
| for i, name in enumerate(DEC_MEAN_NAMES): |
| mean = state[name] |
| mean *= (1.0 - dec_b) |
| mean += out[f'dec{i}_inc'] * dec_b |
| self._dec_t += 1 |
| return np.asarray(out['pred'], dtype=np.float32).reshape(-1) |
|
|
| def run(self, features, progress=None): |
| """Run a whole recording. |
| |
| Returns (T - CONV_DELAY, slots) float32 logits. The first |
| CONV_DELAY frames produce no output, and the trailing 0.9 s is not |
| emitted (the native flush pushes zero *embeddings* past the encoder, |
| which a fused one-frame graph cannot express). |
| """ |
| self.reset() |
| logits = [] |
| total = len(features) |
| for t in range(total): |
| pred = self.step(features[t]) |
| if pred is not None: |
| logits.append(pred) |
| if progress is not None and (t + 1) % progress == 0: |
| print(f' {t + 1}/{total} frames', flush=True) |
| if not logits: |
| raise RuntimeError(f'recording too short: need > {CONV_DELAY} frames, got {total}') |
| return np.stack(logits, axis=0) |
|
|