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()