diff --git a/.github/workflows/release-sdk-swift.yml b/.github/workflows/release-sdk-swift.yml new file mode 100644 index 0000000..448c004 --- /dev/null +++ b/.github/workflows/release-sdk-swift.yml @@ -0,0 +1,212 @@ +name: Release SDK Swift + +# Swift SDK releases use their own `swift-vX.Y.Z` tags so they never trigger +# release.yml (CLI, plugin, Pages), whose `v*` pattern does not match them. +on: + push: + tags: + - "swift-v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + inputs: + publish: + description: "Publish demucs-swift release assets from the selected ref" + type: boolean + default: false + release_tag: + description: "Existing swift-vX.Y.Z tag to publish on manual runs" + type: string + required: false + +permissions: + contents: write + +# One release at a time per tag. Not cancel-in-progress: the later steps create +# a GitHub release and then push a commit and a tag to demucs-swift, and killing +# a run part-way through leaves a release with no assets on it, or demucs-swift +# pointing at a release that no longer matches. +concurrency: + group: release-sdk-swift-${{ github.ref }} + cancel-in-progress: false + +jobs: + preflight: + # macOS rather than Linux: the release artifact is an XCFramework and + # demucs-ffi pulls in burn's Metal backend on Apple targets. + runs-on: macos-26 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + key: sdk-swift-preflight + - name: Validate tag matches demucs-ffi version + if: startsWith(github.ref, 'refs/tags/') + run: | + version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"] == "demucs-ffi"))')" + if [ "${GITHUB_REF_NAME#swift-v}" != "$version" ]; then + echo "::error::Tag '$GITHUB_REF_NAME' does not match demucs-ffi version '$version'." + exit 1 + fi + - run: cargo fmt --all -- --check + - run: cargo clippy --locked -p demucs-ffi --all-targets -- -D warnings + - run: cargo test --locked -p demucs-ffi + + build-and-release: + needs: preflight + runs-on: macos-26 + steps: + - uses: actions/checkout@v6 + with: + path: demucs-rs + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,aarch64-apple-darwin + # tvOS and visionOS are tier 3: the script builds them with + # `+nightly -Z build-std`, which needs rust-src. + - uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + - uses: Swatinem/rust-cache@v2 + with: + workspaces: demucs-rs + key: sdk-swift-apple + - name: Build XCFramework + working-directory: demucs-rs + run: .github/scripts/build-swift-xcframework.sh + - id: meta + working-directory: demucs-rs + run: | + echo "version=$(cat dist/swift/version.txt)" >> "$GITHUB_OUTPUT" + echo "checksum=$(cat dist/swift/DemucsFramework.checksum.txt)" >> "$GITHUB_OUTPUT" + - id: release + env: + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + release_tag="${GITHUB_REF_NAME}" + else + release_tag="${INPUT_RELEASE_TAG}" + fi + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + - name: Validate release tag + if: startsWith(github.ref, 'refs/tags/') || inputs.publish + env: + VERSION: ${{ steps.meta.outputs.version }} + RELEASE_TAG: ${{ steps.release.outputs.release_tag }} + HAS_PAT: ${{ secrets.DEMUCS_SWIFT_PAT != '' }} + run: | + # Fail before the GitHub release exists rather than after, when the + # push to demucs-swift would be the first thing to notice. + if [ "$HAS_PAT" != "true" ]; then + echo "::error::DEMUCS_SWIFT_PAT is not set; it needs contents:write on SplitFireAI/demucs-swift." + exit 1 + fi + if [ -z "$RELEASE_TAG" ]; then + echo "::error::release_tag is required when publish=true on workflow_dispatch runs." + exit 1 + fi + if [ "$RELEASE_TAG" != "swift-v$VERSION" ]; then + echo "::error::Release tag '$RELEASE_TAG' does not match 'swift-v$VERSION' (demucs-ffi version)." + exit 1 + fi + - name: Checkout demucs-swift + uses: actions/checkout@v6 + with: + repository: SplitFireAI/demucs-swift + # Read-only use for dry runs; the PAT is only needed to push, but a + # private repository would need it to clone as well. + token: ${{ secrets.DEMUCS_SWIFT_PAT || github.token }} + path: demucs-swift + # Full history and tags: the push step rebases onto main, and the + # commit step compares against an existing release tag. + fetch-depth: 0 + # Gate the release on the Swift package building and passing its tests + # against the framework just built. Package.swift prefers a local + # DemucsFramework.xcframework over the release URL when one is present, + # so this exercises the new binary without publishing it first. + - name: Test Swift package against the new framework + working-directory: demucs-swift + run: | + cp -R ../demucs-rs/dist/swift/DemucsFramework.xcframework DemucsFramework.xcframework + cp ../demucs-rs/dist/swift/Package/Sources/Demucs/demucs_ffi.swift Sources/Demucs/demucs_ffi.swift + swift test + rm -rf DemucsFramework.xcframework .build + - name: Upload build artifact + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v7 + with: + name: demucs-swift-xcframework + path: | + demucs-rs/dist/swift/DemucsFramework.xcframework.zip + demucs-rs/dist/swift/DemucsFramework.checksum.txt + demucs-rs/dist/swift/version.txt + demucs-rs/dist/swift/Package/Sources/Demucs/demucs_ffi.swift + - name: Update Swift package + if: startsWith(github.ref, 'refs/tags/') || inputs.publish + env: + CHECKSUM: ${{ steps.meta.outputs.checksum }} + RELEASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ steps.release.outputs.release_tag }}/DemucsFramework.xcframework.zip + run: | + python3 - <<'PY' + import os, pathlib, re + package = pathlib.Path("demucs-swift/Package.swift") + source = package.read_text() + source, urls = re.subn(r'(let releaseFrameworkURL =\s*")([^"]+)(")', rf'\g<1>{os.environ["RELEASE_URL"]}\g<3>', source, count=1) + source, sums = re.subn(r'(let releaseFrameworkChecksum =\s*")([^"]+)(")', rf'\g<1>{os.environ["CHECKSUM"]}\g<3>', source, count=1) + if (urls, sums) != (1, 1): + raise RuntimeError("Package.swift release constants were not found") + package.write_text(source) + PY + # Commit locally before the GitHub release exists, so a conflicting tag + # stops the run while nothing has been published yet. + - name: Commit Swift package + if: startsWith(github.ref, 'refs/tags/') || inputs.publish + working-directory: demucs-swift + env: + # SwiftPM resolves `from: "0.1.0"` against plain semver tags, so + # demucs-swift is tagged X.Y.Z rather than swift-vX.Y.Z. + VERSION: ${{ steps.meta.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Package.swift Sources/Demucs/demucs_ffi.swift + git diff --cached --quiet || git commit -m "Release $VERSION" + # A rerun of the same release finds its own tag, which is fine. A tag + # pinning a different framework would leave SwiftPM users on it. + if git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" > /dev/null; then + if ! git diff --quiet "refs/tags/$VERSION" HEAD -- Package.swift Sources/Demucs/demucs_ffi.swift; then + echo "::error::Tag $VERSION already exists on demucs-swift with a different Package.swift or bindings." + exit 1 + fi + fi + - name: Create GitHub release + if: startsWith(github.ref, 'refs/tags/') || inputs.publish + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.release.outputs.release_tag }} + name: Swift SDK ${{ steps.meta.outputs.version }} + generate_release_notes: true + # Keep "Latest" on the CLI/plugin releases from release.yml. + make_latest: false + files: | + demucs-rs/dist/swift/DemucsFramework.xcframework.zip + demucs-rs/dist/swift/DemucsFramework.checksum.txt + - name: Push and tag Swift package + if: startsWith(github.ref, 'refs/tags/') || inputs.publish + working-directory: demucs-swift + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + # main may have moved while the Swift tests ran. If this fails, the + # release above is already up; rerunning the job replaces its assets + # and retries the push. + git pull --rebase origin main + git push origin HEAD:main + if git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" > /dev/null; then + echo "Tag $VERSION already exists with the same release files." + else + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + fi diff --git a/README.md b/README.md index cc430fd..4cfe447 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,21 @@ The browser version compiles the full inference pipeline to WebAssembly and runs Model weights are downloaded automatically from Hugging Face on first use (both CLI and web). +## Swift SDK + +[demucs-swift](https://github.com/SplitFireAI/demucs-swift) packages the same +inference for iOS, macOS, tvOS, and visionOS apps, GPU-accelerated through +Metal. It is built from the `demucs-ffi` crate (UniFFI) and published by the +`Release SDK Swift` workflow whenever a `swift-vX.Y.Z` tag is pushed; the tag +must match the `demucs-ffi` crate version. + +To build the XCFramework locally (macOS with Xcode, a stable toolchain with the +iOS/macOS targets, and a nightly with `rust-src` for tvOS/visionOS): + +```bash +make swift-xcframework +``` + ## CLI ``` @@ -175,12 +190,14 @@ make web | `make dev` | WASM debug + Vite dev server | | `make dev-release` | WASM release + Vite dev server | | `make web` | Full production web build | +| `make swift-xcframework` | Build the demucs-swift XCFramework and bindings into `dist/swift` (macOS) | | `make clean` | Remove all build artifacts | ### Running Tests ```bash cargo test -p demucs-core +cargo test -p demucs-ffi ``` ## Project Structure @@ -192,6 +209,8 @@ demucs-rs/ ├── demucs-cli/ Native CLI binary (clap, symphonia, indicatif) ├── demucs-plugin/ DAW plugin — VST3/CLAP via nih-plug (macOS, SwiftUI editor) ├── demucs-wasm/ Thin wasm-bindgen adapter over demucs-core +├── demucs-ffi/ UniFFI bridge for the Swift SDK (demucs-swift) +├── uniffi-bindgen/ Generates the Swift bindings from demucs-ffi ├── web/ React + TypeScript frontend (Vite) └── bench/ Python benchmark & validation suite ```