hanwenggggggg commited on
Commit
20c78b6
·
1 Parent(s): a7fc9b6

add config.json, remove non-CTC scripts

Browse files

- Restore config.json (model metadata: vocab_size, token IDs)
- Remove convert/parakeet-tdt-v2-0.6b/ (TDT 0.6b, not CTC)
- Remove scripts/run_benchmarks.py (general FluidAudio, not CTC)
- Keep convert/parakeet-tdt-ctc-110m/ (CTC conversion scripts)

config.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token_id": 1,
3
+ "eos_token_id": 2,
4
+ "nemo_model_type": "parakeet",
5
+ "pad_token_id": 0,
6
+ "vocab_size": 1024,
7
+ }
convert/parakeet-tdt-v2-0.6b/coreml/compare-components.py DELETED
@@ -1,958 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Compare Parakeet TDT v2 Torch vs CoreML components on a fixed 15s window.
3
-
4
- Writes numeric diffs to the specified output directory (metadata.json) and
5
- saves plots under a repo-tracked directory: plots/<script-name>/.
6
- """
7
- from __future__ import annotations
8
-
9
- import json
10
- import time
11
- from dataclasses import dataclass
12
- from pathlib import Path
13
- from typing import Dict, Optional, Tuple
14
-
15
- import coremltools as ct
16
- import numpy as np
17
- import soundfile as sf
18
- import torch
19
- import typer
20
-
21
- import nemo.collections.asr as nemo_asr
22
-
23
- # Optional plotting
24
- try:
25
- import matplotlib
26
- matplotlib.use("Agg")
27
- import matplotlib.pyplot as plt
28
- HAS_MPL = True
29
- except Exception:
30
- HAS_MPL = False
31
-
32
-
33
- @dataclass
34
- class ValidationSettings:
35
- audio_path: Optional[Path]
36
- seconds: float
37
- seed: Optional[int]
38
- rtol: float
39
- atol: float
40
-
41
-
42
- def _compute_length(seconds: float, sample_rate: int) -> int:
43
- return int(round(seconds * sample_rate))
44
-
45
-
46
- def _prepare_audio(
47
- validation_audio: Optional[Path],
48
- sample_rate: int,
49
- max_samples: int,
50
- seed: Optional[int],
51
- ) -> torch.Tensor:
52
- if validation_audio is None:
53
- if seed is not None:
54
- torch.manual_seed(seed)
55
- return torch.randn(1, max_samples, dtype=torch.float32)
56
-
57
- data, sr = sf.read(str(validation_audio), dtype="float32")
58
- if sr != sample_rate:
59
- raise typer.BadParameter(
60
- f"Validation audio sample rate {sr} does not match model rate {sample_rate}"
61
- )
62
- if data.ndim > 1:
63
- data = data[:, 0]
64
- if data.size == 0:
65
- raise typer.BadParameter("Validation audio is empty")
66
- if data.size < max_samples:
67
- data = np.pad(data, (0, max_samples - data.size))
68
- elif data.size > max_samples:
69
- data = data[:max_samples]
70
- return torch.from_numpy(data).unsqueeze(0).to(dtype=torch.float32)
71
-
72
-
73
- def _np(x: torch.Tensor, dtype=None) -> np.ndarray:
74
- arr = x.detach().cpu().numpy()
75
- if dtype is not None:
76
- return arr.astype(dtype, copy=False)
77
- return arr
78
-
79
-
80
- def _to_t(x) -> torch.Tensor:
81
- if isinstance(x, torch.Tensor):
82
- return x.detach().cpu()
83
- elif isinstance(x, np.ndarray):
84
- # Ensure a separate tensor (avoid shared memory weirdness)
85
- return torch.from_numpy(np.array(x, copy=True))
86
- else:
87
- return torch.tensor(x)
88
-
89
-
90
- def _max_diffs(a, b, rtol: float, atol: float) -> Tuple[float, float, bool]:
91
- # Use NumPy for comparisons to avoid invoking the PyTorch C-API in contexts
92
- # where the GIL may not be held (which can trigger PyEval_SaveThread errors).
93
- na = np.array(a, dtype=np.float32, copy=True)
94
- nb = np.array(b, dtype=np.float32, copy=True)
95
- if na.size == 0:
96
- return 0.0, 0.0, True
97
- diff = np.abs(na - nb)
98
- max_abs = float(diff.max())
99
- denom = np.maximum(np.abs(na), np.abs(nb))
100
- with np.errstate(divide="ignore", invalid="ignore"):
101
- rel = np.where(denom == 0.0, 0.0, diff / denom)
102
- max_rel = float(rel.max())
103
- ok = bool(np.allclose(na, nb, rtol=rtol, atol=atol))
104
- return max_abs, max_rel, ok
105
-
106
-
107
- def _plot_line(x_ref: np.ndarray, x_ml: np.ndarray, title: str, path: Path, also_delta: bool = False):
108
- if not HAS_MPL:
109
- return None
110
- if also_delta:
111
- fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
112
- axes[0].plot(x_ref, label="torch", linewidth=1)
113
- axes[0].plot(x_ml, label="coreml", linewidth=1, alpha=0.8)
114
- axes[0].set_title(title)
115
- axes[0].legend()
116
- delta = np.asarray(x_ref) - np.asarray(x_ml)
117
- axes[1].plot(delta, color="C3", linewidth=1)
118
- axes[1].set_title("Delta (torch - coreml)")
119
- axes[1].set_xlabel("time/step")
120
- plt.tight_layout()
121
- plt.savefig(path)
122
- plt.close(fig)
123
- else:
124
- plt.figure(figsize=(8, 3))
125
- plt.plot(x_ref, label="torch", linewidth=1)
126
- plt.plot(x_ml, label="coreml", linewidth=1, alpha=0.8)
127
- plt.title(title)
128
- plt.legend()
129
- plt.tight_layout()
130
- plt.savefig(path)
131
- plt.close()
132
- return str(path.name)
133
-
134
-
135
- def _plot_image(img: np.ndarray, title: str, path: Path, vmin=None, vmax=None):
136
- if not HAS_MPL:
137
- return None
138
- plt.figure(figsize=(6, 4))
139
- plt.imshow(img, aspect='auto', origin='lower', interpolation='nearest', vmin=vmin, vmax=vmax)
140
- plt.title(title)
141
- plt.colorbar(shrink=0.8)
142
- plt.tight_layout()
143
- plt.savefig(path)
144
- plt.close()
145
- return str(path.name)
146
-
147
-
148
- def _plot_mel_composite(
149
- mel_torch: np.ndarray,
150
- mel_coreml: np.ndarray,
151
- path: Path,
152
- vmin=None,
153
- vmax=None,
154
- ):
155
- """Create a single PNG with mel torch, mel coreml, abs diff heatmap, and mean-over-time curves with delta."""
156
- if not HAS_MPL:
157
- return None
158
- mel_torch = np.asarray(mel_torch)
159
- mel_coreml = np.asarray(mel_coreml)
160
- absdiff = np.abs(mel_torch - mel_coreml)
161
- mean_t = mel_torch.mean(axis=0)
162
- mean_c = mel_coreml.mean(axis=0)
163
- delta = mean_t - mean_c
164
-
165
- fig = plt.figure(figsize=(12, 8))
166
- gs = fig.add_gridspec(2, 2, height_ratios=[1, 1])
167
- ax1 = fig.add_subplot(gs[0, 0])
168
- im1 = ax1.imshow(mel_torch, aspect='auto', origin='lower', interpolation='nearest', vmin=vmin, vmax=vmax)
169
- ax1.set_title("Mel (Torch)")
170
- fig.colorbar(im1, ax=ax1, shrink=0.8)
171
-
172
- ax2 = fig.add_subplot(gs[0, 1])
173
- im2 = ax2.imshow(mel_coreml, aspect='auto', origin='lower', interpolation='nearest', vmin=vmin, vmax=vmax)
174
- ax2.set_title("Mel (CoreML)")
175
- fig.colorbar(im2, ax=ax2, shrink=0.8)
176
-
177
- ax3 = fig.add_subplot(gs[1, 0])
178
- im3 = ax3.imshow(absdiff, aspect='auto', origin='lower', interpolation='nearest')
179
- ax3.set_title("Mel |diff|")
180
- fig.colorbar(im3, ax=ax3, shrink=0.8)
181
-
182
- ax4 = fig.add_subplot(gs[1, 1])
183
- ax4.plot(mean_t, label="torch", linewidth=1)
184
- ax4.plot(mean_c, label="coreml", linewidth=1, alpha=0.8)
185
- ax4.plot(delta, label="delta", linewidth=1, color="C3")
186
- ax4.set_title("Mel mean over time + delta")
187
- ax4.legend()
188
-
189
- plt.tight_layout()
190
- plt.savefig(path)
191
- plt.close(fig)
192
- return str(path.name)
193
-
194
-
195
- def _plot_latency_bars(
196
- labels,
197
- torch_means,
198
- torch_stds,
199
- coreml_means,
200
- coreml_stds,
201
- path: Path,
202
- ):
203
- if not HAS_MPL:
204
- return None
205
- x = np.arange(len(labels))
206
- width = 0.35
207
- fig, ax = plt.subplots(figsize=(8, 4))
208
- b1 = ax.bar(x - width/2, torch_means, width, yerr=torch_stds, label="torch", color="C0", alpha=0.9)
209
- b2 = ax.bar(x + width/2, coreml_means, width, yerr=coreml_stds, label="coreml", color="C1", alpha=0.9)
210
- ax.set_xticks(x, labels, rotation=15)
211
- ax.set_ylabel("latency (ms)")
212
- ax.set_title("Component latency (15s window inputs)")
213
- ax.legend()
214
- # Add value labels on bars
215
- def _annotate(bars):
216
- for bar in bars:
217
- h = bar.get_height()
218
- if np.isnan(h):
219
- continue
220
- ax.annotate(f"{h:.0f}",
221
- xy=(bar.get_x() + bar.get_width()/2, h),
222
- xytext=(0, 3), textcoords="offset points",
223
- ha='center', va='bottom', fontsize=8)
224
- _annotate(b1)
225
- _annotate(b2)
226
- plt.tight_layout()
227
- plt.savefig(path)
228
- plt.close(fig)
229
- return str(path.name)
230
-
231
-
232
- def _plot_speedup_bars(labels, torch_means, coreml_means, path: Path):
233
- if not HAS_MPL:
234
- return None
235
- speedup = []
236
- for t, c in zip(torch_means, coreml_means):
237
- if c and c > 0:
238
- speedup.append(float(t) / float(c))
239
- else:
240
- speedup.append(np.nan)
241
- x = np.arange(len(labels))
242
- fig, ax = plt.subplots(figsize=(8, 4))
243
- bars = ax.bar(x, speedup, color="C2")
244
- ax.set_xticks(x, labels, rotation=15)
245
- ax.set_ylabel("torch/coreml speedup")
246
- ax.set_title("CoreML speedup vs Torch (higher is better)")
247
- ax.axhline(1.0, color="gray", linestyle="--", linewidth=1)
248
- # Add value labels
249
- for bar in bars:
250
- h = bar.get_height()
251
- if np.isnan(h):
252
- continue
253
- ax.annotate(f"{h:.2f}",
254
- xy=(bar.get_x() + bar.get_width()/2, h),
255
- xytext=(0, 3), textcoords="offset points",
256
- ha='center', va='bottom', fontsize=8)
257
- plt.tight_layout()
258
- plt.savefig(path)
259
- plt.close(fig)
260
- return str(path.name)
261
-
262
-
263
-
264
- app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
265
-
266
-
267
- @app.command()
268
- def compare(
269
- output_dir: Path = typer.Option(Path("parakeet_coreml"), help="Directory containing mlpackages + metadata.json"),
270
- nemo_path: Optional[Path] = typer.Option(None, "--nemo-path", exists=True, resolve_path=True, help="Path to .nemo checkpoint"),
271
- model_id: str = typer.Option("nvidia/parakeet-tdt-0.6b-v2", "--model-id", help="HF model id if --nemo-path omitted"),
272
- validation_audio: Optional[Path] = typer.Option(None, exists=True, resolve_path=True, help="15s, 16kHz wav for validation (defaults to audio/yc_first_minute_16k_15s.wav if present)"),
273
- seed: Optional[int] = typer.Option(None, help="Random seed for synthetic input when audio is not provided"),
274
- rtol: float = typer.Option(1e-3, help="Relative tolerance for comparisons"),
275
- atol: float = typer.Option(1e-4, help="Absolute tolerance for comparisons"),
276
- runs: int = typer.Option(10, help="Timed runs per model for latency measurement"),
277
- warmup: int = typer.Option(3, help="Warmup runs before timing (compilation, caches)"),
278
- symbol_steps: int = typer.Option(
279
- 32,
280
- help="Number of sequential decoder steps to validate with streaming U=1 inputs",
281
- ),
282
- ) -> None:
283
- """Run Torch vs CoreML comparisons and update metadata.json with plots and diffs."""
284
- output_dir.mkdir(parents=True, exist_ok=True)
285
- if symbol_steps < 1:
286
- raise typer.BadParameter("symbol_steps must be >= 1")
287
-
288
- meta_path = output_dir / "metadata.json"
289
- exported_meta: Dict[str, object] = {}
290
- if meta_path.exists():
291
- try:
292
- exported_meta = json.loads(meta_path.read_text())
293
- except Exception:
294
- exported_meta = {}
295
- exported_max_u = int(exported_meta.get("max_symbol_steps", 1))
296
- if exported_max_u != 1:
297
- typer.echo(
298
- f"Note: CoreML export reports max_symbol_steps={exported_max_u}; "
299
- "comparison still drives decoder step-wise with U=1 inputs."
300
- )
301
- if nemo_path is not None:
302
- typer.echo(f"Loading NeMo model from {nemo_path}…")
303
- asr_model = nemo_asr.models.EncDecRNNTBPEModel.restore_from(str(nemo_path), map_location="cpu")
304
- else:
305
- typer.echo(f"Downloading NeMo model via {model_id}…")
306
- asr_model = nemo_asr.models.EncDecRNNTBPEModel.from_pretrained(model_id, map_location="cpu")
307
- asr_model.eval()
308
-
309
- sample_rate = int(asr_model.cfg.preprocessor.sample_rate)
310
- max_samples = _compute_length(15.0, sample_rate)
311
- default_audio = (Path(__file__).parent / "audio" / "yc_first_minute_16k_15s.wav").resolve()
312
- chosen_audio = validation_audio if validation_audio is not None else (default_audio if default_audio.exists() else None)
313
- if chosen_audio is not None and validation_audio is None:
314
- typer.echo(f"Using default validation audio: {chosen_audio}")
315
-
316
- audio_tensor = _prepare_audio(chosen_audio, sample_rate, max_samples, seed)
317
- audio_length = torch.tensor([max_samples], dtype=torch.int32)
318
-
319
- asr_model.decoder._rnnt_export = True
320
- # Disable fused loss/WER computation for simpler joint inference
321
- asr_model.joint.set_fuse_loss_wer(False)
322
- # Important: ensure the joint returns raw logits (not log-softmax)
323
- # RNNTJoint applies log_softmax on CPU by default when `log_softmax is None`.
324
- # Our exported CoreML joint emits pre-softmax logits, so make the Torch
325
- # reference do the same to avoid systematic offsets in comparisons/plots.
326
- try:
327
- # Some versions expose this as a plain attribute
328
- asr_model.joint.log_softmax = False
329
- except Exception:
330
- pass
331
-
332
- # Generate reference outputs directly from NeMo model components
333
- with torch.inference_mode():
334
- # Preprocessor - direct NeMo call
335
- mel_ref, mel_length_ref = asr_model.preprocessor(
336
- input_signal=audio_tensor,
337
- length=audio_length.to(dtype=torch.long)
338
- )
339
- mel_length_ref = mel_length_ref.to(dtype=torch.int32)
340
-
341
- # Encoder - direct NeMo call
342
- encoder_ref, encoder_length_ref = asr_model.encoder(
343
- audio_signal=mel_ref,
344
- length=mel_length_ref.to(dtype=torch.long)
345
- )
346
- encoder_length_ref = encoder_length_ref.to(dtype=torch.int32)
347
-
348
- vocab_size = int(asr_model.tokenizer.vocab_size)
349
- num_extra = int(asr_model.joint.num_extra_outputs)
350
- decoder_hidden = int(asr_model.decoder.pred_hidden)
351
- decoder_layers = int(asr_model.decoder.pred_rnn_layers)
352
- blank_id = int(asr_model.decoder.blank_idx)
353
-
354
- blank_targets = torch.tensor([[blank_id]], dtype=torch.int32)
355
- blank_target_lengths = torch.tensor([1], dtype=torch.int32)
356
- blank_targets_long = blank_targets.to(dtype=torch.long)
357
- blank_target_lengths_long = blank_target_lengths.to(dtype=torch.long)
358
-
359
- def _decoder_rollout_torch(num_steps: int) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
360
- outputs = []
361
- h_state = torch.zeros(decoder_layers, 1, decoder_hidden, dtype=torch.float32)
362
- c_state = torch.zeros(decoder_layers, 1, decoder_hidden, dtype=torch.float32)
363
- state = [h_state, c_state]
364
- with torch.inference_mode():
365
- for _ in range(num_steps):
366
- y, _, new_state = asr_model.decoder(
367
- targets=blank_targets_long,
368
- target_length=blank_target_lengths_long,
369
- states=state,
370
- )
371
- outputs.append(y.detach())
372
- state = [new_state[0].detach(), new_state[1].detach()]
373
- if outputs:
374
- decoder_seq = torch.cat(outputs, dim=-1)
375
- else:
376
- decoder_seq = torch.zeros(1, decoder_hidden, 0, dtype=torch.float32)
377
- return decoder_seq, state[0], state[1]
378
-
379
- decoder_ref, h_ref, c_ref = _decoder_rollout_torch(symbol_steps)
380
-
381
- with torch.inference_mode():
382
- logits_ref = asr_model.joint(
383
- encoder_outputs=encoder_ref,
384
- decoder_outputs=decoder_ref,
385
- )
386
-
387
- # Convert tensors to numpy for CoreML
388
- def _np32(x):
389
- return np.array(x.detach().cpu().numpy(), dtype=np.float32, copy=True)
390
-
391
- # Prepare plot dir (write to repo-tracked plots/<script-name>/)
392
- plots_root = Path(__file__).parent / "plots"
393
- plots_dir = plots_root / Path(__file__).stem
394
- plots_dir.mkdir(parents=True, exist_ok=True)
395
-
396
- encoder_np = _np32(encoder_ref)
397
- decoder_ref_np = _np32(decoder_ref)
398
-
399
- summary: Dict[str, object] = {
400
- "requested": True,
401
- "status": "ok",
402
- "atol": atol,
403
- "rtol": rtol,
404
- "symbol_steps": int(symbol_steps),
405
- "audio_path": None if validation_audio is None else str(validation_audio),
406
- "components": {},
407
- }
408
-
409
- # Preprocessor
410
- pre = ct.models.MLModel(str(output_dir / "parakeet_preprocessor.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
411
- t0 = time.perf_counter()
412
- pre_out = pre.predict({"audio_signal": _np32(audio_tensor), "audio_length": _np32(audio_length).astype(np.int32)})
413
- t1 = time.perf_counter()
414
- pre_first_ms = (t1 - t0) * 1000.0
415
- mel_ml = np.array(pre_out["mel"], dtype=np.float32, copy=True)
416
- mel_len_ml = np.array(pre_out["mel_length"], dtype=np.int32, copy=True)
417
- pre_atol, pre_rtol = max(atol, 1.0), max(rtol, 1e-2)
418
- a_mel, r_mel, ok_mel = _max_diffs(_np32(mel_ref), mel_ml, pre_rtol, pre_atol)
419
- ok_len = int(_np32(mel_length_ref).astype(np.int32)[0]) == int(np.array(mel_len_ml).astype(np.int32)[0])
420
- mel_t = _np32(mel_ref)[0]
421
- mel_c = mel_ml[0]
422
- vmin = float(min(mel_t.min(), mel_c.min()))
423
- vmax = float(max(mel_t.max(), mel_c.max()))
424
- pre_plots = {
425
- "mel_composite.png": _plot_mel_composite(mel_t, mel_c, plots_dir / "mel_composite.png", vmin=vmin, vmax=vmax),
426
- }
427
- # Latency measurements: Torch and CoreML
428
- def _time_coreml(model: ct.models.MLModel, inputs: Dict[str, np.ndarray]) -> Tuple[float, float]:
429
- # Warmup
430
- for _ in range(max(0, warmup)):
431
- _ = model.predict(inputs)
432
- times = []
433
- for _ in range(max(1, runs)):
434
- t0 = time.perf_counter()
435
- _ = model.predict(inputs)
436
- t1 = time.perf_counter()
437
- times.append((t1 - t0) * 1000.0)
438
- arr = np.array(times, dtype=np.float64)
439
- return float(arr.mean()), float(arr.std(ddof=1) if arr.size > 1 else 0.0)
440
-
441
- def _time_torch(fn, *args, **kwargs) -> Tuple[float, float]:
442
- with torch.inference_mode():
443
- for _ in range(max(0, warmup)):
444
- _ = fn(*args, **kwargs)
445
- times = []
446
- for _ in range(max(1, runs)):
447
- t0 = time.perf_counter()
448
- _ = fn(*args, **kwargs)
449
- t1 = time.perf_counter()
450
- times.append((t1 - t0) * 1000.0)
451
- arr = np.array(times, dtype=np.float64)
452
- return float(arr.mean()), float(arr.std(ddof=1) if arr.size > 1 else 0.0)
453
-
454
- pre_torch_ms_mean, pre_torch_ms_std = _time_torch(
455
- asr_model.preprocessor, input_signal=audio_tensor, length=audio_length.to(dtype=torch.long)
456
- )
457
- pre_coreml_ms_mean, pre_coreml_ms_std = _time_coreml(
458
- pre,
459
- {"audio_signal": _np32(audio_tensor), "audio_length": _np32(audio_length).astype(np.int32)},
460
- )
461
- seconds = 15.0
462
- pre_coreml_rtf = float(pre_coreml_ms_mean / (seconds * 1000.0)) if pre_coreml_ms_mean > 0 else None
463
- pre_torch_rtf = float(pre_torch_ms_mean / (seconds * 1000.0)) if pre_torch_ms_mean > 0 else None
464
-
465
- summary["components"]["preprocessor"] = {
466
- "mel": {"max_abs": a_mel, "max_rel": r_mel, "match": bool(ok_mel)},
467
- "length_match": bool(ok_len),
468
- "latency": {
469
- "runs": int(runs),
470
- "warmup": int(warmup),
471
- "coreml_first_ms": pre_first_ms,
472
- "torch_ms": {"mean": pre_torch_ms_mean, "std": pre_torch_ms_std},
473
- "coreml_ms": {"mean": pre_coreml_ms_mean, "std": pre_coreml_ms_std},
474
- "rtf": {"torch": pre_torch_rtf, "coreml": pre_coreml_rtf},
475
- },
476
- "plots": {k: v for k, v in pre_plots.items() if v},
477
- }
478
-
479
- # Encoder
480
- enc = ct.models.MLModel(str(output_dir / "parakeet_encoder.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
481
- t0 = time.perf_counter()
482
- enc_out = enc.predict({"mel": _np32(mel_ref), "mel_length": _np32(mel_length_ref).astype(np.int32)})
483
- t1 = time.perf_counter()
484
- enc_first_ms = (t1 - t0) * 1000.0
485
- enc_ml = np.array(enc_out["encoder"], dtype=np.float32, copy=True)
486
- enc_len_ml = np.array(enc_out["encoder_length"], dtype=np.int32, copy=True)
487
- a_enc, r_enc, ok_enc = _max_diffs(_np32(encoder_ref), enc_ml, max(rtol, 5e-3), max(atol, 5e-2))
488
- ok_enc_len = int(_np32(encoder_length_ref).astype(np.int32)[0]) == int(np.array(enc_len_ml).astype(np.int32)[0])
489
- enc_t = _np32(encoder_ref)[0]
490
- enc_c = enc_ml[0]
491
- enc_plots = {
492
- "encoder_time_l2.png": _plot_line(
493
- np.linalg.norm(enc_t, axis=0), # L2 norm over features (D) for each time step
494
- np.linalg.norm(enc_c, axis=0), # enc_t shape is (D, T), so axis=0 is features
495
- "Encoder L2 over time",
496
- plots_dir / "encoder_time_l2.png",
497
- also_delta=True,
498
- ),
499
- }
500
- enc_torch_ms_mean, enc_torch_ms_std = _time_torch(
501
- asr_model.encoder, audio_signal=mel_ref, length=mel_length_ref.to(dtype=torch.long)
502
- )
503
- enc_coreml_ms_mean, enc_coreml_ms_std = _time_coreml(
504
- enc, {"mel": _np32(mel_ref), "mel_length": _np32(mel_length_ref).astype(np.int32)}
505
- )
506
- enc_coreml_rtf = float(enc_coreml_ms_mean / (seconds * 1000.0)) if enc_coreml_ms_mean > 0 else None
507
- enc_torch_rtf = float(enc_torch_ms_mean / (seconds * 1000.0)) if enc_torch_ms_mean > 0 else None
508
-
509
- summary["components"]["encoder"] = {
510
- "encoder": {"max_abs": a_enc, "max_rel": r_enc, "match": bool(ok_enc)},
511
- "length_match": bool(ok_enc_len),
512
- "latency": {
513
- "runs": int(runs),
514
- "warmup": int(warmup),
515
- "coreml_first_ms": enc_first_ms,
516
- "torch_ms": {"mean": enc_torch_ms_mean, "std": enc_torch_ms_std},
517
- "coreml_ms": {"mean": enc_coreml_ms_mean, "std": enc_coreml_ms_std},
518
- "rtf": {"torch": enc_torch_rtf, "coreml": enc_coreml_rtf},
519
- },
520
- "plots": {k: v for k, v in enc_plots.items() if v},
521
- }
522
-
523
- # Decoder (sequential U=1 rollout)
524
- dec = ct.models.MLModel(str(output_dir / "parakeet_decoder.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
525
-
526
- zero_state_np = np.zeros((decoder_layers, 1, decoder_hidden), dtype=np.float32)
527
- blank_targets_np = np.array(blank_targets.detach().cpu().numpy(), dtype=np.int32, copy=True)
528
- blank_target_lengths_np = np.array(blank_target_lengths.detach().cpu().numpy(), dtype=np.int32, copy=True)
529
-
530
- def _decoder_rollout_coreml(num_steps: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]:
531
- outputs = []
532
- h_np = zero_state_np.copy()
533
- c_np = zero_state_np.copy()
534
- first_ms: Optional[float] = None
535
- for i in range(num_steps):
536
- t0_i = time.perf_counter() if i == 0 else None
537
- res = dec.predict(
538
- {
539
- "targets": blank_targets_np,
540
- "target_length": blank_target_lengths_np,
541
- "h_in": h_np,
542
- "c_in": c_np,
543
- }
544
- )
545
- if t0_i is not None:
546
- t1_i = time.perf_counter()
547
- first_ms = (t1_i - t0_i) * 1000.0
548
- outputs.append(np.array(res["decoder"], dtype=np.float32, copy=True))
549
- h_np = np.array(res["h_out"], dtype=np.float32, copy=True)
550
- c_np = np.array(res["c_out"], dtype=np.float32, copy=True)
551
- if outputs:
552
- decoder_seq = np.concatenate(outputs, axis=-1)
553
- else:
554
- decoder_seq = np.zeros((1, decoder_hidden, 0), dtype=np.float32)
555
- return decoder_seq, h_np, c_np, (0.0 if first_ms is None else float(first_ms))
556
-
557
- dec_ml, h_ml, c_ml, dec_first_ms = _decoder_rollout_coreml(symbol_steps)
558
- h_ref_np = _np32(h_ref)
559
- c_ref_np = _np32(c_ref)
560
-
561
- a_dec, r_dec, ok_dec = _max_diffs(decoder_ref_np, dec_ml, max(rtol, 1e-2), max(atol, 1e-1))
562
- a_h, r_h, ok_h = _max_diffs(h_ref_np, h_ml, max(rtol, 1e-2), max(atol, 2.5e-1))
563
- a_c, r_c, ok_c = _max_diffs(c_ref_np, c_ml, max(rtol, 5e-2), max(atol, 1.5e0))
564
-
565
- dec_t = decoder_ref_np[0]
566
- dec_c = dec_ml[0]
567
- dec_plots = {
568
- "decoder_steps_l2.png": _plot_line(
569
- np.linalg.norm(dec_t, axis=0),
570
- np.linalg.norm(dec_c, axis=0),
571
- "Decoder L2 over steps",
572
- plots_dir / "decoder_steps_l2.png",
573
- also_delta=True,
574
- ),
575
- }
576
-
577
- def _time_decoder_coreml() -> Tuple[float, float]:
578
- for _ in range(max(0, warmup)):
579
- _decoder_rollout_coreml(symbol_steps)
580
- times = []
581
- for _ in range(max(1, runs)):
582
- t0 = time.perf_counter()
583
- _decoder_rollout_coreml(symbol_steps)
584
- t1 = time.perf_counter()
585
- times.append((t1 - t0) * 1000.0)
586
- arr = np.array(times, dtype=np.float64)
587
- return float(arr.mean()), float(arr.std(ddof=1) if arr.size > 1 else 0.0)
588
-
589
- dec_torch_ms_mean, dec_torch_ms_std = _time_torch(lambda: _decoder_rollout_torch(symbol_steps))
590
- dec_coreml_ms_mean, dec_coreml_ms_std = _time_decoder_coreml()
591
- dec_coreml_rtf = float(dec_coreml_ms_mean / (seconds * 1000.0)) if dec_coreml_ms_mean > 0 else None
592
- dec_torch_rtf = float(dec_torch_ms_mean / (seconds * 1000.0)) if dec_torch_ms_mean > 0 else None
593
-
594
- summary["components"]["decoder"] = {
595
- "decoder": {"max_abs": a_dec, "max_rel": r_dec, "match": bool(ok_dec)},
596
- "h_out": {"max_abs": a_h, "max_rel": r_h, "match": bool(ok_h)},
597
- "c_out": {"max_abs": a_c, "max_rel": r_c, "match": bool(ok_c)},
598
- "latency": {
599
- "runs": int(runs),
600
- "warmup": int(warmup),
601
- "coreml_first_ms": dec_first_ms,
602
- "torch_ms": {"mean": dec_torch_ms_mean, "std": dec_torch_ms_std},
603
- "coreml_ms": {"mean": dec_coreml_ms_mean, "std": dec_coreml_ms_std},
604
- "rtf": {"torch": dec_torch_rtf, "coreml": dec_coreml_rtf},
605
- },
606
- "plots": {k: v for k, v in dec_plots.items() if v},
607
- }
608
-
609
- # Joint (sequential U=1 rollouts)
610
- j = ct.models.MLModel(str(output_dir / "parakeet_joint.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
611
-
612
- def _joint_rollout_coreml(decoder_seq_np: np.ndarray) -> Tuple[np.ndarray, float]:
613
- logits_steps = []
614
- first_ms: Optional[float] = None
615
- for u in range(decoder_seq_np.shape[2]):
616
- dec_slice = decoder_seq_np[:, :, u : u + 1]
617
- t0_u = time.perf_counter() if u == 0 else None
618
- res = j.predict({"encoder": encoder_np, "decoder": dec_slice})
619
- if t0_u is not None:
620
- t1_u = time.perf_counter()
621
- first_ms = (t1_u - t0_u) * 1000.0
622
- logits_steps.append(np.array(res["logits"], dtype=np.float32, copy=True))
623
- if not logits_steps:
624
- raise RuntimeError("No decoder steps provided for joint rollout")
625
- return np.concatenate(logits_steps, axis=2), (0.0 if first_ms is None else float(first_ms))
626
-
627
- logits_ml, joint_first_ms = _joint_rollout_coreml(decoder_ref_np)
628
- logits_ref_np = _np32(logits_ref)
629
- a_j, r_j, ok_j = _max_diffs(logits_ref_np, logits_ml, max(rtol, 1e-2), max(atol, 1e-1))
630
- joint_plots = {}
631
- if HAS_MPL:
632
- lt = logits_ref_np[0, 0, 0, :]
633
- lc = logits_ml[0, 0, 0, :]
634
- top_idx = np.argsort(-np.abs(lt))[:50]
635
- path = plots_dir / "joint_top50.png"
636
- plt.figure(figsize=(8, 3))
637
- plt.plot(lt[top_idx], label="torch")
638
- plt.plot(lc[top_idx], label="coreml", alpha=0.8)
639
- plt.title("Joint logits (t=0,u=0) top-50 |torch|")
640
- plt.legend(); plt.tight_layout(); plt.savefig(path); plt.close()
641
- joint_plots["joint_top50.png"] = str(path.name)
642
-
643
- # Delta-over-time visualization (fix u=0; summarize over vocab)
644
- jt = logits_ref_np[0, :, 0, :]
645
- jc = logits_ml[0, :, 0, :]
646
- l2_t = np.linalg.norm(jt, axis=1)
647
- l2_c = np.linalg.norm(jc, axis=1)
648
- path2 = plots_dir / "joint_time_l2.png"
649
- _plot_line(l2_t, l2_c, "Joint L2 over time (u=0)", path2, also_delta=True)
650
- joint_plots["joint_time_l2.png"] = str(path2.name)
651
-
652
- def _time_joint_coreml() -> Tuple[float, float]:
653
- for _ in range(max(0, warmup)):
654
- _joint_rollout_coreml(decoder_ref_np)
655
- times = []
656
- for _ in range(max(1, runs)):
657
- t0 = time.perf_counter()
658
- _joint_rollout_coreml(decoder_ref_np)
659
- t1 = time.perf_counter()
660
- times.append((t1 - t0) * 1000.0)
661
- arr = np.array(times, dtype=np.float64)
662
- return float(arr.mean()), float(arr.std(ddof=1) if arr.size > 1 else 0.0)
663
-
664
- joint_torch_ms_mean, joint_torch_ms_std = _time_torch(
665
- asr_model.joint, encoder_outputs=encoder_ref, decoder_outputs=decoder_ref
666
- )
667
- joint_coreml_ms_mean, joint_coreml_ms_std = _time_joint_coreml()
668
- joint_coreml_rtf = float(joint_coreml_ms_mean / (seconds * 1000.0)) if joint_coreml_ms_mean > 0 else None
669
- joint_torch_rtf = float(joint_torch_ms_mean / (seconds * 1000.0)) if joint_torch_ms_mean > 0 else None
670
-
671
- summary["components"]["joint"] = {
672
- "logits": {"max_abs": a_j, "max_rel": r_j, "match": bool(ok_j)},
673
- "latency": {
674
- "runs": int(runs),
675
- "warmup": int(warmup),
676
- "coreml_first_ms": joint_first_ms,
677
- "torch_ms": {"mean": joint_torch_ms_mean, "std": joint_torch_ms_std},
678
- "coreml_ms": {"mean": joint_coreml_ms_mean, "std": joint_coreml_ms_std},
679
- "rtf": {"torch": joint_torch_rtf, "coreml": joint_coreml_rtf},
680
- },
681
- "plots": joint_plots,
682
- }
683
-
684
- # Fused components
685
- # 1) Mel+Encoder fused vs separate
686
- mel_enc_plots = {}
687
- try:
688
- mel_enc = ct.models.MLModel(str(output_dir / "parakeet_mel_encoder.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
689
- t0 = time.perf_counter()
690
- mel_enc_out = mel_enc.predict({
691
- "audio_signal": _np32(audio_tensor),
692
- "audio_length": _np32(audio_length).astype(np.int32),
693
- })
694
- t1 = time.perf_counter()
695
- mel_enc_first_ms = (t1 - t0) * 1000.0
696
- mel_enc_ml = np.array(mel_enc_out["encoder"], dtype=np.float32, copy=True)
697
- mel_enc_len_ml = np.array(mel_enc_out["encoder_length"], dtype=np.int32, copy=True)
698
- # Compare fused output vs Torch reference encoder
699
- a_melenc, r_melenc, ok_melenc = _max_diffs(_np32(encoder_ref), mel_enc_ml, max(rtol, 5e-3), max(atol, 5e-2))
700
- ok_melenc_len = int(_np32(encoder_length_ref).astype(np.int32)[0]) == int(mel_enc_len_ml.astype(np.int32)[0])
701
- # Also compare fused vs separate CoreML pipeline (pre -> enc)
702
- a_melenc_vs_sep, r_melenc_vs_sep, ok_melenc_vs_sep = _max_diffs(enc_ml, mel_enc_ml, max(rtol, 5e-3), max(atol, 5e-2))
703
-
704
- # Plots: L2 over time (fused vs torch)
705
- enc_t_ref = _np32(encoder_ref)[0]
706
- enc_c_fused = mel_enc_ml[0]
707
- mel_enc_plots["mel_encoder_time_l2.png"] = _plot_line(
708
- np.linalg.norm(enc_t_ref, axis=0),
709
- np.linalg.norm(enc_c_fused, axis=0),
710
- "Mel+Encoder (fused) L2 over time",
711
- plots_dir / "mel_encoder_time_l2.png",
712
- also_delta=True,
713
- )
714
-
715
- # Latency: fused CoreML vs separate (CoreML pre + CoreML enc)
716
- mel_enc_coreml_ms_mean, mel_enc_coreml_ms_std = _time_coreml(
717
- mel_enc,
718
- {"audio_signal": _np32(audio_tensor), "audio_length": _np32(audio_length).astype(np.int32)},
719
- )
720
- sep_coreml_ms_mean = float(pre_coreml_ms_mean + enc_coreml_ms_mean)
721
- sep_coreml_ms_std = float((pre_coreml_ms_std ** 2 + enc_coreml_ms_std ** 2) ** 0.5)
722
- # Torch baseline (separate torch pre + enc)
723
- sep_torch_ms_mean = float(pre_torch_ms_mean + enc_torch_ms_mean)
724
- sep_torch_ms_std = float((pre_torch_ms_std ** 2 + enc_torch_ms_std ** 2) ** 0.5)
725
-
726
- mel_enc_coreml_rtf = float(mel_enc_coreml_ms_mean / (seconds * 1000.0)) if mel_enc_coreml_ms_mean > 0 else None
727
- sep_coreml_rtf = float(sep_coreml_ms_mean / (seconds * 1000.0)) if sep_coreml_ms_mean > 0 else None
728
- sep_torch_rtf = float(sep_torch_ms_mean / (seconds * 1000.0)) if sep_torch_ms_mean > 0 else None
729
-
730
- summary["components"]["mel_encoder"] = {
731
- "encoder": {"max_abs": a_melenc, "max_rel": r_melenc, "match": bool(ok_melenc)},
732
- "length_match": bool(ok_melenc_len),
733
- "vs_separate_coreml": {"max_abs": a_melenc_vs_sep, "max_rel": r_melenc_vs_sep, "match": bool(ok_melenc_vs_sep)},
734
- "latency": {
735
- "runs": int(runs),
736
- "warmup": int(warmup),
737
- "fused_coreml_first_ms": mel_enc_first_ms,
738
- "fused_coreml_ms": {"mean": mel_enc_coreml_ms_mean, "std": mel_enc_coreml_ms_std},
739
- "separate_coreml_ms": {"mean": sep_coreml_ms_mean, "std": sep_coreml_ms_std},
740
- "separate_torch_ms": {"mean": sep_torch_ms_mean, "std": sep_torch_ms_std},
741
- "rtf": {"fused_coreml": mel_enc_coreml_rtf, "separate_coreml": sep_coreml_rtf, "separate_torch": sep_torch_rtf},
742
- },
743
- "plots": {k: v for k, v in mel_enc_plots.items() if v},
744
- }
745
- except Exception as e:
746
- summary["components"]["mel_encoder_error"] = str(e)
747
-
748
- # 2) JointDecision fused vs CPU PyTorch post-processing
749
- jd_plots = {}
750
- try:
751
- # Fused CoreML joint decision
752
- jd = ct.models.MLModel(str(output_dir / "parakeet_joint_decision.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
753
- def _joint_decision_rollout_coreml(decoder_seq_np: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]:
754
- token_ids = []
755
- token_probs = []
756
- durations = []
757
- first_ms: Optional[float] = None
758
- for u in range(decoder_seq_np.shape[2]):
759
- dec_slice = decoder_seq_np[:, :, u : u + 1]
760
- t0_u = time.perf_counter() if u == 0 else None
761
- res = jd.predict({"encoder": encoder_np, "decoder": dec_slice})
762
- if t0_u is not None:
763
- t1_u = time.perf_counter()
764
- first_ms = (t1_u - t0_u) * 1000.0
765
- token_ids.append(np.array(res["token_id"], dtype=np.int32, copy=True))
766
- token_probs.append(np.array(res["token_prob"], dtype=np.float32, copy=True))
767
- durations.append(np.array(res["duration"], dtype=np.int32, copy=True))
768
- if not token_ids:
769
- raise RuntimeError("No decoder steps provided for joint decision rollout")
770
- return (
771
- np.concatenate(token_ids, axis=2),
772
- np.concatenate(token_probs, axis=2),
773
- np.concatenate(durations, axis=2),
774
- (0.0 if first_ms is None else float(first_ms)),
775
- )
776
-
777
- token_id_ml, token_prob_ml, duration_ml, jd_first_ms = _joint_decision_rollout_coreml(decoder_ref_np)
778
-
779
- # CPU PyTorch decision using Torch logits
780
- vocab_with_blank = int(vocab_size) + 1
781
- with torch.inference_mode():
782
- logits_t = logits_ref
783
- token_logits_t = logits_t[..., :vocab_with_blank]
784
- duration_logits_t = logits_t[..., -num_extra:] if num_extra > 0 else None
785
- token_ids_t = torch.argmax(token_logits_t, dim=-1).to(dtype=torch.int32)
786
- token_probs_all_t = torch.softmax(token_logits_t, dim=-1)
787
- token_prob_t = torch.gather(
788
- token_probs_all_t, dim=-1, index=token_ids_t.long().unsqueeze(-1)
789
- ).squeeze(-1)
790
- if duration_logits_t is not None and duration_logits_t.numel() > 0:
791
- duration_t = torch.argmax(duration_logits_t, dim=-1).to(dtype=torch.int32)
792
- else:
793
- duration_t = torch.zeros_like(token_ids_t, dtype=torch.int32)
794
-
795
- # Also derive CPU decision from CoreML joint logits for "separate" path
796
- token_logits_c = _to_t(logits_ml)[..., :vocab_with_blank]
797
- duration_logits_c = _to_t(logits_ml)[..., -num_extra:] if num_extra > 0 else None
798
- token_ids_c = torch.argmax(token_logits_c, dim=-1).to(dtype=torch.int32)
799
- token_probs_all_c = torch.softmax(token_logits_c, dim=-1)
800
- token_prob_c = torch.gather(
801
- token_probs_all_c, dim=-1, index=token_ids_c.long().unsqueeze(-1)
802
- ).squeeze(-1)
803
- if duration_logits_c is not None and duration_logits_c.numel() > 0:
804
- duration_c = torch.argmax(duration_logits_c, dim=-1).to(dtype=torch.int32)
805
- else:
806
- duration_c = torch.zeros_like(token_ids_c, dtype=torch.int32)
807
-
808
- # Compare fused outputs to CPU PyTorch decisions
809
- a_tid_t, r_tid_t, ok_tid_t = _max_diffs(_np(token_ids_t), token_id_ml, 0.0, 0.0)
810
- a_tprob_t, r_tprob_t, ok_tprob_t = _max_diffs(_np(token_prob_t), token_prob_ml, max(rtol, 1e-2), max(atol, 1e-1))
811
- a_dur_t, r_dur_t, ok_dur_t = _max_diffs(_np(duration_t), duration_ml, 0.0, 0.0)
812
-
813
- a_tid_c, r_tid_c, ok_tid_c = _max_diffs(_np(token_ids_c), token_id_ml, 0.0, 0.0)
814
- a_tprob_c, r_tprob_c, ok_tprob_c = _max_diffs(_np(token_prob_c), token_prob_ml, max(rtol, 1e-2), max(atol, 1e-1))
815
- a_dur_c, r_dur_c, ok_dur_c = _max_diffs(_np(duration_c), duration_ml, 0.0, 0.0)
816
-
817
- # Plots: token_prob over time for u=0 (fused vs torch CPU)
818
- if HAS_MPL:
819
- prob_t = _np(token_prob_t)[0, :, 0]
820
- prob_ml = token_prob_ml[0, :, 0]
821
- jd_plots["joint_decision_prob_u0.png"] = _plot_line(
822
- prob_t,
823
- prob_ml,
824
- "JointDecision token_prob (u=0)",
825
- plots_dir / "joint_decision_prob_u0.png",
826
- also_delta=True,
827
- )
828
-
829
- # Agreement heatmap for token_id
830
- agree = (_np(token_ids_t)[0] == token_id_ml[0]).astype(np.float32)
831
- jd_plots["joint_decision_token_agree.png"] = _plot_image(
832
- agree,
833
- "token_id agreement (torch CPU vs fused)",
834
- plots_dir / "joint_decision_token_agree.png",
835
- vmin=0.0,
836
- vmax=1.0,
837
- )
838
-
839
- # Latency: fused CoreML vs separate (CoreML joint + CPU PyTorch decision)
840
- def _time_joint_decision_coreml() -> Tuple[float, float]:
841
- for _ in range(max(0, warmup)):
842
- _joint_decision_rollout_coreml(decoder_ref_np)
843
- times = []
844
- for _ in range(max(1, runs)):
845
- t0 = time.perf_counter()
846
- _joint_decision_rollout_coreml(decoder_ref_np)
847
- t1 = time.perf_counter()
848
- times.append((t1 - t0) * 1000.0)
849
- arr = np.array(times, dtype=np.float64)
850
- return float(arr.mean()), float(arr.std(ddof=1) if arr.size > 1 else 0.0)
851
-
852
- jd_coreml_ms_mean, jd_coreml_ms_std = _time_joint_decision_coreml()
853
-
854
- # Time CPU post-processing only (Torch) on top of CoreML or Torch logits. Use Torch logits.
855
- def _decision_torch_call():
856
- with torch.inference_mode():
857
- tl = logits_ref
858
- tl_token = tl[..., :vocab_with_blank]
859
- tl_ids = torch.argmax(tl_token, dim=-1)
860
- tl_probs = torch.softmax(tl_token, dim=-1)
861
- _ = torch.gather(tl_probs, -1, tl_ids.long().unsqueeze(-1)).squeeze(-1)
862
- if num_extra > 0:
863
- _ = torch.argmax(tl[..., -num_extra:], dim=-1)
864
- return None
865
-
866
- jd_decision_torch_ms_mean, jd_decision_torch_ms_std = _time_torch(lambda: _decision_torch_call())
867
- sep_joint_plus_cpu_ms_mean = float(joint_coreml_ms_mean + jd_decision_torch_ms_mean)
868
- sep_joint_plus_cpu_ms_std = float((joint_coreml_ms_std ** 2 + jd_decision_torch_ms_std ** 2) ** 0.5)
869
- jd_coreml_rtf = float(jd_coreml_ms_mean / (seconds * 1000.0)) if jd_coreml_ms_mean > 0 else None
870
- sep_joint_cpu_rtf = float(sep_joint_plus_cpu_ms_mean / (seconds * 1000.0)) if sep_joint_plus_cpu_ms_mean > 0 else None
871
-
872
- summary["components"]["joint_decision"] = {
873
- "vs_torch_cpu": {
874
- "token_id": {"max_abs": a_tid_t, "max_rel": r_tid_t, "match": bool(ok_tid_t)},
875
- "token_prob": {"max_abs": a_tprob_t, "max_rel": r_tprob_t, "match": bool(ok_tprob_t)},
876
- "duration": {"max_abs": a_dur_t, "max_rel": r_dur_t, "match": bool(ok_dur_t)},
877
- },
878
- "vs_coreml_joint_cpu": {
879
- "token_id": {"max_abs": a_tid_c, "max_rel": r_tid_c, "match": bool(ok_tid_c)},
880
- "token_prob": {"max_abs": a_tprob_c, "max_rel": r_tprob_c, "match": bool(ok_tprob_c)},
881
- "duration": {"max_abs": a_dur_c, "max_rel": r_dur_c, "match": bool(ok_dur_c)},
882
- },
883
- "latency": {
884
- "runs": int(runs),
885
- "warmup": int(warmup),
886
- "fused_coreml_first_ms": jd_first_ms,
887
- "fused_coreml_ms": {"mean": jd_coreml_ms_mean, "std": jd_coreml_ms_std},
888
- "separate_joint_coreml_plus_cpu_ms": {"mean": sep_joint_plus_cpu_ms_mean, "std": sep_joint_plus_cpu_ms_std},
889
- "rtf": {"fused_coreml": jd_coreml_rtf, "separate_joint_coreml_plus_cpu": sep_joint_cpu_rtf},
890
- },
891
- "plots": {k: v for k, v in jd_plots.items() if v},
892
- }
893
- except Exception as e:
894
- summary["components"]["joint_decision_error"] = str(e)
895
-
896
- # Latency overview plots (saved alongside component plots)
897
- latency_plots = {}
898
- labels = ["preprocessor", "encoder", "decoder", "joint"]
899
- torch_means = [pre_torch_ms_mean, enc_torch_ms_mean, dec_torch_ms_mean, joint_torch_ms_mean]
900
- torch_stds = [pre_torch_ms_std, enc_torch_ms_std, dec_torch_ms_std, joint_torch_ms_std]
901
- coreml_means = [pre_coreml_ms_mean, enc_coreml_ms_mean, dec_coreml_ms_mean, joint_coreml_ms_mean]
902
- coreml_stds = [pre_coreml_ms_std, enc_coreml_ms_std, dec_coreml_ms_std, joint_coreml_ms_std]
903
- lat_path = plots_dir / "latency_summary.png"
904
- spd_path = plots_dir / "latency_speedup.png"
905
- latency_plots["latency_summary.png"] = _plot_latency_bars(
906
- labels, torch_means, torch_stds, coreml_means, coreml_stds, lat_path
907
- )
908
- latency_plots["latency_speedup.png"] = _plot_speedup_bars(
909
- labels, torch_means, coreml_means, spd_path
910
- )
911
-
912
- # Fused vs separate latency summary
913
- fused_labels = ["mel+encoder", "joint_decision"]
914
- fused_baseline_means = [
915
- float(pre_torch_ms_mean + enc_torch_ms_mean),
916
- float(joint_coreml_ms_mean + jd_decision_torch_ms_mean if 'jd_coreml_ms_mean' in locals() else joint_coreml_ms_mean),
917
- ]
918
- fused_coreml_means = [
919
- float(mel_enc_coreml_ms_mean if 'mel_enc_coreml_ms_mean' in locals() else np.nan),
920
- float(jd_coreml_ms_mean if 'jd_coreml_ms_mean' in locals() else np.nan),
921
- ]
922
- fused_latency_path = plots_dir / "latency_fused_vs_separate.png"
923
- fused_speedup_path = plots_dir / "latency_fused_speedup.png"
924
- latency_plots["latency_fused_vs_separate.png"] = _plot_latency_bars(
925
- fused_labels, fused_baseline_means, [0, 0], fused_coreml_means, [0, 0], fused_latency_path
926
- )
927
- latency_plots["latency_fused_speedup.png"] = _plot_speedup_bars(
928
- fused_labels, fused_baseline_means, fused_coreml_means, fused_speedup_path
929
- )
930
-
931
- all_ok = (
932
- summary["components"]["preprocessor"]["mel"]["match"]
933
- and summary["components"]["preprocessor"]["length_match"]
934
- and summary["components"]["encoder"]["encoder"]["match"]
935
- and summary["components"]["encoder"]["length_match"]
936
- and summary["components"]["decoder"]["decoder"]["match"]
937
- and summary["components"]["decoder"]["h_out"]["match"]
938
- and summary["components"]["decoder"]["c_out"]["match"]
939
- and summary["components"]["joint"]["logits"]["match"]
940
- )
941
- summary["status"] = "ok" if all_ok else "mismatch"
942
-
943
- # Update metadata.json
944
- meta_path = output_dir / "metadata.json"
945
- try:
946
- meta = json.loads(meta_path.read_text())
947
- except Exception:
948
- meta = {}
949
- meta["validation"] = summary
950
- meta_path.write_text(json.dumps(meta, indent=2))
951
-
952
- typer.echo(f"Validation {'passed' if all_ok else 'mismatched'}. Updated {meta_path}")
953
- if HAS_MPL:
954
- typer.echo(f"Saved plots to {plots_dir}")
955
-
956
-
957
- if __name__ == "__main__":
958
- app()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
convert/parakeet-tdt-v2-0.6b/coreml/compile_modelc.py DELETED
@@ -1,91 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Compile Core ML packages into ``.mlmodelc`` bundles via ``xcrun``.
3
-
4
- This script walks through the default Parakeet CoreML directories, finds
5
- all ``*.mlpackage`` bundles, and compiles each of them with
6
- ``xcrun coremlcompiler`` into ``./compiled`` while preserving the
7
- relative directory structure.
8
- """
9
- from __future__ import annotations
10
-
11
- import shutil
12
- import subprocess
13
- import sys
14
- from pathlib import Path
15
-
16
- BASE_DIR = Path(__file__).resolve().parent
17
- OUTPUT_ROOT = BASE_DIR / "compiled"
18
- SOURCE_DIRS = [BASE_DIR / "parakeet_coreml", BASE_DIR / "parakeet_coreml_quantized"]
19
-
20
-
21
- def ensure_coremlcompiler() -> None:
22
- """Ensure ``xcrun coremlcompiler`` is available for the active Xcode."""
23
- xcrun_path = shutil.which("xcrun")
24
- if xcrun_path is None:
25
- print("Error: 'xcrun' not found on PATH. Install Xcode command line tools.", file=sys.stderr)
26
- sys.exit(1)
27
-
28
- try:
29
- subprocess.run([
30
- xcrun_path,
31
- "--find",
32
- "coremlcompiler",
33
- ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
34
- except subprocess.CalledProcessError:
35
- print("Error: 'coremlcompiler' not found via xcrun. Check your Xcode installation.", file=sys.stderr)
36
- sys.exit(1)
37
-
38
-
39
- def gather_packages() -> list[Path]:
40
- """Return a list of all ``*.mlpackage`` bundles under the source dirs."""
41
- packages: list[Path] = []
42
- for source in SOURCE_DIRS:
43
- if not source.exists():
44
- print(f"Warning: {source.relative_to(BASE_DIR)} does not exist; skipping", file=sys.stderr)
45
- continue
46
- packages.extend(source.rglob("*.mlpackage"))
47
- return packages
48
-
49
-
50
- def compile_package(package: Path) -> None:
51
- """Compile a single ``.mlpackage`` bundle using ``xcrun coremlcompiler``."""
52
- relative_pkg = package.relative_to(BASE_DIR)
53
- output_dir = OUTPUT_ROOT / relative_pkg.parent
54
- output_dir.mkdir(parents=True, exist_ok=True)
55
- output_path = output_dir / f"{package.stem}.mlmodelc"
56
-
57
- if output_path.exists():
58
- shutil.rmtree(output_path)
59
-
60
- cmd = [
61
- "xcrun",
62
- "coremlcompiler",
63
- "compile",
64
- str(package),
65
- str(output_dir),
66
- ]
67
-
68
- print(f"Compiling {relative_pkg} -> {output_path.relative_to(BASE_DIR)}")
69
- subprocess.run(cmd, check=True)
70
-
71
-
72
- def main() -> None:
73
- ensure_coremlcompiler()
74
- packages = gather_packages()
75
-
76
- if not packages:
77
- print("No .mlpackage bundles found to compile.")
78
- return
79
-
80
- for package in packages:
81
- try:
82
- compile_package(package)
83
- except subprocess.CalledProcessError as exc:
84
- print(f"Failed to compile {package}: {exc}", file=sys.stderr)
85
- sys.exit(exc.returncode)
86
-
87
- print(f"Finished compiling {len(packages)} package(s) into {OUTPUT_ROOT.relative_to(BASE_DIR)}.")
88
-
89
-
90
- if __name__ == "__main__":
91
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
convert/parakeet-tdt-v2-0.6b/coreml/convert-parakeet.py DELETED
@@ -1,619 +0,0 @@
1
- #!/usr/bin/env python3
2
- """CLI for exporting Parakeet TDT v2 components to CoreML."""
3
- from __future__ import annotations
4
-
5
- import json
6
- from dataclasses import asdict
7
- from pathlib import Path
8
- from typing import Dict, Optional, Tuple
9
-
10
- import coremltools as ct
11
- import numpy as np
12
- import soundfile as sf
13
- import torch
14
- import typer
15
-
16
- import nemo.collections.asr as nemo_asr
17
-
18
- from individual_components import (
19
- DecoderWrapper,
20
- EncoderWrapper,
21
- ExportSettings,
22
- JointWrapper,
23
- JointDecisionWrapper,
24
- JointDecisionSingleStep,
25
- PreprocessorWrapper,
26
- MelEncoderWrapper,
27
- _coreml_convert,
28
- )
29
-
30
- DEFAULT_MODEL_ID = "nvidia/parakeet-tdt-0.6b-v2"
31
- AUTHOR = "Fluid Inference"
32
-
33
-
34
- def _compute_length(seconds: float, sample_rate: int) -> int:
35
- return int(round(seconds * sample_rate))
36
-
37
-
38
- def _prepare_audio(
39
- validation_audio: Optional[Path],
40
- sample_rate: int,
41
- max_samples: int,
42
- seed: Optional[int],
43
- ) -> torch.Tensor:
44
- if validation_audio is None:
45
- if seed is not None:
46
- torch.manual_seed(seed)
47
- audio = torch.randn(1, max_samples, dtype=torch.float32)
48
- return audio
49
-
50
- data, sr = sf.read(str(validation_audio), dtype="float32")
51
- if sr != sample_rate:
52
- raise typer.BadParameter(
53
- f"Validation audio sample rate {sr} does not match model rate {sample_rate}"
54
- )
55
-
56
- if data.ndim > 1:
57
- data = data[:, 0]
58
-
59
- if data.size == 0:
60
- raise typer.BadParameter("Validation audio is empty")
61
-
62
- if data.size < max_samples:
63
- pad_width = max_samples - data.size
64
- data = np.pad(data, (0, pad_width))
65
- elif data.size > max_samples:
66
- data = data[:max_samples]
67
-
68
- audio = torch.from_numpy(data).unsqueeze(0).to(dtype=torch.float32)
69
- return audio
70
-
71
-
72
- def _save_mlpackage(model: ct.models.MLModel, path: Path, description: str) -> None:
73
- # Ensure iOS 17+ target for MLProgram ops and ANE readiness
74
- try:
75
- model.minimum_deployment_target = ct.target.iOS17
76
- except Exception:
77
- pass
78
- model.short_description = description
79
- model.author = AUTHOR
80
- path.parent.mkdir(parents=True, exist_ok=True)
81
- model.save(str(path))
82
-
83
-
84
- def _tensor_shape(tensor: torch.Tensor) -> Tuple[int, ...]:
85
- return tuple(int(dim) for dim in tensor.shape)
86
-
87
-
88
- def _parse_compute_units(name: str) -> ct.ComputeUnit:
89
- """Parse a human-friendly compute units string into ct.ComputeUnit.
90
-
91
- Accepted (case-insensitive): ALL, CPU_ONLY, CPU_AND_GPU, CPU_AND_NE.
92
- """
93
- normalized = str(name).strip().upper()
94
- mapping = {
95
- "ALL": ct.ComputeUnit.ALL,
96
- "CPU_ONLY": ct.ComputeUnit.CPU_ONLY,
97
- "CPU_AND_GPU": ct.ComputeUnit.CPU_AND_GPU,
98
- "CPU_AND_NE": ct.ComputeUnit.CPU_AND_NE,
99
- "CPU_AND_NEURALENGINE": ct.ComputeUnit.CPU_AND_NE,
100
- }
101
- if normalized not in mapping:
102
- raise typer.BadParameter(
103
- f"Unknown compute units '{name}'. Choose from: " + ", ".join(mapping.keys())
104
- )
105
- return mapping[normalized]
106
-
107
-
108
- def _parse_compute_precision(name: Optional[str]) -> Optional[ct.precision]:
109
- """Parse compute precision string into ct.precision or None.
110
-
111
- Accepted (case-insensitive): FLOAT32, FLOAT16. If None/empty, returns None (tool default).
112
- """
113
- if name is None:
114
- return None
115
- normalized = str(name).strip().upper()
116
- if normalized == "":
117
- return None
118
- mapping = {
119
- "FLOAT32": ct.precision.FLOAT32,
120
- "FLOAT16": ct.precision.FLOAT16,
121
- }
122
- if normalized not in mapping:
123
- raise typer.BadParameter(
124
- f"Unknown compute precision '{name}'. Choose from: " + ", ".join(mapping.keys())
125
- )
126
- return mapping[normalized]
127
-
128
-
129
- # Validation logic removed; use compare-compnents.py for comparisons.
130
-
131
-
132
- # Fixed export choices: CPU_ONLY + FP32, min target iOS17
133
-
134
-
135
- app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
136
-
137
-
138
- @app.command()
139
- def convert(
140
- nemo_path: Optional[Path] = typer.Option(
141
- None,
142
- "--nemo-path",
143
- exists=True,
144
- resolve_path=True,
145
- help="Path to parakeet-tdt-0.6b-v2 .nemo checkpoint (skip to auto-download)",
146
- ),
147
- model_id: str = typer.Option(
148
- DEFAULT_MODEL_ID,
149
- "--model-id",
150
- help="Model identifier to download when --nemo-path is omitted",
151
- ),
152
- output_dir: Path = typer.Option(Path("parakeet_coreml"), help="Directory where mlpackages and metadata will be written"),
153
- preprocessor_cu: str = typer.Option(
154
- "CPU_ONLY",
155
- "--preprocessor-cu",
156
- help="Compute units for preprocessor (default CPU_ONLY)",
157
- ),
158
- mel_encoder_cu: str = typer.Option(
159
- "CPU_ONLY",
160
- "--mel-encoder-cu",
161
- help="Compute units for fused mel+encoder (default CPU_ONLY)",
162
- ),
163
- compute_precision: Optional[str] = typer.Option(
164
- None,
165
- "--compute-precision",
166
- help="Export precision: FLOAT32 (default) or FLOAT16 to shrink non-quantized weights.",
167
- ),
168
- ) -> None:
169
- """Export all Parakeet sub-modules to CoreML with a fixed 15-second window."""
170
- # Runtime CoreML contract keeps U=1 so the prediction net matches the streaming decoder.
171
- export_settings = ExportSettings(
172
- output_dir=output_dir,
173
- compute_units=ct.ComputeUnit.CPU_ONLY, # Default: CPU-only for all components
174
- deployment_target=ct.target.iOS17, # iOS 17+ features and kernels
175
- compute_precision=_parse_compute_precision(compute_precision),
176
- max_audio_seconds=15.0,
177
- max_symbol_steps=1,
178
- )
179
-
180
- typer.echo("Export configuration:")
181
- typer.echo(asdict(export_settings))
182
-
183
- output_dir.mkdir(parents=True, exist_ok=True)
184
- pre_cu = _parse_compute_units(preprocessor_cu)
185
- melenc_cu = _parse_compute_units(mel_encoder_cu)
186
-
187
- if nemo_path is not None:
188
- typer.echo(f"Loading NeMo model from {nemo_path}…")
189
- asr_model = nemo_asr.models.EncDecRNNTBPEModel.restore_from(
190
- str(nemo_path), map_location="cpu"
191
- )
192
- checkpoint_meta = {
193
- "type": "file",
194
- "path": str(nemo_path),
195
- }
196
- else:
197
- typer.echo(f"Downloading NeMo model via {model_id}…")
198
- asr_model = nemo_asr.models.EncDecRNNTBPEModel.from_pretrained(
199
- model_id, map_location="cpu"
200
- )
201
- checkpoint_meta = {
202
- "type": "pretrained",
203
- "model_id": model_id,
204
- }
205
- asr_model.eval()
206
-
207
- sample_rate = int(asr_model.cfg.preprocessor.sample_rate)
208
- max_samples = _compute_length(export_settings.max_audio_seconds, sample_rate)
209
- # Prefer a bundled 15s 16kHz audio if available
210
- default_audio = (Path(__file__).parent / "audio" / "yc_first_minute_16k_15s.wav").resolve()
211
- if not default_audio.exists():
212
- raise typer.BadParameter(f"Expected 15s trace audio at {default_audio}; add the file to proceed.")
213
- typer.echo(f"Using trace audio: {default_audio}")
214
- audio_tensor = _prepare_audio(default_audio, sample_rate, max_samples, seed=None)
215
- audio_length = torch.tensor([max_samples], dtype=torch.int32)
216
-
217
- preprocessor = PreprocessorWrapper(asr_model.preprocessor.eval())
218
- encoder = EncoderWrapper(asr_model.encoder.eval())
219
- decoder = DecoderWrapper(asr_model.decoder.eval())
220
- joint = JointWrapper(asr_model.joint.eval())
221
-
222
- decoder_export_flag = getattr(asr_model.decoder, "_rnnt_export", False)
223
- asr_model.decoder._rnnt_export = True
224
-
225
- try:
226
- with torch.inference_mode():
227
- mel_ref, mel_length_ref = preprocessor(audio_tensor, audio_length)
228
- mel_length_ref = mel_length_ref.to(dtype=torch.int32)
229
- encoder_ref, encoder_length_ref = encoder(mel_ref, mel_length_ref)
230
- encoder_length_ref = encoder_length_ref.to(dtype=torch.int32)
231
-
232
- # Clone Tensors to drop the inference tensor flag before tracing
233
- mel_ref = mel_ref.clone()
234
- mel_length_ref = mel_length_ref.clone()
235
- encoder_ref = encoder_ref.clone()
236
- encoder_length_ref = encoder_length_ref.clone()
237
-
238
- vocab_size = int(asr_model.tokenizer.vocab_size)
239
- num_extra = int(asr_model.joint.num_extra_outputs)
240
- decoder_hidden = int(asr_model.decoder.pred_hidden)
241
- decoder_layers = int(asr_model.decoder.pred_rnn_layers)
242
-
243
- targets = torch.full(
244
- (1, export_settings.max_symbol_steps),
245
- fill_value=asr_model.decoder.blank_idx,
246
- dtype=torch.int32,
247
- )
248
- target_lengths = torch.tensor(
249
- [export_settings.max_symbol_steps], dtype=torch.int32
250
- )
251
- zero_state = torch.zeros(
252
- decoder_layers,
253
- 1,
254
- decoder_hidden,
255
- dtype=torch.float32,
256
- )
257
-
258
- with torch.inference_mode():
259
- decoder_ref, h_ref, c_ref = decoder(targets, target_lengths, zero_state, zero_state)
260
- joint_ref = joint(encoder_ref, decoder_ref)
261
-
262
- decoder_ref = decoder_ref.clone()
263
- h_ref = h_ref.clone()
264
- c_ref = c_ref.clone()
265
- joint_ref = joint_ref.clone()
266
-
267
- typer.echo("Tracing and converting preprocessor…")
268
- # Ensure tracing happens on CPU explicitly
269
- preprocessor = preprocessor.cpu()
270
- audio_tensor = audio_tensor.cpu()
271
- audio_length = audio_length.cpu()
272
- traced_preprocessor = torch.jit.trace(
273
- preprocessor, (audio_tensor, audio_length), strict=False
274
- )
275
- traced_preprocessor.eval()
276
- preprocessor_inputs = [
277
- # Allow variable-length audio up to the fixed 15s window using RangeDim
278
- ct.TensorType(
279
- name="audio_signal",
280
- shape=(1, ct.RangeDim(1, max_samples)),
281
- dtype=np.float32,
282
- ),
283
- ct.TensorType(name="audio_length", shape=(1,), dtype=np.int32),
284
- ]
285
- preprocessor_outputs = [
286
- ct.TensorType(name="mel", dtype=np.float32),
287
- ct.TensorType(name="mel_length", dtype=np.int32),
288
- ]
289
- # Preprocessor compute units (parametrized; default CPU_ONLY)
290
- preprocessor_model = _coreml_convert(
291
- traced_preprocessor,
292
- preprocessor_inputs,
293
- preprocessor_outputs,
294
- export_settings,
295
- compute_units_override=pre_cu,
296
- )
297
- preprocessor_path = output_dir / "parakeet_preprocessor.mlpackage"
298
- _save_mlpackage(
299
- preprocessor_model,
300
- preprocessor_path,
301
- "Parakeet preprocessor (15 s window)",
302
- )
303
-
304
- typer.echo("Tracing and converting encoder…")
305
- traced_encoder = torch.jit.trace(
306
- encoder, (mel_ref, mel_length_ref), strict=False
307
- )
308
- traced_encoder.eval()
309
- encoder_inputs = [
310
- ct.TensorType(name="mel", shape=_tensor_shape(mel_ref), dtype=np.float32),
311
- ct.TensorType(name="mel_length", shape=(1,), dtype=np.int32),
312
- ]
313
- encoder_outputs = [
314
- ct.TensorType(name="encoder", dtype=np.float32),
315
- ct.TensorType(name="encoder_length", dtype=np.int32),
316
- ]
317
- # Encoder: CPU only
318
- encoder_model = _coreml_convert(
319
- traced_encoder,
320
- encoder_inputs,
321
- encoder_outputs,
322
- export_settings,
323
- compute_units_override=ct.ComputeUnit.CPU_ONLY,
324
- )
325
- encoder_path = output_dir / "parakeet_encoder.mlpackage"
326
- _save_mlpackage(
327
- encoder_model,
328
- encoder_path,
329
- "Parakeet encoder (15 s window)",
330
- )
331
-
332
- # Optional fused export: Preprocessor + Encoder
333
- typer.echo("Tracing and converting fused mel+encoder…")
334
- mel_encoder = MelEncoderWrapper(preprocessor, encoder)
335
- traced_mel_encoder = torch.jit.trace(
336
- mel_encoder, (audio_tensor, audio_length), strict=False
337
- )
338
- traced_mel_encoder.eval()
339
- mel_encoder_inputs = [
340
- # Keep fixed 15s window for fused Mel+Encoder
341
- ct.TensorType(name="audio_signal", shape=(1, max_samples), dtype=np.float32),
342
- ct.TensorType(name="audio_length", shape=(1,), dtype=np.int32),
343
- ]
344
- mel_encoder_outputs = [
345
- ct.TensorType(name="encoder", dtype=np.float32),
346
- ct.TensorType(name="encoder_length", dtype=np.int32),
347
- ]
348
- # Fused mel+encoder compute units (parametrized; default CPU_ONLY)
349
- mel_encoder_model = _coreml_convert(
350
- traced_mel_encoder,
351
- mel_encoder_inputs,
352
- mel_encoder_outputs,
353
- export_settings,
354
- compute_units_override=melenc_cu,
355
- )
356
- mel_encoder_path = output_dir / "parakeet_mel_encoder.mlpackage"
357
- _save_mlpackage(
358
- mel_encoder_model,
359
- mel_encoder_path,
360
- "Parakeet fused Mel+Encoder (15 s window)",
361
- )
362
-
363
- typer.echo("Tracing and converting decoder…")
364
- traced_decoder = torch.jit.trace(
365
- decoder,
366
- (targets, target_lengths, zero_state, zero_state),
367
- strict=False,
368
- )
369
- traced_decoder.eval()
370
- decoder_inputs = [
371
- ct.TensorType(name="targets", shape=_tensor_shape(targets), dtype=np.int32),
372
- ct.TensorType(name="target_length", shape=(1,), dtype=np.int32),
373
- ct.TensorType(name="h_in", shape=_tensor_shape(zero_state), dtype=np.float32),
374
- ct.TensorType(name="c_in", shape=_tensor_shape(zero_state), dtype=np.float32),
375
- ]
376
- decoder_outputs = [
377
- ct.TensorType(name="decoder", dtype=np.float32),
378
- ct.TensorType(name="h_out", dtype=np.float32),
379
- ct.TensorType(name="c_out", dtype=np.float32),
380
- ]
381
- # Decoder: CPU only
382
- decoder_model = _coreml_convert(
383
- traced_decoder,
384
- decoder_inputs,
385
- decoder_outputs,
386
- export_settings,
387
- compute_units_override=ct.ComputeUnit.CPU_ONLY,
388
- )
389
- decoder_path = output_dir / "parakeet_decoder.mlpackage"
390
- _save_mlpackage(
391
- decoder_model,
392
- decoder_path,
393
- "Parakeet decoder (RNNT prediction network)",
394
- )
395
-
396
- typer.echo("Tracing and converting joint…")
397
- traced_joint = torch.jit.trace(
398
- joint,
399
- (encoder_ref, decoder_ref),
400
- strict=False,
401
- )
402
- traced_joint.eval()
403
- joint_inputs = [
404
- ct.TensorType(name="encoder", shape=_tensor_shape(encoder_ref), dtype=np.float32),
405
- ct.TensorType(name="decoder", shape=_tensor_shape(decoder_ref), dtype=np.float32),
406
- ]
407
- joint_outputs = [
408
- ct.TensorType(name="logits", dtype=np.float32),
409
- ]
410
- # Joint: CPU only
411
- joint_model = _coreml_convert(
412
- traced_joint,
413
- joint_inputs,
414
- joint_outputs,
415
- export_settings,
416
- compute_units_override=ct.ComputeUnit.CPU_ONLY,
417
- )
418
- joint_path = output_dir / "parakeet_joint.mlpackage"
419
- _save_mlpackage(
420
- joint_model,
421
- joint_path,
422
- "Parakeet joint network (RNNT)",
423
- )
424
-
425
- # Joint + decision head (split logits, softmax, argmax)
426
- typer.echo("Tracing and converting joint decision head…")
427
- vocab_size = int(asr_model.tokenizer.vocab_size)
428
- num_extra = int(asr_model.joint.num_extra_outputs)
429
- joint_decision = JointDecisionWrapper(joint, vocab_size=vocab_size, num_extra=num_extra)
430
- traced_joint_decision = torch.jit.trace(
431
- joint_decision,
432
- (encoder_ref, decoder_ref),
433
- strict=False,
434
- )
435
- traced_joint_decision.eval()
436
- joint_decision_inputs = [
437
- ct.TensorType(name="encoder", shape=_tensor_shape(encoder_ref), dtype=np.float32),
438
- ct.TensorType(name="decoder", shape=_tensor_shape(decoder_ref), dtype=np.float32),
439
- ]
440
- joint_decision_outputs = [
441
- ct.TensorType(name="token_id", dtype=np.int32),
442
- ct.TensorType(name="token_prob", dtype=np.float32),
443
- ct.TensorType(name="duration", dtype=np.int32),
444
- ]
445
- # JointDecision: CPU only
446
- joint_decision_model = _coreml_convert(
447
- traced_joint_decision,
448
- joint_decision_inputs,
449
- joint_decision_outputs,
450
- export_settings,
451
- compute_units_override=ct.ComputeUnit.CPU_ONLY,
452
- )
453
- joint_decision_path = output_dir / "parakeet_joint_decision.mlpackage"
454
- _save_mlpackage(
455
- joint_decision_model,
456
- joint_decision_path,
457
- "Parakeet joint + decision head (split, softmax, argmax)",
458
- )
459
-
460
- # Single-step JointDecision for [1,1024,1] x [1,640,1] -> [1,1,1]
461
- typer.echo("Tracing and converting single-step joint decision…")
462
- jd_single = JointDecisionSingleStep(joint, vocab_size=vocab_size, num_extra=num_extra)
463
- # Create single-step slices from refs
464
- enc_step = encoder_ref[:, :, :1].contiguous()
465
- dec_step = decoder_ref[:, :, :1].contiguous()
466
- traced_jd_single = torch.jit.trace(
467
- jd_single,
468
- (enc_step, dec_step),
469
- strict=False,
470
- )
471
- traced_jd_single.eval()
472
- jd_single_inputs = [
473
- ct.TensorType(name="encoder_step", shape=(1, enc_step.shape[1], 1), dtype=np.float32),
474
- ct.TensorType(name="decoder_step", shape=(1, dec_step.shape[1], 1), dtype=np.float32),
475
- ]
476
- jd_single_outputs = [
477
- ct.TensorType(name="token_id", dtype=np.int32),
478
- ct.TensorType(name="token_prob", dtype=np.float32),
479
- ct.TensorType(name="duration", dtype=np.int32),
480
- ]
481
- # Single-step JointDecision: CPU only
482
- jd_single_model = _coreml_convert(
483
- traced_jd_single,
484
- jd_single_inputs,
485
- jd_single_outputs,
486
- export_settings,
487
- compute_units_override=ct.ComputeUnit.CPU_ONLY,
488
- )
489
- jd_single_path = output_dir / "parakeet_joint_decision_single_step.mlpackage"
490
- _save_mlpackage(
491
- jd_single_model,
492
- jd_single_path,
493
- "Parakeet single-step joint decision (current frame)",
494
- )
495
-
496
- metadata: Dict[str, object] = {
497
- "model_id": model_id,
498
- "sample_rate": sample_rate,
499
- "max_audio_seconds": export_settings.max_audio_seconds,
500
- "max_audio_samples": max_samples,
501
- "max_symbol_steps": export_settings.max_symbol_steps,
502
- "vocab_size": vocab_size,
503
- "joint_extra_outputs": num_extra,
504
- "checkpoint": checkpoint_meta,
505
- "coreml": {
506
- "compute_units": export_settings.compute_units.name,
507
- "compute_precision": (
508
- export_settings.compute_precision.name
509
- if export_settings.compute_precision is not None
510
- else "FLOAT32"
511
- ),
512
- },
513
- "components": {
514
- "preprocessor": {
515
- "inputs": {
516
- "audio_signal": list(_tensor_shape(audio_tensor)),
517
- "audio_length": [1],
518
- },
519
- "outputs": {
520
- "mel": list(_tensor_shape(mel_ref)),
521
- "mel_length": [1],
522
- },
523
- "path": preprocessor_path.name,
524
- },
525
- "encoder": {
526
- "inputs": {
527
- "mel": list(_tensor_shape(mel_ref)),
528
- "mel_length": [1],
529
- },
530
- "outputs": {
531
- "encoder": list(_tensor_shape(encoder_ref)),
532
- "encoder_length": [1],
533
- },
534
- "path": encoder_path.name,
535
- },
536
- "mel_encoder": {
537
- "inputs": {
538
- "audio_signal": [1, max_samples],
539
- "audio_length": [1],
540
- },
541
- "outputs": {
542
- "encoder": list(_tensor_shape(encoder_ref)),
543
- "encoder_length": [1],
544
- },
545
- "path": mel_encoder_path.name,
546
- },
547
- "decoder": {
548
- "inputs": {
549
- "targets": list(_tensor_shape(targets)),
550
- "target_length": [1],
551
- "h_in": list(_tensor_shape(zero_state)),
552
- "c_in": list(_tensor_shape(zero_state)),
553
- },
554
- "outputs": {
555
- "decoder": list(_tensor_shape(decoder_ref)),
556
- "h_out": list(_tensor_shape(h_ref)),
557
- "c_out": list(_tensor_shape(c_ref)),
558
- },
559
- "path": decoder_path.name,
560
- },
561
- "joint": {
562
- "inputs": {
563
- "encoder": list(_tensor_shape(encoder_ref)),
564
- "decoder": list(_tensor_shape(decoder_ref)),
565
- },
566
- "outputs": {
567
- "logits": list(_tensor_shape(joint_ref)),
568
- },
569
- "path": joint_path.name,
570
- },
571
- "joint_decision": {
572
- "inputs": {
573
- "encoder": list(_tensor_shape(encoder_ref)),
574
- "decoder": list(_tensor_shape(decoder_ref)),
575
- },
576
- "outputs": {
577
- "token_id": [
578
- _tensor_shape(encoder_ref)[0],
579
- _tensor_shape(encoder_ref)[1],
580
- _tensor_shape(decoder_ref)[1],
581
- ],
582
- "token_prob": [
583
- _tensor_shape(encoder_ref)[0],
584
- _tensor_shape(encoder_ref)[1],
585
- _tensor_shape(decoder_ref)[1],
586
- ],
587
- "duration": [
588
- _tensor_shape(encoder_ref)[0],
589
- _tensor_shape(encoder_ref)[1],
590
- _tensor_shape(decoder_ref)[1],
591
- ],
592
- },
593
- "path": joint_decision_path.name,
594
- },
595
- "joint_decision_single_step": {
596
- "inputs": {
597
- "encoder_step": [1, _tensor_shape(encoder_ref)[2-1], 1],
598
- "decoder_step": [1, _tensor_shape(decoder_ref)[2-1], 1],
599
- },
600
- "outputs": {
601
- "token_id": [1, 1, 1],
602
- "token_prob": [1, 1, 1],
603
- "duration": [1, 1, 1],
604
- },
605
- "path": jd_single_path.name,
606
- },
607
- },
608
- }
609
-
610
- metadata_path = output_dir / "metadata.json"
611
- metadata_path.write_text(json.dumps(metadata, indent=2))
612
- typer.echo(f"Export complete. Metadata written to {metadata_path}")
613
-
614
- finally:
615
- asr_model.decoder._rnnt_export = decoder_export_flag
616
-
617
-
618
- if __name__ == "__main__":
619
- app()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
convert/parakeet-tdt-v2-0.6b/coreml/individual_components.py DELETED
@@ -1,229 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Export Parakeet TDT v2 RNNT components into CoreML and validate outputs."""
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from pathlib import Path
7
- from typing import Optional, Tuple
8
-
9
- import coremltools as ct
10
- import torch
11
-
12
-
13
- @dataclass
14
- class ExportSettings:
15
- output_dir: Path
16
- compute_units: ct.ComputeUnit
17
- deployment_target: Optional[ct.target.iOS17]
18
- compute_precision: Optional[ct.precision]
19
- max_audio_seconds: float
20
- max_symbol_steps: int
21
-
22
-
23
- @dataclass
24
- class ValidationSettings:
25
- audio_path: Optional[Path]
26
- seconds: float
27
- seed: Optional[int]
28
- rtol: float
29
- atol: float
30
- skip: bool
31
-
32
-
33
- @dataclass
34
- class ValidationDiff:
35
- name: str
36
- max_abs_diff: float
37
- max_rel_diff: float
38
-
39
-
40
- @dataclass
41
- class ValidationResult:
42
- source: str
43
- audio_num_samples: int
44
- audio_seconds: float
45
- token_length: int
46
- atol: float
47
- rtol: float
48
- diffs: Tuple[ValidationDiff, ...]
49
-
50
-
51
- class PreprocessorWrapper(torch.nn.Module):
52
- def __init__(self, module: torch.nn.Module) -> None:
53
- super().__init__()
54
- self.module = module
55
-
56
- def forward(self, audio_signal: torch.Tensor, length: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
57
- mel, mel_length = self.module(input_signal=audio_signal, length=length.to(dtype=torch.long))
58
- return mel, mel_length
59
-
60
-
61
- class EncoderWrapper(torch.nn.Module):
62
- def __init__(self, module: torch.nn.Module) -> None:
63
- super().__init__()
64
- self.module = module
65
-
66
- def forward(self, features: torch.Tensor, length: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
67
- encoded, encoded_lengths = self.module(audio_signal=features, length=length.to(dtype=torch.long))
68
- return encoded, encoded_lengths
69
-
70
-
71
- class DecoderWrapper(torch.nn.Module):
72
- def __init__(self, module: torch.nn.Module) -> None:
73
- super().__init__()
74
- self.module = module
75
-
76
- def forward(
77
- self,
78
- targets: torch.Tensor,
79
- target_lengths: torch.Tensor,
80
- h_in: torch.Tensor,
81
- c_in: torch.Tensor,
82
- ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
83
- state = [h_in, c_in]
84
- decoder_output, _, new_state = self.module(
85
- targets=targets.to(dtype=torch.long),
86
- target_length=target_lengths.to(dtype=torch.long),
87
- states=state,
88
- )
89
- return decoder_output, new_state[0], new_state[1]
90
-
91
-
92
- class JointWrapper(torch.nn.Module):
93
- def __init__(self, module: torch.nn.Module) -> None:
94
- super().__init__()
95
- self.module = module
96
-
97
- def forward(self, encoder_outputs: torch.Tensor, decoder_outputs: torch.Tensor) -> torch.Tensor:
98
- # Input: encoder_outputs [B, D, T], decoder_outputs [B, D, U]
99
- # Transpose to match what projection layers expect
100
- encoder_outputs = encoder_outputs.transpose(1, 2) # [B, T, D]
101
- decoder_outputs = decoder_outputs.transpose(1, 2) # [B, U, D]
102
-
103
- # Apply projections
104
- enc_proj = self.module.enc(encoder_outputs) # [B, T, 640]
105
- dec_proj = self.module.pred(decoder_outputs) # [B, U, 640]
106
-
107
- # Explicit broadcasting along T and U to avoid converter ambiguity
108
- x = enc_proj.unsqueeze(2) + dec_proj.unsqueeze(1) # [B, T, U, 640]
109
- x = self.module.joint_net[0](x) # ReLU
110
- x = self.module.joint_net[1](x) # Dropout (no-op in eval)
111
- out = self.module.joint_net[2](x) # Linear -> logits [B, T, U, 8198]
112
- return out
113
-
114
-
115
- class MelEncoderWrapper(torch.nn.Module):
116
- """Fused wrapper: waveform -> mel -> encoder.
117
-
118
- Inputs:
119
- - audio_signal: [B, S]
120
- - audio_length: [B]
121
-
122
- Outputs:
123
- - encoder: [B, D, T_enc]
124
- - encoder_length: [B]
125
- """
126
- def __init__(self, preprocessor: PreprocessorWrapper, encoder: EncoderWrapper) -> None:
127
- super().__init__()
128
- self.preprocessor = preprocessor
129
- self.encoder = encoder
130
-
131
- def forward(self, audio_signal: torch.Tensor, audio_length: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
132
- mel, mel_length = self.preprocessor(audio_signal, audio_length)
133
- encoded, enc_len = self.encoder(mel, mel_length.to(dtype=torch.int32))
134
- return encoded, enc_len
135
-
136
-
137
- class JointDecisionWrapper(torch.nn.Module):
138
- """Joint + decision head: outputs label id, label prob, duration frames.
139
-
140
- Splits joint logits into token logits and duration logits, applies softmax
141
- over tokens, argmax for both heads, and gathers probability of the chosen token.
142
-
143
- Inputs:
144
- - encoder_outputs: [B, D, T]
145
- - decoder_outputs: [B, D, U]
146
-
147
- Returns:
148
- - token_id: [B, T, U] int32
149
- - token_prob: [B, T, U] float32
150
- - duration: [B, T, U] int32 (frames; duration buckets as defined by the model)
151
- """
152
- def __init__(self, joint: JointWrapper, vocab_size: int, num_extra: int) -> None:
153
- super().__init__()
154
- self.joint = joint
155
- self.vocab_with_blank = int(vocab_size) + 1
156
- self.num_extra = int(num_extra)
157
-
158
- def forward(self, encoder_outputs: torch.Tensor, decoder_outputs: torch.Tensor):
159
- logits = self.joint(encoder_outputs, decoder_outputs)
160
- token_logits = logits[..., : self.vocab_with_blank]
161
- duration_logits = logits[..., -self.num_extra :]
162
-
163
- # Token selection
164
- token_ids = torch.argmax(token_logits, dim=-1).to(dtype=torch.int32)
165
- token_probs_all = torch.softmax(token_logits, dim=-1)
166
- # gather expects int64 (long) indices; cast only for gather
167
- token_prob = torch.gather(
168
- token_probs_all, dim=-1, index=token_ids.long().unsqueeze(-1)
169
- ).squeeze(-1)
170
-
171
- # Duration prediction (duration bucket argmax → frame increments)
172
- duration = torch.argmax(duration_logits, dim=-1).to(dtype=torch.int32)
173
- return token_ids, token_prob, duration
174
-
175
-
176
- class JointDecisionSingleStep(torch.nn.Module):
177
- """Single-step variant for streaming: encoder_step [1, 1024, 1] -> [1,1,1].
178
-
179
- Inputs:
180
- - encoder_step: [B=1, D=1024, T=1]
181
- - decoder_step: [B=1, D=640, U=1]
182
-
183
- Returns:
184
- - token_id: [1, 1, 1] int32
185
- - token_prob: [1, 1, 1] float32
186
- - duration: [1, 1, 1] int32
187
- """
188
- def __init__(self, joint: JointWrapper, vocab_size: int, num_extra: int) -> None:
189
- super().__init__()
190
- self.joint = joint
191
- self.vocab_with_blank = int(vocab_size) + 1
192
- self.num_extra = int(num_extra)
193
-
194
- def forward(self, encoder_step: torch.Tensor, decoder_step: torch.Tensor):
195
- # Reuse JointWrapper which expects [B, D, T] and [B, D, U]
196
- logits = self.joint(encoder_step, decoder_step) # [1, 1, 1, V+extra]
197
- token_logits = logits[..., : self.vocab_with_blank]
198
- duration_logits = logits[..., -self.num_extra :]
199
-
200
- token_ids = torch.argmax(token_logits, dim=-1, keepdim=False).to(dtype=torch.int32)
201
- token_probs_all = torch.softmax(token_logits, dim=-1)
202
- token_prob = torch.gather(
203
- token_probs_all, dim=-1, index=token_ids.long().unsqueeze(-1)
204
- ).squeeze(-1)
205
- duration = torch.argmax(duration_logits, dim=-1, keepdim=False).to(dtype=torch.int32)
206
- return token_ids, token_prob, duration
207
-
208
-
209
- def _coreml_convert(
210
- traced: torch.jit.ScriptModule,
211
- inputs,
212
- outputs,
213
- settings: ExportSettings,
214
- compute_units_override: Optional[ct.ComputeUnit] = None,
215
- ) -> ct.models.MLModel:
216
- cu = compute_units_override if compute_units_override is not None else settings.compute_units
217
- kwargs = {
218
- "convert_to": "mlprogram",
219
- "inputs": inputs,
220
- "outputs": outputs,
221
- "compute_units": cu,
222
- }
223
- print("Converting:", traced.__class__.__name__)
224
- print("Conversion kwargs:", kwargs)
225
- if settings.deployment_target is not None:
226
- kwargs["minimum_deployment_target"] = settings.deployment_target
227
- if settings.compute_precision is not None:
228
- kwargs["compute_precision"] = settings.compute_precision
229
- return ct.convert(traced, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
convert/parakeet-tdt-v2-0.6b/coreml/quantize_coreml.py DELETED
@@ -1,1207 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Quantize CoreML mlpackages and compare quality, compression, latency, and compile time.
3
-
4
- This script focuses on the fused models:
5
- - parakeet_mel_encoder.mlpackage (waveform -> encoder)
6
- - parakeet_joint_decision.mlpackage (joint + softmax/argmax)
7
-
8
- It also quantizes the rest of the components in the directory for completeness.
9
-
10
- Variants tried by default (examples):
11
- - int8-linear (per-channel)
12
- - palettize 6-bit (Mel-only)
13
- - jd-only variants (int8 and palette)
14
- - prune + int8
15
-
16
- Outputs:
17
- - A new directory per variant under <output_root>/<variant>/ with quantized mlpackages
18
- - quantization_summary.json with aggregate metrics (baseline vs each variant)
19
- - Plots saved under <output_root>/plots/ and mirrored to <repo>/plots/quantize/<compute_units_lower>/
20
- - fused_quality.png / fused_latency.png / fused_compression.png / fused_size.png (latency chart also shows compile time)
21
- - all_components_quality.png / all_components_latency.png / all_components_compression.png / all_components_size.png (latency chart also shows compile time)
22
-
23
- Notes:
24
- - Uses CoreMLTools optimize.coreml linear/palettize/prune for MLProgram models.
25
- - Keeps the fixed 15-second window shapes as per context/coreml_component_io.md.
26
- - Run via `uv run` to ensure reproducible environment.
27
- - Sets minimum deployment target to iOS 17 for all outputs and loads models with `compute_units=ALL` by default to enable ANE.
28
- - Tracks offline compile time (mlpackage->mlmodelc) and includes that metric in plots.
29
- """
30
- from __future__ import annotations
31
-
32
- import json
33
- import platform
34
- import shutil
35
- import subprocess
36
- import time
37
- from dataclasses import dataclass
38
- from pathlib import Path
39
- from typing import Dict, List, Optional, Set, Tuple
40
-
41
- import numpy as np
42
- import soundfile as sf
43
- import typer
44
-
45
- import coremltools as ct
46
- from coremltools.optimize.coreml import (
47
- OptimizationConfig,
48
- OpLinearQuantizerConfig,
49
- OpPalettizerConfig,
50
- OpThresholdPrunerConfig,
51
- linear_quantize_weights,
52
- palettize_weights,
53
- prune_weights,
54
- )
55
-
56
-
57
- # Optional plotting
58
- try:
59
- import matplotlib
60
- matplotlib.use("Agg")
61
- import matplotlib.pyplot as plt
62
- HAS_MPL = True
63
- except Exception:
64
- HAS_MPL = False
65
-
66
-
67
- BASE_DIR = Path(__file__).resolve().parent
68
- BYTES_IN_MB = 1024 * 1024
69
-
70
-
71
- DISPLAY_LABEL_OVERRIDES = {
72
- "mel6bit-palettize": "mel6bit",
73
- "enc6bit-palettize": "enc6bit",
74
- "int8-linear": "int8 linear",
75
- }
76
-
77
-
78
- # When no explicit components are provided, we derive the set of
79
- # components to quantize from the selected variants' whitelists.
80
- # If any selected variant has no whitelist (i.e., applies globally),
81
- # we quantize all components.
82
- DEFAULT_TARGET_COMPONENTS: Tuple[str, str] = ()
83
-
84
-
85
- # Formatting helpers -------------------------------------------------------
86
-
87
-
88
- def _format_labels(labels: List[str]) -> List[str]:
89
- formatted: List[str] = []
90
- for label in labels:
91
- pretty = DISPLAY_LABEL_OVERRIDES.get(label, label)
92
- pretty = pretty.replace("_", " ")
93
- pretty = pretty.replace("-", "\n")
94
- formatted.append(pretty)
95
- return formatted
96
-
97
-
98
- def _friendly_component_name(name: str) -> str:
99
- return name.replace("_", " ").title()
100
-
101
-
102
- def _get_metric_series(
103
- metrics: Dict[str, Dict[str, List[float]]],
104
- component: str,
105
- key: str,
106
- length: int,
107
- ) -> List[float]:
108
- values = metrics.get(component, {}).get(key)
109
- if values is None:
110
- return [float("nan")] * length
111
- if len(values) >= length:
112
- return list(values[:length])
113
- padded = list(values)
114
- padded.extend([float("nan")] * (length - len(values)))
115
- return padded
116
-
117
-
118
- def _plot_bar_rows(
119
- out_path: Path,
120
- display_labels: List[str],
121
- rows: List[Dict[str, object]],
122
- title_suffix: str,
123
- ) -> None:
124
- if not HAS_MPL:
125
- return
126
- if not rows or not display_labels:
127
- return
128
-
129
- n_labels = len(display_labels)
130
- fig_width = max(8.0, 1.3 * n_labels)
131
- fig_height = max(2.6 * len(rows), 3.0)
132
-
133
- fig, axes = plt.subplots(len(rows), 1, figsize=(fig_width, fig_height), squeeze=False)
134
- axes = axes.flatten()
135
- x = np.arange(n_labels)
136
-
137
- for ax, row in zip(axes, rows):
138
- values = np.asarray(row.get("values", [float("nan")] * n_labels), dtype=np.float64)
139
- color = row.get("color")
140
- bars = ax.bar(x, values, width=0.6, color=color)
141
- ax.set_title(str(row.get("title", "")))
142
- ax.set_xticks(x)
143
- ax.set_xticklabels(display_labels, rotation=25, ha="right")
144
- ylim = row.get("ylim")
145
- if isinstance(ylim, tuple) and len(ylim) == 2:
146
- ax.set_ylim(ylim)
147
- ax.grid(axis="y", linestyle="--", alpha=0.3)
148
- # Add value labels for bars
149
- for bar in bars:
150
- h = bar.get_height()
151
- if np.isnan(h):
152
- continue
153
- ax.annotate(
154
- f"{h:.2f}",
155
- xy=(bar.get_x() + bar.get_width() / 2, h),
156
- xytext=(0, 3),
157
- textcoords="offset points",
158
- ha="center",
159
- va="bottom",
160
- fontsize=8,
161
- )
162
-
163
- if title_suffix:
164
- fig.suptitle(title_suffix, fontsize=12)
165
- fig.tight_layout(rect=(0, 0, 1, 0.95))
166
- else:
167
- fig.tight_layout()
168
-
169
- out_path.parent.mkdir(parents=True, exist_ok=True)
170
- plt.savefig(out_path)
171
- plt.close(fig)
172
-
173
-
174
- def _plot_fused_category_charts(
175
- plot_dir: Path,
176
- labels: List[str],
177
- mel_quality: List[float],
178
- mel_latency_ms: List[float],
179
- mel_compression: List[float],
180
- mel_size_mb: List[float],
181
- jd_acc: List[float],
182
- jd_latency_ms: List[float],
183
- jd_compression: List[float],
184
- jd_size_mb: List[float],
185
- mel_compile_ms: Optional[List[float]] = None,
186
- jd_compile_ms: Optional[List[float]] = None,
187
- title_suffix: str = "",
188
- ) -> List[Path]:
189
- if not HAS_MPL:
190
- return []
191
- outputs: List[Path] = []
192
- display = _format_labels(labels)
193
- prefix = f"Fused Components — {title_suffix}" if title_suffix else "Fused Components"
194
-
195
- quality_path = plot_dir / "fused_quality.png"
196
- _plot_bar_rows(
197
- quality_path,
198
- display,
199
- [
200
- {
201
- "title": "MelEncoder quality (1 - norm err)",
202
- "values": mel_quality,
203
- "color": "C0",
204
- "ylim": (0.0, 1.05),
205
- },
206
- {
207
- "title": "JointDecision token-id match rate",
208
- "values": jd_acc,
209
- "color": "C0",
210
- "ylim": (0.0, 1.05),
211
- },
212
- ],
213
- f"{prefix} — Quality",
214
- )
215
- outputs.append(quality_path)
216
-
217
- compression_path = plot_dir / "fused_compression.png"
218
- _plot_bar_rows(
219
- compression_path,
220
- display,
221
- [
222
- {
223
- "title": "MelEncoder compression ratio",
224
- "values": mel_compression,
225
- "color": "C2",
226
- },
227
- {
228
- "title": "JointDecision compression ratio",
229
- "values": jd_compression,
230
- "color": "C2",
231
- },
232
- ],
233
- f"{prefix} — Compression",
234
- )
235
- outputs.append(compression_path)
236
-
237
- size_path = plot_dir / "fused_size.png"
238
- _plot_bar_rows(
239
- size_path,
240
- display,
241
- [
242
- {
243
- "title": "MelEncoder size (MB)",
244
- "values": mel_size_mb,
245
- "color": "C4",
246
- },
247
- {
248
- "title": "JointDecision size (MB)",
249
- "values": jd_size_mb,
250
- "color": "C4",
251
- },
252
- ],
253
- f"{prefix} — Size",
254
- )
255
- outputs.append(size_path)
256
-
257
- latency_rows = [
258
- {
259
- "title": "MelEncoder latency (ms)",
260
- "values": mel_latency_ms,
261
- "color": "C1",
262
- },
263
- {
264
- "title": "JointDecision latency (ms)",
265
- "values": jd_latency_ms,
266
- "color": "C1",
267
- },
268
- ]
269
- if mel_compile_ms is not None and jd_compile_ms is not None:
270
- latency_rows.extend(
271
- [
272
- {
273
- "title": "MelEncoder compile (ms)",
274
- "values": mel_compile_ms,
275
- "color": "C3",
276
- },
277
- {
278
- "title": "JointDecision compile (ms)",
279
- "values": jd_compile_ms,
280
- "color": "C3",
281
- },
282
- ]
283
- )
284
-
285
- latency_path = plot_dir / "fused_latency.png"
286
- _plot_bar_rows(
287
- latency_path,
288
- display,
289
- latency_rows,
290
- f"{prefix} — Latency",
291
- )
292
- outputs.append(latency_path)
293
-
294
- return outputs
295
-
296
-
297
- def _plot_all_component_category_charts(
298
- plot_dir: Path,
299
- labels: List[str],
300
- metrics: Dict[str, Dict[str, List[float]]],
301
- title_suffix: str = "",
302
- ) -> List[Path]:
303
- if not HAS_MPL:
304
- return []
305
- outputs: List[Path] = []
306
- display = _format_labels(labels)
307
- prefix = f"Component Breakdown — {title_suffix}" if title_suffix else "Component Breakdown"
308
- comp_order = [
309
- "preprocessor",
310
- "encoder",
311
- "mel_encoder",
312
- "decoder",
313
- "joint",
314
- "joint_decision",
315
- ]
316
- n = len(labels)
317
-
318
- quality_rows: List[Dict[str, object]] = []
319
- for comp in comp_order:
320
- friendly = _friendly_component_name(comp)
321
- if comp == "joint_decision":
322
- values = _get_metric_series(metrics, comp, "acc", n)
323
- title = f"{friendly} token-id match rate"
324
- else:
325
- values = _get_metric_series(metrics, comp, "quality", n)
326
- title = f"{friendly} quality (1 - norm err)"
327
- quality_rows.append({"title": title, "values": values, "color": "C0", "ylim": (0.0, 1.05)})
328
-
329
- compression_rows: List[Dict[str, object]] = []
330
- for comp in comp_order:
331
- friendly = _friendly_component_name(comp)
332
- compression_rows.append(
333
- {
334
- "title": f"{friendly} compression ratio",
335
- "values": _get_metric_series(metrics, comp, "compression", n),
336
- "color": "C2",
337
- }
338
- )
339
-
340
- size_rows: List[Dict[str, object]] = []
341
- for comp in comp_order:
342
- friendly = _friendly_component_name(comp)
343
- size_rows.append(
344
- {
345
- "title": f"{friendly} size (MB)",
346
- "values": _get_metric_series(metrics, comp, "size_mb", n),
347
- "color": "C4",
348
- }
349
- )
350
-
351
- latency_rows: List[Dict[str, object]] = []
352
- for comp in comp_order:
353
- friendly = _friendly_component_name(comp)
354
- latency_rows.append(
355
- {
356
- "title": f"{friendly} latency (ms)",
357
- "values": _get_metric_series(metrics, comp, "latency_ms", n),
358
- "color": "C1",
359
- }
360
- )
361
-
362
- compile_rows: List[Dict[str, object]] = []
363
- for comp in comp_order:
364
- friendly = _friendly_component_name(comp)
365
- compile_rows.append(
366
- {
367
- "title": f"{friendly} compile (ms)",
368
- "values": _get_metric_series(metrics, comp, "compile_ms", n),
369
- "color": "C3",
370
- }
371
- )
372
-
373
- quality_path = plot_dir / "all_components_quality.png"
374
- _plot_bar_rows(quality_path, display, quality_rows, f"{prefix} — Quality")
375
- outputs.append(quality_path)
376
-
377
- compression_path = plot_dir / "all_components_compression.png"
378
- _plot_bar_rows(compression_path, display, compression_rows, f"{prefix} — Compression")
379
- outputs.append(compression_path)
380
-
381
- size_path = plot_dir / "all_components_size.png"
382
- _plot_bar_rows(size_path, display, size_rows, f"{prefix} — Size")
383
- outputs.append(size_path)
384
-
385
- latency_path = plot_dir / "all_components_latency.png"
386
- _plot_bar_rows(latency_path, display, latency_rows, f"{prefix} — Latency")
387
- outputs.append(latency_path)
388
-
389
- compile_path = plot_dir / "all_components_compile.png"
390
- _plot_bar_rows(compile_path, display, compile_rows, f"{prefix} — Compile")
391
- outputs.append(compile_path)
392
-
393
- return outputs
394
-
395
-
396
- app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
397
-
398
-
399
- @dataclass
400
- class VariantConfig:
401
- name: str
402
- # List of (op_kind, opt_config) steps applied in order; op_kind in {'linear','palettize','prune'}
403
- steps: List[Tuple[str, OptimizationConfig]]
404
- category: str
405
- whitelist: Optional[List[str]] = None # component names to apply; others copied
406
-
407
-
408
- def _dir_size_bytes(path: Path) -> int:
409
- total = 0
410
- for p in path.rglob("*"):
411
- if p.is_file():
412
- try:
413
- total += p.stat().st_size
414
- except OSError:
415
- pass
416
- return total
417
-
418
-
419
- def _load_metadata(input_dir: Path) -> Dict[str, object]:
420
- meta_path = input_dir / "metadata.json"
421
- if not meta_path.exists():
422
- raise typer.BadParameter(f"Expected metadata.json in {input_dir}")
423
- return json.loads(meta_path.read_text())
424
-
425
-
426
- def _prepare_audio(
427
- seconds: float,
428
- sample_rate: int,
429
- audio_path: Optional[Path],
430
- ) -> Tuple[np.ndarray, np.ndarray]:
431
- max_samples = int(round(seconds * sample_rate))
432
- if audio_path is None:
433
- # random but deterministic sample
434
- rng = np.random.default_rng(1234)
435
- audio = rng.standard_normal(size=(1, max_samples), dtype=np.float32).astype(np.float32)
436
- else:
437
- data, sr = sf.read(str(audio_path), dtype="float32")
438
- if sr != sample_rate:
439
- raise typer.BadParameter(
440
- f"Validation audio sample rate {sr} != expected {sample_rate}"
441
- )
442
- if data.ndim > 1:
443
- data = data[:, 0]
444
- if data.size < max_samples:
445
- data = np.pad(data, (0, max_samples - data.size))
446
- elif data.size > max_samples:
447
- data = data[:max_samples]
448
- audio = data.reshape(1, -1).astype(np.float32, copy=False)
449
- length = np.array([max_samples], dtype=np.int32)
450
- return audio, length
451
-
452
-
453
- def _predict_latency(model: ct.models.MLModel, inputs: Dict[str, np.ndarray], runs: int = 10, warmup: int = 3) -> Tuple[float, float]:
454
- # Warmup
455
- for _ in range(max(0, warmup)):
456
- _ = model.predict(inputs)
457
- # Timed runs
458
- times: List[float] = []
459
- for _ in range(max(1, runs)):
460
- t0 = time.perf_counter()
461
- _ = model.predict(inputs)
462
- t1 = time.perf_counter()
463
- times.append((t1 - t0) * 1000.0) # ms
464
- arr = np.array(times, dtype=np.float64)
465
- return float(arr.mean()), float(arr.std(ddof=1) if arr.size > 1 else 0.0)
466
-
467
-
468
- def _max_abs_rel(a: np.ndarray, b: np.ndarray) -> Tuple[float, float]:
469
- a = np.asarray(a, dtype=np.float32)
470
- b = np.asarray(b, dtype=np.float32)
471
- if a.size == 0:
472
- return 0.0, 0.0
473
- diff = np.abs(a - b)
474
- max_abs = float(diff.max())
475
- denom = np.maximum(np.abs(a), np.abs(b))
476
- with np.errstate(divide="ignore", invalid="ignore"):
477
- rel = np.where(denom == 0.0, 0.0, diff / denom)
478
- max_rel = float(rel.max())
479
- return max_abs, max_rel
480
-
481
-
482
- def _save_mlpackage(model: ct.models.MLModel, path: Path, description: str) -> None:
483
- path.parent.mkdir(parents=True, exist_ok=True)
484
- # Ensure iOS 17 target for proper MLProgram ops (e.g., blockwise shift/scale)
485
- try:
486
- model.minimum_deployment_target = ct.target.iOS17
487
- except Exception:
488
- pass
489
- model.short_description = description
490
- model.author = "Fluid Inference"
491
- model.save(str(path))
492
-
493
-
494
- def _offline_compile_time_ms(model_path: Path) -> float:
495
- """Compile mlpackage -> mlmodelc and return wall time in ms (host offline compile).
496
-
497
- Returns NaN on failure.
498
- """
499
- compiled_dir: Optional[Path] = None
500
- expected_dir = model_path.with_suffix(".mlmodelc")
501
- try:
502
- # Delete any prior compile artifact in-place so we can measure a fresh build
503
- if expected_dir.exists():
504
- shutil.rmtree(expected_dir, ignore_errors=True)
505
-
506
- t0 = time.perf_counter()
507
- compiled_path = ct.utils.compile_model(str(model_path))
508
- t1 = time.perf_counter()
509
- compiled_dir = Path(compiled_path)
510
- return (t1 - t0) * 1000.0
511
- except Exception:
512
- return float("nan")
513
- finally:
514
- if (
515
- compiled_dir is not None
516
- and compiled_dir.exists()
517
- and compiled_dir == expected_dir
518
- ):
519
- shutil.rmtree(compiled_dir, ignore_errors=True)
520
-
521
- def _chip_spec_string(compute_units: str) -> str:
522
- try:
523
- chip = subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"]).decode().strip()
524
- except Exception:
525
- chip = platform.processor() or platform.machine()
526
- mac_ver = platform.mac_ver()[0] or platform.platform()
527
- return f"Host: {chip} • macOS {mac_ver} • CoreMLTools {ct.__version__} • ComputeUnits={compute_units} • Min Target: iOS17"
528
-
529
-
530
- def _quantize_dir(
531
- input_dir: Path,
532
- output_dir: Path,
533
- variant: VariantConfig,
534
- global_whitelist: Optional[Set[str]] = None,
535
- ) -> Dict[str, str]:
536
- """Quantize all mlpackages in input_dir into output_dir using given variant config.
537
-
538
- Returns a map of component name -> saved relative path.
539
- """
540
- meta = _load_metadata(input_dir)
541
- comps = meta.get("components", {})
542
- saved: Dict[str, str] = {}
543
- for name, cfg in comps.items():
544
- src_name = cfg.get("path")
545
- if not src_name:
546
- continue
547
- src_path = input_dir / src_name
548
- if not src_path.exists():
549
- continue
550
- dst_path = output_dir / src_name
551
- # Use CPU+GPU for preprocessor to avoid NE preprocessor input size issues; others use CPU+NE
552
- cu = ct.ComputeUnit.CPU_AND_GPU if name == "preprocessor" else ct.ComputeUnit.CPU_AND_NE
553
- base_model = ct.models.MLModel(str(src_path), compute_units=cu)
554
- # Target iOS17 when running optimizations so the right ops are chosen
555
- try:
556
- base_model.minimum_deployment_target = ct.target.iOS17
557
- except Exception:
558
- pass
559
-
560
- if name == "decoder":
561
- typer.echo(f"[{variant.name}] Skipping decoder quantization; copying baseline: {src_name}")
562
- _save_mlpackage(base_model, dst_path, "Baseline copy (decoder quantization disabled) - decoder")
563
- saved[name] = dst_path.name
564
- continue
565
-
566
- skip_reasons: List[str] = []
567
- if variant.whitelist is not None and name not in variant.whitelist:
568
- skip_reasons.append("not targeted by variant")
569
- if global_whitelist is not None and name not in global_whitelist:
570
- skip_reasons.append("not in requested components")
571
-
572
- if skip_reasons:
573
- reason = "; ".join(skip_reasons)
574
- typer.echo(f"[{variant.name}] Skipping {name} ({reason}); copying baseline: {src_name}")
575
- _save_mlpackage(base_model, dst_path, f"Baseline copy ({reason}) - {name}")
576
- saved[name] = dst_path.name
577
- continue
578
-
579
- typer.echo(f"[{variant.name}] Quantizing {name}: {src_name}")
580
-
581
- try:
582
- q_model = base_model
583
- for step_kind, step_cfg in variant.steps:
584
- if step_kind == 'linear':
585
- q_model = linear_quantize_weights(q_model, step_cfg)
586
- elif step_kind == 'palettize':
587
- q_model = palettize_weights(q_model, step_cfg)
588
- elif step_kind == 'prune':
589
- q_model = prune_weights(q_model, step_cfg)
590
- else:
591
- raise ValueError(f"Unknown variant step: {step_kind}")
592
- except Exception as e:
593
- # If quantization fails (e.g., unsupported op), fall back to copying baseline.
594
- typer.echo(f" ! Failed to quantize {name} with {variant.name}: {e}. Copying baseline.")
595
- _save_mlpackage(base_model, dst_path, f"Baseline copy (failed to quantize) - {name}")
596
- else:
597
- _save_mlpackage(q_model, dst_path, f"{variant.name} quantized - {name}")
598
- saved[name] = dst_path.name
599
- # Persist a variant metadata shim
600
- out_meta = {
601
- "variant": variant.name,
602
- "base_dir": str(input_dir.resolve()),
603
- "components": saved,
604
- }
605
- (output_dir / "quantization_metadata.json").write_text(json.dumps(out_meta, indent=2))
606
- return saved
607
-
608
-
609
- @app.command()
610
- def quantize(
611
- input_dir: Path = typer.Option(Path("parakeet_coreml"), help="Directory containing baseline mlpackages + metadata.json"),
612
- output_root: Path = typer.Option(Path("parakeet_coreml_quantized"), help="Root output dir for quantized variants"),
613
- validation_audio: Optional[Path] = typer.Option(None, exists=True, resolve_path=True, help="Optional 15s, 16kHz wav for evaluation (defaults to bundled audio if present)"),
614
- compute_units: str = typer.Option("CPU_AND_NE", help="Compute units for evaluation of non-preprocessor models. Preprocessor is forced to CPU_AND_GPU."),
615
- runs: int = typer.Option(10, help="Timed runs per model for latency measurement"),
616
- categories: Optional[List[str]] = typer.Option(
617
- None,
618
- "--category",
619
- "-c",
620
- help="Only run quantization variants in these categories (e.g., linear, mel-palettize). Can be repeated.",
621
- ),
622
- components: Optional[List[str]] = typer.Option(
623
- None,
624
- "--component",
625
- "-m",
626
- help="Component names to quantize. Defaults to mel_encoder and joint_decision. Use 'all' to keep every component enabled.",
627
- ),
628
- ) -> None:
629
- """Quantize models, then compare quality, compression, latency, and compile time.
630
-
631
- Variants include int8-linear (per-channel/per-tensor/block), palettization (6-bit),
632
- jd-only probes, and prune+int8. Baseline is the pre-converted models.
633
- """
634
- meta = _load_metadata(input_dir)
635
- sr = int(meta.get("sample_rate", 16000))
636
- seconds = float(meta.get("max_audio_seconds", 15.0))
637
-
638
- components_meta = meta.get("components", {})
639
- component_lookup = {name.lower(): name for name in components_meta.keys()}
640
- # Defer computing component_filter until after variant/category selection so
641
- # that, by default, we quantize exactly the components targeted by the
642
- # selected variants (instead of a hard-coded subset).
643
- component_filter: Optional[Set[str]] = None
644
-
645
- # Default audio if present
646
- default_audio = (BASE_DIR / "audio" / "yc_first_minute_16k_15s.wav").resolve()
647
- audio_path = validation_audio if validation_audio is not None else (default_audio if default_audio.exists() else None)
648
- if audio_path is not None and validation_audio is None:
649
- typer.echo(f"Using default validation audio: {audio_path}")
650
- audio, audio_len = _prepare_audio(seconds, sr, audio_path)
651
-
652
- # Load baseline models and helpers for inputs
653
- # Baseline models for input preparation
654
- # Force preprocessor to CPU+GPU and all other components to CPU+NE for evaluation
655
- pre_base = ct.models.MLModel(str(input_dir / "parakeet_preprocessor.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_GPU)
656
- enc_base = ct.models.MLModel(str(input_dir / "parakeet_encoder.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
657
- mel_encoder_base = ct.models.MLModel(str(input_dir / "parakeet_mel_encoder.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
658
- decoder_base = ct.models.MLModel(str(input_dir / "parakeet_decoder.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
659
- joint_decision_base = ct.models.MLModel(str(input_dir / "parakeet_joint_decision.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
660
- joint_base = ct.models.MLModel(str(input_dir / "parakeet_joint.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)
661
-
662
- # Prepare typical inputs once using baseline models
663
- pre_out = pre_base.predict({"audio_signal": audio, "audio_length": audio_len})
664
- mel_ref = np.array(pre_out["mel"], dtype=np.float32, copy=True)
665
- mel_len = np.array(pre_out["mel_length"], dtype=np.int32, copy=True)
666
-
667
- enc_out = enc_base.predict({"mel": mel_ref, "mel_length": mel_len})
668
- encoder_ref = np.array(enc_out["encoder"], dtype=np.float32, copy=True)
669
- encoder_len = np.array(enc_out["encoder_length"], dtype=np.int32, copy=True)
670
-
671
- # Decoder inputs from metadata
672
- dec_in = meta["components"]["decoder"]["inputs"]
673
- targets_shape = tuple(int(x) for x in dec_in["targets"]) # e.g., (1, 256)
674
- h_shape = tuple(int(x) for x in dec_in["h_in"]) # e.g., (2, 1, 640)
675
- # Use zeros as targets for reproducibility without needing blank idx
676
- targets = np.zeros(targets_shape, dtype=np.int32)
677
- target_length = np.array([targets_shape[1]], dtype=np.int32)
678
- h0 = np.zeros(h_shape, dtype=np.float32)
679
- c0 = np.zeros(h_shape, dtype=np.float32)
680
- dec_out = decoder_base.predict({
681
- "targets": targets,
682
- "target_length": target_length,
683
- "h_in": h0,
684
- "c_in": c0,
685
- })
686
- decoder_ref = np.array(dec_out["decoder"], dtype=np.float32, copy=True)
687
-
688
- # Baseline sizes per component
689
- pre_base_size = _dir_size_bytes(input_dir / "parakeet_preprocessor.mlpackage")
690
- enc_base_size = _dir_size_bytes(input_dir / "parakeet_encoder.mlpackage")
691
- mel_base_size = _dir_size_bytes(input_dir / "parakeet_mel_encoder.mlpackage")
692
- dec_base_size = _dir_size_bytes(input_dir / "parakeet_decoder.mlpackage")
693
- joint_base_size = _dir_size_bytes(input_dir / "parakeet_joint.mlpackage")
694
- jd_base_size = _dir_size_bytes(input_dir / "parakeet_joint_decision.mlpackage")
695
-
696
- # Baseline latencies
697
- pre_base_inputs = {"audio_signal": audio, "audio_length": audio_len}
698
- enc_base_inputs = {"mel": mel_ref, "mel_length": mel_len}
699
- mel_base_inputs = {"audio_signal": audio, "audio_length": audio_len}
700
- dec_base_inputs = {"targets": targets, "target_length": target_length, "h_in": h0, "c_in": c0}
701
- joint_base_inputs = {"encoder": encoder_ref, "decoder": decoder_ref}
702
- jd_base_inputs = {"encoder": encoder_ref, "decoder": decoder_ref}
703
- pre_base_ms, _ = _predict_latency(pre_base, pre_base_inputs, runs=runs)
704
- enc_base_ms, _ = _predict_latency(enc_base, enc_base_inputs, runs=runs)
705
- mel_base_ms, _ = _predict_latency(mel_encoder_base, mel_base_inputs, runs=runs)
706
- dec_base_ms, _ = _predict_latency(decoder_base, dec_base_inputs, runs=runs)
707
- joint_base_ms, _ = _predict_latency(joint_base, joint_base_inputs, runs=runs)
708
- jd_base_ms, _ = _predict_latency(joint_decision_base, jd_base_inputs, runs=runs)
709
- # Cache baseline joint logits for comparisons
710
- logits_base = np.array(joint_base.predict(joint_base_inputs)["logits"], dtype=np.float32, copy=True)
711
-
712
- # Variants
713
- variants: List[VariantConfig] = [
714
- VariantConfig(
715
- name="int8-linear",
716
- steps=[(
717
- "linear",
718
- OptimizationConfig(global_config=OpLinearQuantizerConfig(mode="linear", granularity="per_channel")),
719
- )],
720
- category="linear",
721
- ),
722
- # 6-bit palettization for MelEncoder only
723
- VariantConfig(
724
- name="mel6bit-palettize",
725
- steps=[(
726
- "palettize",
727
- OptimizationConfig(
728
- global_config=OpPalettizerConfig(mode="kmeans", nbits=6)
729
- ),
730
- )],
731
- category="mel-palettize",
732
- whitelist=["mel_encoder"],
733
- ),
734
- # 6-bit palettization for Encoder only
735
- VariantConfig(
736
- name="enc6bit-palettize",
737
- steps=[(
738
- "palettize",
739
- OptimizationConfig(
740
- global_config=OpPalettizerConfig(mode="kmeans", nbits=6)
741
- ),
742
- )],
743
- category="encoder-palettize",
744
- whitelist=["encoder"],
745
- ),
746
- # (removed) Global palettization variants
747
- ]
748
-
749
- available_categories = {variant.category for variant in variants}
750
- category_lookup = {cat.lower(): cat for cat in available_categories}
751
- selected_categories: Set[str]
752
- if categories:
753
- normalized_categories = [cat.strip().lower() for cat in categories if cat.strip()]
754
- if normalized_categories:
755
- invalid_categories = [cat for cat in normalized_categories if cat not in category_lookup]
756
- if invalid_categories:
757
- available_str = ", ".join(sorted(available_categories)) or "none"
758
- bad = ", ".join(sorted(set(invalid_categories)))
759
- raise typer.BadParameter(
760
- f"Unknown category (--category): {bad}. Available categories: {available_str}."
761
- )
762
- selected_categories = {category_lookup[cat] for cat in normalized_categories}
763
- variants = [variant for variant in variants if variant.category in selected_categories]
764
- else:
765
- selected_categories = available_categories
766
- else:
767
- selected_categories = available_categories
768
-
769
- if not variants:
770
- typer.echo("No quantization variants matched the requested categories; nothing to do.")
771
- raise typer.Exit(code=0)
772
-
773
- typer.echo("Running variant categories: " + ", ".join(sorted(selected_categories)))
774
-
775
- # Resolve the component whitelist now that variants are known.
776
- if components:
777
- normalized_components = [comp.strip().lower() for comp in components if comp.strip()]
778
- if any(comp == "all" for comp in normalized_components):
779
- component_filter = None
780
- else:
781
- resolved: List[str] = []
782
- invalid: List[str] = []
783
- for comp in normalized_components:
784
- match = component_lookup.get(comp)
785
- if match is None:
786
- invalid.append(comp)
787
- else:
788
- resolved.append(match)
789
- if invalid:
790
- available = ", ".join(sorted(component_lookup.values())) or "none"
791
- bad = ", ".join(sorted(set(invalid)))
792
- raise typer.BadParameter(
793
- f"Unknown component(s) for --component: {bad}. Available components: {available}."
794
- )
795
- component_filter = set(resolved)
796
- else:
797
- # Derive from selected variants' whitelists
798
- derived: Set[str] = set()
799
- has_global = False
800
- for v in variants:
801
- if v.whitelist is None:
802
- has_global = True
803
- break
804
- derived.update(v.whitelist)
805
- component_filter = None if has_global or not derived else derived
806
-
807
- if component_filter is None:
808
- typer.echo("Quantizing components: all components (derived)")
809
- else:
810
- typer.echo("Quantizing components: " + ", ".join(sorted(component_filter)) + " (derived)")
811
-
812
- # Aggregate results (baseline + variants)
813
- summary: Dict[str, Dict[str, object]] = {}
814
- variants_names: List[str] = []
815
- # Build arrays including baseline as first label
816
- fused_labels: List[str] = ["baseline"]
817
- mel_quality_scores: List[float] = [1.0]
818
- mel_latency_ms: List[float] = [mel_base_ms]
819
- mel_compression: List[float] = [1.0]
820
- mel_size_mb: List[float] = [float(mel_base_size) / BYTES_IN_MB]
821
- # Offline compile time (host) for fused models
822
- mel_compile_ms: List[float] = [_offline_compile_time_ms(input_dir / "parakeet_mel_encoder.mlpackage")]
823
- jd_accuracy: List[float] = [1.0]
824
- jd_latency_ms: List[float] = [jd_base_ms]
825
- jd_compression: List[float] = [1.0]
826
- jd_size_mb: List[float] = [float(jd_base_size) / BYTES_IN_MB]
827
- jd_compile_ms: List[float] = [_offline_compile_time_ms(input_dir / "parakeet_joint_decision.mlpackage")]
828
-
829
- # For the all-components chart, collect per-component metrics similarly
830
- all_metrics: Dict[str, Dict[str, List[float]]] = {
831
- "preprocessor": {
832
- "quality": [1.0],
833
- "compression": [1.0],
834
- "latency_ms": [pre_base_ms],
835
- "compile_ms": [_offline_compile_time_ms(input_dir / "parakeet_preprocessor.mlpackage")],
836
- "size_mb": [float(pre_base_size) / BYTES_IN_MB],
837
- },
838
- "encoder": {
839
- "quality": [1.0],
840
- "compression": [1.0],
841
- "latency_ms": [enc_base_ms],
842
- "compile_ms": [_offline_compile_time_ms(input_dir / "parakeet_encoder.mlpackage")],
843
- "size_mb": [float(enc_base_size) / BYTES_IN_MB],
844
- },
845
- "mel_encoder": {
846
- "quality": [1.0],
847
- "compression": [1.0],
848
- "latency_ms": [mel_base_ms],
849
- "compile_ms": [mel_compile_ms[0]],
850
- "size_mb": [float(mel_base_size) / BYTES_IN_MB],
851
- },
852
- "decoder": {
853
- "quality": [1.0],
854
- "compression": [1.0],
855
- "latency_ms": [dec_base_ms],
856
- "compile_ms": [_offline_compile_time_ms(input_dir / "parakeet_decoder.mlpackage")],
857
- "size_mb": [float(dec_base_size) / BYTES_IN_MB],
858
- },
859
- "joint": {
860
- "quality": [1.0],
861
- "compression": [1.0],
862
- "latency_ms": [joint_base_ms],
863
- "compile_ms": [_offline_compile_time_ms(input_dir / "parakeet_joint.mlpackage")],
864
- "size_mb": [float(joint_base_size) / BYTES_IN_MB],
865
- },
866
- "joint_decision": {
867
- "acc": [1.0],
868
- "compression": [1.0],
869
- "latency_ms": [jd_base_ms],
870
- "compile_ms": [jd_compile_ms[0]],
871
- "size_mb": [float(jd_base_size) / BYTES_IN_MB],
872
- },
873
- }
874
-
875
- # Populate baseline entry
876
- summary["baseline"] = {
877
- "components": {
878
- "preprocessor": {
879
- "quality": 1.0,
880
- "latency_ms": pre_base_ms,
881
- "size_bytes": float(pre_base_size),
882
- "size_mb": float(pre_base_size) / BYTES_IN_MB,
883
- "compression_ratio": 1.0,
884
- "compile_ms": all_metrics["preprocessor"]["compile_ms"][0],
885
- },
886
- "encoder": {
887
- "quality": 1.0,
888
- "latency_ms": enc_base_ms,
889
- "size_bytes": float(enc_base_size),
890
- "size_mb": float(enc_base_size) / BYTES_IN_MB,
891
- "compression_ratio": 1.0,
892
- "compile_ms": all_metrics["encoder"]["compile_ms"][0],
893
- },
894
- "mel_encoder": {
895
- "quality": 1.0,
896
- "latency_ms": mel_base_ms,
897
- "size_bytes": float(mel_base_size),
898
- "size_mb": float(mel_base_size) / BYTES_IN_MB,
899
- "compression_ratio": 1.0,
900
- "compile_ms": all_metrics["mel_encoder"]["compile_ms"][0],
901
- },
902
- "decoder": {
903
- "quality": 1.0,
904
- "latency_ms": dec_base_ms,
905
- "size_bytes": float(dec_base_size),
906
- "size_mb": float(dec_base_size) / BYTES_IN_MB,
907
- "compression_ratio": 1.0,
908
- "compile_ms": all_metrics["decoder"]["compile_ms"][0],
909
- },
910
- "joint": {
911
- "quality": 1.0,
912
- "latency_ms": joint_base_ms,
913
- "size_bytes": float(joint_base_size),
914
- "size_mb": float(joint_base_size) / BYTES_IN_MB,
915
- "compression_ratio": 1.0,
916
- "compile_ms": all_metrics["joint"]["compile_ms"][0],
917
- },
918
- "joint_decision": {
919
- "acc": 1.0,
920
- "latency_ms": jd_base_ms,
921
- "size_bytes": float(jd_base_size),
922
- "size_mb": float(jd_base_size) / BYTES_IN_MB,
923
- "compression_ratio": 1.0,
924
- "compile_ms": all_metrics["joint_decision"]["compile_ms"][0],
925
- },
926
- }
927
- }
928
-
929
- for var in variants:
930
- variants_names.append(var.name)
931
- out_dir = output_root / var.name
932
- out_dir_exists = out_dir.exists()
933
- out_dir.mkdir(parents=True, exist_ok=True)
934
-
935
- expected_components = []
936
- for comp_cfg in meta.get("components", {}).values():
937
- rel = comp_cfg.get("path")
938
- if rel:
939
- expected_components.append(out_dir / rel)
940
-
941
- missing = [p for p in expected_components if not p.exists()]
942
- if out_dir_exists and not missing:
943
- typer.echo(f"[{var.name}] Output already present at {out_dir}; skipping quantization step.")
944
- else:
945
- if out_dir_exists and missing:
946
- missing_names = ", ".join(sorted(p.name for p in missing)) or "unknown"
947
- typer.echo(f"[{var.name}] Output directory exists but is incomplete (missing: {missing_names}). Re-quantizing.")
948
- shutil.rmtree(out_dir, ignore_errors=True)
949
- out_dir.mkdir(parents=True, exist_ok=True)
950
- _quantize_dir(input_dir, out_dir, var, component_filter)
951
-
952
- # Load quantized models and pre-compute offline compile time
953
- pre_q_path = out_dir / "parakeet_preprocessor.mlpackage"
954
- enc_q_path = out_dir / "parakeet_encoder.mlpackage"
955
- mel_q_path = out_dir / "parakeet_mel_encoder.mlpackage"
956
- dec_q_path = out_dir / "parakeet_decoder.mlpackage"
957
- joint_q_path = out_dir / "parakeet_joint.mlpackage"
958
- jd_q_path = out_dir / "parakeet_joint_decision.mlpackage"
959
-
960
- pre_compile_ms = _offline_compile_time_ms(pre_q_path)
961
- enc_compile_ms = _offline_compile_time_ms(enc_q_path)
962
- mel_compile_q_ms = _offline_compile_time_ms(mel_q_path)
963
- dec_compile_ms = _offline_compile_time_ms(dec_q_path)
964
- joint_compile_ms = _offline_compile_time_ms(joint_q_path)
965
- jd_compile_q_ms = _offline_compile_time_ms(jd_q_path)
966
-
967
- # Match compute units for quantized artifacts: preprocessor on CPU+GPU; others on CPU+NE
968
- pre_q = ct.models.MLModel(str(pre_q_path), compute_units=ct.ComputeUnit.CPU_AND_GPU)
969
- enc_q = ct.models.MLModel(str(enc_q_path), compute_units=ct.ComputeUnit.CPU_AND_NE)
970
- mel_q = ct.models.MLModel(str(mel_q_path), compute_units=ct.ComputeUnit.CPU_AND_NE)
971
- dec_q = ct.models.MLModel(str(dec_q_path), compute_units=ct.ComputeUnit.CPU_AND_NE)
972
- joint_q = ct.models.MLModel(str(joint_q_path), compute_units=ct.ComputeUnit.CPU_AND_NE)
973
- jd_q = ct.models.MLModel(str(jd_q_path), compute_units=ct.ComputeUnit.CPU_AND_NE)
974
-
975
- # Preprocessor quality vs baseline
976
- pre_q_out = pre_q.predict(pre_base_inputs)
977
- mel_q_in = np.array(pre_q_out["mel"], dtype=np.float32, copy=True)
978
- a_pre, r_pre = _max_abs_rel(mel_ref, mel_q_in)
979
- l2_pre_ref = float(np.linalg.norm(mel_ref))
980
- l2_pre_err = float(np.linalg.norm(mel_ref - mel_q_in))
981
- pre_norm_err = (l2_pre_err / (l2_pre_ref + 1e-8)) if l2_pre_ref > 0 else 0.0
982
- pre_quality = float(max(0.0, 1.0 - pre_norm_err))
983
- pre_q_ms, _ = _predict_latency(pre_q, pre_base_inputs, runs=runs)
984
- pre_q_size = _dir_size_bytes(out_dir / "parakeet_preprocessor.mlpackage")
985
- all_metrics["preprocessor"]["quality"].append(pre_quality)
986
- all_metrics["preprocessor"]["latency_ms"].append(pre_q_ms)
987
- all_metrics["preprocessor"]["compression"].append(float(pre_base_size) / float(pre_q_size if pre_q_size > 0 else 1))
988
- all_metrics["preprocessor"].setdefault("compile_ms", []).append(pre_compile_ms)
989
- all_metrics["preprocessor"]["size_mb"].append(float(pre_q_size) / BYTES_IN_MB)
990
-
991
- # Encoder quality vs baseline (feed baseline mel to both)
992
- enc_q_out = enc_q.predict({"mel": mel_ref, "mel_length": mel_len})
993
- encoder_q = np.array(enc_q_out["encoder"], dtype=np.float32, copy=True)
994
- l2_enc_ref = float(np.linalg.norm(encoder_ref))
995
- l2_enc_err = float(np.linalg.norm(encoder_ref - encoder_q))
996
- enc_norm_err = (l2_enc_err / (l2_enc_ref + 1e-8)) if l2_enc_ref > 0 else 0.0
997
- enc_quality = float(max(0.0, 1.0 - enc_norm_err))
998
- enc_q_ms, _ = _predict_latency(enc_q, enc_base_inputs, runs=runs)
999
- enc_q_size = _dir_size_bytes(out_dir / "parakeet_encoder.mlpackage")
1000
- all_metrics["encoder"]["quality"].append(enc_quality)
1001
- all_metrics["encoder"]["latency_ms"].append(enc_q_ms)
1002
- all_metrics["encoder"]["compression"].append(float(enc_base_size) / float(enc_q_size if enc_q_size > 0 else 1))
1003
- all_metrics["encoder"].setdefault("compile_ms", []).append(enc_compile_ms)
1004
- all_metrics["encoder"]["size_mb"].append(float(enc_q_size) / BYTES_IN_MB)
1005
-
1006
- # MelEncoder quality
1007
- mel_q_out = mel_q.predict(mel_base_inputs)
1008
- enc_q_fused = np.array(mel_q_out["encoder"], dtype=np.float32, copy=True)
1009
- a_mel, r_mel = _max_abs_rel(encoder_ref, enc_q_fused)
1010
- # Normalize error into a [0,1] quality score: 1 / (1 + normalized L2)
1011
- # Use relative measure derived from L2 norms as more stable than max.
1012
- l2_ref = float(np.linalg.norm(encoder_ref))
1013
- l2_err = float(np.linalg.norm(encoder_ref - enc_q_fused))
1014
- norm_err = (l2_err / (l2_ref + 1e-8)) if l2_ref > 0 else 0.0
1015
- mel_quality = float(max(0.0, 1.0 - norm_err))
1016
- mel_q_ms, _ = _predict_latency(mel_q, mel_base_inputs, runs=runs)
1017
- mel_q_size = _dir_size_bytes(out_dir / "parakeet_mel_encoder.mlpackage")
1018
- mel_ratio = float(mel_base_size) / float(mel_q_size if mel_q_size > 0 else 1)
1019
- mel_size_mb.append(float(mel_q_size) / BYTES_IN_MB)
1020
- all_metrics["mel_encoder"]["quality"].append(mel_quality)
1021
- all_metrics["mel_encoder"]["latency_ms"].append(mel_q_ms)
1022
- all_metrics["mel_encoder"]["compression"].append(float(mel_base_size) / float(mel_q_size if mel_q_size > 0 else 1))
1023
- all_metrics["mel_encoder"].setdefault("compile_ms", []).append(mel_compile_q_ms)
1024
- all_metrics["mel_encoder"]["size_mb"].append(float(mel_q_size) / BYTES_IN_MB)
1025
-
1026
- # JointDecision quality: token-id and duration match rates
1027
- jd_base_out = joint_decision_base.predict(jd_base_inputs)
1028
- token_id_base = np.array(jd_base_out["token_id"], dtype=np.int32, copy=True)
1029
- duration_base = np.array(jd_base_out["duration"], dtype=np.int32, copy=True)
1030
- token_prob_base = np.array(jd_base_out["token_prob"], dtype=np.float32, copy=True)
1031
-
1032
- jd_q_out = jd_q.predict(jd_base_inputs)
1033
- token_id_q = np.array(jd_q_out["token_id"], dtype=np.int32, copy=True)
1034
- duration_q = np.array(jd_q_out["duration"], dtype=np.int32, copy=True)
1035
- token_prob_q = np.array(jd_q_out["token_prob"], dtype=np.float32, copy=True)
1036
-
1037
- # Accuracy metrics
1038
- id_match = float((token_id_q == token_id_base).mean())
1039
- dur_match = float((duration_q == duration_base).mean())
1040
- # Aggregate a single "accuracy" number as token-id match rate (primary)
1041
- jd_acc = id_match
1042
- jd_q_ms, _ = _predict_latency(jd_q, jd_base_inputs, runs=runs)
1043
- jd_q_size = _dir_size_bytes(out_dir / "parakeet_joint_decision.mlpackage")
1044
- jd_ratio = float(jd_base_size) / float(jd_q_size if jd_q_size > 0 else 1)
1045
- jd_size_mb.append(float(jd_q_size) / BYTES_IN_MB)
1046
- all_metrics["joint_decision"].setdefault("acc", []).append(jd_acc)
1047
- all_metrics["joint_decision"]["latency_ms"].append(jd_q_ms)
1048
- all_metrics["joint_decision"]["compression"].append(float(jd_base_size) / float(jd_q_size if jd_q_size > 0 else 1))
1049
- all_metrics["joint_decision"].setdefault("compile_ms", []).append(jd_compile_q_ms)
1050
- all_metrics["joint_decision"]["size_mb"].append(float(jd_q_size) / BYTES_IN_MB)
1051
-
1052
- # Decoder quality vs baseline
1053
- dec_q_out = dec_q.predict(dec_base_inputs)
1054
- decoder_q = np.array(dec_q_out["decoder"], dtype=np.float32, copy=True)
1055
- l2_dec_ref = float(np.linalg.norm(decoder_ref))
1056
- l2_dec_err = float(np.linalg.norm(decoder_ref - decoder_q))
1057
- dec_norm_err = (l2_dec_err / (l2_dec_ref + 1e-8)) if l2_dec_ref > 0 else 0.0
1058
- dec_quality = float(max(0.0, 1.0 - dec_norm_err))
1059
- dec_q_ms, _ = _predict_latency(dec_q, dec_base_inputs, runs=runs)
1060
- dec_q_size = _dir_size_bytes(out_dir / "parakeet_decoder.mlpackage")
1061
- all_metrics["decoder"]["quality"].append(dec_quality)
1062
- all_metrics["decoder"]["latency_ms"].append(dec_q_ms)
1063
- all_metrics["decoder"]["compression"].append(float(dec_base_size) / float(dec_q_size if dec_q_size > 0 else 1))
1064
- all_metrics["decoder"].setdefault("compile_ms", []).append(dec_compile_ms)
1065
- all_metrics["decoder"]["size_mb"].append(float(dec_q_size) / BYTES_IN_MB)
1066
-
1067
- # Joint quality vs baseline (compare logits)
1068
- joint_q_out = joint_q.predict(joint_base_inputs)
1069
- logits_q = np.array(joint_q_out["logits"], dtype=np.float32, copy=True)
1070
- l2_joint_ref = float(np.linalg.norm(logits_base))
1071
- l2_joint_err = float(np.linalg.norm(logits_base - logits_q))
1072
- joint_norm_err = (l2_joint_err / (l2_joint_ref + 1e-8)) if l2_joint_ref > 0 else 0.0
1073
- joint_quality = float(max(0.0, 1.0 - joint_norm_err))
1074
- joint_q_ms, _ = _predict_latency(joint_q, joint_base_inputs, runs=runs)
1075
- joint_q_size = _dir_size_bytes(out_dir / "parakeet_joint.mlpackage")
1076
- all_metrics["joint"]["quality"].append(joint_quality)
1077
- all_metrics["joint"]["latency_ms"].append(joint_q_ms)
1078
- all_metrics["joint"]["compression"].append(float(joint_base_size) / float(joint_q_size if joint_q_size > 0 else 1))
1079
- all_metrics["joint"].setdefault("compile_ms", []).append(joint_compile_ms)
1080
- all_metrics["joint"]["size_mb"].append(float(joint_q_size) / BYTES_IN_MB)
1081
-
1082
- # Decoder deltas for JSON
1083
- a_dec, r_dec = _max_abs_rel(decoder_ref, decoder_q)
1084
- # Joint deltas for JSON
1085
- a_joint, r_joint = _max_abs_rel(logits_base, logits_q)
1086
-
1087
- # Store metrics
1088
- summary[var.name] = {
1089
- "components": {
1090
- "preprocessor": {
1091
- "quality": pre_quality,
1092
- "latency_ms": pre_q_ms,
1093
- "size_bytes": float(pre_q_size),
1094
- "size_mb": float(pre_q_size) / BYTES_IN_MB,
1095
- "compression_ratio": float(pre_base_size) / float(pre_q_size if pre_q_size > 0 else 1),
1096
- "max_abs": a_pre,
1097
- "max_rel": r_pre,
1098
- "compile_ms": pre_compile_ms,
1099
- },
1100
- "encoder": {
1101
- "quality": enc_quality,
1102
- "latency_ms": enc_q_ms,
1103
- "size_bytes": float(enc_q_size),
1104
- "size_mb": float(enc_q_size) / BYTES_IN_MB,
1105
- "compression_ratio": float(enc_base_size) / float(enc_q_size if enc_q_size > 0 else 1),
1106
- "compile_ms": enc_compile_ms,
1107
- },
1108
- "mel_encoder": {
1109
- "quality": mel_quality,
1110
- "latency_ms": mel_q_ms,
1111
- "size_bytes": float(mel_q_size),
1112
- "size_mb": float(mel_q_size) / BYTES_IN_MB,
1113
- "compression_ratio": mel_ratio,
1114
- "max_abs": a_mel,
1115
- "max_rel": r_mel,
1116
- "compile_ms": mel_compile_q_ms,
1117
- },
1118
- "decoder": {
1119
- "quality": dec_quality,
1120
- "latency_ms": dec_q_ms,
1121
- "size_bytes": float(dec_q_size),
1122
- "size_mb": float(dec_q_size) / BYTES_IN_MB,
1123
- "compression_ratio": float(dec_base_size) / float(dec_q_size if dec_q_size > 0 else 1),
1124
- "max_abs": a_dec,
1125
- "max_rel": r_dec,
1126
- "compile_ms": dec_compile_ms,
1127
- },
1128
- "joint": {
1129
- "quality": joint_quality,
1130
- "latency_ms": joint_q_ms,
1131
- "size_bytes": float(joint_q_size),
1132
- "size_mb": float(joint_q_size) / BYTES_IN_MB,
1133
- "compression_ratio": float(joint_base_size) / float(joint_q_size if joint_q_size > 0 else 1),
1134
- "max_abs": a_joint,
1135
- "max_rel": r_joint,
1136
- "compile_ms": joint_compile_ms,
1137
- },
1138
- "joint_decision": {
1139
- "acc": jd_acc,
1140
- "duration_match": dur_match,
1141
- "prob_mae": float(np.mean(np.abs(token_prob_q - token_prob_base))),
1142
- "latency_ms": jd_q_ms,
1143
- "size_bytes": float(jd_q_size),
1144
- "size_mb": float(jd_q_size) / BYTES_IN_MB,
1145
- "compression_ratio": jd_ratio,
1146
- "compile_ms": jd_compile_q_ms,
1147
- },
1148
- }
1149
- }
1150
-
1151
- fused_labels.append(var.name)
1152
- mel_quality_scores.append(mel_quality)
1153
- mel_latency_ms.append(mel_q_ms)
1154
- mel_compression.append(mel_ratio)
1155
- jd_accuracy.append(jd_acc)
1156
- jd_latency_ms.append(jd_q_ms)
1157
- jd_compression.append(jd_ratio)
1158
- mel_compile_ms.append(mel_compile_q_ms)
1159
- jd_compile_ms.append(jd_compile_q_ms)
1160
-
1161
- # Write summary JSON
1162
- out_root = output_root
1163
- out_root.mkdir(parents=True, exist_ok=True)
1164
- (out_root / "quantization_summary.json").write_text(json.dumps(summary, indent=2))
1165
-
1166
- # Plot
1167
- plot_dir = out_root / "plots"
1168
- title_suffix = _chip_spec_string(compute_units)
1169
- fused_paths = _plot_fused_category_charts(
1170
- plot_dir,
1171
- fused_labels,
1172
- mel_quality_scores,
1173
- mel_latency_ms,
1174
- mel_compression,
1175
- mel_size_mb,
1176
- jd_accuracy,
1177
- jd_latency_ms,
1178
- jd_compression,
1179
- jd_size_mb,
1180
- mel_compile_ms,
1181
- jd_compile_ms,
1182
- title_suffix,
1183
- )
1184
- component_paths = _plot_all_component_category_charts(
1185
- plot_dir,
1186
- fused_labels,
1187
- all_metrics,
1188
- title_suffix,
1189
- )
1190
-
1191
- typer.echo(f"Wrote summary JSON: {out_root / 'quantization_summary.json'}")
1192
- if HAS_MPL:
1193
- all_plot_paths = fused_paths + component_paths
1194
- repo_plot_dir = BASE_DIR / "plots" / "quantize" / compute_units.lower()
1195
- repo_plot_dir.mkdir(parents=True, exist_ok=True)
1196
- for path in all_plot_paths:
1197
- typer.echo(f"Wrote plot: {path}")
1198
- if path.exists():
1199
- dest = repo_plot_dir / path.name
1200
- shutil.copy2(path, dest)
1201
- typer.echo(f"Mirrored plot: {dest}")
1202
- else:
1203
- typer.echo("matplotlib unavailable; skipped plotting.")
1204
-
1205
-
1206
- if __name__ == "__main__":
1207
- app()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
convert/parakeet-tdt-v2-0.6b/coreml/speech_to_text_streaming_infer_rnnt.py DELETED
@@ -1,341 +0,0 @@
1
- """Streaming inference helper that stitches Parakeet CoreML components using the RNNT greedy loop from Nemo."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import json
6
- import logging
7
- from pathlib import Path
8
- from typing import Iterable, List, Optional, Sequence
9
-
10
- import coremltools as ct
11
- import librosa
12
- import numpy as np
13
- import torch
14
-
15
- from parakeet_components import CoreMLModelBundle
16
-
17
- LOGGER = logging.getLogger("parakeet_streaming")
18
-
19
-
20
- class BatchedHyps:
21
- """Minimal port of Nemo's batched hypothesis buffer."""
22
-
23
- def __init__(
24
- self,
25
- batch_size: int,
26
- init_length: int,
27
- device: torch.device,
28
- float_dtype: torch.dtype,
29
- ) -> None:
30
- if init_length <= 0:
31
- raise ValueError("init_length must be > 0")
32
- self._max_length = init_length
33
- self.current_lengths = torch.zeros(batch_size, device=device, dtype=torch.long)
34
- self.transcript = torch.zeros((batch_size, self._max_length), device=device, dtype=torch.long)
35
- self.timestamps = torch.zeros((batch_size, self._max_length), device=device, dtype=torch.long)
36
- self.scores = torch.zeros(batch_size, device=device, dtype=float_dtype)
37
- self.last_timestamp = torch.full((batch_size,), -1, device=device, dtype=torch.long)
38
- self.last_timestamp_lasts = torch.zeros(batch_size, device=device, dtype=torch.long)
39
- self._batch_indices = torch.arange(batch_size, device=device)
40
- self._ones = torch.ones_like(self._batch_indices)
41
-
42
- def add_results(
43
- self,
44
- active_mask: torch.Tensor,
45
- labels: torch.Tensor,
46
- time_indices: torch.Tensor,
47
- scores: torch.Tensor,
48
- ) -> None:
49
- self.scores = torch.where(active_mask, self.scores + scores, self.scores)
50
- self.transcript[self._batch_indices, self.current_lengths] = labels
51
- self.timestamps[self._batch_indices, self.current_lengths] = time_indices
52
- torch.where(
53
- torch.logical_and(active_mask, self.last_timestamp == time_indices),
54
- self.last_timestamp_lasts + 1,
55
- self.last_timestamp_lasts,
56
- out=self.last_timestamp_lasts,
57
- )
58
- torch.where(
59
- torch.logical_and(active_mask, self.last_timestamp != time_indices),
60
- self._ones,
61
- self.last_timestamp_lasts,
62
- out=self.last_timestamp_lasts,
63
- )
64
- torch.where(active_mask, time_indices, self.last_timestamp, out=self.last_timestamp)
65
- self.current_lengths += active_mask
66
-
67
-
68
- class CoreMLStreamingDecoder:
69
- """Use exported decoder and joint CoreML models with Nemo's greedy RNNT loop."""
70
-
71
- def __init__(
72
- self,
73
- decoder_model: ct.models.MLModel,
74
- joint_model: ct.models.MLModel,
75
- *,
76
- vocab_size: int,
77
- blank_id: int,
78
- num_layers: int,
79
- hidden_size: int,
80
- durations: Sequence[int] = (0, 1, 2, 3, 4),
81
- max_symbols: int = 10,
82
- device: torch.device = torch.device("cpu"),
83
- ) -> None:
84
- self.decoder_model = decoder_model
85
- self.joint_model = joint_model
86
- self.vocab_size = vocab_size
87
- self.blank_id = blank_id
88
- self.num_layers = num_layers
89
- self.hidden_size = hidden_size
90
- self.durations = torch.tensor(durations, dtype=torch.long, device=device)
91
- self.max_symbols = max_symbols
92
- self.device = device
93
-
94
- def _predict_decoder(self, labels: torch.Tensor, h_in: np.ndarray, c_in: np.ndarray) -> tuple[torch.Tensor, np.ndarray, np.ndarray]:
95
- outputs = self.decoder_model.predict(
96
- {
97
- "targets": np.array([labels.cpu().numpy()], dtype=np.int32),
98
- "target_lengths": np.array([labels.numel()], dtype=np.int32),
99
- "h_in": h_in,
100
- "c_in": c_in,
101
- }
102
- )
103
- decoder_output = torch.from_numpy(outputs["decoder_output"]).to(self.device)
104
- return decoder_output, outputs["h_out"], outputs["c_out"]
105
-
106
- def _predict_joint(self, encoder_frame: torch.Tensor, decoder_output: torch.Tensor) -> torch.Tensor:
107
- outputs = self.joint_model.predict(
108
- {
109
- "encoder_outputs": encoder_frame.unsqueeze(1).cpu().numpy().astype(np.float32),
110
- "decoder_outputs": decoder_output.cpu().numpy().astype(np.float32),
111
- }
112
- )
113
- logits = torch.from_numpy(outputs["logits"]).to(self.device)
114
- return logits.squeeze(1).squeeze(1)
115
-
116
- def decode(self, encoder_output: torch.Tensor, encoder_lengths: torch.Tensor) -> List[List[int]]:
117
- batch_size, max_time, _ = encoder_output.shape
118
- encoder_output = encoder_output.to(self.device)
119
- encoder_lengths = encoder_lengths.to(self.device)
120
-
121
- float_dtype = encoder_output.dtype
122
- batch_indices = torch.arange(batch_size, device=self.device)
123
- labels = torch.full((batch_size,), fill_value=self.blank_id, device=self.device, dtype=torch.long)
124
- time_indices = torch.zeros_like(labels)
125
- safe_time_indices = torch.zeros_like(labels)
126
- time_indices_current = torch.zeros_like(labels)
127
- last_timesteps = encoder_lengths - 1
128
- active_mask = encoder_lengths > 0
129
- advance_mask = torch.empty_like(active_mask)
130
- active_mask_prev = torch.empty_like(active_mask)
131
- became_inactive = torch.empty_like(active_mask)
132
-
133
- hyps = BatchedHyps(
134
- batch_size=batch_size,
135
- init_length=max_time * self.max_symbols if self.max_symbols else max_time,
136
- device=self.device,
137
- float_dtype=float_dtype,
138
- )
139
-
140
- h_in = np.zeros((self.num_layers, batch_size, self.hidden_size), dtype=np.float32)
141
- c_in = np.zeros((self.num_layers, batch_size, self.hidden_size), dtype=np.float32)
142
-
143
- while active_mask.any():
144
- active_mask_prev.copy_(active_mask)
145
- decoder_output, h_in, c_in = self._predict_decoder(labels, h_in, c_in)
146
- logits = self._predict_joint(encoder_output[batch_indices, safe_time_indices], decoder_output)
147
-
148
- scores, labels = logits[:, : self.vocab_size].max(dim=-1)
149
- duration_indices = logits[:, self.vocab_size : self.vocab_size + len(self.durations)].argmax(dim=-1)
150
- durations = self.durations[duration_indices]
151
-
152
- blank_mask = labels == self.blank_id
153
- durations.masked_fill_(torch.logical_and(durations == 0, blank_mask), 1)
154
- time_indices_current.copy_(time_indices)
155
- time_indices += durations
156
- torch.minimum(time_indices, last_timesteps, out=safe_time_indices)
157
- torch.less(time_indices, encoder_lengths, out=active_mask)
158
- torch.logical_and(active_mask, blank_mask, out=advance_mask)
159
-
160
- while advance_mask.any():
161
- torch.where(advance_mask, time_indices, time_indices_current, out=time_indices_current)
162
- logits = self._predict_joint(encoder_output[batch_indices, safe_time_indices], decoder_output)
163
- more_scores, more_labels = logits[:, : self.vocab_size].max(dim=-1)
164
- labels = torch.where(advance_mask, more_labels, labels)
165
- scores = torch.where(advance_mask, more_scores, scores)
166
- duration_indices = logits[:, self.vocab_size : self.vocab_size + len(self.durations)].argmax(dim=-1)
167
- durations = self.durations[duration_indices]
168
- blank_mask = labels == self.blank_id
169
- durations.masked_fill_(torch.logical_and(durations == 0, blank_mask), 1)
170
- torch.where(advance_mask, time_indices + durations, time_indices, out=time_indices)
171
- torch.minimum(time_indices, last_timesteps, out=safe_time_indices)
172
- torch.less(time_indices, encoder_lengths, out=active_mask)
173
- torch.logical_and(active_mask, blank_mask, out=advance_mask)
174
-
175
- torch.ne(active_mask, active_mask_prev, out=became_inactive)
176
- hyps.add_results(active_mask, labels, time_indices_current, scores)
177
-
178
- if self.max_symbols is not None:
179
- force_blank = torch.logical_and(
180
- active_mask,
181
- torch.logical_and(
182
- torch.logical_and(labels != self.blank_id, hyps.last_timestamp_lasts >= self.max_symbols),
183
- hyps.last_timestamp == time_indices,
184
- ),
185
- )
186
- time_indices += force_blank
187
- torch.minimum(time_indices, last_timesteps, out=safe_time_indices)
188
- torch.less(time_indices, encoder_lengths, out=active_mask)
189
-
190
- results: List[List[int]] = []
191
- for hyp in hyps.transcript:
192
- tokens = [int(token) for token in hyp.tolist() if 0 < token < self.vocab_size]
193
- results.append(tokens)
194
- return results
195
-
196
-
197
- class StreamingTranscriber:
198
- def __init__(
199
- self,
200
- bundle: CoreMLModelBundle,
201
- *,
202
- blank_id: Optional[int] = None,
203
- num_layers: int = 2,
204
- hidden_size: int = 640,
205
- durations: Sequence[int] = (0, 1, 2, 3, 4),
206
- ) -> None:
207
- self.preprocessor = ct.models.MLModel(str(bundle.preprocessor), compute_units=ct.ComputeUnit.CPU_ONLY)
208
- self.encoder = ct.models.MLModel(str(bundle.encoder), compute_units=ct.ComputeUnit.CPU_ONLY)
209
- self.decoder = ct.models.MLModel(str(bundle.decoder), compute_units=ct.ComputeUnit.CPU_ONLY)
210
- self.joint = ct.models.MLModel(str(bundle.joint), compute_units=ct.ComputeUnit.CPU_ONLY)
211
- self.tokenizer = self._load_tokenizer(bundle.tokenizer)
212
-
213
- vocab_size = max(self.tokenizer.keys()) + 1
214
- if blank_id is None:
215
- blank_id = vocab_size - 1
216
- self.decoder_helper = CoreMLStreamingDecoder(
217
- self.decoder,
218
- self.joint,
219
- vocab_size=vocab_size,
220
- blank_id=blank_id,
221
- num_layers=num_layers,
222
- hidden_size=hidden_size,
223
- durations=durations,
224
- )
225
- self.blank_id = blank_id
226
-
227
- @staticmethod
228
- def _load_tokenizer(tokenizer_path: Optional[Path]) -> dict[int, str]:
229
- if tokenizer_path is None:
230
- raise ValueError("Tokenizer JSON is required")
231
- with Path(tokenizer_path).open() as f:
232
- data = json.load(f)
233
- return {int(k): v for k, v in data.items()}
234
-
235
- def _tokens_to_text(self, tokens: Iterable[int]) -> str:
236
- pieces: List[str] = []
237
- for token in tokens:
238
- piece = self.tokenizer.get(token)
239
- if piece is None:
240
- continue
241
- if piece.startswith("▁"):
242
- if pieces:
243
- pieces.append(" ")
244
- pieces.append(piece[1:])
245
- else:
246
- pieces.append(piece)
247
- return "".join(pieces).strip()
248
-
249
- def _preprocess(self, audio: np.ndarray) -> tuple[np.ndarray, int]:
250
- audio_2d = audio.reshape(1, -1).astype(np.float32)
251
- length = np.array([audio_2d.shape[-1]], dtype=np.int32)
252
- outputs = self.preprocessor.predict({
253
- "audio_signal": audio_2d,
254
- "audio_length": length,
255
- })
256
- return outputs["melspectrogram"], int(outputs["melspectrogram_length"][0])
257
-
258
- def _encode(self, mel: np.ndarray, mel_length: int) -> tuple[torch.Tensor, torch.Tensor]:
259
- outputs = self.encoder.predict({
260
- "melspectrogram": mel.astype(np.float32),
261
- "melspectrogram_length": np.array([mel_length], dtype=np.int32),
262
- })
263
- encoder_output = outputs["encoder_output"]
264
- if encoder_output.ndim == 3:
265
- encoder_output = np.transpose(encoder_output, (0, 2, 1))
266
- length = torch.tensor(outputs["encoder_output_length"], dtype=torch.long)
267
- return torch.from_numpy(encoder_output.astype(np.float32)), length
268
-
269
- def transcribe(self, audio_path: Path) -> str:
270
- audio, _ = librosa.load(str(audio_path), sr=16000)
271
- mel, mel_length = self._preprocess(audio)
272
- encoder_output, encoder_length = self._encode(mel, mel_length)
273
- token_ids = self.decoder_helper.decode(encoder_output, encoder_length)[0]
274
- return self._tokens_to_text(token_ids)
275
-
276
- def transcribe_many(self, audio_paths: Sequence[Path]) -> List[str]:
277
- results: List[str] = []
278
- for path in audio_paths:
279
- LOGGER.info("Transcribing %s", path)
280
- results.append(self.transcribe(path))
281
- return results
282
-
283
-
284
- def _resolve_bundle(args: argparse.Namespace) -> CoreMLModelBundle:
285
- base = Path(args.model_dir) if args.model_dir else None
286
- if base is None and not all([args.preprocessor, args.encoder, args.decoder, args.joint, args.tokenizer]):
287
- raise ValueError("Either --model-dir or explicit model paths are required")
288
- return CoreMLModelBundle(
289
- preprocessor=Path(args.preprocessor) if args.preprocessor else base / "Melspectrogram.mlpackage",
290
- encoder=Path(args.encoder) if args.encoder else base / "ParakeetEncoder.mlpackage",
291
- decoder=Path(args.decoder) if args.decoder else base / "ParakeetDecoder.mlpackage",
292
- joint=Path(args.joint) if args.joint else base / "RNNTJoint.mlpackage",
293
- tokenizer=Path(args.tokenizer) if args.tokenizer else base / "tokenizer.json",
294
- )
295
-
296
-
297
- def _build_parser() -> argparse.ArgumentParser:
298
- parser = argparse.ArgumentParser(description="Streaming RNNT inference with CoreML components")
299
- parser.add_argument("--model-dir", type=Path, help="Directory containing exported CoreML models")
300
- parser.add_argument("--preprocessor", type=Path, help="Path to the preprocessor .mlpackage")
301
- parser.add_argument("--encoder", type=Path, help="Path to the encoder .mlpackage")
302
- parser.add_argument("--decoder", type=Path, help="Path to the decoder .mlpackage")
303
- parser.add_argument("--joint", type=Path, help="Path to the joint .mlpackage")
304
- parser.add_argument("--tokenizer", type=Path, help="Path to tokenizer JSON")
305
- parser.add_argument("audio", nargs="+", help="Audio files to transcribe")
306
- parser.add_argument("--blank-id", type=int, help="Blank token id")
307
- parser.add_argument("--num-layers", type=int, default=2, help="Prediction network layer count")
308
- parser.add_argument("--hidden-size", type=int, default=640, help="Prediction network hidden size")
309
- parser.add_argument("--durations", type=int, nargs="+", default=[0, 1, 2, 3, 4], help="RNNT duration bucket values")
310
- parser.add_argument("--verbose", "-v", action="count", default=0, help="Increase log verbosity")
311
- return parser
312
-
313
-
314
- def _configure_logging(verbosity: int) -> None:
315
- level = logging.WARNING - (10 * verbosity)
316
- logging.basicConfig(level=max(logging.DEBUG, level), format="[%(levelname)s] %(message)s")
317
-
318
-
319
- def main(argv: Optional[Sequence[str]] = None) -> None:
320
- parser = _build_parser()
321
- args = parser.parse_args(argv)
322
- _configure_logging(args.verbose)
323
-
324
- try:
325
- bundle = _resolve_bundle(args)
326
- transcriber = StreamingTranscriber(
327
- bundle,
328
- blank_id=args.blank_id,
329
- num_layers=args.num_layers,
330
- hidden_size=args.hidden_size,
331
- durations=tuple(args.durations),
332
- )
333
- transcripts = transcriber.transcribe_many([Path(p) for p in args.audio])
334
- for path, text in zip(args.audio, transcripts):
335
- print(f"{path}: {text}")
336
- except ValueError as exc:
337
- parser.error(str(exc))
338
-
339
-
340
- if __name__ == "__main__": # pragma: no cover
341
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/run_benchmarks.py DELETED
@@ -1,273 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- FluidAudio Benchmark Suite
4
-
5
- Runs ASR, VAD, and Diarization benchmarks and saves results to JSON.
6
- Compare results against Documentation/Benchmarks.md baselines.
7
-
8
- Usage:
9
- python run_benchmarks.py # Run all benchmarks
10
- python run_benchmarks.py --quick # Quick smoke test
11
- python run_benchmarks.py --asr-only # ASR benchmark only
12
- python run_benchmarks.py --vad-only # VAD benchmark only
13
- python run_benchmarks.py --diar-only # Diarization only
14
- """
15
-
16
- import argparse
17
- import json
18
- import subprocess
19
- import sys
20
- from datetime import datetime
21
- from pathlib import Path
22
-
23
-
24
- # Baseline values from Documentation/Benchmarks.md
25
- BASELINES = {
26
- "asr": {
27
- "wer_percent": 5.8,
28
- "rtfx_min": 200, # M4 Pro: ~210x
29
- "description": "LibriSpeech test-clean, Parakeet TDT 0.6B"
30
- },
31
- "vad": {
32
- "f1_percent": 85.0,
33
- "rtfx_min": 500,
34
- "description": "VOiCES dataset, Silero VAD"
35
- },
36
- "diarization": {
37
- "der_percent": 17.7,
38
- "rtfx_min": 1.0,
39
- "description": "AMI SDM, pyannote-based"
40
- }
41
- }
42
-
43
-
44
- def run_command(cmd: list[str], output_file: Path | None = None) -> tuple[int, str]:
45
- """Run a command and optionally save output."""
46
- print(f"Running: {' '.join(cmd)}")
47
-
48
- result = subprocess.run(
49
- cmd,
50
- capture_output=True,
51
- text=True
52
- )
53
-
54
- output = result.stdout + result.stderr
55
-
56
- if output_file:
57
- output_file.write_text(output)
58
-
59
- return result.returncode, output
60
-
61
-
62
- def build_release() -> bool:
63
- """Build the project in release mode."""
64
- print("\n" + "=" * 60)
65
- print("Building release...")
66
- print("=" * 60)
67
-
68
- returncode, _ = run_command(["swift", "build", "-c", "release"])
69
-
70
- if returncode != 0:
71
- print("ERROR: Build failed!")
72
- return False
73
-
74
- print("Build successful.")
75
- return True
76
-
77
-
78
- def run_asr_benchmark(output_dir: Path, quick: bool = False) -> dict | None:
79
- """Run ASR benchmark on LibriSpeech test-clean."""
80
- print("\n" + "=" * 60)
81
- print("ASR Benchmark (LibriSpeech test-clean)")
82
- print("=" * 60)
83
-
84
- max_files = "100" if quick else "all"
85
- output_json = output_dir / f"asr_results.json"
86
-
87
- cmd = [
88
- "swift", "run", "-c", "release", "fluidaudio", "asr-benchmark",
89
- "--subset", "test-clean",
90
- "--max-files", max_files,
91
- "--output", str(output_json)
92
- ]
93
-
94
- returncode, output = run_command(cmd, output_dir / "asr_log.txt")
95
-
96
- if returncode != 0:
97
- print(f"ERROR: ASR benchmark failed!")
98
- return None
99
-
100
- if output_json.exists():
101
- return json.loads(output_json.read_text())
102
-
103
- return None
104
-
105
-
106
- def run_vad_benchmark(output_dir: Path, quick: bool = False) -> dict | None:
107
- """Run VAD benchmark."""
108
- print("\n" + "=" * 60)
109
- print("VAD Benchmark")
110
- print("=" * 60)
111
-
112
- dataset = "mini50" if quick else "voices-subset"
113
- output_json = output_dir / f"vad_results.json"
114
-
115
- cmd = [
116
- "swift", "run", "-c", "release", "fluidaudio", "vad-benchmark",
117
- "--dataset", dataset,
118
- "--all-files",
119
- "--threshold", "0.5",
120
- "--output", str(output_json)
121
- ]
122
-
123
- returncode, output = run_command(cmd, output_dir / "vad_log.txt")
124
-
125
- if returncode != 0:
126
- print(f"ERROR: VAD benchmark failed!")
127
- return None
128
-
129
- if output_json.exists():
130
- return json.loads(output_json.read_text())
131
-
132
- return None
133
-
134
-
135
- def run_diarization_benchmark(output_dir: Path, quick: bool = False) -> dict | None:
136
- """Run diarization benchmark on AMI SDM."""
137
- print("\n" + "=" * 60)
138
- print("Diarization Benchmark (AMI SDM)")
139
- print("=" * 60)
140
-
141
- output_json = output_dir / f"diarization_results.json"
142
-
143
- cmd = [
144
- "swift", "run", "-c", "release", "fluidaudio", "diarization-benchmark",
145
- "--auto-download",
146
- "--output", str(output_json)
147
- ]
148
-
149
- if quick:
150
- cmd.extend(["--single-file", "ES2004a"])
151
-
152
- returncode, output = run_command(cmd, output_dir / "diarization_log.txt")
153
-
154
- if returncode != 0:
155
- print(f"ERROR: Diarization benchmark failed!")
156
- return None
157
-
158
- if output_json.exists():
159
- return json.loads(output_json.read_text())
160
-
161
- return None
162
-
163
-
164
- def compare_results(results: dict) -> None:
165
- """Compare results against baselines."""
166
- print("\n" + "=" * 60)
167
- print("Results vs Baselines (Documentation/Benchmarks.md)")
168
- print("=" * 60)
169
-
170
- if "asr" in results and results["asr"]:
171
- asr = results["asr"]
172
- baseline = BASELINES["asr"]
173
- wer = asr.get("wer", asr.get("average_wer", 0)) * 100
174
- rtfx = asr.get("rtfx", asr.get("median_rtfx", 0))
175
-
176
- wer_status = "✓" if wer <= baseline["wer_percent"] * 1.1 else "✗"
177
- rtfx_status = "✓" if rtfx >= baseline["rtfx_min"] * 0.8 else "✗"
178
-
179
- print(f"\nASR ({baseline['description']}):")
180
- print(f" WER: {wer:.1f}% (baseline: {baseline['wer_percent']}%) {wer_status}")
181
- print(f" RTFx: {rtfx:.1f}x (baseline: {baseline['rtfx_min']}x+) {rtfx_status}")
182
-
183
- if "vad" in results and results["vad"]:
184
- vad = results["vad"]
185
- baseline = BASELINES["vad"]
186
- f1 = vad.get("f1_score", 0)
187
- rtfx = vad.get("rtfx", 0)
188
-
189
- f1_status = "✓" if f1 >= baseline["f1_percent"] * 0.9 else "✗"
190
- rtfx_status = "✓" if rtfx >= baseline["rtfx_min"] * 0.5 else "✗"
191
-
192
- print(f"\nVAD ({baseline['description']}):")
193
- print(f" F1: {f1:.1f}% (baseline: {baseline['f1_percent']}%+) {f1_status}")
194
- print(f" RTFx: {rtfx:.1f}x (baseline: {baseline['rtfx_min']}x+) {rtfx_status}")
195
-
196
- if "diarization" in results and results["diarization"]:
197
- diar = results["diarization"]
198
- baseline = BASELINES["diarization"]
199
- der = diar.get("der", diar.get("average_der", 0)) * 100
200
- rtfx = diar.get("rtfx", diar.get("average_rtfx", 0))
201
-
202
- der_status = "✓" if der <= baseline["der_percent"] * 1.2 else "✗"
203
- rtfx_status = "✓" if rtfx >= baseline["rtfx_min"] else "✗"
204
-
205
- print(f"\nDiarization ({baseline['description']}):")
206
- print(f" DER: {der:.1f}% (baseline: {baseline['der_percent']}%) {der_status}")
207
- print(f" RTFx: {rtfx:.1f}x (baseline: {baseline['rtfx_min']}x+) {rtfx_status}")
208
-
209
-
210
- def main():
211
- parser = argparse.ArgumentParser(description="FluidAudio Benchmark Suite")
212
- parser.add_argument("--quick", action="store_true", help="Quick smoke test with smaller datasets")
213
- parser.add_argument("--asr-only", action="store_true", help="Run ASR benchmark only")
214
- parser.add_argument("--vad-only", action="store_true", help="Run VAD benchmark only")
215
- parser.add_argument("--diar-only", action="store_true", help="Run diarization benchmark only")
216
- parser.add_argument("--output-dir", type=str, help="Output directory for results")
217
- args = parser.parse_args()
218
-
219
- # Determine which benchmarks to run
220
- run_all = not (args.asr_only or args.vad_only or args.diar_only)
221
-
222
- # Setup output directory
223
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
224
- if args.output_dir:
225
- output_dir = Path(args.output_dir)
226
- else:
227
- output_dir = Path("benchmark-results") / timestamp
228
-
229
- output_dir.mkdir(parents=True, exist_ok=True)
230
-
231
- print("=" * 60)
232
- print("FluidAudio Benchmark Suite")
233
- print("=" * 60)
234
- print(f"Mode: {'Quick' if args.quick else 'Full'}")
235
- print(f"Output: {output_dir}")
236
- print(f"Time: {timestamp}")
237
-
238
- # Build first
239
- if not build_release():
240
- sys.exit(1)
241
-
242
- results = {}
243
-
244
- # Run benchmarks
245
- if run_all or args.asr_only:
246
- results["asr"] = run_asr_benchmark(output_dir, args.quick)
247
-
248
- if run_all or args.vad_only:
249
- results["vad"] = run_vad_benchmark(output_dir, args.quick)
250
-
251
- if run_all or args.diar_only:
252
- results["diarization"] = run_diarization_benchmark(output_dir, args.quick)
253
-
254
- # Save combined results
255
- combined_output = output_dir / "benchmark_results.json"
256
- combined_output.write_text(json.dumps({
257
- "timestamp": timestamp,
258
- "mode": "quick" if args.quick else "full",
259
- "baselines": BASELINES,
260
- "results": results
261
- }, indent=2))
262
-
263
- # Compare against baselines
264
- compare_results(results)
265
-
266
- print("\n" + "=" * 60)
267
- print("Benchmark complete!")
268
- print("=" * 60)
269
- print(f"Results saved to: {combined_output}")
270
-
271
-
272
- if __name__ == "__main__":
273
- main()