| |
| """Export a self-describing endpoint model to ONNX and optionally INT8.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import inspect |
| import json |
| import math |
| import sys |
| from pathlib import Path |
| from typing import Any |
|
|
| REPOSITORY_ROOT = Path(__file__).resolve().parents[1] |
| SOURCE_ROOT = REPOSITORY_ROOT / "src" |
| if str(SOURCE_ROOT) not in sys.path: |
| sys.path.insert(0, str(SOURCE_ROOT)) |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--checkpoint", required=True) |
| parser.add_argument("--output", required=True, help="FP32 .onnx output path") |
| parser.add_argument("--opset", type=int, default=17) |
| parser.add_argument( |
| "--quantize", |
| choices=("none", "dynamic", "static"), |
| default="none", |
| help="dynamic suits transformers; static suits the TinyTCN CNN", |
| ) |
| parser.add_argument( |
| "--calibration-npz", |
| help="static INT8 arrays: log_mel [N,M,T], frame_mask [N,T]", |
| ) |
| parser.add_argument("--skip-parity", action="store_true") |
| return parser.parse_args() |
|
|
|
|
| def _sha256(path: Path) -> str: |
| digest = hashlib.sha256() |
| with path.open("rb") as handle: |
| for block in iter(lambda: handle.read(1024 * 1024), b""): |
| digest.update(block) |
| return digest.hexdigest() |
|
|
|
|
| def _file_evidence(path: Path) -> dict[str, Any]: |
| if path.is_symlink() or not path.is_file(): |
| raise SystemExit(f"cannot bind non-regular source file: {path}") |
| resolved = path.resolve() |
| try: |
| portable = resolved.relative_to(REPOSITORY_ROOT).as_posix() |
| except ValueError: |
| portable = resolved.name |
| return { |
| "path": portable, |
| "bytes": resolved.stat().st_size, |
| "sha256": _sha256(resolved), |
| } |
|
|
|
|
| def _deployment_source_paths() -> list[Path]: |
| """Return the exact executable source surface shipped with an export.""" |
|
|
| paths = [ |
| *sorted((REPOSITORY_ROOT / "src" / "turn_detection").rglob("*.py")), |
| *sorted((REPOSITORY_ROOT / "scripts").glob("*.py")), |
| *sorted((REPOSITORY_ROOT / "scripts").glob("*.sh")), |
| *( |
| path |
| for path in sorted((REPOSITORY_ROOT / "deployment").rglob("*")) |
| if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc" |
| ), |
| REPOSITORY_ROOT / "app.py", |
| REPOSITORY_ROOT / "pyproject.toml", |
| REPOSITORY_ROOT / "space" / "requirements.txt", |
| *sorted(REPOSITORY_ROOT.glob("requirements-*.txt")), |
| ] |
| return sorted(set(paths), key=lambda path: path.relative_to(REPOSITORY_ROOT).as_posix()) |
|
|
|
|
| def _legacy_export_without_onnx_package( |
| torch: Any, |
| model: Any, |
| model_args: tuple[Any, ...], |
| output_path: Path, |
| *, |
| input_names: list[str], |
| output_names: list[str], |
| dynamic_axes: dict[str, dict[int, str]], |
| opset: int, |
| ) -> None: |
| """Serialize via Torch's private legacy graph only when ``onnx`` is absent. |
| |
| This narrow fallback is useful in network-restricted build environments. |
| It is intentionally not used for arbitrary exporter failures, and the |
| resulting graph is still required to pass ONNX Runtime parity below. |
| """ |
|
|
| graph, params, _ = torch.onnx.utils._model_to_graph( |
| model, |
| model_args, |
| input_names=input_names, |
| output_names=output_names, |
| operator_export_type=torch.onnx.OperatorExportTypes.ONNX, |
| do_constant_folding=True, |
| training=torch.onnx.TrainingMode.EVAL, |
| dynamic_axes=dynamic_axes, |
| ) |
| serialized, *_ = graph._export_onnx( |
| params, |
| opset, |
| dynamic_axes, |
| False, |
| torch.onnx.OperatorExportTypes.ONNX, |
| True, |
| False, |
| {}, |
| True, |
| "", |
| {}, |
| ) |
| output_path.write_bytes(serialized) |
|
|
|
|
| def _quantize_dynamic(source: Path, destination: Path) -> None: |
| try: |
| from onnxruntime.quantization import QuantType, quantize_dynamic |
| except ImportError as exc: |
| raise SystemExit("INT8 export requires onnxruntime") from exc |
| quantize_dynamic( |
| str(source), |
| str(destination), |
| weight_type=QuantType.QInt8, |
| per_channel=True, |
| ) |
|
|
|
|
| def _quantize_static(source: Path, destination: Path, calibration_path: Path) -> None: |
| try: |
| import numpy as np |
| from onnxruntime.quantization import ( |
| CalibrationDataReader, |
| CalibrationMethod, |
| QuantFormat, |
| QuantType, |
| quantize_static, |
| ) |
| except ImportError as exc: |
| raise SystemExit("static INT8 export requires numpy and onnxruntime") from exc |
|
|
| loaded = np.load(calibration_path) |
| if "log_mel" not in loaded or "frame_mask" not in loaded: |
| raise SystemExit("calibration NPZ needs log_mel and frame_mask arrays") |
| features = loaded["log_mel"].astype("float32") |
| masks = loaded["frame_mask"].astype("float32") |
| if features.ndim != 3 or masks.shape != (features.shape[0], features.shape[2]): |
| raise SystemExit("invalid calibration shapes") |
|
|
| class Reader(CalibrationDataReader): |
| def __init__(self) -> None: |
| self.index = 0 |
|
|
| def get_next(self) -> dict[str, Any] | None: |
| if self.index >= features.shape[0]: |
| return None |
| item = { |
| "log_mel": features[self.index : self.index + 1], |
| "frame_mask": masks[self.index : self.index + 1], |
| } |
| self.index += 1 |
| return item |
|
|
| quantize_static( |
| str(source), |
| str(destination), |
| Reader(), |
| quant_format=QuantFormat.QDQ, |
| activation_type=QuantType.QInt8, |
| weight_type=QuantType.QInt8, |
| per_channel=True, |
| calibrate_method=CalibrationMethod.MinMax, |
| ) |
|
|
|
|
| def _parity_check(model_path: Path, features: Any, mask: Any, expected: Any) -> float: |
| try: |
| import numpy as np |
| import onnxruntime as ort |
| except ImportError as exc: |
| raise SystemExit("ONNX parity checking requires numpy and onnxruntime") from exc |
| session = ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"]) |
| actual = session.run( |
| ["endpoint_probability"], |
| { |
| "log_mel": features.detach().cpu().numpy().astype("float32"), |
| "frame_mask": mask.detach().cpu().numpy().astype("float32"), |
| }, |
| )[0] |
| return float(np.max(np.abs(actual - expected.detach().cpu().numpy()))) |
|
|
|
|
| def main() -> int: |
| args = parse_args() |
| try: |
| import torch |
| from torch import nn |
| except ImportError as exc: |
| raise SystemExit("ONNX export requires PyTorch") from exc |
|
|
| from turn_detection.models import ( |
| LogMelConfig, |
| build_runtime_metadata, |
| load_model_checkpoint, |
| ) |
|
|
| checkpoint_path = Path(args.checkpoint) |
| if not checkpoint_path.is_absolute(): |
| checkpoint_path = REPOSITORY_ROOT / checkpoint_path |
| output_path = Path(args.output) |
| if not output_path.is_absolute(): |
| output_path = REPOSITORY_ROOT / output_path |
| if output_path.suffix.lower() != ".onnx": |
| raise SystemExit("--output must end in .onnx") |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| model, checkpoint = load_model_checkpoint(checkpoint_path, map_location="cpu") |
| model.eval() |
| checkpoint_metadata = dict(checkpoint.get("metadata", {})) |
| feature_config = LogMelConfig.from_mapping(checkpoint_metadata.get("feature_config", {})) |
| max_seconds = float(checkpoint_metadata.get("max_seconds", 8.0)) |
| frames = max( |
| 2, int(round(max_seconds * feature_config.sample_rate / feature_config.hop_length)) |
| ) |
| model_type = str(checkpoint["model_config"].get("type", "tiny_tcn")) |
| fixed_frames = model_type in {"whisper", "whisper_teacher", "teacher"} |
| threshold = float(checkpoint.get("threshold", 0.5)) |
| if not math.isfinite(threshold): |
| raise SystemExit("checkpoint threshold must be finite") |
| run_metadata = checkpoint_metadata.get("run_metadata", {}) |
| if not isinstance(run_metadata, dict): |
| run_metadata = {} |
| smoke_test = bool(checkpoint_metadata.get("smoke_test", False)) |
| training_status = str(run_metadata.get("status", "smoke" if smoke_test else "development")) |
| |
| |
| development_only = smoke_test or training_status.lower() != "final" |
| data_scope = checkpoint_metadata.get("data_scope") |
| try: |
| metadata = build_runtime_metadata( |
| feature_config, |
| max_seconds=max_seconds, |
| threshold=threshold, |
| model_name=str(checkpoint_metadata.get("run_name", output_path.stem)), |
| architecture=model_type, |
| model_version=str(checkpoint.get("format_version", 1)), |
| development_only=development_only, |
| training_status=training_status, |
| data_scope=None if data_scope is None else str(data_scope), |
| data_revision=( |
| None |
| if checkpoint_metadata.get("data_revision") is None |
| else str(checkpoint_metadata["data_revision"]) |
| ), |
| parameter_count=sum(parameter.numel() for parameter in model.parameters()), |
| ) |
| except ValueError as exc: |
| raise SystemExit( |
| f"checkpoint preprocessing cannot be represented by the current runtime: {exc}. " |
| "Export a deployment-compatible distilled TinyTCN student." |
| ) from exc |
|
|
| class EndpointWrapper(nn.Module): |
| def __init__(self, wrapped: nn.Module) -> None: |
| super().__init__() |
| self.wrapped = wrapped |
|
|
| def forward(self, log_mel: Any, frame_mask: Any) -> Any: |
| return torch.sigmoid(self.wrapped(log_mel, frame_mask > 0.5).endpoint_logits) |
|
|
| wrapper = EndpointWrapper(model).eval() |
| generator = torch.Generator().manual_seed(17) |
| dummy_features = torch.randn( |
| (1, feature_config.n_mels, frames), generator=generator, dtype=torch.float32 |
| ) |
| dummy_mask = torch.ones((1, frames), dtype=torch.float32) |
| with torch.inference_mode(): |
| expected = wrapper(dummy_features, dummy_mask) |
|
|
| dynamic_axes = { |
| "log_mel": {0: "batch"}, |
| "frame_mask": {0: "batch"}, |
| "endpoint_probability": {0: "batch"}, |
| } |
| if not fixed_frames: |
| dynamic_axes["log_mel"][2] = "frames" |
| dynamic_axes["frame_mask"][1] = "frames" |
| try: |
| exporter_options: dict[str, Any] = {} |
| if "dynamo" in inspect.signature(torch.onnx.export).parameters: |
| exporter_options["dynamo"] = False |
| torch.onnx.export( |
| wrapper, |
| (dummy_features, dummy_mask), |
| str(output_path), |
| input_names=["log_mel", "frame_mask"], |
| output_names=["endpoint_probability"], |
| dynamic_axes=dynamic_axes, |
| opset_version=args.opset, |
| do_constant_folding=True, |
| **exporter_options, |
| ) |
| except Exception as exc: |
| missing_module = isinstance(exc, ModuleNotFoundError) and getattr(exc, "name", None) in { |
| "onnx", |
| "onnxscript", |
| } |
| missing_message = str(exc) in { |
| "Module onnx is not installed!", |
| "No module named 'onnx'", |
| "No module named 'onnxscript'", |
| } |
| if not (missing_module or missing_message): |
| raise |
| print( |
| "warning: onnx package unavailable; using Torch's private legacy serializer", |
| file=sys.stderr, |
| ) |
| try: |
| _legacy_export_without_onnx_package( |
| torch, |
| wrapper, |
| (dummy_features, dummy_mask), |
| output_path, |
| input_names=["log_mel", "frame_mask"], |
| output_names=["endpoint_probability"], |
| dynamic_axes=dynamic_axes, |
| opset=args.opset, |
| ) |
| except Exception as fallback_exc: |
| raise SystemExit( |
| "ONNX package is unavailable and Torch's private fallback was incompatible" |
| ) from fallback_exc |
|
|
| parity: dict[str, float | None] = { |
| "fp32_max_abs_error": None, |
| "int8_max_abs_error": None, |
| } |
| if not args.skip_parity: |
| parity["fp32_max_abs_error"] = _parity_check( |
| output_path, dummy_features, dummy_mask, expected |
| ) |
| if parity["fp32_max_abs_error"] > 1e-4: |
| raise SystemExit(f"FP32 ONNX parity failed: {parity['fp32_max_abs_error']:.6g}") |
|
|
| quantized_path: Path | None = None |
| if args.quantize != "none": |
| quantized_path = output_path.with_name(output_path.stem + ".int8.onnx") |
| if args.quantize == "dynamic": |
| _quantize_dynamic(output_path, quantized_path) |
| else: |
| if not args.calibration_npz: |
| raise SystemExit("--quantize static requires --calibration-npz") |
| _quantize_static(output_path, quantized_path, Path(args.calibration_npz)) |
| if not args.skip_parity: |
| parity["int8_max_abs_error"] = _parity_check( |
| quantized_path, dummy_features, dummy_mask, expected |
| ) |
|
|
| files: dict[str, dict[str, Any]] = { |
| "fp32": { |
| "filename": output_path.name, |
| "bytes": output_path.stat().st_size, |
| "sha256": _sha256(output_path), |
| } |
| } |
| if quantized_path is not None: |
| files["int8"] = { |
| "filename": quantized_path.name, |
| "bytes": quantized_path.stat().st_size, |
| "sha256": _sha256(quantized_path), |
| "quantization": args.quantize, |
| } |
|
|
| resolved_config_path = checkpoint_path.parent / "resolved_config.json" |
| resolved_config_evidence: dict[str, Any] | None = None |
| training_data: dict[str, Any] | None = None |
| if resolved_config_path.is_file(): |
| try: |
| resolved_config = json.loads(resolved_config_path.read_text(encoding="utf-8")) |
| except json.JSONDecodeError as exc: |
| raise SystemExit("resolved_config.json is invalid") from exc |
| resolved_config_evidence = _file_evidence(resolved_config_path) |
| data_config = resolved_config.get("data", {}) |
| if isinstance(data_config, dict): |
| sources: dict[str, Any] = {} |
| for key in ("train_source", "validation_source"): |
| value = data_config.get(key) |
| if not isinstance(value, str): |
| continue |
| candidate = Path(value) |
| if not candidate.is_absolute(): |
| candidate = REPOSITORY_ROOT / candidate |
| sources[key] = ( |
| _file_evidence(candidate) if candidate.is_file() else {"identifier": value} |
| ) |
| training_data = { |
| "revision": data_config.get("revision"), |
| "scope": data_config.get("scope"), |
| "sources": sources, |
| } |
|
|
| source_files = [_file_evidence(path) for path in _deployment_source_paths()] |
| source_inventory_sha256 = hashlib.sha256( |
| json.dumps(source_files, sort_keys=True, separators=(",", ":")).encode("utf-8") |
| ).hexdigest() |
| export_manifest = { |
| "format_version": 2, |
| "task": "audio-turn-end-detection", |
| "model_type": model_type, |
| "parameter_count": sum(parameter.numel() for parameter in model.parameters()), |
| "checkpoint": { |
| "filename": checkpoint_path.name, |
| "bytes": checkpoint_path.stat().st_size, |
| "sha256": _sha256(checkpoint_path), |
| "selected_epoch": checkpoint.get("epoch"), |
| }, |
| "model_config": checkpoint.get("model_config"), |
| "threshold": threshold, |
| "controller": metadata["controller"], |
| "resolved_config": resolved_config_evidence, |
| "training_data": training_data, |
| "source_files": source_files, |
| "source_inventory_sha256": source_inventory_sha256, |
| "input_names": ["log_mel", "frame_mask"], |
| "output_names": ["endpoint_probability"], |
| "input_dtypes": {"log_mel": "float32", "frame_mask": "float32"}, |
| "input_shapes": { |
| "log_mel": ["batch", feature_config.n_mels, frames if fixed_frames else "frames"], |
| "frame_mask": ["batch", frames if fixed_frames else "frames"], |
| }, |
| "dynamic_frames": not fixed_frames, |
| "files": files, |
| "parity": parity, |
| "quantized_threshold_recalibration_required": quantized_path is not None, |
| "development_only": development_only, |
| "training_status": training_status, |
| "data_scope": data_scope, |
| "data_revision": checkpoint_metadata.get("data_revision"), |
| "notes": ( |
| "Whisper export uses a fixed time axis dictated by encoder positional embeddings." |
| if fixed_frames |
| else "TinyTCN accepts a dynamic number of log-mel frames." |
| ), |
| } |
| metadata_path = output_path.parent / "model_metadata.json" |
| metadata_path.write_text( |
| json.dumps(metadata, indent=2, sort_keys=True, allow_nan=False), encoding="utf-8" |
| ) |
| export_manifest_path = output_path.parent / "export_manifest.json" |
| export_manifest_path.write_text( |
| json.dumps(export_manifest, indent=2, sort_keys=True, allow_nan=False), |
| encoding="utf-8", |
| ) |
| print( |
| json.dumps( |
| { |
| "model": str(output_path), |
| "metadata": str(metadata_path), |
| "export_manifest": str(export_manifest_path), |
| **parity, |
| }, |
| indent=2, |
| ) |
| ) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|