diff --git a/Scripts/build-xcframework.sh b/Scripts/build-xcframework.sh new file mode 100755 index 0000000..13352a3 --- /dev/null +++ b/Scripts/build-xcframework.sh @@ -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: /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.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/" \; + + # 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" diff --git a/Scripts/compile-mlx-metallib.sh b/Scripts/compile-mlx-metallib.sh index 6ef8d27..9d2bb22 100755 --- a/Scripts/compile-mlx-metallib.sh +++ b/Scripts/compile-mlx-metallib.sh @@ -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 @@ -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 diff --git a/Scripts/xcframework-resources/Info.plist.tmpl b/Scripts/xcframework-resources/Info.plist.tmpl new file mode 100644 index 0000000..7b19931 --- /dev/null +++ b/Scripts/xcframework-resources/Info.plist.tmpl @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + StableAudioKit + CFBundleIdentifier + com.olilarkin.StableAudioKit + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + StableAudioKit + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + CFBundleSupportedPlatforms + + __SUPPORTED_PLATFORM__ + + MinimumOSVersion + __MIN_OS_VERSION__ + + diff --git a/Scripts/xcframework-resources/StableAudioKit.h b/Scripts/xcframework-resources/StableAudioKit.h new file mode 100644 index 0000000..3ae65a8 --- /dev/null +++ b/Scripts/xcframework-resources/StableAudioKit.h @@ -0,0 +1,85 @@ +#ifndef STABLE_AUDIO_KIT_H +#define STABLE_AUDIO_KIT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Opaque pipeline handle. Create with stable_audio_pipeline_create, +// release with stable_audio_pipeline_destroy. +typedef struct StableAudioPipeline StableAudioPipeline; + +typedef enum StableAudioModel { + STABLE_AUDIO_MODEL_SMALL_MUSIC = 0, + STABLE_AUDIO_MODEL_SMALL_SFX = 1 +} StableAudioModel; + +// Status codes returned by stable_audio_generate and stable_audio_write_wav. +// 0 means success; negative values indicate failure (call stable_audio_last_error). +#define STABLE_AUDIO_OK 0 +#define STABLE_AUDIO_ERR_ARG -1 +#define STABLE_AUDIO_ERR_IO -2 +#define STABLE_AUDIO_ERR_RT -3 + +// Progress callback. Either step_index/step_total are >= 0 and stage_name is NULL +// (sampling progress), or step_index/step_total are -1 and stage_name is non-NULL +// (lifecycle stage event). May be invoked from a background thread. +typedef void (*StableAudioProgressCallback)( + int32_t step_index, + int32_t step_total, + const char *stage_name, + void *user_data); + +// Returns the last error message for the calling thread, or NULL if none. +// The pointer is owned by the library and remains valid until the next +// call into the library on this thread. +const char *stable_audio_last_error(void); + +// Load the pipeline using prepared weights at weights_directory_path +// (the directory produced by Scripts/prepare_weights.py). Returns NULL on failure; +// inspect stable_audio_last_error() for details. +StableAudioPipeline *stable_audio_pipeline_create( + const char *weights_directory_path); + +// Releases the pipeline. NULL is a no-op. +void stable_audio_pipeline_destroy(StableAudioPipeline *pipeline); + +// Run text-to-audio generation. On success returns 0 and writes a newly +// allocated PCM-float32 buffer to *out_samples (free with stable_audio_samples_free), +// along with the buffer size and audio metadata. The samples are PLANAR stereo: +// the first (*out_sample_count / 2) values are the left channel, the next half +// are the right channel. +int32_t stable_audio_generate( + StableAudioPipeline *pipeline, + StableAudioModel model, + const char *prompt_utf8, + float duration_seconds, + int32_t steps, + uint64_t seed, + StableAudioProgressCallback progress, + void *user_data, + float **out_samples, + size_t *out_sample_count, + int32_t *out_channel_count, + int32_t *out_sample_rate, + double *out_elapsed_seconds); + +// Free a buffer returned by stable_audio_generate. NULL is a no-op. +void stable_audio_samples_free(float *samples); + +// Write a stereo float32 buffer (planar layout, same convention as +// stable_audio_generate) to a 16-bit PCM WAV file at output_path_utf8. +int32_t stable_audio_write_wav( + const float *samples, + size_t sample_count, + int32_t sample_rate, + const char *output_path_utf8); + +#ifdef __cplusplus +} +#endif + +#endif // STABLE_AUDIO_KIT_H diff --git a/Scripts/xcframework-resources/module.modulemap b/Scripts/xcframework-resources/module.modulemap new file mode 100644 index 0000000..d41cc50 --- /dev/null +++ b/Scripts/xcframework-resources/module.modulemap @@ -0,0 +1,6 @@ +framework module StableAudioKit { + umbrella header "StableAudioKit.h" + export * + module * { export * } + link "c++" +} diff --git a/Sources/StableAudioKit/CInterop.swift b/Sources/StableAudioKit/CInterop.swift new file mode 100644 index 0000000..3751bbf --- /dev/null +++ b/Sources/StableAudioKit/CInterop.swift @@ -0,0 +1,168 @@ +import Foundation + +// C-ABI surface for the StableAudioKit XCFramework. Callable from C/C++/Swift. +// Headers shipped with the framework: StableAudioKit.h + +private let lastErrorKey = "com.stableaudiokit.lastErrorBuffer" + +private func recordLastError(_ message: String) { + var bytes = Array(message.utf8) + bytes.append(0) + Thread.current.threadDictionary[lastErrorKey] = NSData(bytes: bytes, length: bytes.count) +} + +private func clearLastError() { + Thread.current.threadDictionary.removeObject(forKey: lastErrorKey) +} + +@_cdecl("stable_audio_last_error") +public func stable_audio_last_error() -> UnsafePointer? { + guard let data = Thread.current.threadDictionary[lastErrorKey] as? NSData else { + return nil + } + return data.bytes.assumingMemoryBound(to: CChar.self) +} + +@_cdecl("stable_audio_pipeline_create") +public func stable_audio_pipeline_create( + _ weightsDirectoryPath: UnsafePointer? +) -> OpaquePointer? { + clearLastError() + guard let weightsDirectoryPath else { + recordLastError("weights_directory_path is NULL") + return nil + } + let path = String(cString: weightsDirectoryPath) + let url = URL(fileURLWithPath: path, isDirectory: true) + do { + let pipeline = try StableAudioPipeline(weightsDirectory: url) + let retained = Unmanaged.passRetained(pipeline) + return OpaquePointer(retained.toOpaque()) + } catch { + recordLastError("\(error)") + return nil + } +} + +@_cdecl("stable_audio_pipeline_destroy") +public func stable_audio_pipeline_destroy(_ pipelinePtr: OpaquePointer?) { + guard let pipelinePtr else { return } + Unmanaged.fromOpaque(UnsafeRawPointer(pipelinePtr)).release() +} + +private typealias CProgressCallback = @convention(c) ( + Int32, Int32, UnsafePointer?, UnsafeMutableRawPointer? +) -> Void + +private final class ResultBox: @unchecked Sendable { + var value: Result? +} + +@_cdecl("stable_audio_generate") +public func stable_audio_generate( + _ pipelinePtr: OpaquePointer?, + _ modelRaw: Int32, + _ promptUTF8: UnsafePointer?, + _ durationSeconds: Float, + _ steps: Int32, + _ seed: UInt64, + _ progress: CProgressCallback?, + _ userData: UnsafeMutableRawPointer?, + _ outSamples: UnsafeMutablePointer?>?, + _ outSampleCount: UnsafeMutablePointer?, + _ outChannelCount: UnsafeMutablePointer?, + _ outSampleRate: UnsafeMutablePointer?, + _ outElapsedSeconds: UnsafeMutablePointer? +) -> Int32 { + clearLastError() + guard let pipelinePtr, let promptUTF8 else { + recordLastError("pipeline or prompt is NULL") + return -1 + } + let pipeline = Unmanaged + .fromOpaque(UnsafeRawPointer(pipelinePtr)) + .takeUnretainedValue() + let kind: StableAudioModelKind = modelRaw == 1 ? .smallSFX : .smallMusic + let request = StableAudioGenerationRequest( + model: kind, + prompt: String(cString: promptUTF8), + seconds: durationSeconds, + steps: Int(steps), + seed: seed + ) + + let semaphore = DispatchSemaphore(value: 0) + let box = ResultBox() + let progressFP = progress + let progressUD = userData + Task.detached { + do { + let result = try await pipeline.generate(request) { event in + guard let progressFP else { return } + switch event { + case .stage(let name): + name.withCString { ptr in + progressFP(-1, -1, ptr, progressUD) + } + case .samplingStep(let index, let total): + progressFP(Int32(index), Int32(total), nil, progressUD) + } + } + box.value = .success(result) + } catch { + box.value = .failure(error) + } + semaphore.signal() + } + semaphore.wait() + + switch box.value! { + case .success(let result): + let count = result.samples.count + let buffer = UnsafeMutablePointer.allocate(capacity: max(count, 1)) + result.samples.withUnsafeBufferPointer { src in + if let base = src.baseAddress { + buffer.initialize(from: base, count: count) + } + } + outSamples?.pointee = buffer + outSampleCount?.pointee = count + outChannelCount?.pointee = Int32(result.channelCount) + outSampleRate?.pointee = Int32(result.sampleRate) + outElapsedSeconds?.pointee = result.elapsedSeconds + return 0 + case .failure(let error): + recordLastError("\(error)") + return -3 + } +} + +@_cdecl("stable_audio_samples_free") +public func stable_audio_samples_free(_ samples: UnsafeMutablePointer?) { + guard let samples else { return } + samples.deallocate() +} + +@_cdecl("stable_audio_write_wav") +public func stable_audio_write_wav( + _ samples: UnsafePointer?, + _ sampleCount: Int, + _ sampleRate: Int32, + _ outputPathUTF8: UnsafePointer? +) -> Int32 { + clearLastError() + guard let samples, let outputPathUTF8, sampleCount > 0, sampleRate > 0 else { + recordLastError("invalid arguments to stable_audio_write_wav") + return -1 + } + let path = String(cString: outputPathUTF8) + let url = URL(fileURLWithPath: path) + let array = Array(UnsafeBufferPointer(start: samples, count: sampleCount)) + do { + try AudioWriter.writeStereoSamples(array, sampleRate: Int(sampleRate), to: url) + return 0 + } catch { + recordLastError("\(error)") + return -2 + } +}