From 6eb1f58f4139a91dc2029da01551914e2fef8d54 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 22:41:34 +0000 Subject: [PATCH 1/2] Add GitHub Actions CI and tag-triggered release pipeline The release workflow runs on macos-14 when a v* tag is pushed (or on manual workflow_dispatch, which produces a draft release for dry runs). It builds the multi-platform XCFramework via Scripts/build-xcframework.sh with MLXSWIFT_USE_REMOTE=1, builds StableAudioCLI in release config with a colocated mlx.metallib, and publishes both archives plus a standalone PEP 723 version of prepare_weights.py. Release notes cover the Gatekeeper quarantine workaround for the unsigned binary and the uv-based weights prep flow. A separate CI workflow on pull_request / push to main verifies that swift build and a macOS xcframework slice still succeed. --- .github/workflows/ci.yml | 49 ++++ .github/workflows/release.yml | 194 +++++++++++++++ Scripts/prepare_weights.py | 2 + Scripts/prepare_weights_standalone.py | 345 ++++++++++++++++++++++++++ 4 files changed, 590 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Scripts/prepare_weights_standalone.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4060e03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_15.4.app + xcodebuild -version + swift --version + + - name: Rewrite Package.swift to use remote mlx-swift + run: | + python3 - Package.swift "https://github.com/olilarkin/mlx-swift" "main" <<'PY' + import re, sys, pathlib + path, url, branch = sys.argv[1], sys.argv[2], sys.argv[3] + src = pathlib.Path(path).read_text() + pathlib.Path(path).write_text(re.sub( + r'\.package\(\s*path:\s*"\.\./mlx-swift"\s*\)', + f'.package(url: "{url}", branch: "{branch}")', src)) + PY + rm -f Package.resolved + + - name: swift build StableAudioKit (library) + run: swift build --product StableAudioKit + + - name: swift build StableAudioCLI + run: swift build --product StableAudioCLI + + - name: Build macOS XCFramework slice (smoke) + env: + MLXSWIFT_USE_REMOTE: "1" + run: ./Scripts/build-xcframework.sh build/xcframework macos + + # TODO: swift test once we have a way to ship/fetch a tiny test + # fixture set of weights. Requires HF_TOKEN secret + the colocated + # mlx.metallib produced by Scripts/compile-mlx-metallib.sh. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1ebb778 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,194 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: 'Version label (no leading v). Blank = short SHA.' + required: false + default: '' + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Resolve version and artifact names + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + RAW="${{ github.ref_name }}" + VERSION="${RAW#v}" + elif [ -n "${{ github.event.inputs.version }}" ]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="$(git rev-parse --short HEAD)" + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "XCF_ZIP=StableAudioKit-${VERSION}.xcframework.zip" >> "$GITHUB_ENV" + echo "CLI_TAR=StableAudioCLI-${VERSION}-macos-arm64.tar.gz" >> "$GITHUB_ENV" + echo "CLI_STAGE=StableAudioCLI-${VERSION}-macos-arm64" >> "$GITHUB_ENV" + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_15.4.app + xcodebuild -version + swift --version + + - name: Build XCFramework (all platforms) + env: + MLXSWIFT_USE_REMOTE: "1" + run: ./Scripts/build-xcframework.sh build/xcframework + + - name: Zip XCFramework + run: | + cd build/xcframework + zip -r --symlinks "$GITHUB_WORKSPACE/${XCF_ZIP}" StableAudioKit.xcframework + + - name: Rewrite Package.swift to use remote mlx-swift + run: | + python3 - Package.swift "https://github.com/olilarkin/mlx-swift" "main" <<'PY' + import re, sys, pathlib + path, url, branch = sys.argv[1], sys.argv[2], sys.argv[3] + src = pathlib.Path(path).read_text() + pathlib.Path(path).write_text(re.sub( + r'\.package\(\s*path:\s*"\.\./mlx-swift"\s*\)', + f'.package(url: "{url}", branch: "{branch}")', src)) + PY + rm -f Package.resolved + + - name: Build StableAudioCLI (release) + run: swift build -c release --product StableAudioCLI + + - name: Compile mlx.metallib next to CLI binary + run: ./Scripts/compile-mlx-metallib.sh .build/release + + - name: Stage CLI tarball + run: | + mkdir -p "dist/${CLI_STAGE}" + cp .build/release/StableAudioCLI "dist/${CLI_STAGE}/StableAudioCLI" + cp .build/release/mlx.metallib "dist/${CLI_STAGE}/mlx.metallib" + chmod +x "dist/${CLI_STAGE}/StableAudioCLI" + cat > "dist/${CLI_STAGE}/README.txt" < RELEASE_NOTES.md <=1.10,<2", +# "typer>=0.16,<1", +# ] +# /// +# Standalone, self-contained twin of Scripts/prepare_weights.py for users who +# only download this single file from a GitHub Release. Keep the body of this +# file in sync with Scripts/prepare_weights.py; the dependency list above +# mirrors requirements.txt. +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess + +import mlx.core as mx +import numpy as np + + +COMMON_FILES = [ + { + "role": "T5Gemma text encoder", + "sourceFileName": "t5gemma_f16.npz", + "fileName": "t5gemma_f16.safetensors", + }, +] + +SMALL_FILES = [ + { + "role": "DiT small-music", + "sourceFileName": "dit_sm-music_f16.npz", + "fileName": "dit_sm-music_f16.safetensors", + }, + { + "role": "DiT small-sfx", + "sourceFileName": "dit_sm-sfx_f16.npz", + "fileName": "dit_sm-sfx_f16.safetensors", + }, + { + "role": "same-s decoder", + "sourceFileName": "same_s_decoder_f32.npz", + "fileName": "same_s_decoder_f32.safetensors", + }, +] + +SMALL_ENCODER_FILES = [ + { + "role": "same-s encoder", + "sourceFileName": "same_s_encoder_f32.npz", + "fileName": "same_s_encoder_f32.safetensors", + }, +] + +MEDIUM_FILES = [ + { + "role": "DiT medium", + "sourceFileName": "dit_medium_f16.npz", + "fileName": "dit_medium_f16.safetensors", + }, + { + "role": "same-l decoder", + "sourceFileName": "same_l_decoder_f32.npz", + "fileName": "same_l_decoder_f32.safetensors", + }, +] + +MEDIUM_ENCODER_FILES = [ + { + "role": "same-l encoder", + "sourceFileName": "same_l_encoder_f32.npz", + "fileName": "same_l_encoder_f32.safetensors", + }, +] + +TOKENIZER_FILE = "t5gemma_tokenizer.model" +LEGACY_CONDITIONER_FILE = "sa3_conditioner.safetensors" + +SMALL_CONDITIONER_FILES = [ + { + "role": "Conditioner small-music", + "sourceFileName": "dit_sm-music_f16.npz", + "fileName": "sa3_conditioner_sm-music.safetensors", + }, + { + "role": "Conditioner small-sfx", + "sourceFileName": "dit_sm-sfx_f16.npz", + "fileName": "sa3_conditioner_sm-sfx.safetensors", + }, +] + +MEDIUM_CONDITIONER_FILES = [ + { + "role": "Conditioner medium", + "sourceFileName": "dit_medium_f16.npz", + "fileName": "sa3_conditioner_medium.safetensors", + }, +] + + +def ensure_huggingface_auth() -> None: + if os.environ.get("HF_TOKEN"): + return + hf = shutil.which("hf") + if not hf: + raise RuntimeError( + "Gated model download requires authorization. Install the Hugging Face CLI " + "(`hf`) and run `hf auth login`, or set HF_TOKEN to a token that has access " + "to the Stability AI gated model." + ) + result = subprocess.run([hf, "auth", "whoami"], capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + "Gated model download requires authorization. Run `hf auth login` after " + "accepting the model terms on Hugging Face, or set HF_TOKEN to an authorized token." + ) + + +def download_weights(repo_id: str, target: Path, revision: str | None) -> None: + ensure_huggingface_auth() + hf = shutil.which("hf") + if not hf: + raise RuntimeError("Hugging Face CLI `hf` is required for downloads.") + + command = [ + hf, + "download", + repo_id, + "--include", + "MLX/*", + "--local-dir", + str(target), + ] + if revision: + command.extend(["--revision", revision]) + print("downloading gated weights with Hugging Face authorization") + subprocess.run(command, check=True) + + +def convert_file(source: Path, target: Path) -> int: + print(f"loading {source}") + arrays = dict(mx.load(str(source))) + # SAME-L stores mapping.weight as PyTorch Conv1d [out, in, 1]; flatten to + # nn.Linear [out, in] so the Swift loader can use it as a regular matmul. + if "mapping.weight" in arrays: + w = arrays["mapping.weight"] + if w.ndim == 3 and w.shape[-1] == 1: + arrays["mapping.weight"] = w.reshape(w.shape[0], w.shape[1]) + print(f"saving {target} ({len(arrays)} tensors)") + mx.save_safetensors(str(target), arrays) + return target.stat().st_size + + +def extract_tokenizer(source: Path, target: Path) -> int: + print(f"extracting tokenizer {target}") + with np.load(source) as archive: + tokenizer = archive["TOKENIZER_MODEL"].tobytes() + target.write_bytes(tokenizer) + return target.stat().st_size + + +def extract_conditioner(source: Path, target: Path) -> int: + print(f"extracting conditioner {target}") + source_arrays = dict(mx.load(str(source))) + conditioner = { + key: source_arrays[key] + for key in ( + "cond.padding_embedding", + "cond.seconds_total_weight", + "cond.seconds_total_bias", + ) + } + mx.save_safetensors(str(target), conditioner) + return target.stat().st_size + + +def can_skip(target: Path) -> bool: + return target.exists() and target.stat().st_size > 0 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--source", + type=Path, + default=Path("Models/stable-audio-3-optimized/MLX"), + help="Directory containing official optimized/mlx NPZ files.", + ) + parser.add_argument( + "--destination", + type=Path, + default=Path("Resources/Weights"), + help="Directory where iOS safetensors resources will be written.", + ) + parser.add_argument( + "--skip-existing", + action="store_true", + help="Do not rewrite safetensors files that already exist.", + ) + parser.add_argument( + "--download", + action="store_true", + help="Download gated weights with `hf download` before conversion.", + ) + parser.add_argument( + "--repo-id", + default="stabilityai/stable-audio-3-optimized", + help="Hugging Face repo id containing the official MLX weights.", + ) + parser.add_argument( + "--revision", + default=None, + help="Optional Hugging Face revision, tag, or commit to download.", + ) + parser.add_argument( + "--variants", + nargs="+", + choices=["small", "medium"], + default=["small"], + help="Model size(s) to prepare. Defaults to the small variants.", + ) + parser.add_argument( + "--with-encoder", + action="store_true", + help="Also convert the SAME encoder weights needed for audio-to-audio " + "and inpainting (same_s_encoder_f32.npz / same_l_encoder_f32.npz).", + ) + args = parser.parse_args() + + variants = set(args.variants) + + source_dir = args.source.resolve() + destination_dir = args.destination.resolve() + destination_dir.mkdir(parents=True, exist_ok=True) + + if args.download: + download_target = source_dir.parent + download_target.mkdir(parents=True, exist_ok=True) + download_weights(args.repo_id, download_target, args.revision) + + files_to_convert = list(COMMON_FILES) + conditioner_sources = [] + if "small" in variants: + files_to_convert.extend(SMALL_FILES) + if args.with_encoder: + files_to_convert.extend(SMALL_ENCODER_FILES) + conditioner_sources.extend(SMALL_CONDITIONER_FILES) + if "medium" in variants: + files_to_convert.extend(MEDIUM_FILES) + if args.with_encoder: + files_to_convert.extend(MEDIUM_ENCODER_FILES) + conditioner_sources.extend(MEDIUM_CONDITIONER_FILES) + + manifest_files = [] + for item in files_to_convert: + source = source_dir / item["sourceFileName"] + target = destination_dir / item["fileName"] + if not source.exists(): + raise FileNotFoundError(source) + + if args.skip_existing and can_skip(target): + size = target.stat().st_size + else: + size = convert_file(source, target) + + manifest_files.append( + { + "role": item["role"], + "fileName": item["fileName"], + "minimumBytes": size, + "sourceFileName": item["sourceFileName"], + } + ) + + tokenizer_source = source_dir / "t5gemma_f16.npz" + tokenizer_target = destination_dir / TOKENIZER_FILE + if args.skip_existing and can_skip(tokenizer_target): + tokenizer_size = tokenizer_target.stat().st_size + else: + tokenizer_size = extract_tokenizer(tokenizer_source, tokenizer_target) + manifest_files.append( + { + "role": "T5Gemma tokenizer", + "fileName": TOKENIZER_FILE, + "minimumBytes": tokenizer_size, + "sourceFileName": "t5gemma_f16.npz:TOKENIZER_MODEL", + } + ) + + conditioner_manifest = [] + for item in conditioner_sources: + conditioner_source = source_dir / item["sourceFileName"] + conditioner_target = destination_dir / item["fileName"] + if not conditioner_source.exists(): + raise FileNotFoundError(conditioner_source) + + if args.skip_existing and can_skip(conditioner_target): + conditioner_size = conditioner_target.stat().st_size + else: + conditioner_size = extract_conditioner(conditioner_source, conditioner_target) + + manifest_item = { + "role": item["role"], + "fileName": item["fileName"], + "minimumBytes": conditioner_size, + "sourceFileName": item["sourceFileName"], + } + manifest_files.append(manifest_item) + conditioner_manifest.append(manifest_item) + + if item["fileName"] == "sa3_conditioner_sm-music.safetensors": + legacy_target = destination_dir / LEGACY_CONDITIONER_FILE + if not (args.skip_existing and can_skip(legacy_target)): + legacy_target.write_bytes(conditioner_target.read_bytes()) + + model_segments = [] + if "small" in variants: + model_segments.append("stable-audio-3-small-music+stable-audio-3-small-sfx") + if "medium" in variants: + model_segments.append("stable-audio-3-medium") + manifest = { + "model": "stabilityai/" + "+".join(model_segments) if model_segments else "stabilityai/none", + "format": "safetensors", + "tokenizer": { + "fileName": TOKENIZER_FILE, + "bytes": tokenizer_size, + "tokenOffset": 0, + }, + "conditioners": conditioner_manifest, + "files": manifest_files, + } + manifest_path = destination_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") + print(f"wrote {manifest_path}") + + +if __name__ == "__main__": + main() From c48e8089048c74553ca8b2865896d0430c6f587a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 05:09:25 +0000 Subject: [PATCH 2/2] Select newest Xcode in CI for Swift 5.12 toolchain The olilarkin/mlx-swift fork declares swift-tools-version 5.12, which Xcode 15.4 (Swift 5.10) cannot parse. Pick the newest installed Xcode instead, and download the Metal toolchain on demand since Xcode 16.3+ ships it as a separate component. --- .github/workflows/ci.yml | 12 ++++++++++-- .github/workflows/release.yml | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4060e03..b4a7387 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,19 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Select Xcode + - name: Select newest Xcode and ensure Metal toolchain run: | - sudo xcode-select -s /Applications/Xcode_15.4.app + XCODE="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1)" + [ -z "$XCODE" ] && XCODE=/Applications/Xcode.app + echo "Using $XCODE" + sudo xcode-select -s "$XCODE" xcodebuild -version swift --version + # Xcode 16.3+ ships the Metal toolchain as a separate download. + if ! xcrun metal --version >/dev/null 2>&1; then + echo "Metal toolchain missing; downloading..." + xcodebuild -downloadComponent MetalToolchain + fi - name: Rewrite Package.swift to use remote mlx-swift run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ebb778..edbd922 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,11 +38,19 @@ jobs: echo "CLI_TAR=StableAudioCLI-${VERSION}-macos-arm64.tar.gz" >> "$GITHUB_ENV" echo "CLI_STAGE=StableAudioCLI-${VERSION}-macos-arm64" >> "$GITHUB_ENV" - - name: Select Xcode + - name: Select newest Xcode and ensure Metal toolchain run: | - sudo xcode-select -s /Applications/Xcode_15.4.app + XCODE="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1)" + [ -z "$XCODE" ] && XCODE=/Applications/Xcode.app + echo "Using $XCODE" + sudo xcode-select -s "$XCODE" xcodebuild -version swift --version + # Xcode 16.3+ ships the Metal toolchain as a separate download. + if ! xcrun metal --version >/dev/null 2>&1; then + echo "Metal toolchain missing; downloading..." + xcodebuild -downloadComponent MetalToolchain + fi - name: Build XCFramework (all platforms) env: