Remove host code; update README
Browse files- README.md +1 -4
- README.md~ +0 -141
- python/titanet_coreai.py +0 -262
- swift/Host.swift +0 -331
- swift/MelFrontend.swift +0 -112
- swift/SpeakerEngine.swift +0 -157
README.md
CHANGED
|
@@ -33,7 +33,7 @@ All comparisons are against NeMo's own embedding of the same audio (the `.nemo`
|
|
| 33 |
|
| 34 |
| Check | Clips | Cosine to NeMo (min / mean) | Notes |
|
| 35 |
|---|---|---|---|
|
| 36 |
-
| WAV → Python host → Core AI (Mac, GPU) | 40 VoxCeleb1-test, 4.1–64.7 s | **0.9999987** / 0.9999993 | the whole
|
| 37 |
| NeMo's mel → Swift host → Core AI (iPhone, GPU) | same 40 | **0.9999987** / 0.9999993 | all embeddings finite |
|
| 38 |
|
| 39 |
Because the embeddings agree with NeMo's to within 1.3e-6 in cosine, verification behaves as the original does.
|
|
@@ -49,13 +49,10 @@ re-scored on that benchmark.
|
|
| 49 |
| `frontend/mel_fb.f32` | slaney mel filterbank [80, 257], float32 little-endian (the checkpoint's own buffer) |
|
| 50 |
| `frontend/mel_window.f32` | Hann window [400], periodic=False (the checkpoint's own buffer) |
|
| 51 |
| `frontend/config.json` | front-end constants: 16 kHz, n_fft 512, hop 160, win 400, 80 mels, 192-d embedding, threshold 0.7 |
|
| 52 |
-
| `python/titanet_coreai.py` | standalone Python host: mel front end + windowed scan, numpy only |
|
| 53 |
-
| `swift/` | Swift host for iOS / macOS apps: `MelFrontend.swift`, `Host.swift`, `SpeakerEngine.swift` |
|
| 54 |
|
| 55 |
## Input
|
| 56 |
|
| 57 |
- **16 kHz, mono**, float samples in [-1, 1]. The model was trained on 16 kHz audio; resample anything else first.
|
| 58 |
-
The Python CLI reads WAV through `soundfile` and refuses other sample rates rather than resampling silently.
|
| 59 |
- Any length from one 10 ms frame up. A few seconds of speech gives a stable embedding.
|
| 60 |
- The front end is NeMo's `AudioToMelSpectrogramPreprocessor` in eval mode, reproduced step for step: pre-emphasis
|
| 61 |
0.97, centred 512-point STFT (hop 160, Hann 400), power, 80 slaney mel bins, `log(x + 2^-24)`, per-feature
|
|
|
|
| 33 |
|
| 34 |
| Check | Clips | Cosine to NeMo (min / mean) | Notes |
|
| 35 |
|---|---|---|---|
|
| 36 |
+
| WAV → Python host → Core AI (Mac, GPU) | 40 VoxCeleb1-test, 4.1–64.7 s | **0.9999987** / 0.9999993 | the whole pipeline: mel front end, windowed host, graph |
|
| 37 |
| NeMo's mel → Swift host → Core AI (iPhone, GPU) | same 40 | **0.9999987** / 0.9999993 | all embeddings finite |
|
| 38 |
|
| 39 |
Because the embeddings agree with NeMo's to within 1.3e-6 in cosine, verification behaves as the original does.
|
|
|
|
| 49 |
| `frontend/mel_fb.f32` | slaney mel filterbank [80, 257], float32 little-endian (the checkpoint's own buffer) |
|
| 50 |
| `frontend/mel_window.f32` | Hann window [400], periodic=False (the checkpoint's own buffer) |
|
| 51 |
| `frontend/config.json` | front-end constants: 16 kHz, n_fft 512, hop 160, win 400, 80 mels, 192-d embedding, threshold 0.7 |
|
|
|
|
|
|
|
| 52 |
|
| 53 |
## Input
|
| 54 |
|
| 55 |
- **16 kHz, mono**, float samples in [-1, 1]. The model was trained on 16 kHz audio; resample anything else first.
|
|
|
|
| 56 |
- Any length from one 10 ms frame up. A few seconds of speech gives a stable embedding.
|
| 57 |
- The front end is NeMo's `AudioToMelSpectrogramPreprocessor` in eval mode, reproduced step for step: pre-emphasis
|
| 58 |
0.97, centred 512-point STFT (hop 160, Hann 400), power, 80 slaney mel bins, `log(x + 2^-24)`, per-feature
|
README.md~
DELETED
|
@@ -1,141 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
license: cc-by-4.0
|
| 3 |
-
base_model: nvidia/speakerverification_en_titanet_large
|
| 4 |
-
language:
|
| 5 |
-
- en
|
| 6 |
-
library_name: coreai
|
| 7 |
-
tags:
|
| 8 |
-
- coreai
|
| 9 |
-
- apple
|
| 10 |
-
- ios
|
| 11 |
-
- on-device
|
| 12 |
-
- speaker-verification
|
| 13 |
-
- speaker-recognition
|
| 14 |
-
- speaker-embedding
|
| 15 |
-
- titanet
|
| 16 |
-
- audio
|
| 17 |
-
---
|
| 18 |
-
|
| 19 |
-
# TitaNet-Large for Core AI
|
| 20 |
-
|
| 21 |
-
[NVIDIA TitaNet-Large](https://huggingface.co/nvidia/speakerverification_en_titanet_large) (23M parameters) ported
|
| 22 |
-
to Apple's **Core AI** runtime for on-device speaker verification and identification on iPhone, iPad and Mac.
|
| 23 |
-
Give it 16 kHz mono speech and it returns NeMo's 192-d speaker embedding, numerically matching the original:
|
| 24 |
-
**cosine ≥ 0.9999987 against NeMo's `get_embedding` on real recordings from 4 to 65 seconds.**
|
| 25 |
-
|
| 26 |
-
One small bundle (47 MB) handles audio of any length: the graph is compiled for a single 5.12 s window, and a short
|
| 27 |
-
host loop sweeps that window over the clip and rebuilds the model's global statistics exactly. On an iPhone it peaks
|
| 28 |
-
at **117 MB** of memory and embeds a 65-second clip in **1.1 s** on the GPU.
|
| 29 |
-
|
| 30 |
-
## Results
|
| 31 |
-
|
| 32 |
-
All comparisons are against NeMo's own embedding of the same audio (the `.nemo` checkpoint, `get_embedding`).
|
| 33 |
-
|
| 34 |
-
| Check | Clips | Cosine to NeMo (min / mean) | Notes |
|
| 35 |
-
|---|---|---|---|
|
| 36 |
-
| WAV → Python host → Core AI (Mac, GPU) | 40 VoxCeleb1-test, 4.1–64.7 s | **0.9999987** / 0.9999993 | the whole package: mel front end, windowed host, graph |
|
| 37 |
-
| NeMo's mel → Swift host → Core AI (iPhone, GPU) | same 40 | **0.9999987** / 0.9999993 | all embeddings finite |
|
| 38 |
-
|
| 39 |
-
Because the embeddings agree with NeMo's to within 1.3e-6 in cosine, verification behaves as the original does.
|
| 40 |
-
NVIDIA reports **0.66 % EER on VoxCeleb1 (cleaned trials)** for the base model; this port was not separately
|
| 41 |
-
re-scored on that benchmark.
|
| 42 |
-
|
| 43 |
-
## Files
|
| 44 |
-
|
| 45 |
-
| Path | What it is |
|
| 46 |
-
|---|---|
|
| 47 |
-
| `titanet.aimodel/` | the Core AI bundle: functions `scan` and `head`, fp16 weights, fp32 compute |
|
| 48 |
-
| `export.json` | window length (512 frames), context margin (46), function signatures, precision recipe |
|
| 49 |
-
| `frontend/mel_fb.f32` | slaney mel filterbank [80, 257], float32 little-endian (the checkpoint's own buffer) |
|
| 50 |
-
| `frontend/mel_window.f32` | Hann window [400], periodic=False (the checkpoint's own buffer) |
|
| 51 |
-
| `frontend/config.json` | front-end constants: 16 kHz, n_fft 512, hop 160, win 400, 80 mels, 192-d embedding, threshold 0.7 |
|
| 52 |
-
| `python/titanet_coreai.py` | standalone Python host: mel front end + windowed scan, numpy only |
|
| 53 |
-
| `swift/` | Swift host for iOS / macOS apps: `MelFrontend.swift`, `Host.swift`, `SpeakerEngine.swift` |
|
| 54 |
-
|
| 55 |
-
## Input
|
| 56 |
-
|
| 57 |
-
- **16 kHz, mono**, float samples in [-1, 1]. The model was trained on 16 kHz audio; resample anything else first.
|
| 58 |
-
The Python CLI reads WAV through `soundfile` and refuses other sample rates rather than resampling silently.
|
| 59 |
-
- Any length from one 10 ms frame up. A few seconds of speech gives a stable embedding.
|
| 60 |
-
- The front end is NeMo's `AudioToMelSpectrogramPreprocessor` in eval mode, reproduced step for step: pre-emphasis
|
| 61 |
-
0.97, centred 512-point STFT (hop 160, Hann 400), power, 80 slaney mel bins, `log(x + 2^-24)`, per-feature
|
| 62 |
-
normalization over the valid frames. The filterbank and window are the checkpoint's own buffers. Dither is
|
| 63 |
-
training-only and is not applied.
|
| 64 |
-
|
| 65 |
-
## How it works
|
| 66 |
-
|
| 67 |
-
### Why one window
|
| 68 |
-
|
| 69 |
-
In a Core AI bundle the activation arena is sized per compiled shape and allocated for every function in the bundle.
|
| 70 |
-
A ladder of input-length buckets (the usual way to handle variable-length audio) therefore costs the sum of all its
|
| 71 |
-
shapes: 768 MB to over 1.1 GB measured for seven buckets. This port compiles exactly one shape, a 512-frame (5.12 s)
|
| 72 |
-
window, and handles longer audio on the host.
|
| 73 |
-
|
| 74 |
-
### Why a naive window is wrong, and the fix
|
| 75 |
-
|
| 76 |
-
TitaNet has three reductions over the whole time axis:
|
| 77 |
-
|
| 78 |
-
1. **five SqueezeExcite gates**: each block scales its channels by `sigmoid(W2 relu(W1 mean_t(x)))` over the entire
|
| 79 |
-
utterance;
|
| 80 |
-
2. **the pooling mean / std** of the encoder output;
|
| 81 |
-
3. **the attention softmax over time** in attentive statistics pooling.
|
| 82 |
-
|
| 83 |
-
Computing these per window gives the wrong function: chunk-local SqueezeExcite alone drops the cosine to about 0.36.
|
| 84 |
-
The graph here makes all three reductions *injectable* (taken as inputs) and *reportable* (partial sums returned as
|
| 85 |
-
outputs), so the host can rebuild them exactly:
|
| 86 |
-
|
| 87 |
-
| Pass | Calls | Rebuilds |
|
| 88 |
-
|---|---|---|
|
| 89 |
-
| 1–5 | `scan` per window, gate sums fed back as `s0..s4` | the SqueezeExcite fixed point: block *j*'s mean depends only on gates < *j*, so sweep *k* makes gate *k−1* exact and five sweeps make all five exact |
|
| 90 |
-
| 6 | `scan` per window | pooling mean / std from running sums `x_sum`, `x_sqsum` |
|
| 91 |
-
| 7 | `scan` per window, with the pooling stats | the attention softmax, merged across windows by running maximum (`lmax`, `den`, `ax`, `axx`) |
|
| 92 |
-
| — | `head` once | the 192-d embedding from the attentive mean and std |
|
| 93 |
-
|
| 94 |
-
Every convolution is stride 1 with "same" padding and the stack's receptive field is 93 frames, so each window carries
|
| 95 |
-
46 frames of real context on each side and keeps only its interior; the interiors tile the clip exactly once. A clip
|
| 96 |
-
of *n* mel frames (10 ms each) uses 1 window if *n* ≤ 512, otherwise ⌈(*n* − 512) / 420⌉ + 1, and
|
| 97 |
-
**7 × windows + 1** model calls. Host accumulation is float64, and host memory is O(channels), never O(frames).
|
| 98 |
-
|
| 99 |
-
All five sweeps are needed: the gate residual falls to 6e-4 after two sweeps and *looks* converged while the
|
| 100 |
-
embedding cosine is still 0.64. Cosine by number of sweeps: 0.066, 0.639, 0.993, 0.99996, 0.9999987.
|
| 101 |
-
|
| 102 |
-
### Precision
|
| 103 |
-
|
| 104 |
-
`wfp16`: weights stored as fp16 (half the bytes), all compute in fp32. A full-fp16 graph was rejected for long
|
| 105 |
-
audio: its whole-utterance reductions sum thousands of frames in fp16, and the worst-case cosine fell to 0.72 on
|
| 106 |
-
clips up to 65 s, although it looked fine (0.99998) on 4-second clips.
|
| 107 |
-
|
| 108 |
-
### Interface
|
| 109 |
-
|
| 110 |
-
`scan`: one window of the clip.
|
| 111 |
-
|
| 112 |
-
| Input | Shape | Meaning |
|
| 113 |
-
|---|---|---|
|
| 114 |
-
| `mel` | [1, 80, 512] | the window's normalized log-mel, zero past the clip's end |
|
| 115 |
-
| `valid` | [1, 1, 512] | 1 for frames inside the clip |
|
| 116 |
-
| `keep` | [1, 1, 512] | 1 for the window's interior frames, whose statistics this call reports |
|
| 117 |
-
| `n_inv` | [1, 1] | 1 / (valid frames in the whole clip) |
|
| 118 |
-
| `s0`..`s3`, `s4` | [1, 1024] ×4, [1, 3072] | SqueezeExcite sums from the previous sweep (zeros on the first) |
|
| 119 |
-
| `mean`, `std` | [1, 3072] each | pooling statistics for the attention pass (zeros and ones before they are known) |
|
| 120 |
-
|
| 121 |
-
Outputs, all [1, C]: `se0`..`se4` (this window's SqueezeExcite contributions), `x_sum`, `x_sqsum`, `lmax`, `den`,
|
| 122 |
-
`ax`, `axx` (C = 3072).
|
| 123 |
-
|
| 124 |
-
`head`: `mu`, `sg` [1, 3072] → `emb` [1, 192].
|
| 125 |
-
|
| 126 |
-
## License and attribution
|
| 127 |
-
|
| 128 |
-
This is a derivative of [nvidia/speakerverification_en_titanet_large](https://huggingface.co/nvidia/speakerverification_en_titanet_large)
|
| 129 |
-
by NVIDIA, released under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/); this port is released under the
|
| 130 |
-
same license.
|
| 131 |
-
The weights are NVIDIA's; no retraining or fine-tuning was done.
|
| 132 |
-
|
| 133 |
-
```bibtex
|
| 134 |
-
@inproceedings{koluguri2022titanet,
|
| 135 |
-
title = {TitaNet: Neural Model for Speaker Representation with 1D Depth-wise Separable Convolutions and Global Context},
|
| 136 |
-
author = {Koluguri, Nithin Rao and Park, Taejin and Ginsburg, Boris},
|
| 137 |
-
booktitle = {ICASSP 2022 - 2022 IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP)},
|
| 138 |
-
pages = {8102--8106},
|
| 139 |
-
year = {2022}
|
| 140 |
-
}
|
| 141 |
-
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
python/titanet_coreai.py
DELETED
|
@@ -1,262 +0,0 @@
|
|
| 1 |
-
"""TitaNet-Large on Core AI: 16 kHz mono audio in, NeMo's 192-d speaker embedding out. numpy only, no torch.
|
| 2 |
-
|
| 3 |
-
python titanet_coreai.py a.wav b.wav [--compute gpu|ane|cpu|default]
|
| 4 |
-
|
| 5 |
-
from titanet_coreai import TitaNet
|
| 6 |
-
tn = await TitaNet.load("path/to/TitaNet-Large-CoreAI")
|
| 7 |
-
emb = await tn.embed_samples(samples) # float32 [N] at 16 kHz -> float32 [192]
|
| 8 |
-
|
| 9 |
-
The bundle compiles ONE 512-frame (5.12 s) window. Utterances of any length are handled by sweeping that window and
|
| 10 |
-
rebuilding TitaNet's three global reductions on the host, exactly:
|
| 11 |
-
|
| 12 |
-
* five SqueezeExcite gates: a triangular fixed point, made exact by five sweeps over the windows
|
| 13 |
-
(sweep k makes gate k-1 exact; fewer sweeps look converged but are not);
|
| 14 |
-
* the pooling mean / std of the encoder output: one pass of running sums;
|
| 15 |
-
* the attention softmax over time: one pass, combined across windows by running maximum.
|
| 16 |
-
|
| 17 |
-
Each window carries 46 frames of real context on each side (the conv stack's receptive field is 93 frames) and only
|
| 18 |
-
its interior is kept, so the interiors tile the utterance exactly once. Host sums are float64.
|
| 19 |
-
"""
|
| 20 |
-
from __future__ import annotations
|
| 21 |
-
|
| 22 |
-
import argparse
|
| 23 |
-
import asyncio
|
| 24 |
-
import gc
|
| 25 |
-
import json
|
| 26 |
-
from pathlib import Path
|
| 27 |
-
|
| 28 |
-
import numpy as np
|
| 29 |
-
|
| 30 |
-
# ---- the mel front end: NeMo FilterbankFeatures, eval mode ------------------------------------------------------
|
| 31 |
-
|
| 32 |
-
N_FFT, HOP, WIN, N_MELS = 512, 160, 400, 80
|
| 33 |
-
PREEMPH = np.float32(0.97)
|
| 34 |
-
LOG_GUARD = np.float32(2.0 ** -24)
|
| 35 |
-
MEL_STD_EPS = np.float32(1e-5)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
class MelFrontend:
|
| 39 |
-
"""samples float32 [N] (16 kHz) -> (mel float32 [80, N // 160 + 1], valid frames N // 160).
|
| 40 |
-
|
| 41 |
-
preemphasis 0.97; centred STFT (n_fft 512, hop 160, Hann(400) centred in 512), DFT summed in float64; power;
|
| 42 |
-
slaney mel [80, 257]; log(x + 2^-24); per-feature normalization over the valid frames (unbiased std + 1e-5).
|
| 43 |
-
The filterbank and window are the checkpoint's own buffers (frontend/*.f32). Dither is training-only.
|
| 44 |
-
"""
|
| 45 |
-
|
| 46 |
-
def __init__(self, fb: np.ndarray, window: np.ndarray):
|
| 47 |
-
assert fb.shape == (N_MELS, N_FFT // 2 + 1) and window.shape == (WIN,)
|
| 48 |
-
self.fb = fb.astype(np.float64)
|
| 49 |
-
w = np.zeros(N_FFT, np.float64)
|
| 50 |
-
off = (N_FFT - WIN) // 2
|
| 51 |
-
w[off:off + WIN] = window.astype(np.float64)
|
| 52 |
-
self.window = w
|
| 53 |
-
k = np.arange(N_FFT // 2 + 1)[:, None]
|
| 54 |
-
j = np.arange(N_FFT)[None, :]
|
| 55 |
-
ang = 2.0 * np.pi * ((k * j) % N_FFT) / N_FFT # the angle reduced exactly before the trig call
|
| 56 |
-
self.cos, self.sin = np.cos(ang), -np.sin(ang)
|
| 57 |
-
|
| 58 |
-
@classmethod
|
| 59 |
-
def load(cls, frontend_dir: Path) -> "MelFrontend":
|
| 60 |
-
fb = np.fromfile(frontend_dir / "mel_fb.f32", "<f4").reshape(N_MELS, N_FFT // 2 + 1)
|
| 61 |
-
return cls(fb, np.fromfile(frontend_dir / "mel_window.f32", "<f4"))
|
| 62 |
-
|
| 63 |
-
def __call__(self, x: np.ndarray) -> tuple[np.ndarray, int]:
|
| 64 |
-
x = np.asarray(x, np.float32)
|
| 65 |
-
n_valid = len(x) // HOP
|
| 66 |
-
if len(x) == 0:
|
| 67 |
-
return np.zeros((N_MELS, 1), np.float32), 0
|
| 68 |
-
y = np.empty_like(x)
|
| 69 |
-
y[0] = x[0]
|
| 70 |
-
y[1:] = x[1:] - PREEMPH * x[:-1]
|
| 71 |
-
padded = np.concatenate([np.zeros(N_FFT // 2, np.float32), y, np.zeros(N_FFT // 2, np.float32)])
|
| 72 |
-
n_frames = 1 + (len(padded) - N_FFT) // HOP
|
| 73 |
-
idx = np.arange(N_FFT)[None, :] + HOP * np.arange(n_frames)[:, None]
|
| 74 |
-
frames = padded[idx].astype(np.float64) * self.window # [frames, 512]
|
| 75 |
-
re = (frames @ self.cos.T).astype(np.float32)
|
| 76 |
-
im = (frames @ self.sin.T).astype(np.float32)
|
| 77 |
-
mag = np.sqrt(re * re + im * im)
|
| 78 |
-
power = mag * mag
|
| 79 |
-
mel = (self.fb @ power.T.astype(np.float64)).astype(np.float32) # [80, frames]
|
| 80 |
-
logmel = np.log(mel.astype(np.float64) + float(LOG_GUARD)).astype(np.float32)
|
| 81 |
-
return _normalize(logmel, n_valid), n_valid
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def _normalize(x: np.ndarray, n: int) -> np.ndarray:
|
| 85 |
-
out = np.zeros_like(x)
|
| 86 |
-
if n == 0:
|
| 87 |
-
return out
|
| 88 |
-
v = x[:, :n].astype(np.float64)
|
| 89 |
-
ref = x[:, 0].astype(np.float64)
|
| 90 |
-
centered = (v - ref[:, None]).astype(np.float32).astype(np.float64)
|
| 91 |
-
mean = (ref + centered.sum(1) / n).astype(np.float32)
|
| 92 |
-
dev = (v - mean[:, None].astype(np.float64)).astype(np.float32).astype(np.float64)
|
| 93 |
-
std = np.sqrt((dev * dev).sum(1) / (n - 1.0)).astype(np.float32) if n > 1 else np.zeros(x.shape[0], np.float32)
|
| 94 |
-
out[:, :n] = (x[:, :n] - mean[:, None]) / (std + MEL_STD_EPS)[:, None]
|
| 95 |
-
return out
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
# ---- the windowed host ------------------------------------------------------------------------------------------
|
| 99 |
-
|
| 100 |
-
C_OUT = 3072 # encoder output channels
|
| 101 |
-
SE_CHANNELS = (1024, 1024, 1024, 1024, 3072) # the five SqueezeExcite blocks
|
| 102 |
-
STD_EPS = 1e-10
|
| 103 |
-
SWEEPS = 5
|
| 104 |
-
GC_EVERY = 32
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def windows(n: int, F: int, margin: int) -> list[tuple[int, int, int]]:
|
| 108 |
-
"""[(start, keep_lo, keep_hi)] in frame coordinates: the kept interiors tile [0, n) exactly once."""
|
| 109 |
-
if n <= F:
|
| 110 |
-
return [(0, 0, n)]
|
| 111 |
-
step = F - 2 * margin
|
| 112 |
-
out, s = [], 0
|
| 113 |
-
while True:
|
| 114 |
-
end = s + F
|
| 115 |
-
out.append((s, 0 if s == 0 else s + margin, n if end >= n else end - margin))
|
| 116 |
-
if end >= n:
|
| 117 |
-
return out
|
| 118 |
-
s += step
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def options(name: str):
|
| 122 |
-
from coreai.runtime import ComputeUnitKind, SpecializationOptions
|
| 123 |
-
return {"default": SpecializationOptions.default(), "cpu": SpecializationOptions.cpu_only(),
|
| 124 |
-
"gpu": SpecializationOptions.from_preferred_compute_unit_kind(ComputeUnitKind.gpu()),
|
| 125 |
-
"ane": SpecializationOptions.from_preferred_compute_unit_kind(ComputeUnitKind.neural_engine())}[name]
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
class TitaNet:
|
| 129 |
-
@classmethod
|
| 130 |
-
async def load(cls, repo: str | Path, compute: str = "gpu") -> "TitaNet":
|
| 131 |
-
from coreai.runtime import AIModel
|
| 132 |
-
self = cls()
|
| 133 |
-
repo = Path(repo)
|
| 134 |
-
rec = json.loads((repo / "export.json").read_text())
|
| 135 |
-
self.F = int(rec["shape"])
|
| 136 |
-
self.margin = int(rec["margin"])
|
| 137 |
-
self.mel = MelFrontend.load(repo / "frontend")
|
| 138 |
-
self.model = await AIModel.load(repo / rec["aimodel"], options(compute))
|
| 139 |
-
self.scan = self.model.load_function("scan")
|
| 140 |
-
self.head = self.model.load_function("head")
|
| 141 |
-
self.calls = 0
|
| 142 |
-
return self
|
| 143 |
-
|
| 144 |
-
async def embed_samples(self, samples: np.ndarray) -> np.ndarray:
|
| 145 |
-
"""float32 [N] at 16 kHz, mono -> embedding float32 [192]."""
|
| 146 |
-
mel, n = self.mel(samples)
|
| 147 |
-
return await self.embed_mel(mel, n)
|
| 148 |
-
|
| 149 |
-
async def embed_mel(self, mel: np.ndarray, n: int) -> np.ndarray:
|
| 150 |
-
"""mel [80, >= n] with the utterance's n valid frames first -> embedding float32 [192]."""
|
| 151 |
-
if n < 1:
|
| 152 |
-
raise ValueError("the clip is shorter than one 10 ms frame")
|
| 153 |
-
wins = windows(n, self.F, self.margin)
|
| 154 |
-
n_inv = 1.0 / n
|
| 155 |
-
zero = np.zeros(C_OUT, np.float32)
|
| 156 |
-
one = np.ones(C_OUT, np.float32)
|
| 157 |
-
|
| 158 |
-
sums = [np.zeros(c, np.float64) for c in SE_CHANNELS]
|
| 159 |
-
for _ in range(SWEEPS):
|
| 160 |
-
new = [np.zeros(c, np.float64) for c in SE_CHANNELS]
|
| 161 |
-
for w in wins:
|
| 162 |
-
o = await self._scan(mel, n, w, n_inv, sums, zero, one)
|
| 163 |
-
for j in range(5):
|
| 164 |
-
new[j] += o[f"se{j}"]
|
| 165 |
-
sums = new
|
| 166 |
-
|
| 167 |
-
sx = np.zeros(C_OUT)
|
| 168 |
-
sxx = np.zeros(C_OUT)
|
| 169 |
-
for w in wins:
|
| 170 |
-
o = await self._scan(mel, n, w, n_inv, sums, zero, one)
|
| 171 |
-
sx += o["x_sum"]
|
| 172 |
-
sxx += o["x_sqsum"]
|
| 173 |
-
pmean = sx / n
|
| 174 |
-
pstd = np.sqrt(np.maximum(sxx / n - pmean * pmean, STD_EPS))
|
| 175 |
-
|
| 176 |
-
mx = None
|
| 177 |
-
den, ax, axx = np.zeros(C_OUT), np.zeros(C_OUT), np.zeros(C_OUT)
|
| 178 |
-
for w in wins:
|
| 179 |
-
o = await self._scan(mel, n, w, n_inv, sums, pmean.astype(np.float32), pstd.astype(np.float32))
|
| 180 |
-
lmax = o["lmax"]
|
| 181 |
-
if mx is None:
|
| 182 |
-
mx, scale, f = lmax, np.zeros(C_OUT), np.ones(C_OUT)
|
| 183 |
-
else:
|
| 184 |
-
m_new = np.maximum(mx, lmax)
|
| 185 |
-
scale, f, mx = np.exp(mx - m_new), np.exp(lmax - m_new), m_new
|
| 186 |
-
den = den * scale + o["den"] * f
|
| 187 |
-
ax = ax * scale + o["ax"] * f
|
| 188 |
-
axx = axx * scale + o["axx"] * f
|
| 189 |
-
mu = ax / den
|
| 190 |
-
sg = np.sqrt(np.maximum(axx / den - mu * mu, STD_EPS))
|
| 191 |
-
|
| 192 |
-
out = await self.head({"mu": _nd(mu[None]), "sg": _nd(sg[None])})
|
| 193 |
-
self.calls += 1
|
| 194 |
-
return out["emb"].numpy().astype(np.float32)[0]
|
| 195 |
-
|
| 196 |
-
async def _scan(self, mel, n, w, n_inv, sums, pmean, pstd):
|
| 197 |
-
s, lo, hi = w
|
| 198 |
-
F = self.F
|
| 199 |
-
take = max(0, min(F, n - s))
|
| 200 |
-
mw = np.zeros((1, N_MELS, F), np.float32)
|
| 201 |
-
mw[0, :, :take] = mel[:, s:s + take]
|
| 202 |
-
vw = np.zeros((1, 1, F), np.float32)
|
| 203 |
-
vw[0, 0, :take] = 1
|
| 204 |
-
kw = np.zeros((1, 1, F), np.float32)
|
| 205 |
-
kw[0, 0, lo - s:hi - s] = 1
|
| 206 |
-
feeds = {"mel": _nd(mw), "valid": _nd(vw), "keep": _nd(kw), "n_inv": _nd(np.array([[n_inv]])),
|
| 207 |
-
"mean": _nd(pmean[None]), "std": _nd(pstd[None])}
|
| 208 |
-
for j in range(5):
|
| 209 |
-
feeds[f"s{j}"] = _nd(sums[j][None])
|
| 210 |
-
out = await self.scan(feeds)
|
| 211 |
-
self.calls += 1
|
| 212 |
-
res = {k: v.numpy().astype(np.float64)[0] for k, v in out.items()}
|
| 213 |
-
# The Python runtime returns NDArray storage to its pool only on collection; collect regularly or a long
|
| 214 |
-
# utterance (hundreds of calls) can exhaust it. Swift hosts are not affected.
|
| 215 |
-
del out, feeds
|
| 216 |
-
if self.calls % GC_EVERY == 0:
|
| 217 |
-
gc.collect()
|
| 218 |
-
return res
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def _nd(a):
|
| 222 |
-
from coreai.runtime import NDArray
|
| 223 |
-
return NDArray(np.ascontiguousarray(a, dtype=np.float32))
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
def cosine(a, b) -> float:
|
| 227 |
-
a, b = np.asarray(a, np.float64), np.asarray(b, np.float64)
|
| 228 |
-
return float(a @ b / np.sqrt((a @ a) * (b @ b)))
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
def score(a, b) -> float:
|
| 232 |
-
"""NeMo's verification score, (cos + 1) / 2; the same speaker at >= 0.7."""
|
| 233 |
-
return (cosine(a, b) + 1.0) / 2.0
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
def read_wav(path: str | Path) -> np.ndarray:
|
| 237 |
-
import soundfile as sf
|
| 238 |
-
x, sr = sf.read(str(path), dtype="float32")
|
| 239 |
-
if x.ndim > 1:
|
| 240 |
-
x = x.mean(axis=1)
|
| 241 |
-
if sr != 16000:
|
| 242 |
-
raise ValueError(f"{path}: {sr} Hz; resample to 16 kHz first (the model was trained on 16 kHz)")
|
| 243 |
-
return x
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
async def _main() -> None:
|
| 247 |
-
ap = argparse.ArgumentParser(description="Compare two recordings with TitaNet-Large on Core AI.")
|
| 248 |
-
ap.add_argument("a")
|
| 249 |
-
ap.add_argument("b")
|
| 250 |
-
ap.add_argument("--repo", default=str(Path(__file__).resolve().parent.parent))
|
| 251 |
-
ap.add_argument("--compute", default="gpu", choices=["gpu", "ane", "cpu", "default"])
|
| 252 |
-
ap.add_argument("--threshold", type=float, default=0.7)
|
| 253 |
-
args = ap.parse_args()
|
| 254 |
-
tn = await TitaNet.load(args.repo, args.compute)
|
| 255 |
-
ea = await tn.embed_samples(read_wav(args.a))
|
| 256 |
-
eb = await tn.embed_samples(read_wav(args.b))
|
| 257 |
-
sc = score(ea, eb)
|
| 258 |
-
print(f"score {sc:.4f} (cos {2 * sc - 1:.4f}): {'same speaker' if sc >= args.threshold else 'different speakers'}")
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
if __name__ == "__main__":
|
| 262 |
-
asyncio.run(_main())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
swift/Host.swift
DELETED
|
@@ -1,331 +0,0 @@
|
|
| 1 |
-
// Host — one loaded titanet.aimodel and the seven-pass windowed scan that rebuilds TitaNet's three global reductions
|
| 2 |
-
// (five SqueezeExcite gates, pooling mean/std, attention softmax) exactly, from one 512-frame window swept over an
|
| 3 |
-
// utterance of any length. python/titanet_coreai.py is the same algorithm in Python. Host accumulation is Double.
|
| 4 |
-
|
| 5 |
-
import CoreAI
|
| 6 |
-
import Foundation
|
| 7 |
-
|
| 8 |
-
enum TitaNetError: Error, CustomStringConvertible {
|
| 9 |
-
case contract(String)
|
| 10 |
-
case audio(String)
|
| 11 |
-
|
| 12 |
-
var description: String {
|
| 13 |
-
switch self {
|
| 14 |
-
case .contract(let s): return s
|
| 15 |
-
case .audio(let s): return s
|
| 16 |
-
}
|
| 17 |
-
}
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
/// The fields of export.json the host needs.
|
| 21 |
-
struct ExportRecord: Decodable, Sendable {
|
| 22 |
-
struct Signature: Decodable, Sendable { var inputs: [String]; var outputs: [String] }
|
| 23 |
-
var shape: Int
|
| 24 |
-
var margin: Int?
|
| 25 |
-
var entrypoints: [String]
|
| 26 |
-
var compute_dtype: String
|
| 27 |
-
var seconds_of_audio: Double?
|
| 28 |
-
var arm: String?
|
| 29 |
-
var signature: [String: Signature]?
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
/// [(start, keep_lo, keep_hi)] in global frame coordinates: interiors tile [0, n) exactly once; each window carries a 46-frame margin of real context.
|
| 33 |
-
func windows(_ n: Int, _ F: Int, _ margin: Int) throws -> [(s: Int, lo: Int, hi: Int)] {
|
| 34 |
-
if n <= F { return [(0, 0, n)] }
|
| 35 |
-
let step = F - 2 * margin
|
| 36 |
-
guard step > 0 else { throw TitaNetError.contract("window \(F) is too small for a \(margin)-frame margin on each side") }
|
| 37 |
-
var out: [(Int, Int, Int)] = []
|
| 38 |
-
var s = 0
|
| 39 |
-
while true {
|
| 40 |
-
let end = s + F
|
| 41 |
-
out.append((s, s == 0 ? 0 : s + margin, end >= n ? n : end - margin))
|
| 42 |
-
if end >= n { return out }
|
| 43 |
-
s += step
|
| 44 |
-
}
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
final class Host {
|
| 48 |
-
static let stdEps = 1e-10
|
| 49 |
-
let record: ExportRecord
|
| 50 |
-
let F: Int
|
| 51 |
-
let margin: Int
|
| 52 |
-
let nMels: Int
|
| 53 |
-
let cOut: Int
|
| 54 |
-
private let model: AIModel
|
| 55 |
-
private let scan: InferenceFunction?
|
| 56 |
-
private let head: InferenceFunction?
|
| 57 |
-
private let once: InferenceFunction?
|
| 58 |
-
private let scanIn: [String: NDArrayDescriptor]
|
| 59 |
-
private let headIn: [String: NDArrayDescriptor]
|
| 60 |
-
private let onceIn: [String: NDArrayDescriptor]
|
| 61 |
-
private let seSizes: [Int]
|
| 62 |
-
private let scanOutputs: [String]
|
| 63 |
-
|
| 64 |
-
init(graph: URL, record: ExportRecord, options: SpecializationOptions) async throws {
|
| 65 |
-
// Everything is computed into locals first: a nested function or closure that touched `self` here would
|
| 66 |
-
// capture it before all of its members were initialized, which Swift 6 refuses.
|
| 67 |
-
let F = record.shape
|
| 68 |
-
let m = try await AIModel(contentsOf: graph, options: options)
|
| 69 |
-
let names = Set(m.functionNames)
|
| 70 |
-
let hasScan = record.entrypoints.contains("scan") && names.contains("scan")
|
| 71 |
-
let hasOnce = record.entrypoints.contains("once") && names.contains("once")
|
| 72 |
-
guard hasScan || hasOnce else {
|
| 73 |
-
throw TitaNetError.contract("\(graph.lastPathComponent): neither scan nor once; functions \(names.sorted())")
|
| 74 |
-
}
|
| 75 |
-
let scanD = hasScan ? try Host.descriptors(m, "scan") : [:]
|
| 76 |
-
let onceD = hasOnce ? try Host.descriptors(m, "once") : [:]
|
| 77 |
-
let headD = hasScan ? try Host.descriptors(m, "head") : [:]
|
| 78 |
-
|
| 79 |
-
guard let melD = scanD["mel"] ?? onceD["mel"], melD.shape.count == 3, melD.shape[2] == F else {
|
| 80 |
-
throw TitaNetError.contract("mel input is \((scanD["mel"] ?? onceD["mel"])?.shape.description ?? "missing"), "
|
| 81 |
-
+ "expected [1, mels, \(F)]")
|
| 82 |
-
}
|
| 83 |
-
var c = 0
|
| 84 |
-
var se: [Int] = []
|
| 85 |
-
if hasScan {
|
| 86 |
-
guard let mean = scanD["mean"], mean.shape.count == 2 else { throw TitaNetError.contract("scan.mean is missing") }
|
| 87 |
-
c = mean.shape[1]
|
| 88 |
-
for j in 0..<5 {
|
| 89 |
-
guard let d = scanD["s\(j)"], d.shape.count == 2 else { throw TitaNetError.contract("scan.s\(j) is missing") }
|
| 90 |
-
se.append(d.shape[1])
|
| 91 |
-
}
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
self.record = record
|
| 95 |
-
self.F = F
|
| 96 |
-
self.margin = record.margin ?? 46
|
| 97 |
-
self.model = m
|
| 98 |
-
// the names `scan` returns, as export.json's signature records them
|
| 99 |
-
self.scanOutputs = record.signature?["scan"]?.outputs
|
| 100 |
-
?? ["se0", "se1", "se2", "se3", "se4", "x_sum", "x_sqsum", "lmax", "den", "ax", "axx"]
|
| 101 |
-
self.scanIn = scanD
|
| 102 |
-
self.onceIn = onceD
|
| 103 |
-
self.headIn = headD
|
| 104 |
-
self.scan = hasScan ? try Host.load(m, "scan") : nil
|
| 105 |
-
self.head = hasScan ? try Host.load(m, "head") : nil
|
| 106 |
-
self.once = hasOnce ? try Host.load(m, "once") : nil
|
| 107 |
-
self.nMels = melD.shape[1]
|
| 108 |
-
self.cOut = c
|
| 109 |
-
self.seSizes = se
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
/// Every NDArray input of `name`, by name, as the graph itself declares it (shape and scalar type).
|
| 113 |
-
private static func descriptors(_ model: AIModel, _ name: String) throws -> [String: NDArrayDescriptor] {
|
| 114 |
-
guard let md = model.functionDescriptor(for: name) else { throw TitaNetError.contract("\(name): no function descriptor") }
|
| 115 |
-
var out: [String: NDArrayDescriptor] = [:]
|
| 116 |
-
for i in md.inputNames {
|
| 117 |
-
guard let d = ND.descriptor(md.inputDescriptor(of: i)) else {
|
| 118 |
-
throw TitaNetError.contract("\(name).\(i) is not an NDArray input")
|
| 119 |
-
}
|
| 120 |
-
out[i] = d
|
| 121 |
-
}
|
| 122 |
-
return out
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
private static func load(_ model: AIModel, _ name: String) throws -> InferenceFunction {
|
| 126 |
-
guard let fn = try model.loadFunction(named: name) else { throw TitaNetError.contract("\(name) did not load") }
|
| 127 |
-
return fn
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
/// mel: [nMels * n] feature-major with the utterance's n valid frames first. emb [192] is returned unchecked.
|
| 131 |
-
func embed(mel: [Float], n: Int, progress: @MainActor @Sendable @escaping (String) -> Void) async throws
|
| 132 |
-
-> (emb: [Double], calls: Int, windows: Int) {
|
| 133 |
-
guard n >= 1 else { throw TitaNetError.contract("no valid mel frame") }
|
| 134 |
-
if n <= F, let once {
|
| 135 |
-
let (mw, vw, _) = window(mel, n: n, s: 0, keep: (0, n))
|
| 136 |
-
var inputs: [String: NDArray] = [:]
|
| 137 |
-
inputs["mel"] = nd(mw, onceIn["mel"]!)
|
| 138 |
-
inputs["valid"] = nd(vw, onceIn["valid"]!)
|
| 139 |
-
var o = try await once.run(inputs: inputs)
|
| 140 |
-
guard let e = o.remove("emb")?.ndArray else { throw TitaNetError.contract("once: no emb output") }
|
| 141 |
-
return (ND.doubles(e), 1, 1)
|
| 142 |
-
}
|
| 143 |
-
guard let scan, let head else { throw TitaNetError.contract("\(n) frames need the scan path, which this bundle lacks") }
|
| 144 |
-
let wins = try windows(n, F, margin)
|
| 145 |
-
let nInv = 1.0 / Double(n)
|
| 146 |
-
var calls = 0
|
| 147 |
-
var sums = seSizes.map { [Double](repeating: 0, count: $0) }
|
| 148 |
-
let zeroMean = [Double](repeating: 0, count: cOut)
|
| 149 |
-
let unitStd = [Double](repeating: 1, count: cOut)
|
| 150 |
-
|
| 151 |
-
// 1. the SqueezeExcite gate fixed point: five sweeps, the sums fed back as s0..s4
|
| 152 |
-
for sweep in 0..<5 {
|
| 153 |
-
await progress("Gate sweep \(sweep + 1) of 5")
|
| 154 |
-
var new = seSizes.map { [Double](repeating: 0, count: $0) }
|
| 155 |
-
for w in wins {
|
| 156 |
-
let o = try await call(scan, mel: mel, n: n, w: w, nInv: nInv, sums: sums, mean: zeroMean, std: unitStd)
|
| 157 |
-
calls += 1
|
| 158 |
-
for j in 0..<5 {
|
| 159 |
-
guard let se = o["se\(j)"] else { throw TitaNetError.contract("scan: no se\(j) output") }
|
| 160 |
-
for k in 0..<min(new[j].count, se.count) { new[j][k] += se[k] }
|
| 161 |
-
}
|
| 162 |
-
}
|
| 163 |
-
sums = new
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
// 2. the pooling mean / std of the encoder output
|
| 167 |
-
await progress("Pooling statistics")
|
| 168 |
-
var sx = [Double](repeating: 0, count: cOut)
|
| 169 |
-
var sxx = [Double](repeating: 0, count: cOut)
|
| 170 |
-
for w in wins {
|
| 171 |
-
let o = try await call(scan, mel: mel, n: n, w: w, nInv: nInv, sums: sums, mean: zeroMean, std: unitStd)
|
| 172 |
-
calls += 1
|
| 173 |
-
guard let a = o["x_sum"], let b = o["x_sqsum"] else { throw TitaNetError.contract("scan: no x_sum / x_sqsum") }
|
| 174 |
-
for k in 0..<cOut { sx[k] += a[k]; sxx[k] += b[k] }
|
| 175 |
-
}
|
| 176 |
-
var pmean = [Double](repeating: 0, count: cOut)
|
| 177 |
-
var pstd = [Double](repeating: 0, count: cOut)
|
| 178 |
-
for k in 0..<cOut {
|
| 179 |
-
pmean[k] = sx[k] / Double(n)
|
| 180 |
-
pstd[k] = (max(sxx[k] / Double(n) - pmean[k] * pmean[k], Host.stdEps)).squareRoot()
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
// 3. the attention softmax over time, combined across windows by running maximum
|
| 184 |
-
await progress("Attentive pooling")
|
| 185 |
-
var mx: [Double]? = nil
|
| 186 |
-
var den = [Double](repeating: 0, count: cOut)
|
| 187 |
-
var ax = [Double](repeating: 0, count: cOut)
|
| 188 |
-
var axx = [Double](repeating: 0, count: cOut)
|
| 189 |
-
for w in wins {
|
| 190 |
-
let o = try await call(scan, mel: mel, n: n, w: w, nInv: nInv, sums: sums, mean: pmean, std: pstd)
|
| 191 |
-
calls += 1
|
| 192 |
-
guard let lmax = o["lmax"], let d = o["den"], let a = o["ax"], let aa = o["axx"] else {
|
| 193 |
-
throw TitaNetError.contract("scan: no lmax / den / ax / axx")
|
| 194 |
-
}
|
| 195 |
-
var scale = [Double](repeating: 0, count: cOut)
|
| 196 |
-
var f = [Double](repeating: 1, count: cOut)
|
| 197 |
-
if let m = mx {
|
| 198 |
-
var mNew = [Double](repeating: 0, count: cOut)
|
| 199 |
-
for k in 0..<cOut {
|
| 200 |
-
mNew[k] = max(m[k], lmax[k])
|
| 201 |
-
scale[k] = exp(m[k] - mNew[k])
|
| 202 |
-
f[k] = exp(lmax[k] - mNew[k])
|
| 203 |
-
}
|
| 204 |
-
mx = mNew
|
| 205 |
-
} else {
|
| 206 |
-
mx = lmax
|
| 207 |
-
}
|
| 208 |
-
for k in 0..<cOut {
|
| 209 |
-
den[k] = den[k] * scale[k] + d[k] * f[k]
|
| 210 |
-
ax[k] = ax[k] * scale[k] + a[k] * f[k]
|
| 211 |
-
axx[k] = axx[k] * scale[k] + aa[k] * f[k]
|
| 212 |
-
}
|
| 213 |
-
}
|
| 214 |
-
var mu = [Double](repeating: 0, count: cOut)
|
| 215 |
-
var sg = [Double](repeating: 0, count: cOut)
|
| 216 |
-
for k in 0..<cOut {
|
| 217 |
-
let q = den[k] == 0 ? 0 : ax[k] / den[k]
|
| 218 |
-
mu[k] = q
|
| 219 |
-
let v = den[k] == 0 ? Host.stdEps : axx[k] / den[k] - q * q
|
| 220 |
-
sg[k] = max(v, Host.stdEps).squareRoot()
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
await progress("Embedding head")
|
| 224 |
-
var inputs: [String: NDArray] = [:]
|
| 225 |
-
inputs["mu"] = nd(mu.map { Float($0) }, headIn["mu"]!)
|
| 226 |
-
inputs["sg"] = nd(sg.map { Float($0) }, headIn["sg"]!)
|
| 227 |
-
var o = try await head.run(inputs: inputs)
|
| 228 |
-
calls += 1
|
| 229 |
-
guard let e = o.remove("emb")?.ndArray else { throw TitaNetError.contract("head: no emb output") }
|
| 230 |
-
return (ND.doubles(e), calls, wins.count)
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
/// One `scan` call with its eleven inputs.
|
| 234 |
-
private func call(_ scan: InferenceFunction, mel: [Float], n: Int, w: (s: Int, lo: Int, hi: Int), nInv: Double,
|
| 235 |
-
sums: [[Double]], mean: [Double], std: [Double]) async throws -> [String: [Double]] {
|
| 236 |
-
let (mw, vw, kw) = window(mel, n: n, s: w.s, keep: (w.lo, w.hi))
|
| 237 |
-
var inputs: [String: NDArray] = [:]
|
| 238 |
-
inputs["mel"] = nd(mw, scanIn["mel"]!)
|
| 239 |
-
inputs["valid"] = nd(vw, scanIn["valid"]!)
|
| 240 |
-
inputs["keep"] = nd(kw, scanIn["keep"]!)
|
| 241 |
-
inputs["n_inv"] = nd([Float(nInv)], scanIn["n_inv"]!)
|
| 242 |
-
inputs["mean"] = nd(mean.map { Float($0) }, scanIn["mean"]!)
|
| 243 |
-
inputs["std"] = nd(std.map { Float($0) }, scanIn["std"]!)
|
| 244 |
-
for j in 0..<5 { inputs["s\(j)"] = nd(sums[j].map { Float($0) }, scanIn["s\(j)"]!) }
|
| 245 |
-
var out = try await scan.run(inputs: inputs)
|
| 246 |
-
var r: [String: [Double]] = [:]
|
| 247 |
-
for name in scanOutputs {
|
| 248 |
-
guard let a = out.remove(name)?.ndArray else { continue }
|
| 249 |
-
r[name] = ND.doubles(a)
|
| 250 |
-
}
|
| 251 |
-
return r
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
/// mel [nMels, n] -> (mel_w [1, nMels, F], valid_w [1, 1, F], keep_w [1, 1, F]).
|
| 255 |
-
private func window(_ mel: [Float], n: Int, s: Int, keep: (Int, Int)) -> ([Float], [Float], [Float]) {
|
| 256 |
-
var mw = [Float](repeating: 0, count: nMels * F)
|
| 257 |
-
let take = max(0, min(F, n - s))
|
| 258 |
-
if take > 0 {
|
| 259 |
-
for c in 0..<nMels {
|
| 260 |
-
let src = c * n + s
|
| 261 |
-
for f in 0..<take { mw[c * F + f] = mel[src + f] }
|
| 262 |
-
}
|
| 263 |
-
}
|
| 264 |
-
var vw = [Float](repeating: 0, count: F)
|
| 265 |
-
for f in 0..<take { vw[f] = 1 }
|
| 266 |
-
var kw = [Float](repeating: 0, count: F)
|
| 267 |
-
let lo = max(0, keep.0 - s), hi = min(F, keep.1 - s)
|
| 268 |
-
if hi > lo { for f in lo..<hi { kw[f] = 1 } }
|
| 269 |
-
return (mw, vw, kw)
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
/// Input dtype comes from the graph's own descriptor, which export.json `compute_dtype` set.
|
| 273 |
-
private func nd(_ v: [Float], _ d: NDArrayDescriptor) -> NDArray {
|
| 274 |
-
d.scalarType == .float16 ? ND.make(v.map { Float16($0) }, d) : ND.make(v, d)
|
| 275 |
-
}
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
// MARK: - NDArray plumbing
|
| 279 |
-
|
| 280 |
-
enum ND {
|
| 281 |
-
static func descriptor(_ value: InferenceValue.Descriptor?) -> NDArrayDescriptor? {
|
| 282 |
-
guard case .ndArray(let d) = value else { return nil }
|
| 283 |
-
return d
|
| 284 |
-
}
|
| 285 |
-
|
| 286 |
-
/// A new array for `descriptor`, filled row-major from `values`.
|
| 287 |
-
static func make<T: BitwiseCopyable>(_ values: [T], _ descriptor: NDArrayDescriptor) -> NDArray {
|
| 288 |
-
var array = NDArray(descriptor: descriptor)
|
| 289 |
-
var view = array.mutableView(as: T.self)
|
| 290 |
-
view.copyElements(fromContentsOf: values)
|
| 291 |
-
return array
|
| 292 |
-
}
|
| 293 |
-
|
| 294 |
-
/// Row-major copy of an array's scalars, honoring its strides (in elements).
|
| 295 |
-
static func read<T: BitwiseCopyable>(_ array: NDArray, as type: T.Type) -> [T] {
|
| 296 |
-
let shape = array.shape
|
| 297 |
-
let count = shape.reduce(1, *)
|
| 298 |
-
return array.view(as: T.self).withUnsafePointer { ptr, _, strides in
|
| 299 |
-
var expected = 1
|
| 300 |
-
var contiguous = true
|
| 301 |
-
for d in stride(from: shape.count - 1, through: 0, by: -1) {
|
| 302 |
-
if shape[d] > 1 && strides[d] != expected {
|
| 303 |
-
contiguous = false
|
| 304 |
-
break
|
| 305 |
-
}
|
| 306 |
-
expected *= shape[d]
|
| 307 |
-
}
|
| 308 |
-
if contiguous { return Array(UnsafeBufferPointer(start: ptr, count: count)) }
|
| 309 |
-
var out = [T]()
|
| 310 |
-
out.reserveCapacity(count)
|
| 311 |
-
var index = [Int](repeating: 0, count: shape.count)
|
| 312 |
-
for _ in 0..<count {
|
| 313 |
-
var offset = 0
|
| 314 |
-
for d in 0..<shape.count { offset += index[d] * strides[d] }
|
| 315 |
-
out.append(ptr[offset])
|
| 316 |
-
var d = shape.count - 1
|
| 317 |
-
while d >= 0 {
|
| 318 |
-
index[d] += 1
|
| 319 |
-
if index[d] < shape[d] { break }
|
| 320 |
-
index[d] = 0
|
| 321 |
-
d -= 1
|
| 322 |
-
}
|
| 323 |
-
}
|
| 324 |
-
return out
|
| 325 |
-
}
|
| 326 |
-
}
|
| 327 |
-
|
| 328 |
-
static func doubles(_ a: NDArray) -> [Double] {
|
| 329 |
-
a.scalarType == .float16 ? read(a, as: Float16.self).map { Double($0) } : read(a, as: Float.self).map { Double($0) }
|
| 330 |
-
}
|
| 331 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
swift/MelFrontend.swift
DELETED
|
@@ -1,112 +0,0 @@
|
|
| 1 |
-
// MelFrontend — NeMo's FilterbankFeatures (eval mode) for one utterance, the front end TitaNet-Large was trained on.
|
| 2 |
-
// Needs frontend/mel_fb.f32 (slaney filterbank [80, 257]) and frontend/mel_window.f32 (Hann(400), periodic=False),
|
| 3 |
-
// both the checkpoint's own buffers. python/titanet_coreai.py implements the same steps in numpy.
|
| 4 |
-
//
|
| 5 |
-
// samples Float [N] (16 kHz) -> mel Float [80, N / 160 + 1] row-major (feature-major), valid frames n = N / 160
|
| 6 |
-
// 1 preemphasis y[0] = x[0], y[i] = x[i] - 0.97 x[i-1] (Float)
|
| 7 |
-
// 2 STFT centred, 256 zeros each side, Hann(400, periodic=false) placed at offset 56 of 512; the DFT summed
|
| 8 |
-
// in Double (vDSP_mmulD over an exact (k j mod 512) cos / -sin basis); re / im rounded to Float
|
| 9 |
-
// 3 power sqrt(re^2 + im^2) then squared, in Float (NeMo: magnitude, then pow(2))
|
| 10 |
-
// 4 mel filterbank [80, 257] x power, summed in Double, rounded to Float
|
| 11 |
-
// 5 log log(mel + 2^-24) in Double, rounded to Float
|
| 12 |
-
// 6 normalize per feature over the n valid frames: reference-centred mean, unbiased std + 1e-5 (Float), frames
|
| 13 |
-
// >= n set to 0
|
| 14 |
-
|
| 15 |
-
import Accelerate
|
| 16 |
-
import Foundation
|
| 17 |
-
|
| 18 |
-
final class MelFrontend: @unchecked Sendable {
|
| 19 |
-
static let nFFT = 512, hop = 160, win = 400, nMels = 80, bins = 257
|
| 20 |
-
static let preemph: Float = 0.97
|
| 21 |
-
static let logGuard: Double = Double(Float(pow(2.0, -24.0)))
|
| 22 |
-
static let stdEps: Float = 1e-5
|
| 23 |
-
|
| 24 |
-
private let window: [Double] // [512], the 400 taps centred
|
| 25 |
-
private let basis: [Double] // [512, 514]: column k = cos(2 pi k j / 512), column 257 + k = -sin(...)
|
| 26 |
-
private let fb: [Double] // [80, 257]
|
| 27 |
-
|
| 28 |
-
init(filterbank: [Float], window w: [Float]) {
|
| 29 |
-
precondition(filterbank.count == Self.nMels * Self.bins && w.count == Self.win)
|
| 30 |
-
var win = [Double](repeating: 0, count: Self.nFFT)
|
| 31 |
-
let off = (Self.nFFT - Self.win) / 2
|
| 32 |
-
for i in 0..<Self.win { win[off + i] = Double(w[i]) }
|
| 33 |
-
window = win
|
| 34 |
-
var b = [Double](repeating: 0, count: Self.nFFT * 2 * Self.bins)
|
| 35 |
-
for j in 0..<Self.nFFT {
|
| 36 |
-
for k in 0..<Self.bins {
|
| 37 |
-
let ang = 2.0 * Double.pi * Double((k * j) % Self.nFFT) / Double(Self.nFFT)
|
| 38 |
-
b[j * 2 * Self.bins + k] = cos(ang)
|
| 39 |
-
b[j * 2 * Self.bins + Self.bins + k] = -sin(ang)
|
| 40 |
-
}
|
| 41 |
-
}
|
| 42 |
-
basis = b
|
| 43 |
-
fb = filterbank.map(Double.init)
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
/// The normalized log-mel of `samples`: (mel [80 * frames] feature-major, frames, valid frames).
|
| 47 |
-
func callAsFunction(_ x: [Float]) -> (mel: [Float], frames: Int, valid: Int) {
|
| 48 |
-
let n = x.count
|
| 49 |
-
let valid = n / Self.hop
|
| 50 |
-
guard n > 0 else { return ([Float](repeating: 0, count: Self.nMels), 1, 0) }
|
| 51 |
-
// 1 preemphasis
|
| 52 |
-
var y = [Float](repeating: 0, count: n)
|
| 53 |
-
y[0] = x[0]
|
| 54 |
-
for i in 1..<n { y[i] = x[i] - Self.preemph * x[i - 1] }
|
| 55 |
-
// 2 framing of the centred, zero-padded signal
|
| 56 |
-
let pad = Self.nFFT / 2
|
| 57 |
-
let total = n + 2 * pad
|
| 58 |
-
let frames = 1 + (total - Self.nFFT) / Self.hop
|
| 59 |
-
var framed = [Double](repeating: 0, count: frames * Self.nFFT)
|
| 60 |
-
for f in 0..<frames {
|
| 61 |
-
let start = f * Self.hop - pad
|
| 62 |
-
for j in 0..<Self.nFFT {
|
| 63 |
-
let s = start + j
|
| 64 |
-
if s >= 0 && s < n { framed[f * Self.nFFT + j] = Double(y[s]) * window[j] }
|
| 65 |
-
}
|
| 66 |
-
}
|
| 67 |
-
var spec = [Double](repeating: 0, count: frames * 2 * Self.bins)
|
| 68 |
-
vDSP_mmulD(framed, 1, basis, 1, &spec, 1, vDSP_Length(frames), vDSP_Length(2 * Self.bins), vDSP_Length(Self.nFFT))
|
| 69 |
-
// 3 power, Float, stored [257, frames] in Double for the mel product
|
| 70 |
-
var power = [Double](repeating: 0, count: Self.bins * frames)
|
| 71 |
-
for f in 0..<frames {
|
| 72 |
-
for k in 0..<Self.bins {
|
| 73 |
-
let re = Float(spec[f * 2 * Self.bins + k]), im = Float(spec[f * 2 * Self.bins + Self.bins + k])
|
| 74 |
-
let mag = (re * re + im * im).squareRoot()
|
| 75 |
-
power[k * frames + f] = Double(mag * mag)
|
| 76 |
-
}
|
| 77 |
-
}
|
| 78 |
-
// 4 mel, 5 log
|
| 79 |
-
var mel64 = [Double](repeating: 0, count: Self.nMels * frames)
|
| 80 |
-
vDSP_mmulD(fb, 1, power, 1, &mel64, 1, vDSP_Length(Self.nMels), vDSP_Length(frames), vDSP_Length(Self.bins))
|
| 81 |
-
var mel = [Float](repeating: 0, count: Self.nMels * frames)
|
| 82 |
-
for i in 0..<mel.count {
|
| 83 |
-
mel[i] = Float(log(Double(Float(mel64[i])) + Self.logGuard))
|
| 84 |
-
}
|
| 85 |
-
// 6 normalize
|
| 86 |
-
Self.normalize(&mel, frames: frames, valid: valid)
|
| 87 |
-
return (mel, frames, valid)
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
/// normalize_batch(per_feature) for one utterance (model/host_mel.normalize).
|
| 91 |
-
static func normalize(_ x: inout [Float], frames: Int, valid n: Int) {
|
| 92 |
-
for c in 0..<nMels {
|
| 93 |
-
let row = c * frames
|
| 94 |
-
if n == 0 {
|
| 95 |
-
for f in 0..<frames { x[row + f] = 0 }
|
| 96 |
-
continue
|
| 97 |
-
}
|
| 98 |
-
let ref = Double(x[row])
|
| 99 |
-
var sum = 0.0
|
| 100 |
-
for f in 0..<n { sum += Double(Float(Double(x[row + f]) - ref)) }
|
| 101 |
-
let mean = Float(ref + sum / Double(n))
|
| 102 |
-
var ss = 0.0
|
| 103 |
-
for f in 0..<n {
|
| 104 |
-
let d = Double(Float(Double(x[row + f]) - Double(mean)))
|
| 105 |
-
ss += d * d
|
| 106 |
-
}
|
| 107 |
-
let std: Float = (n > 1 ? Float((ss / (Double(n) - 1.0)).squareRoot()) : 0) + stdEps
|
| 108 |
-
for f in 0..<n { x[row + f] = (x[row + f] - mean) / std }
|
| 109 |
-
for f in n..<frames { x[row + f] = 0 }
|
| 110 |
-
}
|
| 111 |
-
}
|
| 112 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
swift/SpeakerEngine.swift
DELETED
|
@@ -1,157 +0,0 @@
|
|
| 1 |
-
// SpeakerEngine — 16 kHz mono samples in, the 192-d TitaNet embedding out. Owns the model (Host) and the mel front
|
| 2 |
-
// end, off the main actor; every load and embed runs one at a time, in order. Looks for titanet.aimodel, export.json,
|
| 3 |
-
// mel_fb.f32 and mel_window.f32 in the app bundle (Bundle.main); add them to your target's resources.
|
| 4 |
-
|
| 5 |
-
import CoreAI
|
| 6 |
-
import Foundation
|
| 7 |
-
|
| 8 |
-
enum ComputeUnit: String, CaseIterable, Identifiable, Sendable {
|
| 9 |
-
case auto = "default", gpu, ane, cpu
|
| 10 |
-
|
| 11 |
-
var id: String { rawValue }
|
| 12 |
-
|
| 13 |
-
var label: String {
|
| 14 |
-
switch self {
|
| 15 |
-
case .auto: return "Auto"
|
| 16 |
-
case .gpu: return "GPU"
|
| 17 |
-
case .ane: return "Neural Engine"
|
| 18 |
-
case .cpu: return "CPU"
|
| 19 |
-
}
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
var short: String { self == .ane ? "ANE" : label }
|
| 23 |
-
|
| 24 |
-
/// Core AI specialization options for each choice
|
| 25 |
-
var options: SpecializationOptions {
|
| 26 |
-
switch self {
|
| 27 |
-
case .cpu: return .cpuOnly
|
| 28 |
-
case .gpu: return SpecializationOptions(preferredComputeUnitKind: .gpu)
|
| 29 |
-
case .ane: return SpecializationOptions(preferredComputeUnitKind: .neuralEngine)
|
| 30 |
-
case .auto: return .default
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
struct EngineInfo: Sendable, Equatable {
|
| 36 |
-
var window: Int // F, mel frames per scan call
|
| 37 |
-
var windowSeconds: Double
|
| 38 |
-
var margin: Int
|
| 39 |
-
var mels: Int
|
| 40 |
-
var entrypoints: [String]
|
| 41 |
-
var arm: String
|
| 42 |
-
var architecture: String
|
| 43 |
-
var compute: ComputeUnit
|
| 44 |
-
var loadSeconds: Double
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
struct Embedding: Sendable, Codable, Equatable {
|
| 48 |
-
var vector: [Float]
|
| 49 |
-
var frames: Int // valid mel frames (10 ms each)
|
| 50 |
-
var windows: Int
|
| 51 |
-
var calls: Int
|
| 52 |
-
var featureSeconds: Double // the mel front end
|
| 53 |
-
var modelSeconds: Double // scan sweeps + head
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
actor SpeakerEngine {
|
| 57 |
-
static let sampleRate = 16000
|
| 58 |
-
|
| 59 |
-
private var host: Host?
|
| 60 |
-
private var mel: MelFrontend?
|
| 61 |
-
private var tail: Task<Void, Never>?
|
| 62 |
-
|
| 63 |
-
func load(_ unit: ComputeUnit) async throws -> EngineInfo {
|
| 64 |
-
try await serially { try await self.loadNow(unit) }
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
func embed(_ samples: [Float], progress: @MainActor @Sendable @escaping (String) -> Void) async throws -> Embedding {
|
| 68 |
-
try await serially { try await self.embedNow(samples, progress: progress) }
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
private func serially<T: Sendable>(_ body: @Sendable @escaping () async throws -> T) async throws -> T {
|
| 72 |
-
let previous = tail
|
| 73 |
-
let job = Task { await previous?.value; return try await body() }
|
| 74 |
-
tail = Task { _ = try? await job.value }
|
| 75 |
-
return try await job.value
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
private func loadNow(_ unit: ComputeUnit) async throws -> EngineInfo {
|
| 79 |
-
host = nil // release the old specialization before building the next
|
| 80 |
-
let res = Bundle.main
|
| 81 |
-
guard let graph = res.url(forResource: "titanet", withExtension: "aimodel"),
|
| 82 |
-
let exportURL = res.url(forResource: "export", withExtension: "json") else {
|
| 83 |
-
throw TitaNetError.contract("titanet.aimodel / export.json are missing from the app bundle")
|
| 84 |
-
}
|
| 85 |
-
let record = try JSONDecoder().decode(ExportRecord.self, from: Data(contentsOf: exportURL))
|
| 86 |
-
if mel == nil {
|
| 87 |
-
let fb = try Self.floats("mel_fb", count: MelFrontend.nMels * MelFrontend.bins)
|
| 88 |
-
let w = try Self.floats("mel_window", count: MelFrontend.win)
|
| 89 |
-
mel = MelFrontend(filterbank: fb, window: w)
|
| 90 |
-
}
|
| 91 |
-
let t0 = ContinuousClock.now
|
| 92 |
-
let h = try await Host(graph: graph, record: record, options: unit.options)
|
| 93 |
-
guard h.nMels == MelFrontend.nMels else {
|
| 94 |
-
throw TitaNetError.contract("the bundle takes \(h.nMels) mels, the front end makes \(MelFrontend.nMels)")
|
| 95 |
-
}
|
| 96 |
-
host = h
|
| 97 |
-
return EngineInfo(window: h.F, windowSeconds: Double(h.F * MelFrontend.hop) / Double(Self.sampleRate),
|
| 98 |
-
margin: h.margin, mels: h.nMels, entrypoints: record.entrypoints, arm: record.arm ?? "—",
|
| 99 |
-
architecture: AIModel.deviceArchitectureName, compute: unit, loadSeconds: seconds(since: t0))
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
private func embedNow(_ samples: [Float], progress: @MainActor @Sendable @escaping (String) -> Void) async throws -> Embedding {
|
| 103 |
-
guard let host, let mel else { throw TitaNetError.contract("the model is not loaded") }
|
| 104 |
-
await progress("Extracting log-mel features")
|
| 105 |
-
let t0 = ContinuousClock.now
|
| 106 |
-
let (m, frames, n) = mel(samples)
|
| 107 |
-
guard n >= 1 else { throw TitaNetError.audio("The clip is too short to embed.") }
|
| 108 |
-
// repack the n valid frames into a tight [80, n] buffer: the window slicer's row stride is n
|
| 109 |
-
var packed = [Float](repeating: 0, count: MelFrontend.nMels * n)
|
| 110 |
-
for c in 0..<MelFrontend.nMels {
|
| 111 |
-
let src = c * frames, dst = c * n
|
| 112 |
-
for t in 0..<n { packed[dst + t] = m[src + t] }
|
| 113 |
-
}
|
| 114 |
-
let featureSeconds = seconds(since: t0)
|
| 115 |
-
let t1 = ContinuousClock.now
|
| 116 |
-
let r = try await host.embed(mel: packed, n: n, progress: progress)
|
| 117 |
-
let vector = r.emb.map { Float($0) }
|
| 118 |
-
guard vector.allSatisfy(\.isFinite) else { throw TitaNetError.contract("the embedding is not finite") }
|
| 119 |
-
return Embedding(vector: vector, frames: n, windows: r.windows, calls: r.calls,
|
| 120 |
-
featureSeconds: featureSeconds, modelSeconds: seconds(since: t1))
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
private static func floats(_ name: String, count: Int) throws -> [Float] {
|
| 124 |
-
guard let url = Bundle.main.url(forResource: name, withExtension: "f32") else {
|
| 125 |
-
throw TitaNetError.contract("\(name).f32 is missing from the app bundle")
|
| 126 |
-
}
|
| 127 |
-
let d = try Data(contentsOf: url)
|
| 128 |
-
guard d.count == 4 * count else { throw TitaNetError.contract("\(name).f32: \(d.count) bytes, expected \(4 * count)") }
|
| 129 |
-
return d.withUnsafeBytes { raw in
|
| 130 |
-
(0..<count).map { Float(bitPattern: UInt32(littleEndian: raw.loadUnaligned(fromByteOffset: 4 * $0, as: UInt32.self))) }
|
| 131 |
-
}
|
| 132 |
-
}
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
/// NeMo's speaker-verification score (verify_speakers): (cos + 1) / 2 on the raw embeddings, same speaker at >= 0.7.
|
| 136 |
-
enum Scoring {
|
| 137 |
-
static let threshold = 0.7
|
| 138 |
-
|
| 139 |
-
static func cosine(_ a: [Float], _ b: [Float]) -> Double {
|
| 140 |
-
var ab = 0.0, aa = 0.0, bb = 0.0
|
| 141 |
-
for i in 0..<min(a.count, b.count) {
|
| 142 |
-
let x = Double(a[i]), y = Double(b[i])
|
| 143 |
-
ab += x * y
|
| 144 |
-
aa += x * x
|
| 145 |
-
bb += y * y
|
| 146 |
-
}
|
| 147 |
-
let d = (aa * bb).squareRoot()
|
| 148 |
-
return d == 0 ? 0 : ab / d
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
static func score(_ a: [Float], _ b: [Float]) -> Double { (cosine(a, b) + 1) / 2 }
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
func seconds(since t: ContinuousClock.Instant) -> Double {
|
| 155 |
-
let d = ContinuousClock.now - t
|
| 156 |
-
return Double(d.components.seconds) + Double(d.components.attoseconds) * 1e-18
|
| 157 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|