Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 261 additions & 0 deletions Scripts/build-xcframework.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
# Builds StableAudioKit.xcframework with all transitive dependencies (mlx-swift,
# swift-sentencepiece) statically bundled into a single static framework binary.
# Produces one slice per platform/destination and combines them with
# xcodebuild -create-xcframework.
#
# Usage:
# ./Scripts/build-xcframework.sh [output_dir]
#
# Output: <output_dir>/StableAudioKit.xcframework (default: build/xcframework)
#
# Requirements: macOS with Xcode 15+ command line tools. The script must be run
# from a clean checkout — it temporarily rewrites Package.swift to swap the
# local ../mlx-swift dependency for the upstream git URL.

set -euo pipefail

if [[ "$(uname -s)" != "Darwin" ]]; then
echo "error: This script must run on macOS." >&2
exit 1
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_DIR="$(dirname "$SCRIPT_DIR")"
RESOURCES_DIR="$SCRIPT_DIR/xcframework-resources"
COMPILE_METALLIB="$SCRIPT_DIR/compile-mlx-metallib.sh"

OUTPUT_DIR="${1:-"$PACKAGE_DIR/build/xcframework"}"
WORK_DIR="$PACKAGE_DIR/build/xcframework-work"
SLICES_DIR="$WORK_DIR/slices"
XCFRAMEWORK_PATH="$OUTPUT_DIR/StableAudioKit.xcframework"

# Mlx-swift fork used in place of the ../mlx-swift path dependency.
MLXSWIFT_URL="https://github.com/olilarkin/mlx-swift"
MLXSWIFT_BRANCH="${MLXSWIFT_BRANCH:-main}"

# The package's Sources/StableAudioKit module name as exposed to Swift consumers.
MODULE_NAME="StableAudioKit"
FRAMEWORK_NAME="StableAudioKit"

# (slice_id, scheme_destination, sdk, min_os, swift_triple_prefix, metal_target_prefix)
# We build StableAudioKit (the SPM library product) per slice via xcodebuild.
SLICES=(
"macos|generic/platform=macOS|macosx|14.0|arm64-apple-macosx|air64-apple-macosx"
"ios|generic/platform=iOS|iphoneos|17.0|arm64-apple-ios|air64-apple-ios"
"ios-simulator|generic/platform=iOS Simulator|iphonesimulator|17.0|arm64-apple-ios-simulator|air64-apple-ios-simulator"
"xros|generic/platform=visionOS|xros|1.0|arm64-apple-xros|air64-apple-xros"
"xros-simulator|generic/platform=visionOS Simulator|xrsimulator|1.0|arm64-apple-xros-simulator|air64-apple-xros-simulator"
)

if [[ ! -x "$COMPILE_METALLIB" ]]; then
echo "error: $COMPILE_METALLIB not found or not executable." >&2
exit 1
fi

mkdir -p "$WORK_DIR" "$SLICES_DIR" "$OUTPUT_DIR"

# --- Prep: rewrite Package.swift to use the public mlx-swift fork ---

PACKAGE_FILE="$PACKAGE_DIR/Package.swift"
PACKAGE_BACKUP="$WORK_DIR/Package.swift.bak"
RESOLVED_FILE="$PACKAGE_DIR/Package.resolved"
RESOLVED_BACKUP="$WORK_DIR/Package.resolved.bak"

cp "$PACKAGE_FILE" "$PACKAGE_BACKUP"
if [[ -f "$RESOLVED_FILE" ]]; then
cp "$RESOLVED_FILE" "$RESOLVED_BACKUP"
fi

restore_package() {
if [[ -f "$PACKAGE_BACKUP" ]]; then
mv "$PACKAGE_BACKUP" "$PACKAGE_FILE"
fi
if [[ -f "$RESOLVED_BACKUP" ]]; then
mv "$RESOLVED_BACKUP" "$RESOLVED_FILE"
fi
}
trap restore_package EXIT

