File size: 7,544 Bytes
8976c3c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | #!/usr/bin/env python3
"""Export Nemotron-3-Diarization (Sortformer) to CoreML via forward_for_export.
The export boundary is NVIDIA's own streaming graph:
(chunk, chunk_lengths, spkcache, spkcache_lengths, fifo, fifo_lengths)
-> (spkcache_fifo_chunk_preds, chunk_pre_encode_embs, chunk_pre_encode_lengths)
Mel features are deliberately NOT part of the graph: the host computes them, so
front-end drift stays isolated from conversion error (gate 1 vs gates 2-3).
The speaker cache / FIFO are host-owned state passed in per chunk, which keeps
the CoreML model stateless and matches how FluidAudio drives Sortformer today.
"""
from __future__ import annotations
import argparse
import warnings
from pathlib import Path
import numpy as np
import torch
warnings.filterwarnings("ignore")
import coremltools as ct # noqa: E402
from nemo.collections.asr.models import SortformerEncLabelModel # noqa: E402
import coreml_patches # noqa: E402
coreml_patches.apply()
HERE = Path(__file__).resolve().parent
NEMO_PATH = HERE.parent / "Nemotron-3-Diarization-preview.nemo"
# The final release ships bare weights; its graph is identical to the preview's.
FINAL_WEIGHTS = Path.home() / "Nemotron-3-Diarization-safetensors" / "model.safetensors"
# Auxiliary 3-class activity head: feeds only the training loss upstream, never the speaker preds.
TRAINING_ONLY_PREFIX = "sortformer_modules.activity_head."
# Latency profiles from the model card. Frames are 80 ms; the input-buffer
# latency is (chunk_len + chunk_right_context) * 80 ms. spkcache_len is 264 in
# every profile, so only the chunk/FIFO geometry changes.
PROFILES = {
# 30.4 s buffer -- batch use, one call covers half a minute of audio.
"offline": dict(chunk_len=340, chunk_right_context=40, fifo_len=40,
spkcache_update_period=300),
# 1.04 s buffer -- the usable streaming profile.
"low": dict(chunk_len=9, chunk_right_context=4, fifo_len=264,
spkcache_update_period=222),
# 0.64 s buffer.
"verylow": dict(chunk_len=6, chunk_right_context=2, fifo_len=264,
spkcache_update_period=222),
# 0.32 s buffer -- lowest latency the card documents.
"ultralow": dict(chunk_len=3, chunk_right_context=1, fifo_len=264,
spkcache_update_period=222),
}
PROFILE_SECONDS = {
name: (cfg["chunk_len"] + cfg["chunk_right_context"]) * 0.08
for name, cfg in PROFILES.items()
}
OFFLINE_PROFILE = PROFILES["offline"]
OUTPUT_NAMES = ["spkcache_fifo_chunk_preds", "chunk_pre_encode_embs",
"chunk_pre_encode_lengths"]
class StreamingWrapper(torch.nn.Module):
"""Trace-friendly shim.
Lengths cross the CoreML boundary as int32 (CoreML has no int64 inputs) and
are widened back to int64 here, where the NeMo graph expects them.
"""
def __init__(self, model: SortformerEncLabelModel):
super().__init__()
self.model = model
def forward(self, chunk, chunk_lengths, spkcache, spkcache_lengths, fifo, fifo_lengths):
return self.model.forward_for_export(
chunk,
chunk_lengths.to(torch.int64),
spkcache,
spkcache_lengths.to(torch.int64),
fifo,
fifo_lengths.to(torch.int64),
)
def load_model(profile: dict, weights: str = "preview") -> SortformerEncLabelModel:
model = SortformerEncLabelModel.restore_from(
restore_path=str(NEMO_PATH), map_location="cpu", strict=False
)
if weights == "final":
from safetensors.torch import load_file
state = {
key: value.float()
for key, value in load_file(str(FINAL_WEIGHTS)).items()
if not key.startswith(TRAINING_ONLY_PREFIX)
}
missing, unexpected = model.load_state_dict(state, strict=False)
if missing or unexpected:
raise SystemExit(f"weight mismatch: missing={missing[:5]} unexpected={unexpected[:5]}")
model.eval()
for key, value in profile.items():
setattr(model.sortformer_modules, key, value)
return model
def build_examples(model: SortformerEncLabelModel):
"""Model-sized examples, but with lengths as int32 to match the CoreML iface."""
chunk, chunk_len, spkcache, spkcache_len, fifo, fifo_len = model.streaming_input_examples(1)
return (
chunk,
chunk_len.to(torch.int32),
spkcache,
spkcache_len.to(torch.int32),
fifo,
fifo_len.to(torch.int32),
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--precision", choices=["fp16", "fp32"], default="fp16")
parser.add_argument("--profile", choices=sorted(PROFILES), default="offline")
parser.add_argument("--weights", choices=["preview", "final"], default="preview")
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args()
suffix = "" if args.profile == "offline" else f"_{args.profile}"
prefix = "nemotron_diar" if args.weights == "preview" else "nemotron_diar_final"
out_path = args.out or (HERE / "artifacts" /
f"{prefix}_{args.precision}{suffix}.mlpackage")
out_path.parent.mkdir(parents=True, exist_ok=True)
profile = PROFILES[args.profile]
print(f"profile: {args.profile} ({PROFILE_SECONDS[args.profile]:.2f}s buffer) {profile}")
model = load_model(profile, args.weights)
wrapper = StreamingWrapper(model).eval()
examples = build_examples(model)
for name, tensor in zip(
["chunk", "chunk_lengths", "spkcache", "spkcache_lengths", "fifo", "fifo_lengths"],
examples,
):
print(f" input {name:<18} {tuple(tensor.shape)} {tensor.dtype}")
with torch.no_grad():
reference = wrapper(*examples)
for name, tensor in zip(OUTPUT_NAMES, reference):
print(f" output {name:<28} {tuple(tensor.shape)} {tensor.dtype}")
print("\ntracing...")
with torch.no_grad():
traced = torch.jit.trace(wrapper, examples, strict=False)
# Every shape in this graph is static (batch 1, fixed chunk/cache/FIFO), but the
# trace still carries ~200 aten::Int shape computations. Freezing constant-folds
# them; coremltools chokes on the ones that carry (1,)-shaped tensors.
traced = torch.jit.freeze(traced)
traced = torch.jit.run_frozen_optimizations(traced) or traced
print(f"converting to CoreML ({args.precision})...")
precision = ct.precision.FLOAT16 if args.precision == "fp16" else ct.precision.FLOAT32
mlmodel = ct.convert(
traced,
convert_to="mlprogram",
inputs=[
ct.TensorType(name="chunk", shape=examples[0].shape, dtype=np.float32),
ct.TensorType(name="chunk_lengths", shape=examples[1].shape, dtype=np.int32),
ct.TensorType(name="spkcache", shape=examples[2].shape, dtype=np.float32),
ct.TensorType(name="spkcache_lengths", shape=examples[3].shape, dtype=np.int32),
ct.TensorType(name="fifo", shape=examples[4].shape, dtype=np.float32),
ct.TensorType(name="fifo_lengths", shape=examples[5].shape, dtype=np.int32),
],
outputs=[ct.TensorType(name=name) for name in OUTPUT_NAMES],
compute_precision=precision,
minimum_deployment_target=ct.target.macOS14,
)
mlmodel.save(str(out_path))
size_mb = sum(f.stat().st_size for f in out_path.rglob("*") if f.is_file()) / 1e6
print(f"\nsaved {out_path} ({size_mb:.1f} MB)")
if __name__ == "__main__":
main()
|