smdesai commited on
Commit
e3f565c
·
verified ·
1 Parent(s): e88007c

Remove host code; update README

Browse files
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 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.
@@ -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
- }