python3 - "$PACKAGE_FILE" "$MLXSWIFT_URL" "$MLXSWIFT_BRANCH" <<'PY'
import re, sys, pathlib
path, url, branch = sys.argv[1], sys.argv[2], sys.argv[3]
src = pathlib.Path(path).read_text()
new = re.sub(
r'\.package\(\s*path:\s*"\.\./mlx-swift"\s*\)',
f'.package(url: "{url}", branch: "{branch}")',
src,
)
if new == src:
print("warn: no ../mlx-swift path dependency found in Package.swift to rewrite",
file=sys.stderr)
pathlib.Path(path).write_text(new)
PY

# --- Build each slice ---

build_slice() {
local id="$1" dest="$2" sdk="$3" min_os="$4" triple_prefix="$5" metal_target_prefix="$6"

echo ""
echo "==> Building slice: $id ($dest)"

local derived="$WORK_DIR/derived-$id"
rm -rf "$derived"

xcodebuild build \
-scheme "$MODULE_NAME" \
-destination "$dest" \
-derivedDataPath "$derived" \
-configuration Release \
-skipPackagePluginValidation \
BUILD_LIBRARY_FOR_DISTRIBUTION=YES \
SKIP_INSTALL=NO \
SWIFT_OPTIMIZATION_LEVEL="-O" \
OTHER_SWIFT_FLAGS="-no-verify-emitted-module-interface" \
ONLY_ACTIVE_ARCH=NO

# Locate the build products directory for this slice.
local products
products="$(find "$derived/Build/Products" -maxdepth 1 -type d -name "Release-*" | head -n1)"
if [[ -z "$products" ]]; then
echo "error: Could not find Release-* products directory for slice $id." >&2
exit 1
fi
echo " products: $products"

# Collect every static archive produced by the package build.
# SPM Swift/C/C++ targets each emit lib<Target>.a here; xcodebuild puts
# mlx-swift, sentencepiece, sentencepiece C++, and our own targets together.
local merge_input
merge_input="$WORK_DIR/merge-$id"
rm -rf "$merge_input"
mkdir -p "$merge_input"

local archives=()
while IFS= read -r -d '' lib; do
archives+=("$lib")
done < <(find "$products" -maxdepth 2 -name "*.a" -print0)

# Some SPM builds bury archives under PackageFrameworks; pick those up too.
while IFS= read -r -d '' lib; do
archives+=("$lib")
done < <(find "$derived/Build/Intermediates.noindex" -name "*.a" -print0 2>/dev/null)

if [[ ${#archives[@]} -eq 0 ]]; then
echo "error: No .a archives found for slice $id." >&2
exit 1
fi

echo " merging ${#archives[@]} static archive(s)"
local merged_binary="$merge_input/$FRAMEWORK_NAME"
xcrun libtool -static -no_warning_for_no_symbols -o "$merged_binary" "${archives[@]}"

# Assemble the .framework bundle.
local slice_root="$SLICES_DIR/$id"
local fw="$slice_root/$FRAMEWORK_NAME.framework"
rm -rf "$slice_root"
mkdir -p "$fw/Headers" "$fw/Modules/$MODULE_NAME.swiftmodule"

cp "$merged_binary" "$fw/$FRAMEWORK_NAME"
cp "$RESOURCES_DIR/StableAudioKit.h" "$fw/Headers/StableAudioKit.h"
cp "$RESOURCES_DIR/module.modulemap" "$fw/Modules/module.modulemap"

# Find the StableAudioKit swiftmodule directory produced for this slice and
# copy its arch-specific files into the framework. xcodebuild typically
# writes it under Build/Intermediates.noindex/.../$MODULE_NAME.swiftmodule/.
local found_modules=()
while IFS= read -r -d '' f; do
found_modules+=("$f")
done < <(find "$derived" \
-type d -name "$MODULE_NAME.swiftmodule" -print0 2>/dev/null)

if [[ ${#found_modules[@]} -eq 0 ]]; then
echo "error: No $MODULE_NAME.swiftmodule directory found for slice $id." >&2
exit 1
fi

# Prefer a directory that contains a .swiftinterface (BUILD_LIBRARY_FOR_DISTRIBUTION output).
local best=""
for d in "${found_modules[@]}"; do
if compgen -G "$d/*.swiftinterface" > /dev/null; then
best="$d"
break
fi
done
if [[ -z "$best" ]]; then
best="${found_modules[0]}"
fi
echo " swiftmodule: $best"

# Copy every per-arch artifact (swiftmodule/swiftdoc/swiftinterface/abi.json).
find "$best" -maxdepth 1 -type f \
\( -name "*.swiftmodule" -o -name "*.swiftdoc" \
-o -name "*.swiftinterface" -o -name "*.private.swiftinterface" \
-o -name "*.abi.json" -o -name "*.swiftsourceinfo" \) \
-exec cp {} "$fw/Modules/$MODULE_NAME.swiftmodule/" \;
Comment on lines +191 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include dependency Swift modules in the XCFramework

For Swift consumers, copying only StableAudioKit.swiftmodule leaves the generated .swiftinterface with unresolved public dependency types: AudioWriter.write(_ audio: MLXArray, ...) is public, so the interface imports/references MLX, but this script only libtool-merges the dependency binaries and never packages the dependency .swiftmodule/.swiftinterface files. A client that adds only this supposedly self-contained XCFramework and runs import StableAudioKit will fail to compile because it cannot load the MLX module; either hide those dependency types from the public API or include the required Swift modules alongside the framework.

Useful? React with 👍 / 👎.


# Write the per-slice Info.plist with the right CFBundleSupportedPlatforms /
# MinimumOSVersion. We use a single hand-rolled file; macOS frameworks
# normally use Versions/A but the flat layout works for create-xcframework
# consumption on every Apple platform.
local platform_value
case "$sdk" in
macosx) platform_value="MacOSX" ;;
iphoneos) platform_value="iPhoneOS" ;;
iphonesimulator) platform_value="iPhoneSimulator" ;;
xros) platform_value="XROS" ;;
xrsimulator) platform_value="XRSimulator" ;;
*) echo "error: unknown sdk $sdk" >&2; exit 1 ;;
esac
sed -e "s/__SUPPORTED_PLATFORM__/$platform_value/" \
-e "s/__MIN_OS_VERSION__/$min_os/" \
"$RESOURCES_DIR/Info.plist.tmpl" > "$fw/Info.plist"

# Compile mlx.metallib for this slice and embed it in the framework.
# The compile script targets macOS by default — invoke it with the
# right Metal target so the resulting metallib loads at runtime.
local metal_target
case "$sdk" in
macosx) metal_target="${metal_target_prefix}${min_os}" ;;
iphoneos) metal_target="${metal_target_prefix}${min_os}" ;;
iphonesimulator) metal_target="${metal_target_prefix}${min_os}" ;;
xros) metal_target="${metal_target_prefix}${min_os}" ;;
xrsimulator) metal_target="${metal_target_prefix}${min_os}" ;;
esac

