Download conversion/03_convert_coreml.py from altic-dev/nemotron-3-diarization-coreml: direct link, hf CLI and curl.
- Browser
- Download file 7.54 kB
-
https://huggingface.co/altic-dev/nemotron-3-diarization-coreml/resolve/main/conversion/03_convert_coreml.py
- Command line
-
hf download hf://altic-dev/nemotron-3-diarization-coreml/conversion/03_convert_coreml.py
-
curl -L -o 03_convert_coreml.py https://huggingface.co/altic-dev/nemotron-3-diarization-coreml/resolve/main/conversion/03_convert_coreml.py
7.54 kB
| #!/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() | |