File size: 6,959 Bytes
adde09b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
"""Speaker Encoder ONNX Export.

Exports the ECAPA-TDNN speaker encoder from a PyTorch checkpoint to ONNX
format with dynamic batch and time axes.

Usage:
    uv run python -m piper_train.speaker_encoder.export_encoder \\
        --checkpoint speaker_encoder.ckpt \\
        --output speaker_encoder.onnx

    # With FP16 conversion (default)
    uv run python -m piper_train.speaker_encoder.export_encoder \\
        --checkpoint speaker_encoder.ckpt \\
        --output speaker_encoder.onnx \\
        --fp16

    # Without FP16 conversion
    uv run python -m piper_train.speaker_encoder.export_encoder \\
        --checkpoint speaker_encoder.ckpt \\
        --output speaker_encoder.onnx \\
        --no-fp16
"""

from __future__ import annotations

import argparse
import logging
from pathlib import Path

import torch


_LOGGER = logging.getLogger("piper_train.speaker_encoder.export_encoder")

OPSET_VERSION = 17


def export_speaker_encoder(
    checkpoint_path: Path,
    output_path: Path,
    *,
    fp16: bool = True,
    opset_version: int = OPSET_VERSION,
) -> None:
    """Export ECAPA-TDNN speaker encoder to ONNX.

    Args:
        checkpoint_path: Path to PyTorch checkpoint.
        output_path: Path for the output ONNX file.
        fp16: Whether to apply FP16 conversion (default: True).
        opset_version: ONNX opset version (default: 17).
    """
    from .ecapa_tdnn import ECAPATDNN  # noqa: PLC0415
    from .encoder import _infer_hparams  # noqa: PLC0415

    _LOGGER.info("Loading checkpoint: %s", checkpoint_path)

    # Load checkpoint
    ckpt = torch.load(str(checkpoint_path), map_location="cpu", weights_only=True)

    if isinstance(ckpt, dict) and "model_state_dict" in ckpt:
        state_dict = ckpt["model_state_dict"]
    elif isinstance(ckpt, dict) and all(isinstance(k, str) for k in ckpt.keys()):
        state_dict = ckpt
    else:
        raise ValueError(
            "Checkpoint format not recognised. Expected a state_dict or a "
            "dict with 'model_state_dict' key."
        )

    hparams = _infer_hparams(state_dict)
    model = ECAPATDNN(**hparams)
    model.load_state_dict(state_dict)
    model.eval()

    _LOGGER.info(
        "Model loaded: input_dim=%d, channels=%d, emb_dim=%d",
        model.input_dim,
        model.channels,
        model.emb_dim,
    )

    # Dummy input: (batch=1, n_mels=80, time=200)
    dummy_mel = torch.randn(1, model.input_dim, 200)

    # Export
    output_path.parent.mkdir(parents=True, exist_ok=True)

    torch.onnx.export(
        model,
        (dummy_mel,),
        str(output_path),
        verbose=False,
        opset_version=opset_version,
        input_names=["mel"],
        output_names=["embedding"],
        dynamic_axes={
            "mel": {0: "batch_size", 2: "time"},
            "embedding": {0: "batch_size"},
        },
        dynamo=False,
    )

    _LOGGER.info("Exported ONNX model to %s (opset %d)", output_path, opset_version)

    # Verify the ONNX model
    _verify_onnx(output_path, model, dummy_mel)

    # FP16 conversion
    if fp16:
        _apply_fp16(output_path)

    file_size = output_path.stat().st_size
    _LOGGER.info(
        "Final model size: %.2f MB (%s)",
        file_size / (1024 * 1024),
        "FP16" if fp16 else "FP32",
    )


def _verify_onnx(
    onnx_path: Path, torch_model: torch.nn.Module, dummy_mel: torch.Tensor
) -> None:
    """Verify ONNX model produces outputs matching PyTorch.

    Args:
        onnx_path: Path to ONNX model.
        torch_model: Original PyTorch model.
        dummy_mel: Dummy input used during export.
    """
    try:
        import onnxruntime  # noqa: PLC0415
    except ImportError:
        _LOGGER.warning("onnxruntime not available; skipping ONNX verification")
        return

    import numpy as np  # noqa: PLC0415

    session = onnxruntime.InferenceSession(str(onnx_path))
    onnx_result = session.run(
        ["embedding"],
        {"mel": dummy_mel.numpy()},
    )[0]

    with torch.no_grad():
        torch_result = torch_model(dummy_mel).numpy()

    max_diff = np.abs(onnx_result - torch_result).max()
    _LOGGER.info(
        "ONNX verification: max absolute difference = %.2e (shape: %s)",
        max_diff,
        onnx_result.shape,
    )

    if max_diff > 1e-4:
        _LOGGER.warning(
            "ONNX verification: difference (%.2e) exceeds tolerance (1e-4). "
            "The exported model may have numerical issues.",
            max_diff,
        )


def _apply_fp16(onnx_path: Path) -> None:
    """Apply FP16 conversion to the ONNX model.

    Uses the project's convert_fp16 utility for VITS-compatible conversion
    that preserves numerically sensitive operators in FP32.

    Args:
        onnx_path: Path to ONNX model (modified in-place).
    """
    fp32_size = onnx_path.stat().st_size
    tmp_fp16 = onnx_path.with_suffix(".onnx.fp16_tmp")

    try:
        from ..tools.convert_fp16 import convert_fp16  # noqa: PLC0415

        convert_fp16(onnx_path, tmp_fp16)
        tmp_fp16.replace(onnx_path)
    except ImportError:
        _LOGGER.warning(
            "convert_fp16 not available; skipping FP16 conversion. "
            "The model will remain in FP32."
        )
        return
    except Exception:
        tmp_fp16.unlink(missing_ok=True)
        raise

    fp16_size = onnx_path.stat().st_size
    reduction_pct = ((fp32_size - fp16_size) / fp32_size) * 100 if fp32_size > 0 else 0
    _LOGGER.info(
        "FP16 conversion: %.2f MB -> %.2f MB (%.1f%% reduction)",
        fp32_size / (1024 * 1024),
        fp16_size / (1024 * 1024),
        reduction_pct,
    )


def main() -> None:
    """CLI entry point."""
    parser = argparse.ArgumentParser(
        description="Export ECAPA-TDNN speaker encoder to ONNX",
    )
    parser.add_argument(
        "--checkpoint",
        type=Path,
        required=True,
        help="Path to PyTorch checkpoint (.ckpt or .pt)",
    )
    parser.add_argument(
        "--output",
        type=Path,
        required=True,
        help="Path for the output ONNX model (.onnx)",
    )
    parser.add_argument(
        "--fp16",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Apply FP16 conversion (default: enabled). Use --no-fp16 to disable.",
    )
    parser.add_argument(
        "--opset",
        type=int,
        default=OPSET_VERSION,
        help=f"ONNX opset version (default: {OPSET_VERSION})",
    )
    parser.add_argument(
        "--debug",
        action="store_true",
        help="Enable debug logging",
    )
    args = parser.parse_args()

    logging.basicConfig(
        level=logging.DEBUG if args.debug else logging.INFO,
        format="%(asctime)s %(name)s %(levelname)s %(message)s",
    )

    export_speaker_encoder(
        checkpoint_path=args.checkpoint,
        output_path=args.output,
        fp16=args.fp16,
        opset_version=args.opset,
    )


if __name__ == "__main__":
    main()