local mlx_checkout="$derived/SourcePackages/checkouts/mlx-swift"
METAL_TARGET="$metal_target" MLX_SWIFT_DIR="$mlx_checkout" \
"$COMPILE_METALLIB" "$fw" >/dev/null

# Copy any *.bundle resources produced by SPM dependencies (e.g. sentencepiece).
while IFS= read -r -d '' bundle; do
cp -R "$bundle" "$fw/"
done < <(find "$products" -maxdepth 2 -type d -name "*.bundle" -print0)

echo " slice ready: $fw"
}

for entry in "${SLICES[@]}"; do
IFS='|' read -r id dest sdk min_os triple_prefix metal_target_prefix <<<"$entry"
build_slice "$id" "$dest" "$sdk" "$min_os" "$triple_prefix" "$metal_target_prefix"
done

# --- Combine slices into a single XCFramework ---

echo ""
echo "==> Creating XCFramework"
rm -rf "$XCFRAMEWORK_PATH"

CREATE_ARGS=()
for entry in "${SLICES[@]}"; do
IFS='|' read -r id _rest <<<"$entry"
CREATE_ARGS+=(-framework "$SLICES_DIR/$id/$FRAMEWORK_NAME.framework")
done

xcodebuild -create-xcframework \
"${CREATE_ARGS[@]}" \
-output "$XCFRAMEWORK_PATH"

