import argparse import shutil import tarfile import tempfile import urllib.request from dataclasses import dataclass from pathlib import Path import coremltools as ct import tensorflow as tf DEFAULT_TFHUB_URL = "https://tfhub.dev/google/yamnet/1?tf-hub-format=compressed" def parse_args(): script_dir = Path(__file__).resolve().parent parser = argparse.ArgumentParser(description="Convert YAMNet TensorFlow SavedModel to Core ML.") parser.add_argument( "--model-path", default=script_dir / "yamnet_model", type=Path, help="Path to a TensorFlow SavedModel directory, .keras file, or .h5 file.", ) parser.add_argument( "--output", default=script_dir / "YAMNet.mlpackage", type=Path, help="Output Core ML package path.", ) parser.add_argument( "--waveform-samples", default=15_600, type=int, help="Fixed waveform length for the Core ML input. YAMNet commonly uses 0.975s at 16 kHz.", ) parser.add_argument( "--output-key", default=None, help="SavedModel output key for --conversion-mode waveform. Defaults to output_0 for TFHub YAMNet.", ) parser.add_argument( "--conversion-mode", choices=["features", "waveform"], default="features", help=( "features converts the YAMNet classifier from 96x64 log-mel patches and avoids unsupported " "TensorFlow FFT ops. waveform attempts direct full SavedModel conversion." ), ) parser.add_argument( "--download-tfhub", action="store_true", help="Download YAMNet from TFHub into --model-path before conversion if it is missing.", ) parser.add_argument( "--force-download", action="store_true", help="Replace --model-path with a fresh TFHub download before conversion.", ) parser.add_argument( "--download-only", action="store_true", help="Download YAMNet from TFHub into --model-path and exit without Core ML conversion.", ) parser.add_argument( "--tfhub-url", default=DEFAULT_TFHUB_URL, help="TFHub compressed SavedModel URL.", ) return parser.parse_args() @dataclass(frozen=True) class YamnetParams: patch_frames: int = 96 patch_bands: int = 64 num_classes: int = 521 conv_padding: str = "same" batchnorm_center: bool = True batchnorm_scale: bool = False batchnorm_epsilon: float = 1e-4 classifier_activation: str = "sigmoid" YAMNET_LAYER_DEFS = [ ("conv", [3, 3], 2, 32), ("separable_conv", [3, 3], 1, 64), ("separable_conv", [3, 3], 2, 128), ("separable_conv", [3, 3], 1, 128), ("separable_conv", [3, 3], 2, 256), ("separable_conv", [3, 3], 1, 256), ("separable_conv", [3, 3], 2, 512), ("separable_conv", [3, 3], 1, 512), ("separable_conv", [3, 3], 1, 512), ("separable_conv", [3, 3], 1, 512), ("separable_conv", [3, 3], 1, 512), ("separable_conv", [3, 3], 1, 512), ("separable_conv", [3, 3], 2, 1024), ("separable_conv", [3, 3], 1, 1024), ] def download_tfhub_saved_model(tfhub_url, model_path, force=False): if model_path.exists() and not force: print(f"Using existing model: {model_path}") return if model_path.exists(): if model_path.is_dir(): shutil.rmtree(model_path) else: model_path.unlink() model_path.parent.mkdir(parents=True, exist_ok=True) print(f"Downloading TFHub model: {tfhub_url}") with tempfile.TemporaryDirectory(prefix="yamnet-tfhub-") as temp_dir: archive_path = Path(temp_dir) / "model.tar.gz" request = urllib.request.Request(tfhub_url, headers={"User-Agent": "langpipe-yamnet-coreml"}) with urllib.request.urlopen(request) as response, archive_path.open("wb") as archive: shutil.copyfileobj(response, archive) extract_path = Path(temp_dir) / "model" extract_path.mkdir() with tarfile.open(archive_path, "r:gz") as tar: tar.extractall(extract_path, filter="data") saved_model_pb = next(extract_path.rglob("saved_model.pb"), None) if saved_model_pb is None: raise ValueError(f"TFHub archive did not contain saved_model.pb: {tfhub_url}") extracted_model_root = saved_model_pb.parent shutil.copytree(extracted_model_root, model_path) print(f"Saved TFHub model: {model_path}") def batch_norm(name, params, layer_input): return tf.keras.layers.BatchNormalization( name=name, center=params.batchnorm_center, scale=params.batchnorm_scale, epsilon=params.batchnorm_epsilon, )(layer_input) def build_yamnet_feature_model(params=YamnetParams()): features = tf.keras.Input( shape=(params.patch_frames, params.patch_bands), dtype=tf.float32, name="features", ) net = tf.keras.layers.Reshape( (params.patch_frames, params.patch_bands, 1), name="features_4d", )(features) for layer_index, (layer_type, kernel, stride, filters) in enumerate(YAMNET_LAYER_DEFS, start=1): prefix = f"layer{layer_index}" if layer_type == "conv": net = tf.keras.layers.Conv2D( name=f"{prefix}_conv", filters=filters, kernel_size=kernel, strides=stride, padding=params.conv_padding, use_bias=False, activation=None, )(net) net = batch_norm(f"{prefix}_conv_bn", params, net) net = tf.keras.layers.ReLU(name=f"{prefix}_relu")(net) continue net = tf.keras.layers.DepthwiseConv2D( name=f"{prefix}_depthwise_conv", kernel_size=kernel, strides=stride, depth_multiplier=1, padding=params.conv_padding, use_bias=False, activation=None, )(net) net = batch_norm(f"{prefix}_depthwise_conv_bn", params, net) net = tf.keras.layers.ReLU(name=f"{prefix}_depthwise_relu")(net) net = tf.keras.layers.Conv2D( name=f"{prefix}_pointwise_conv", filters=filters, kernel_size=(1, 1), strides=1, padding=params.conv_padding, use_bias=False, activation=None, )(net) net = batch_norm(f"{prefix}_pointwise_conv_bn", params, net) net = tf.keras.layers.ReLU(name=f"{prefix}_pointwise_relu")(net) embeddings = tf.keras.layers.GlobalAveragePooling2D(name="embeddings")(net) logits = tf.keras.layers.Dense(units=params.num_classes, use_bias=True, name="dense")(embeddings) class_scores = tf.keras.layers.Activation( activation=params.classifier_activation, name="class_scores", )(logits) return tf.keras.Model(name="yamnet_features", inputs=features, outputs=class_scores) def load_feature_model_weights_from_saved_model(model, model_path): loaded = tf.saved_model.load(str(model_path)) if not hasattr(loaded, "_yamnet"): raise ValueError("SavedModel does not expose the expected TFHub YAMNet _yamnet object.") source_weights = list(loaded._yamnet.variables) target_weights = model.weights if len(source_weights) != len(target_weights): raise ValueError(f"Weight count mismatch: source={len(source_weights)}, target={len(target_weights)}") for index, (target, source) in enumerate(zip(target_weights, source_weights)): if tuple(target.shape) != tuple(source.shape): raise ValueError( f"Weight shape mismatch at {index}: target {target.name} {target.shape}, " f"source {source.name} {source.shape}" ) model.set_weights([weight.numpy() for weight in source_weights]) def describe_signature(signature): _, keyword_specs = signature.structured_input_signature print("SavedModel inputs:") for name, spec in keyword_specs.items(): print(f" {name}: shape={spec.shape}, dtype={spec.dtype.name}") print("SavedModel outputs:") for name, spec in signature.structured_outputs.items(): print(f" {name}: shape={spec.shape}, dtype={spec.dtype.name}") def select_output_key(signature, requested_key): outputs = signature.structured_outputs if requested_key: if requested_key not in outputs: raise ValueError(f"--output-key {requested_key!r} not found. Available keys: {list(outputs)}") return requested_key if "output_0" in outputs: return "output_0" for key in outputs: if "score" in key.lower() or "class" in key.lower(): return key if not outputs: raise ValueError("SavedModel serving_default has no outputs.") return next(iter(outputs)) class SavedModelClassScores(tf.Module): def __init__(self, signature, input_key, output_key): super().__init__() self.signature = signature self.input_key = input_key self.output_key = output_key @tf.function def __call__(self, waveform): outputs = self.signature(**{self.input_key: waveform}) return {"class_scores": outputs[self.output_key]} def convert_saved_model(model_path, output_path, waveform_samples, output_key): loaded = tf.saved_model.load(str(model_path)) if "serving_default" not in loaded.signatures: raise ValueError(f"SavedModel has no serving_default signature. Available: {list(loaded.signatures)}") signature = loaded.signatures["serving_default"] describe_signature(signature) _, keyword_specs = signature.structured_input_signature if len(keyword_specs) != 1: raise ValueError( "Expected one SavedModel input. Pass a wrapper model if this SavedModel has " f"{len(keyword_specs)} inputs: {list(keyword_specs)}" ) input_key = next(iter(keyword_specs)) selected_output = select_output_key(signature, output_key) print(f"Converting input {input_key!r} -> output {selected_output!r} as 'class_scores'") wrapper = SavedModelClassScores(signature, input_key, selected_output) concrete = wrapper.__call__.get_concrete_function( tf.TensorSpec([waveform_samples], tf.float32, name="waveform") ) return ct.convert( [concrete], source="tensorflow", inputs=[ct.TensorType(shape=(waveform_samples,), name="waveform")], convert_to="mlprogram", minimum_deployment_target=ct.target.macOS13, ) def convert_keras_model(model_path, output_path): model = tf.keras.models.load_model(str(model_path)) return ct.convert( model, inputs=[ct.TensorType(shape=(1, 64, 96, 1), name="features")], outputs=[ct.TensorType(name="class_scores")], convert_to="mlprogram", minimum_deployment_target=ct.target.macOS13, ) def convert_feature_model(model_path): model = build_yamnet_feature_model() model(tf.zeros((1, 96, 64), dtype=tf.float32)) load_feature_model_weights_from_saved_model(model, model_path) return ct.convert( model, source="tensorflow", inputs=[ct.TensorType(shape=(1, 96, 64), name="features")], convert_to="mlprogram", minimum_deployment_target=ct.target.macOS13, ) def main(): args = parse_args() if args.download_tfhub or args.force_download or args.download_only: download_tfhub_saved_model(args.tfhub_url, args.model_path, force=args.force_download) if args.download_only: return if not args.model_path.exists(): raise FileNotFoundError( f"Model path does not exist: {args.model_path}. " "Pass --download-tfhub to download YAMNet from TFHub." ) if args.conversion_mode == "features": mlmodel = convert_feature_model(args.model_path) elif args.model_path.suffix in {".keras", ".h5", ".hdf5"}: mlmodel = convert_keras_model(args.model_path, args.output) else: mlmodel = convert_saved_model( args.model_path, args.output, args.waveform_samples, args.output_key, ) args.output.parent.mkdir(parents=True, exist_ok=True) mlmodel.save(str(args.output)) print(f"Saved Core ML model: {args.output}") if __name__ == "__main__": main()