echo ""
echo "Done: $XCFRAMEWORK_PATH"
47 changes: 34 additions & 13 deletions Scripts/compile-mlx-metallib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,39 @@ PACKAGE_DIR="$(dirname "$SCRIPT_DIR")"
OUTPUT_DIR="${1:-"$PACKAGE_DIR/.build/arm64-apple-macosx/debug"}"
OUTPUT_LIB="$OUTPUT_DIR/mlx.metallib"

# Find the SPM-resolved mlx-swift checkout (prefer local override)
MLXSWIFT_LOCAL="$PACKAGE_DIR/../mlx-swift"
MLXSWIFT_SPM="$PACKAGE_DIR/.build/checkouts/mlx-swift"

if [ -d "$MLXSWIFT_LOCAL/Source/Cmlx/mlx-generated/metal" ]; then
METAL_BASE="$MLXSWIFT_LOCAL/Source/Cmlx"
elif [ -d "$MLXSWIFT_SPM/Source/Cmlx/mlx-generated/metal" ]; then
METAL_BASE="$MLXSWIFT_SPM/Source/Cmlx"
else
echo "error: Cannot find mlx-swift Metal sources." >&2
echo " Tried: $MLXSWIFT_LOCAL" >&2
echo " Tried: $MLXSWIFT_SPM" >&2
# Target triple for AIR compilation. Override via METAL_TARGET to target
# iOS, visionOS, simulators, etc. The default targets macOS 14 arm64.
METAL_TARGET="${METAL_TARGET:-air64-apple-macosx14.0}"

# Search locations for the mlx-swift checkout, in order. The caller can prepend
# an extra path via MLX_SWIFT_DIR — useful when xcodebuild has resolved the
# package into its DerivedData SourcePackages directory.
SEARCH_DIRS=()
if [ -n "${MLX_SWIFT_DIR:-}" ]; then
SEARCH_DIRS+=("$MLX_SWIFT_DIR")
fi
SEARCH_DIRS+=(
"$PACKAGE_DIR/../mlx-swift"
"$PACKAGE_DIR/.build/checkouts/mlx-swift"
)
# Plus any SourcePackages checkouts under build/xcframework-work derived dirs.
while IFS= read -r -d '' found; do
SEARCH_DIRS+=("$found")
done < <(find "$PACKAGE_DIR/build" -type d -name "mlx-swift" -path "*/SourcePackages/checkouts/*" -print0 2>/dev/null)

METAL_BASE=""
for candidate in "${SEARCH_DIRS[@]}"; do
if [ -d "$candidate/Source/Cmlx/mlx-generated/metal" ]; then
METAL_BASE="$candidate/Source/Cmlx"
break
fi
done

if [ -z "$METAL_BASE" ]; then
echo "error: Cannot find mlx-swift Metal sources. Tried:" >&2
for d in "${SEARCH_DIRS[@]}"; do
echo " $d" >&2
done
exit 1
fi

Expand Down Expand Up @@ -72,7 +93,7 @@ for metal_file in "${METAL_FILES[@]}"; do
echo " Compiling $base.metal ..."
if ! xcrun metal \
-arch air64 \
-target "air64-apple-macosx14.0" \
-target "$METAL_TARGET" \
"${INCLUDE_ARGS[@]}" \
-c "$metal_file" \
-o "$air_file" 2>&1; then
Expand Down
28 changes: 28 additions & 0 deletions Scripts/xcframework-resources/Info.plist.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>StableAudioKit</string>
<key>CFBundleIdentifier</key>
<string>com.olilarkin.StableAudioKit</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>StableAudioKit</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>__SUPPORTED_PLATFORM__</string>
</array>
<key>MinimumOSVersion</key>
<string>__MIN_OS_VERSION__</string>
</dict>
</plist>
Loading