From 1fbb448acc2c5662677bf0c8f30676453c6248d2 Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 19 Oct 2026 17:04:38 +0200 Subject: [PATCH 1/2] feat: spm support for audio api --- .../build-compilation-dependencies/SKILL.md | 89 ++++++--- .../build-details.md | 37 ++-- .../maintenance.md | 2 +- .gitignore | 7 + THIRD_PARTY.md | 2 +- apps/fabric-example/android/gradle.properties | 2 - apps/fabric-example/ios/Podfile | 2 - apps/fabric-example/package.json | 3 + .../audiodocs/docs/other/audio-api-plugin.mdx | 18 +- .../docs/other/prebuilt-libraries-support.mdx | 172 +++++++++++----- .../docs/other/swift-package-manager.mdx | 33 ++++ packages/react-native-audio-api/Package.swift | 186 ++++++++++++++++++ .../react-native-audio-api/RNAudioAPI.podspec | 55 ++---- .../android/build.gradle | 35 +++- .../cpp/audioapi/libs/ffmpeg/relinking.md | 10 +- .../ios/audioapi/ios/core/IOSAudioPlayer.h | 2 +- .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 3 +- .../ios/spm/dsp-include/.gitkeep | 4 + .../ios/spm/include/.gitkeep | 3 + .../ios/spm/sources/AudioThreadGuardDebug.cpp | 5 + .../spm/sources/MiniaudioImplementation.mm | 3 + packages/react-native-audio-api/package.json | 8 + .../scripts/download-prebuilt-binaries.sh | 156 ++++++--------- .../scripts/rnaa_utils.rb | 24 +++ .../src/plugin/withAudioAPI.ts | 183 ++++++----------- .../react-native-audio-api/src/utils/flags.ts | 6 +- scripts/collect-knowledge-changes.sh | 2 +- 27 files changed, 660 insertions(+), 392 deletions(-) create mode 100644 packages/audiodocs/docs/other/swift-package-manager.mdx create mode 100644 packages/react-native-audio-api/Package.swift create mode 100644 packages/react-native-audio-api/ios/spm/dsp-include/.gitkeep create mode 100644 packages/react-native-audio-api/ios/spm/include/.gitkeep create mode 100644 packages/react-native-audio-api/ios/spm/sources/AudioThreadGuardDebug.cpp create mode 100644 packages/react-native-audio-api/ios/spm/sources/MiniaudioImplementation.mm diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 67427ff37..98daadd4f 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -2,14 +2,15 @@ name: build-compilation-dependencies description: > Build system overview for react-native-audio-api across all platforms — CMakeLists.txt, - android/build.gradle, RNAudioAPI.podspec, prebuilt external libraries, and the standalone + android/build.gradle, RNAudioAPI.podspec, Package.swift (SwiftPM), prebuilt external libraries, and the standalone C++ test build. Use this skill when adding a new source file, modifying CMakeLists or podspec, debugging compilation errors, integrating a new dependency, or understanding why includes work differently in tests vs the app. Trigger phrases: "add source file", "CMakeLists", "podspec", "build.gradle", "prebuilt binaries", "FFmpeg disabled", "pod install", "new architecture", "compile error", "undefined symbol", "SIMD", "worklets build flag", "C++ tests", "conditional compilation", "include path", "gradle build fails", "link error", "yarn catalog", "catalog:", "add a dependency", - "bump a dependency", "yarn workspaces", "npmMinimalAgeGate", "shared dependency version". + "bump a dependency", "yarn workspaces", "npmMinimalAgeGate", "shared dependency version", + "Package.swift", "SwiftPM", "Swift Package Manager", "react-native spm". --- # Skill: Build, Compilation & Dependencies @@ -35,10 +36,7 @@ react-native-audio-api/ │ │ ├── libs/ # Third-party wrappers (FFmpeg, miniaudio, pffft, …) │ │ └── external/ # Prebuilt binaries per platform │ │ ├── android/ # .a static libs (Opus, Ogg, Vorbis, OpenSSL) -│ │ ├── iphoneos/ # iOS device .a libs -│ │ ├── iphonesimulator/ # iOS sim .a libs -│ │ ├── macosx/ # macOS .a libs -│ │ ├── ffmpeg_ios/ # FFmpeg .xcframeworks (iOS only) +│ │ ├── xcframeworks/ # Every iOS prebuilt as .xcframework (static Opus/Ogg/Vorbis, dynamic FFmpeg) │ │ ├── include/ # Headers for Opus/Ogg/Vorbis/OpenSSL │ │ └── include_ffmpeg/ # FFmpeg headers (downloaded, FFmpeg builds only) │ ├── common/cpp/test/ @@ -46,10 +44,11 @@ react-native-audio-api/ │ │ ├── run-tests.sh / filters.sh # smoke|extended|full (+ categories, CI path filters) │ │ └── src/ # Google Test files │ ├── RNAudioAPI.podspec # CocoaPods spec for iOS +│ ├── Package.swift # SwiftPM manifest for RN's `react-native spm` mode +│ ├── ios/spm/ # SwiftPM-only wrapper sources + empty public-header dirs │ └── scripts/ │ ├── download-prebuilt-binaries.sh # Downloads externals from GitHub Releases -│ ├── rnaa_utils.rb # Ruby helpers for podspec (path resolution, worklets check) -│ └── validate-worklets-version.js +│ └── rnaa_utils.rb # Ruby helpers for podspec (path resolution) └── apps/ └── fabric-example/ └── ios/ @@ -120,9 +119,9 @@ https://github.com/software-mansion-labs/rn-audio-libs/releases/download// Current tag: **v3.0.0** (see `scripts/download-prebuilt-binaries.sh`). The download script is triggered automatically: -**FFmpeg is opt-in (off by default).** iOS: `ENV['ENABLE_AUDIOAPI_FFMPEG'] = '1'` in the Podfile; Android: `enableAudioapiFFmpeg=true` in `gradle.properties`; Expo: `enableFFmpeg: true`. The pre-1.0 `DISABLE_AUDIOAPI_FFMPEG` / `disableAudioapiFFmpeg` / `disableFFmpeg` flags are **not** read any more and produce no warning either: a leftover disable flag asks for the default, so there is nothing to tell the user, and a 1.0 migration note in the docs covers the switch. Internally every build file and source keys off the positive `$RN_AUDIO_API_FFMPEG_ENABLED` / `RN_AUDIO_API_FFMPEG_ENABLED`. The define is only emitted (`=1`) in an FFmpeg build; `#if RN_AUDIO_API_FFMPEG_ENABLED` reads an undefined macro as `0`, so the test, clangd and WPT builds do not mention it at all. The download script passes `skipffmpeg` in the default build, so neither the `.so`/xcframeworks nor (once published) the `include_ffmpeg.zip` header archive are fetched. +**FFmpeg is opt-in (off by default).** Both build options (`enableFFmpeg`, `disableStaticExternalLibs`) come from the `"react-native-audio-api"` section of the **app's package.json** on every path: podspec (`rnaa_utils.rb` `read_audio_api_build_options`, `/../package.json`), Gradle (`readAudioApiBuildOptions`, `rootProject.projectDir.parentFile`), `Package.swift` (walk from the autolinking symlink). The 0.13 inputs (Podfile `ENV[...]`, `gradle.properties` keys) are simply not read any more — no guard, by decision: it ships in a major. Non-boolean values are errors. The Expo plugin (`src/plugin/withAudioAPI.ts`) still accepts `enableFFmpeg` / `disableStaticExternalLibs`, but only as a *writer*: a `withDangerousMod` on both platforms (so a single-platform prebuild still runs it; idempotent) merges the given keys into the app package.json section during `expo prebuild`, keeping the file's indentation, before prebuild's `pod install`. Omitted options leave package.json alone; the builds never read the plugin options themselves. The pre-1.0 `DISABLE_AUDIOAPI_FFMPEG` / `disableAudioapiFFmpeg` / `disableFFmpeg` flags are **not** read any more and produce no warning either: a leftover disable flag asks for the default, so there is nothing to tell the user, and the switch is covered by the separate upgrade docs page (planned, not written yet). Migration notes belong on that page only — feature pages such as `prebuilt-libraries-support.mdx` describe the current behaviour without "Upgrading from…" notes. Internally every build file and source keys off the positive `$RN_AUDIO_API_FFMPEG_ENABLED` / `RN_AUDIO_API_FFMPEG_ENABLED`. The define is only emitted (`=1`) in an FFmpeg build; `#if RN_AUDIO_API_FFMPEG_ENABLED` reads an undefined macro as `0`, so the test, clangd and WPT builds do not mention it at all. The download script passes `skipffmpeg` in the default build, so neither the `.so`/xcframeworks nor (once published) the `include_ffmpeg.zip` header archive are fetched. -- **iOS**: by podspec `prepare_command` during `pod install` **and** by a `script_phase` (`[CP-User] Download RNAudioAPI prebuilt binaries`, `:before_headers`, always-out-of-date) on every Xcode build — downloads `ffmpeg_ios`, `iphoneos`, `iphonesimulator`, `macosx`. `prepare_command` ensures all vendored `.xcframework`s exist when CocoaPods generates `[CP] Copy XCFrameworks` (required for correct FFmpeg linking). The build-time phase handles CI setups that cache `Pods/` independently from `node_modules/` — it is a fast no-op when binaries are already present. **It must use `:before_headers`, not `:before_compile`** — CocoaPods inserts its own `[CP] Copy XCFrameworks` phase right after `Headers`; `:before_compile` would land *after* it. The phase's `output_files` declares both the `-force_load` static libs and the four FFmpeg xcframeworks. The whole phase is skipped only when `DISABLE_AUDIOAPI_STATIC_EXTERNAL_LIBS` is set *and* FFmpeg is not enabled. +- **iOS**: by podspec `prepare_command` during `pod install` **and** by a `script_phase` (`[CP-User] Download RNAudioAPI prebuilt binaries`, `:before_headers`, always-out-of-date) on every Xcode build — downloads one `.xcframework.zip` per library (plus `include_ffmpeg.zip` with FFmpeg). `prepare_command` ensures all vendored `.xcframework`s exist when CocoaPods generates `[CP] Copy XCFrameworks`. The build-time phase handles CI setups that cache `Pods/` independently from `node_modules/` — it is a fast no-op when binaries are already present. **It must use `:before_headers`, not `:before_compile`** — CocoaPods inserts its own `[CP] Copy XCFrameworks` phase right after `Headers`; `:before_compile` would land *after* it. The phase's `output_files` declares every vendored xcframework. The whole phase is skipped only when `DISABLE_AUDIOAPI_STATIC_EXTERNAL_LIBS` is set *and* FFmpeg is not enabled. - **Android**: by `downloadPrebuiltBinaries` Gradle task, which runs before `preBuild` — downloads `android`, `jniLibs` The script is idempotent — it skips any archive whose expected artifacts already exist (`artifacts_present`), so the per-build invocations are fast no-ops once binaries are present. A failed download is a hard error (`exit 1`): a silent skip used to surface much later as a missing header or `undefined symbol`. @@ -132,8 +131,7 @@ The script is idempotent — it skips any archive whose expected artifacts alrea Downloaded artifacts land in: - `common/cpp/audioapi/external/android//` — `.a` static libs for Android ABIs - `android/src/main/jniLibs//` — FFmpeg `.so` shared libs for Android (loaded at runtime) -- `common/cpp/audioapi/external/ffmpeg_ios/` — FFmpeg `.xcframework` files for iOS -- `common/cpp/audioapi/external/iphoneos/` / `iphonesimulator/` / `macosx/` — Opus/Ogg/etc `.a` +- `common/cpp/audioapi/external/xcframeworks/` — every iOS prebuilt as `.xcframework` (static Opus/Ogg/Vorbis, dynamic FFmpeg) (each release zip unpacks to exactly that directory). Older checkouts may still hold the pre-v4.0.0 `ffmpeg_ios/`, `iphoneos/`, `iphonesimulator/`, `macosx/` — unused and gitignored **These directories are gitignored.** If they're missing, run `pod install` (iOS) or Gradle build (Android) to re-download them. Do not commit them. @@ -150,7 +148,7 @@ Downloaded artifacts land in: - `common/cpp/audioapi/compatibility/StableAPI.h` — single public C++ compatibility header for extensions ### Key behaviors -- Feature flags (`newArchEnabled`, `enableAudioapiFFmpeg`, `disableAudioapiStaticExternalLibs`) are read from app's `gradle.properties` and forwarded to both CMake and Kotlin `BuildConfig` +- `newArchEnabled` is read from the app's `gradle.properties` and defaults to **on**: only an explicit `newArchEnabled=false` builds the legacy path, because RN 0.82+ apps no longer declare the property (the podspec likewise treats only `RCT_NEW_ARCH_ENABLED=0` as legacy); `enableFFmpeg` / `disableStaticExternalLibs` from the app's `package.json` (`readAudioApiBuildOptions`); all are forwarded to both CMake and Kotlin `BuildConfig` - Source exclusion in `android/src/main/cpp/audioapi/CMakeLists.txt` must use `list(FILTER … EXCLUDE REGEX)` — `COMMON_CPP_SOURCES` holds paths relative to that directory, so a `list(REMOVE_ITEM …)` with an absolute path silently matches nothing (this is how the FFmpeg exclusion was a no-op for a long time) - Third-party include dirs (`external/include`, `external/include_ffmpeg`) are only added when the corresponding library set is enabled - DSP sources always compiled with `-O3` regardless of overall build type @@ -174,8 +172,7 @@ For full per-line analysis see [build-details.md](build-details.md#android-andro ### Key behaviors - Four subspecs split compilation: `audioapi` (core C++), `audioapi/ios` (ObjC++), `audioapi/audioapi_dsp` (DSP with `-O3`), `audioapi/miniaudio_impl` (compiled as `-x objective-c++`) -- Static prebuilt libs linked with `-force_load` to prevent dead-stripping -- FFmpeg xcframeworks listed in `s.ios.vendored_frameworks` — CocoaPods handles embedding and signing +- Every prebuilt (static Opus/Ogg/Vorbis, dynamic FFmpeg) is an `.xcframework` in `s.ios.vendored_frameworks` (named after the library: CocoaPods links a dynamic xcframework as `-framework `) — the same release assets Package.swift downloads; CocoaPods picks the device/simulator/Catalyst slice and embeds the dynamic ones. The pre-v4.0.0 per-SDK `.a` dirs (`iphoneos/`, `iphonesimulator/`, `macosx/`) and their `-force_load …/$(PLATFORM_NAME)` flags are gone - `Accelerate` framework linked, enabling `HAVE_ACCELERATE=1` for vDSP SIMD on iOS - Header search paths split: `pod_target_xcconfig` (library compilation) vs `xcconfig` (app consumers) - `rnaa_utils.rb` resolves dynamic paths at `pod install` time (not hardcoded) @@ -183,6 +180,49 @@ For full per-line analysis see [build-details.md](build-details.md#android-andro For full per-line analysis see [build-details.md](build-details.md#ios-rnaudioapipodspec--detailed-analysis). +**Include our own headers by full path** (``), never bare +(``): CocoaPods' header map resolves bare names, SwiftPM does not. + +--- + +## iOS Build (SwiftPM) — `Package.swift` + +React Native's experimental `npx react-native spm` mode (0.87+) symlinks each library to +`/ios/build/generated/autolinking/libs//` and resolves our hand-written +`Package.swift` from there — hence the fixed relative paths `../../../../xcframeworks` (`ReactNative` +headers-only package) and `../../../ios` (`React-GeneratedCode`, which serves ``). +It only builds inside such an app. + +**The package and its library product must both be named `ReactNativeAudioApi`.** Autolinking emits +`.product(name: X, package: X)` for a library's Swift name X. 0.88+ takes X from `swiftpmConfig.name` in +package.json, then the deprecated `spm.name` in `react-native.config.js`. 0.87 reads only `spm.name` and +otherwise derives X from the npm name (`react-native-audio-api` → `ReactNativeAudioApi`). Setting +`swiftpmConfig.name` to that derived name makes both versions agree without a `react-native.config.js` +(0.88 would warn that `spm` is deprecated). A different name breaks 0.87 with "product 'ReactNativeAudioApi' +… not found". The internal targets keep the `RNAudioAPI` / `RNAudioAPIDSP` names. Verified 2026-10-19 on +0.87.1 and 0.88.0. + +Keep it in sync with the podspec; how each podspec feature maps: + +| Podspec | SwiftPM | +|---|---| +| `miniaudio_impl` subspec (`-x objective-c++`) | `ios/spm/sources/MiniaudioImplementation.mm` includes the `.cpp`; the `.cpp` is excluded | +| `EXCLUDED_SOURCE_FILE_NAMES[config=Release]` `AudioThreadGuard.cpp` | `ios/spm/sources/AudioThreadGuardDebug.cpp` wraps it in `#ifdef DEBUG` | +| `audioapi_dsp` subspec (`-O3`) | separate `RNAudioAPIDSP` target with `.unsafeFlags(["-O3"])` | +| `vendored_frameworks`, `include_ffmpeg` | remote `.binaryTarget(url:checksum:)` per library — the same zips (a binary target is named after its xcframework, so the targets are plain `libopus`, `libavcodec`, …); FFmpeg headers ship as the header-only `FFmpegHeaders.xcframework` (stub lib + `Headers/`) because the FFmpeg frameworks carry none. SwiftPM rejects `$(…)` macros in `unsafeFlags`, so per-SDK `.a` linking is impossible | +| `prepare_command` download | none — SwiftPM downloads and checksum-verifies the zips while resolving. Assets come from rn-audio-libs `scripts/pack_spm_xcframeworks.sh`; bump `prebuiltBinariesTag` and every checksum together | +| package.json build options (`rnaa_utils.rb`) | the same `"react-native-audio-api"` section, found by walking up from the autolinking symlink; `booleanOption()` rejects non-booleans. No env vars: Xcode re-resolves packages without the shell env | + +- Every non-source file under `common/cpp/audioapi` (`.md` docs so far) must be listed in `mainTargetExcludes`. Not fatal, tested 2026-10-19: without the excludes the build succeeds but Xcode shows "no rule to process file … of type 'file'" once per file per architecture in every SwiftPM app. SwiftPM has no globs, so add each new doc file by path. +- `DEBUG=1` / `NDEBUG` per configuration are mandatory: the prebuilt React framework's C++ ABI depends on `NDEBUG`. +- `publicHeadersPath` points at the empty `ios/spm/include` / `ios/spm/dsp-include` (a `.gitkeep` holding a note keeps each in the npm tarball, which drops empty directories; same trick as Reanimated's root `include/.gitkeep`). `package.json` `files` must re-list both `.gitkeep` paths after its `!**/.*` rule, or npm drops them and SwiftPM resolution fails — check with `npm pack --dry-run --ignore-scripts | grep ios/spm`. Both are required, tested 2026-10-19: with the default `include` missing (targets use `path: "."`), resolution fails with "public headers (\"include\") directory path for 'RNAudioAPIDSP' is invalid"; one shared directory resolves but compiling fails with "umbrella for module 'RNAudioAPIDSP' already covers this directory", because each target gets its own umbrella module map. Pointing it at `common/cpp` would build an umbrella module over every C++ header; revisit when react-native-audio-worklets needs our headers under SwiftPM. +- `package.json` `files` negates every download directory (`external/xcframeworks`, `external/android`, `external/include_ffmpeg`), so publishing from a machine with binaries downloaded still ships only the tracked `external/include` headers. Add a negation for any new download directory; check with `npm pack --dry-run --ignore-scripts`. +- **`Context.packageDirectory` is the autolinking symlink** (`/ios/build/generated/autolinking/libs/ReactNativeAudioApi`, probed with a `fatalError` during `xcodebuild -resolvePackageDependencies` on 2026-10-19), not the resolved `node_modules` path. `readAppConfiguration()` therefore walks `libs → … → ios → ` and finds the app even when the package is hoisted in a monorepo; it never passes the library's own `package.json`. This relies on RN's symlink layout — re-probe if a future RN version references libraries by real path. +- **SwiftPM's manifest cache ignores files the manifest reads.** `~/Library/Caches/org.swift.swiftpm/manifests/manifest.db` is keyed by `Package.swift` contents + path + tools version + env + SwiftPM version (SwiftPM `ManifestLoader.CacheKey`), so a changed `package.json` section is invisible until `rm -rf ~/Library/Caches/org.swift.swiftpm/manifests` (Xcode ▸ File ▸ Packages ▸ Reset Package Caches) — verified 2026-10-19: an identical manifest kept FFmpeg on after the option flipped off; appending a comment to the manifest made it re-evaluate. When testing option changes, bust the cache or change the manifest text; a fresh DerivedData is not enough. +- **Never replace a published asset in place.** SwiftPM remembers each binary target's checksum per URL and refuses a changed artifact ("has changed checksum; this is a potential security risk"), so anyone who already resolved the tag breaks. Repacking always changes checksums (zip timestamps) — publish under a new tag instead. +- To test unpublished assets, rewrite a packed copy's binary targets to `binaryTarget(path: "/X.xcframework.zip")` (local zips work) and install it with `npm install --ignore-scripts` (a directory dependency otherwise runs `prepare`). +- Verify in throwaway apps on the oldest (0.87.x) and newest supported RN: `@react-native-community/cli init … --version ` (a bare `0.87` finds no template), install an `npm pack --ignore-scripts` tarball under a unique file name, `cd ios && npx react-native spm --deintegrate --yes` (both versions pick `add`/`update` themselves), then `xcodebuild -project .xcodeproj`. + --- ## Building the Apps @@ -207,12 +247,12 @@ open apps/fabric-example/ios/FabricExample.xcworkspace - After `yarn install` (any dependency change) - After changing `RNAudioAPI.podspec` - After adding/removing iOS source files that need to be picked up -- After changing `rnaa_utils.rb` or `scripts/validate-worklets-version.js` +- After changing `rnaa_utils.rb` - When prebuilt binaries need to be re-downloaded (podspec `prepare_command` runs on `pod install`) -**Enable FFmpeg on iOS** (off by default; `apps/fabric-example/ios/Podfile` enables it): -```bash -ENABLE_AUDIOAPI_FFMPEG=1 pod install +**Enable FFmpeg** (off by default; `apps/fabric-example/package.json` enables it for every build path): +```json +"react-native-audio-api": { "enableFFmpeg": true } ``` ### Android (fabric-example) @@ -223,11 +263,6 @@ yarn workspace fabric-example android open apps/fabric-example/android ``` -**Enable FFmpeg on Android** (off by default; `apps/fabric-example/android/gradle.properties` enables it): -``` -enableAudioapiFFmpeg=true -``` - **Clean CMake cache** (fixes most mysterious native build failures): ```bash yarn workspace react-native-audio-api clean # or manually: @@ -356,7 +391,7 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | Flag | Android (CMake) | iOS (podspec) | Tests | |---|---|---|---| -| `RN_AUDIO_API_FFMPEG_ENABLED` | `-DRN_AUDIO_API_FFMPEG_ENABLED=1` (only when `enableAudioapiFFmpeg=true`) | `-DRN_AUDIO_API_FFMPEG_ENABLED=1` (only when `ENABLE_AUDIOAPI_FFMPEG=1`) | Not set (off) | +| `RN_AUDIO_API_FFMPEG_ENABLED` | `-DRN_AUDIO_API_FFMPEG_ENABLED=1` (only with `enableFFmpeg: true` in the app's package.json) | same | | Not set (off) | | `RN_AUDIO_API_ENABLE_WORKLETS` | `-DRN_AUDIO_API_ENABLE_WORKLETS=1/0` | `-DRN_AUDIO_API_ENABLE_WORKLETS=1` | Always set to 0 | | `RCT_NEW_ARCH_ENABLED` | `-DRCT_NEW_ARCH_ENABLED` | `-DRCT_NEW_ARCH_ENABLED` | Not set | | `HAVE_ARM_NEON_INTRINSICS` | Set by CMake SIMD detection | Set by Xcode/Clang for arm64 | Set by CMake SIMD detection | @@ -374,13 +409,13 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) |---|---|---| | `file not found: libopus.a` | Prebuilt binaries not downloaded | Run `pod install` (iOS) or Gradle build (triggers download task) | | `No such module 'RNAudioAPI'` | Pod not installed | `cd apps/fabric-example/ios && pod install` | -| `undefined symbol: av_*` | FFmpeg .so not in jniLibs | Build triggers download; verify `enableAudioapiFFmpeg=true` is set (FFmpeg is off by default) | +| `undefined symbol: av_*` | FFmpeg .so not in jniLibs | Build triggers download; verify `"enableFFmpeg": true` is in the app's package.json (FFmpeg is off by default) | | `'libavcodec/avcodec.h' file not found` | FFmpeg include dir is gated on the enable flag; something compiled `FfmpegDecoder.cpp` in a disabled build | Check the source filter (`list(FILTER … FfmpegDecoder\.cpp$)` / podspec `exclude_files`) picked the file up | | CMake error on clean build | Stale `.cxx` cache | `rm -rf packages/react-native-audio-api/android/.cxx` | | Test build: `Cannot open include file: audioapi/...` | Node modules not linked | `yarn install` from root, then re-run tests | | New `.cpp` not compiled in tests | Glob picks it up automatically — may need cmake reconfigure | Delete `test/build/` and re-run | | iOS compile error `unknown type 'id'` | C++ file included ObjC-only header | Compile that file as ObjC++ (separate subspec with `-x objective-c++`) | -| `RCT_NEW_ARCH_ENABLED` undefined on Android | Old RN gradle plugin | Ensure `newArchEnabled=true` in app's `gradle.properties` | +| `RCT_NEW_ARCH_ENABLED` undefined on Android | App sets `newArchEnabled=false` | Remove it; a missing property already means the New Architecture | | iOS: `'to_chars' is unavailable: introduced in iOS 16.3` from `formatter_floating_point.h`, instantiated by `std::format<...>` | `std::format` in code compiled for iOS. libc++ availability-gates the whole `` library to iOS 16.3; the podspec minimum is `ios_min_version = '14.0'`. The desktop C++ test build and Android NDK have no such gate, so `yarn test:cpp` passes and only the iOS build fails. | Use `std::string` concatenation / `std::to_string` in `common/cpp` and `ios/`. Zero-pad by hand (`insert(0, n, '0')`). Android-only files (`android/src/main/cpp`) may keep `std::format`. | | Android incremental build only: `Unresolved reference` to a top-level Kotlin function, or `Cannot access '…': it is internal in file`, while `--rerun-tasks` passes | Incremental Kotlin rounds treat the module's own earlier classes as a foreign module (library is built through the `node_modules/react-native-audio-api` symlink) | Use only public classes/objects across files in `android/src/main/java`; no top-level functions, no `internal` | | clangd only: `'React/RCTBridgeModule.h' file not found` in `.mm` files | `compile_commands.json` has no framework search path | See *clangd compile database* below — regenerate with `yarn setup:clangd` | diff --git a/.claude/skills/build-compilation-dependencies/build-details.md b/.claude/skills/build-compilation-dependencies/build-details.md index 9ff0f0c7d..a0ab9b1c3 100644 --- a/.claude/skills/build-compilation-dependencies/build-details.md +++ b/.claude/skills/build-compilation-dependencies/build-details.md @@ -8,14 +8,14 @@ ## Android: `build.gradle` — detailed analysis -### Feature flags (set by app's `gradle.properties`) +### Feature flags (`newArchEnabled` from `gradle.properties`, build options from the app's `package.json`) ```groovy -def isNewArchitectureEnabled() { - return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" +def isNewArchitectureEnabled() { // on unless the app sets newArchEnabled=false + return !rootProject.hasProperty("newArchEnabled") || rootProject.getProperty("newArchEnabled") != "false" } def isFFmpegEnabled() { - return isPropertyTrue("enableAudioapiFFmpeg") + return booleanBuildOption("enableFFmpeg") // app package.json "react-native-audio-api" section } ``` @@ -179,30 +179,21 @@ ReactAndroid/src/main/jni/... — TurboModule JNI headers miniaudio's implementation file must be compiled as Objective-C++ on iOS (it uses Apple APIs). CocoaPods can't set per-file compiler flags the same way CMake can, so it gets its own subspec with `compiler_flags = "-x objective-c++"`. -### Prebuilt binaries linked via `-force_load` +### All prebuilt binaries listed in `s.ios.vendored_frameworks` -All static libs (Opus, Ogg, Vorbis, OpenSSL) use `-force_load` to prevent dead-stripping of symbols needed at runtime but not referenced at link time: +Each library is a `.xcframework` downloaded into `external/xcframeworks/` — the +static Opus/Ogg/Vorbis ones unless `DISABLE_AUDIOAPI_STATIC_EXTERNAL_LIBS`, the dynamic FFmpeg ones +only with FFmpeg enabled. CocoaPods' `[CP] Copy XCFrameworks` picks the device / simulator / +Catalyst slice, links the static libraries normally (no `-force_load`; every symbol used is +referenced from miniaudio's decoders) and embeds + signs the FFmpeg frameworks: ```ruby -s.xcconfig = { - 'OTHER_LDFLAGS' => %W[ - -force_load #{lib_dir}/libopus.a - -force_load #{lib_dir}/libogg.a - ... - ].join(" ") -} +s.ios.vendored_frameworks = vendored_xcframeworks.map { |xcframework| "#{external_dir_relative}/xcframeworks/#{xcframework}" } ``` -### FFmpeg xcframeworks listed in `s.ios.vendored_frameworks` - -CocoaPods handles embedding and signing automatically: - -```ruby -s.ios.vendored_frameworks = $RN_AUDIO_API_FFMPEG_ENABLED ? [ - 'common/cpp/audioapi/external/ffmpeg_ios/libavcodec.xcframework', - ... -] : [] -``` +They are the same release assets `Package.swift` downloads through `binaryTarget(url:checksum:)`. +FFmpeg headers still come from `external/include_ffmpeg/` (`include_ffmpeg.zip`) for CocoaPods; +SwiftPM uses the header-only `FFmpegHeaders.xcframework` instead. ### Header search paths split between pod target and consumers diff --git a/.claude/skills/build-compilation-dependencies/maintenance.md b/.claude/skills/build-compilation-dependencies/maintenance.md index 885df2da2..c78ff02d4 100644 --- a/.claude/skills/build-compilation-dependencies/maintenance.md +++ b/.claude/skills/build-compilation-dependencies/maintenance.md @@ -9,7 +9,7 @@ Review this skill when `pre-push-update` reports changes in: | `android/CMakeLists.txt` | SIMD detection, RN version flag workaround | | `android/src/main/cpp/audioapi/CMakeLists.txt` | Source glob patterns, FFmpeg exclusion, DSP `-O3` targets, IMPORTED lib list, include paths | | `android/build.gradle` | Feature flag detection, CMake arg forwarding, BuildConfig fields, worklets task dependency, packaging options | -| `RNAudioAPI.podspec` | Subspecs table, `miniaudio_impl` workaround, `-force_load` list, xcframeworks list, `rnaa_utils.rb` dynamic paths | +| `RNAudioAPI.podspec` | Subspecs table, `miniaudio_impl` workaround, vendored xcframeworks list (mirrors `Package.swift` binary targets), `rnaa_utils.rb` dynamic paths | | `apps/fabric-example/ios/Podfile` | New Architecture enablement, minimum iOS version helper | | `common/cpp/test/CMakeLists.txt` | Excluded sources list, compile definitions, GoogleTest fetch URL, include paths, `ENABLE_COVERAGE` | | `common/cpp/test/run-coverage.sh` | Coverage build dir, Clang CC/CXX defaults, llvm-profdata/llvm-cov report+HTML, `GITHUB_STEP_SUMMARY`, ignore regexes, smoke filter via `filters.sh` | diff --git a/.gitignore b/.gitignore index c35863d51..e44029ac5 100644 --- a/.gitignore +++ b/.gitignore @@ -101,9 +101,16 @@ output/ packages/react-native-audio-api/android/src/main/jniLibs/ packages/react-native-audio-api/common/cpp/audioapi/external/**/*.a packages/react-native-audio-api/common/cpp/audioapi/external/*.xcframework +packages/react-native-audio-api/common/cpp/audioapi/external/xcframeworks/ +# Pre-xcframework download layout, still present in older checkouts. packages/react-native-audio-api/common/cpp/audioapi/external/ffmpeg_ios/ packages/react-native-audio-api/common/cpp/audioapi/external/include_ffmpeg/ +# SwiftPM local state (Package.swift is only resolved inside an app's SPM build) +.build/ +.swiftpm/ +packages/react-native-audio-api/Package.resolved + # Clangd cache .cache diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 0c90310db..94d834ed8 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -13,7 +13,7 @@ WebKit, 3 Clause BSD Copyright (c) The WebKit Authors [https://github.com/WebKit/webkit](https://github.com/WebKit/webkit) -Builds that enable the optional FFmpeg flag (`enableFFmpeg` / `ENABLE_AUDIOAPI_FFMPEG` / `enableAudioapiFFmpeg`, off by default) link the FFmpeg library under GNU Lesser General Public License (LGPL) version 2.1 or later +Builds that enable the optional FFmpeg flag (`enableFFmpeg: true` in the Expo plugin options, or `"enableFFmpeg": true` in the `"react-native-audio-api"` section of the app's package.json in a bare React Native app; off by default) link the FFmpeg library under GNU Lesser General Public License (LGPL) version 2.1 or later - Source: https://github.com/FFmpeg/FFmpeg/releases/tag/n8.0 - Used in audio decoding module [packages/react-native-audio-api/common/cpp/audioapi/decoding/backends/FfmpegDecoder.cpp](https://github.com/software-mansion/react-native-audio-api/blob/main/packages/react-native-audio-api/common/cpp/audioapi/decoding/backends/FfmpegDecoder.cpp) diff --git a/apps/fabric-example/android/gradle.properties b/apps/fabric-example/android/gradle.properties index 1e7516e46..e4e0a9c51 100644 --- a/apps/fabric-example/android/gradle.properties +++ b/apps/fabric-example/android/gradle.properties @@ -48,5 +48,3 @@ edgeToEdgeEnabled=true android.builtInKotlin=false android.newDsl=false -# The example app exercises remote streaming / HLS demos, which need FFmpeg (off by default). -enableAudioapiFFmpeg=true diff --git a/apps/fabric-example/ios/Podfile b/apps/fabric-example/ios/Podfile index 7c6a40092..d0b5c1106 100644 --- a/apps/fabric-example/ios/Podfile +++ b/apps/fabric-example/ios/Podfile @@ -1,6 +1,4 @@ ENV['RCT_NEW_ARCH_ENABLED'] = '1' -# The example app exercises remote streaming / HLS demos, which need FFmpeg (off by default). -ENV['ENABLE_AUDIOAPI_FFMPEG'] = '1' # Uncomment when building for macOS with Catalyst # ENV['RCT_BUILD_FROM_SOURCE'] = '1' diff --git a/apps/fabric-example/package.json b/apps/fabric-example/package.json index 299ec574d..df89454a3 100644 --- a/apps/fabric-example/package.json +++ b/apps/fabric-example/package.json @@ -22,6 +22,9 @@ "engines": { "node": ">=22.11.0" }, + "react-native-audio-api": { + "enableFFmpeg": true + }, "reactNativeVectorIcons": { "fontDir": "./assets/fonts" } diff --git a/packages/audiodocs/docs/other/audio-api-plugin.mdx b/packages/audiodocs/docs/other/audio-api-plugin.mdx index de2d5a515..6abb2e3bc 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -19,8 +19,8 @@ interface Options { androidForegroundService: boolean; androidFSTypes: string[]; androidFSStopWithTask: boolean; - enableFFmpeg: boolean; - disableStaticExternalLibs: boolean; + enableFFmpeg?: boolean; + disableStaticExternalLibs?: boolean; } ``` @@ -149,14 +149,12 @@ Set it to `false` to emit `android:stopWithTask="false"` on the service entry The Foreground Service only exists while a library notification is shown, so this option has an effect only if a notification (e.g. via `RecordingNotificationManager.show()`) is displayed before the user closes the app. See [keeping the recording alive when the app is closed](../inputs/audio-recorder.mdx#keeping-the-recording-alive-when-the-app-is-closed) for the full set of requirements. ::: -### `enableFFmpeg` +### `enableFFmpeg` / `disableStaticExternalLibs` {#build-options} -Defaults to `false`. +Optional. The [prebuilt libraries](./prebuilt-libraries-support.mdx) build options. -Links the prebuilt FFmpeg libraries into the app, which enables remote URL streaming, HLS (`.m3u8`) playback and remote URL metadata. See [Prebuilt libraries support](./prebuilt-libraries-support.mdx) for what FFmpeg adds and what it costs. +Every build (CocoaPods, Swift Package Manager, Gradle) reads them from the `react-native-audio-api` section of the app's `package.json`. When given here, `expo prebuild` writes them into that section for you, so an Expo app can keep its whole configuration in `app.json`. Options you leave out are not touched in `package.json`. -### `disableStaticExternalLibs` - -Defaults to `false`. - -Removes the bundled Opus / Ogg / Vorbis static libraries. See [Prebuilt libraries support](./prebuilt-libraries-support.mdx#disabling-other-external-libraries). +:::note +A value given here overwrites the `package.json` one on every prebuild, so keep each option in one place only. +::: diff --git a/packages/audiodocs/docs/other/prebuilt-libraries-support.mdx b/packages/audiodocs/docs/other/prebuilt-libraries-support.mdx index 813eebdf5..3987f6d58 100644 --- a/packages/audiodocs/docs/other/prebuilt-libraries-support.mdx +++ b/packages/audiodocs/docs/other/prebuilt-libraries-support.mdx @@ -7,71 +7,143 @@ import TabItem from '@theme/TabItem'; # Prebuilt libraries support -`react-native-audio-api` can link a number of prebuilt third-party libraries that are downloaded at build time. They enable additional decoding and streaming features, but also add several megabytes to the resulting application binary, so each one can be toggled with a build flag. +`react-native-audio-api` can link a number of prebuilt third-party libraries that are downloaded at build time. They enable additional decoding and streaming features, but also add several megabytes to the resulting application binary, so each one can be toggled with a build option. -| Library set | Default | Flag | What it adds | +| Library set | Default | Option | What it adds | | :---- | :---: | :---: | :---- | | FFmpeg (`libavcodec`, `libavformat`, `libavutil`, `libswresample`) | **off** | `enableFFmpeg` | Remote URL streaming (HTTP byte ranges), HLS (`.m3u8`), remote URL metadata — see [Runtime flags](./runtime-flags#where-ffmpeg-is-used). | | Static libs (`libopus`, `libopusfile`, `libogg`, `libvorbis`, `libvorbisenc`, `libvorbisfile`) | **on** | `disableStaticExternalLibs` | Decoding `ogg`, `opus`, `oga` files | -The flags are independent and can be combined. +The options are independent and can be combined. + +:::tip Reducing app size +Leaving FFmpeg off (the default) and setting `disableStaticExternalLibs` can shave several MB off the final app size across all ABIs / device slices. Pick the set of formats you actually need and turn the rest off. +::: :::info FFmpeg is opt-in Apps that would like to decode HLS or make use of URL streaming have to enable it explicitly (see below). Use [`isFfmpegEnabled`](./runtime-flags#isffmpegenabled) to branch at runtime. ::: +## Setting the options + +Every build (CocoaPods, [Swift Package Manager](./swift-package-manager.mdx) and Gradle) reads the +options from a `react-native-audio-api` section of the app's `package.json`. In an Expo app, pass +them as [Expo plugin options](./audio-api-plugin.mdx#build-options) instead and `expo prebuild` +writes that section for you. + + + + +`app.json` / `app.config.js`: + +```json +"plugins": [ + [ + "react-native-audio-api", + { + "enableFFmpeg": true, + "disableStaticExternalLibs": false + } + ] +] +``` + + + + +The app's `package.json`: + +```json +"react-native-audio-api": { + "enableFFmpeg": true, + "disableStaticExternalLibs": false +} +``` + + + + +After changing them: + + + + +Run `npx expo prebuild`. It updates the `package.json` section from the plugin options and then runs `pod install`. + + + + +- **CocoaPods**: run `pod install`. +- **Swift Package Manager**: run `npx react-native spm` in `ios`. If the app was already built, [reset the package cache](./swift-package-manager.mdx#build-options) first. +- **Android**: just rebuild. Gradle reads the file on every build. + + + + ## Enabling FFmpeg - - - Add an entry in the [Audio API Expo plugin](./audio-api-plugin.mdx) configuration: - - ``` - "enableFFmpeg": true - ``` - - - Add an entry to the Podfile (before the targets), then run `pod install`: - - ``` - ENV['ENABLE_AUDIOAPI_FFMPEG'] = '1' - ``` - - - In `gradle.properties`: - - ``` - enableAudioapiFFmpeg=true - ``` - + + + +`app.json` / `app.config.js`: + +```json +"plugins": [ + [ + "react-native-audio-api", + { + "enableFFmpeg": true + } + ] +] +``` + + + + +The app's `package.json`: + +```json +"react-native-audio-api": { + "enableFFmpeg": true +} +``` + + -With FFmpeg enabled, the FFmpeg binaries are downloaded during `pod install` / the first Gradle build and linked into the app. FFmpeg is distributed under the LGPL — see `THIRD_PARTY.md` in the repository for the notice that applies to FFmpeg-enabled builds. +With FFmpeg enabled, the FFmpeg binaries are downloaded during `pod install` / Swift package resolution / the first Gradle build and linked into the app. FFmpeg is distributed under the LGPL — see `THIRD_PARTY.md` in the repository for the notice that applies to FFmpeg-enabled builds. ## Disabling other external libraries -This removes the bundled Opus / Ogg / Vorbis static libraries. With this flag enabled, attempts to decode `.ogg`, `.opus`, or `.oga` files through `AudioContext.decodeAudioData` (or any other decoding path) will return an error — those formats are handled inside miniaudio via the now-disabled custom backends. - - - - Add an entry in the [Audio API Expo plugin](./audio-api-plugin.mdx) configuration: - - ``` - "disableStaticExternalLibs": true - ``` - - - Add an entry to the Podfile: - - ``` - ENV['DISABLE_AUDIOAPI_STATIC_EXTERNAL_LIBS'] = '1' - ``` - - - In `gradle.properties`: - - ``` - disableAudioapiStaticExternalLibs=true - ``` - + + + +`app.json` / `app.config.js`: + +```json +"plugins": [ + [ + "react-native-audio-api", + { + "disableStaticExternalLibs": true + } + ] +] +``` + + + + +The app's `package.json`: + +```json +"react-native-audio-api": { + "disableStaticExternalLibs": true +} +``` + + + +This removes the bundled Opus / Ogg / Vorbis static libraries. With this option enabled, attempts to decode `.ogg`, `.opus`, or `.oga` files through `AudioContext.decodeAudioData` (or any other decoding path) will return an error — those formats are handled inside miniaudio via the now-disabled custom backends. diff --git a/packages/audiodocs/docs/other/swift-package-manager.mdx b/packages/audiodocs/docs/other/swift-package-manager.mdx new file mode 100644 index 000000000..c0ccd233e --- /dev/null +++ b/packages/audiodocs/docs/other/swift-package-manager.mdx @@ -0,0 +1,33 @@ +--- +id: swift-package-manager +sidebar_label: Swift Package Manager (iOS) +--- + +# Swift Package Manager (iOS) + +Swift Package Manager (SwiftPM) is an experimental alternative to CocoaPods for building React +Native iOS apps. To find out what it requires and how to switch an app to it, see the +[React Native 0.87 announcement](https://reactnative.dev/blog/2026/08/11/react-native-0.87). + +`react-native-audio-api` ships a `Package.swift`, so in a SwiftPM app it is autolinked like any +other library. There is no extra setup step. Swift Package Manager downloads the prebuilt +Opus / Ogg / Vorbis (and, if enabled, FFmpeg) libraries while it resolves packages. + +## Build options + +[`enableFFmpeg` and `disableStaticExternalLibs`](./prebuilt-libraries-support.mdx#setting-the-options) +are read from the `react-native-audio-api` section of the app's `package.json`, as with CocoaPods +and Gradle. + +:::caution +Swift Package Manager caches an evaluated `Package.swift` by its contents, not by the files it +reads, so a change to these options is ignored until that cache is reset. After changing them: + +```bash +rm -rf ~/Library/Caches/org.swift.swiftpm/manifests # or Xcode ▸ File ▸ Packages ▸ Reset Package Caches +cd ios && npx react-native spm +``` + +[`isFfmpegEnabled()`](./runtime-flags#isffmpegenabled) tells you at runtime which configuration +the app was built with. +::: diff --git a/packages/react-native-audio-api/Package.swift b/packages/react-native-audio-api/Package.swift new file mode 100644 index 000000000..55e19dc2e --- /dev/null +++ b/packages/react-native-audio-api/Package.swift @@ -0,0 +1,186 @@ +// swift-tools-version: 6.0 + +import Foundation +import PackageDescription + +let packageDirectory = Context.packageDirectory +let npmPackageName = "react-native-audio-api" + +// Prebuilt libraries, one zipped xcframework per library. +let prebuiltBinariesTag = "v4.0.0" +let prebuiltBinariesURL = + "https://github.com/software-mansion-labs/rn-audio-libs/releases/download/\(prebuiltBinariesTag)" +let staticExternalLibChecksums: KeyValuePairs = [ + "libopusfile": "c6ca1cd533cf91e2f8ff6e8636a6f9a5282a2548cc4a27d19b03c843b2ba8d09", + "libopus": "5a25f6633c2b95f9e5faef96b17b5f23ffeabe66883608c1d72ca091ff7f54cf", + "libogg": "b1bd6d27cac2ea188f2d34339d52afe5e71715f03c73bb6db277b5cd02828460", + "libvorbis": "8923c7292b71993e0960c39aa29dc90bfd5b8cdf24b070ca1e33eb782cd52508", + "libvorbisenc": "185f50deed4642e5f5b919e4654d281b61e310e0c7228c87cd6c8e13dc35e3b5", + "libvorbisfile": "48594352908306f3c828aa06b53d774b123b745f63e4a7c3d75d23e4fcf00aca", +] +let ffmpegChecksums: KeyValuePairs = [ + "libavcodec": "48421a2a2ba940d8099d48788709860294e8b4dd581219454af80ee63632e4aa", + "libavformat": "b5bd67c71301967d2b6a2cf86efd5bd0cd18ab7f97feeec07cd32241792bdf92", + "libavutil": "9b21830f970d03dc1082ba29890c197a1970b9ce7532d3dc170db7956497a4d5", + "libswresample": "0f89341a856d167ac89ce04f0d9ecec421b458b551a6beccb835c658a281c082", + "FFmpegHeaders": "ffb54a6d111569c37f98f0ff314e9c1546e2327ab6444830ab9929d8b41b07ff", +] + +func readJSONObject(at url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] +} + +/// The build options: the `"react-native-audio-api"` section of the nearest app package.json above +/// this package, e.g. `"react-native-audio-api": { "enableFFmpeg": true, "disableStaticExternalLibs": false }`. +/// Bare apps write the section by hand; in Expo apps `expo prebuild` writes it from the plugin options. +/// React Native evaluates this manifest through its autolinking symlink +/// (`/ios/build/generated/autolinking/libs/ReactNativeAudioApi`), so walking up reaches the app through +/// its `ios/` directory wherever the package manager placed this package. +func readAppConfiguration() -> [String: Any] { + var directory = URL(fileURLWithPath: packageDirectory).deletingLastPathComponent() + while directory.path != "/" { + let packageJSON = readJSONObject(at: directory.appendingPathComponent("package.json")) + if let packageJSON, packageJSON["name"] as? String != npmPackageName, + let configuration = packageJSON[npmPackageName] as? [String: Any] + { + return configuration + } + directory.deleteLastPathComponent() + } + return [:] +} + +func prebuiltBinaryTargets(_ checksums: KeyValuePairs) -> [Target] { + checksums.map { name, checksum in + .binaryTarget( + name: name, url: "\(prebuiltBinariesURL)/\(name).xcframework.zip", checksum: checksum) + } +} + +/// `false` when the key is absent. Any value other than a JSON boolean stops resolution: an +/// option that is silently ignored would build the wrong configuration. +func booleanOption(_ key: String, in configuration: [String: Any]) -> Bool { + guard let value = configuration[key] else { return false } + // JSONSerialization bridges JSON booleans and numbers alike to NSNumber. + if let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + let shownValue = value is String ? "the string \"\(value)\"" : "\(value)" + fatalError("[RNAudioAPI] \"\(key)\" must be true or false, got \(shownValue)") +} + +let appConfiguration = readAppConfiguration() +let isFFmpegEnabled = booleanOption("enableFFmpeg", in: appConfiguration) +let areStaticExternalLibsDisabled = booleanOption("disableStaticExternalLibs", in: appConfiguration) + +var headerSearchPaths = ["common/cpp", "ios", "common/cpp/audioapi/external/include"] +var defines: [(name: String, value: String)] = [ + ("HAVE_ACCELERATE", "1"), + ("RCT_NEW_ARCH_ENABLED", "1"), +] +var binaryTargets: [Target] = [] +var mainTargetExcludes = [ + "common/cpp/audioapi/dsp", + "common/cpp/audioapi/external", + // including them prints warning in the xcode so we exclude them from the main target + "common/cpp/audioapi/compatibility/EXTENSION_API.md", + "common/cpp/audioapi/core/utils/graph/README.md", + "common/cpp/audioapi/libs/ffmpeg/relinking.md", + // Compiled through the wrappers in ios/spm. + "common/cpp/audioapi/utils/MiniaudioImplementation.cpp", + "common/cpp/audioapi/utils/AudioThreadGuard.cpp", +] + +if areStaticExternalLibsDisabled { + defines += [ + ("RN_AUDIO_API_STATIC_EXTERNAL_LIBS_DISABLED", "1"), + ("MA_NO_LIBOPUS", "1"), + ("MA_NO_LIBVORBIS", "1"), + ] +} else { + headerSearchPaths += [ + "common/cpp/audioapi/external/include/opus", + "common/cpp/audioapi/external/include/vorbis", + ] + binaryTargets += prebuiltBinaryTargets(staticExternalLibChecksums) +} + +if isFFmpegEnabled { + defines.append(("RN_AUDIO_API_FFMPEG_ENABLED", "1")) + binaryTargets += prebuiltBinaryTargets(ffmpegChecksums) +} else { + mainTargetExcludes.append("common/cpp/audioapi/decoding/backends/FfmpegDecoder.cpp") +} + +// React Native's headers change class layouts on NDEBUG (without it `DebugStringConvertible` +// gains virtual methods) and the prebuilt React framework is built with NDEBUG in Release, so +// DEBUG/NDEBUG follow the configuration exactly, as in React Native's own generated targets. +let cSettings: [CSetting] = + headerSearchPaths.map { .headerSearchPath($0) } + defines.map { .define($0.name, to: $0.value) } + [ + .define("DEBUG", to: "1", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ] +let cxxSettings: [CXXSetting] = + headerSearchPaths.map { .headerSearchPath($0) } + defines.map { .define($0.name, to: $0.value) } + [ + .define("DEBUG", to: "1", .when(configuration: .debug)), + .define("NDEBUG", .when(configuration: .release)), + ] + +let reactHeaders: [Target.Dependency] = [ + .product(name: "ReactHeaders", package: "ReactNative"), + .product(name: "ReactNativeHeaders", package: "ReactNative"), + .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"), + .product(name: "ReactAppHeaders", package: "React-GeneratedCode"), +] + +let package = Package( + name: "ReactNativeAudioApi", + platforms: [.iOS(.v15)], + products: [ + .library(name: "ReactNativeAudioApi", targets: ["RNAudioAPI"]) + ], + dependencies: [ + .package(name: "ReactNative", path: "../../../../xcframeworks"), + .package(name: "React-GeneratedCode", path: "../../../ios"), + ], + targets: [ + .target( + name: "RNAudioAPI", + dependencies: reactHeaders + ["RNAudioAPIDSP"] + binaryTargets.map { .target(name: $0.name) }, + path: ".", + exclude: mainTargetExcludes, + sources: ["common/cpp/audioapi", "ios/audioapi", "ios/spm/sources"], + publicHeadersPath: "ios/spm/include", + cSettings: cSettings, + cxxSettings: cxxSettings, + linkerSettings: [ + .linkedFramework("Accelerate"), + .linkedFramework("AVFoundation"), + .linkedFramework("AudioToolbox"), + .linkedFramework("CoreGraphics"), + .linkedFramework("Foundation"), + .linkedFramework("ImageIO"), + .linkedFramework("MediaPlayer"), + .linkedFramework("UIKit"), + ] + ), + // The podspec compiles the DSP sources with -O3 in every configuration; SwiftPM has no + // per-file flags, so they get their own target. + .target( + name: "RNAudioAPIDSP", + dependencies: reactHeaders, + path: ".", + exclude: [ + "common/cpp/audioapi/dsp/r8brain/CDSPHBDownsampler.inc", + "common/cpp/audioapi/dsp/r8brain/CDSPHBUpsampler.inc", + ], + sources: ["common/cpp/audioapi/dsp"], + publicHeadersPath: "ios/spm/dsp-include", + cSettings: cSettings + [.unsafeFlags(["-O3"])], + cxxSettings: cxxSettings + [.unsafeFlags(["-O3"])], + linkerSettings: [.linkedFramework("Accelerate")] + ), + ] + binaryTargets, + cxxLanguageStandard: .cxx20 +) diff --git a/packages/react-native-audio-api/RNAudioAPI.podspec b/packages/react-native-audio-api/RNAudioAPI.podspec index 64286239d..e5698f1e5 100644 --- a/packages/react-native-audio-api/RNAudioAPI.podspec +++ b/packages/react-native-audio-api/RNAudioAPI.podspec @@ -4,9 +4,11 @@ require_relative './scripts/rnaa_utils' package_json = JSON.parse(File.read(File.join(__dir__, "package.json"))) $audio_api_config = find_audio_api_config() -$new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1' -$RN_AUDIO_API_FFMPEG_ENABLED = ENV['ENABLE_AUDIOAPI_FFMPEG'] == '1' -$RN_AUDIO_API_STATIC_EXTERNAL_LIBS_DISABLED = ENV['DISABLE_AUDIOAPI_STATIC_EXTERNAL_LIBS'].nil? ? false : ENV['DISABLE_AUDIOAPI_STATIC_EXTERNAL_LIBS'] == '1' # false by default +# Like React Native since 0.76, only an explicit RCT_NEW_ARCH_ENABLED=0 selects the legacy architecture. +$new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] != '0' +$audio_api_build_options = read_audio_api_build_options() +$RN_AUDIO_API_FFMPEG_ENABLED = $audio_api_build_options[:ffmpeg_enabled] +$RN_AUDIO_API_STATIC_EXTERNAL_LIBS_DISABLED = $audio_api_build_options[:static_external_libs_disabled] fabric_flags = $new_arch_enabled ? '-DRCT_NEW_ARCH_ENABLED' : '' version_flag = "-DAUDIOAPI_VERSION=#{package_json['version']}" @@ -64,11 +66,16 @@ Pod::Spec.new do |s| CMD external_dir_relative = "common/cpp/audioapi/external" - lib_dir = "$(PODS_ROOT)/#{$audio_api_config[:dynamic_frameworks_audio_api_dir]}/#{external_dir_relative}/$(PLATFORM_NAME)" - ffmpeg_dir = "$(PODS_ROOT)/#{$audio_api_config[:dynamic_frameworks_audio_api_dir]}/#{external_dir_relative}/ffmpeg_ios" + xcframeworks_dir = "$(PODS_ROOT)/#{$audio_api_config[:dynamic_frameworks_audio_api_dir]}/#{external_dir_relative}/xcframeworks" - ffmpeg_framework_names = %w[libavcodec libavformat libavutil libswresample] - static_external_libs_names = %w[libopusfile libopus libogg libvorbis libvorbisenc libvorbisfile] + # Every prebuilt library is a `.xcframework` in `external/xcframeworks`, the same assets + # Package.swift uses; CocoaPods picks the device / simulator / Catalyst slice. + ffmpeg_xcframeworks = %w[libavcodec libavformat libavutil libswresample].map { |lib| "#{lib}.xcframework" } + static_external_lib_xcframeworks = %w[libopusfile libopus libogg libvorbis libvorbisenc libvorbisfile] + .map { |lib| "#{lib}.xcframework" } + vendored_xcframeworks = [] + vendored_xcframeworks.concat(static_external_lib_xcframeworks) unless $RN_AUDIO_API_STATIC_EXTERNAL_LIBS_DISABLED + vendored_xcframeworks.concat(ffmpeg_xcframeworks) if $RN_AUDIO_API_FFMPEG_ENABLED # `prepare_command` hydrates binaries on a fresh `pod install` so CocoaPods can # register all vendored `.xcframework`s in `[CP] Copy XCFrameworks`. When `Pods/` @@ -76,27 +83,14 @@ Pod::Spec.new do |s| # does not re-run — the build-time phase below re-hydrates on every build. # # It must run at `:before_headers` so it lands ahead of CocoaPods' own - # `[CP] Copy XCFrameworks` phase, which consumes the FFmpeg xcframeworks. If it + # `[CP] Copy XCFrameworks` phase, which consumes the xcframeworks. If it # ran later (e.g. `:before_compile`, which is after Copy XCFrameworks), Xcode # would reject the build when the xcframeworks are missing before we download. # - # `output_files` declares everything the linker (`-force_load` static libs in - # `s.xcconfig`) and Copy XCFrameworks (FFmpeg) consume, so Xcode's build graph - # knows this phase produces them. - if !$RN_AUDIO_API_STATIC_EXTERNAL_LIBS_DISABLED || $RN_AUDIO_API_FFMPEG_ENABLED - download_output_files = [] - - unless $RN_AUDIO_API_STATIC_EXTERNAL_LIBS_DISABLED - static_external_libs_names.each do |lib| - download_output_files << "#{lib_dir}/#{lib}.a" - end - end - - if $RN_AUDIO_API_FFMPEG_ENABLED - ffmpeg_framework_names.each do |framework| - download_output_files << "#{ffmpeg_dir}/#{framework}.xcframework" - end - end + # `output_files` declares everything Copy XCFrameworks consumes, so Xcode's + # build graph knows this phase produces them. + unless vendored_xcframeworks.empty? + download_output_files = vendored_xcframeworks.map { |xcframework| "#{xcframeworks_dir}/#{xcframework}" } s.script_phase = { :name => 'Download RNAudioAPI prebuilt binaries', @@ -111,9 +105,7 @@ Pod::Spec.new do |s| } end - s.ios.vendored_frameworks = $RN_AUDIO_API_FFMPEG_ENABLED ? ffmpeg_framework_names.map { |framework| - "common/cpp/audioapi/external/ffmpeg_ios/#{framework}.xcframework" - } : [] + s.ios.vendored_frameworks = vendored_xcframeworks.map { |xcframework| "#{external_dir_relative}/xcframeworks/#{xcframework}" } s.pod_target_xcconfig = { "USE_HEADERMAP" => "YES", @@ -155,13 +147,6 @@ Pod::Spec.new do |s| "\"$(PODS_ROOT)/#{$audio_api_config[:dynamic_frameworks_audio_api_dir]}/common/cpp\"", ] .join(' '), - 'OTHER_LDFLAGS' => %W[ - $(inherited) - ] - .concat($RN_AUDIO_API_STATIC_EXTERNAL_LIBS_DISABLED ? [] : static_external_libs_names.flat_map { |lib| - ['-force_load', "#{lib_dir}/#{lib}.a"] - }) - .join(" "), } # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79. diff --git a/packages/react-native-audio-api/android/build.gradle b/packages/react-native-audio-api/android/build.gradle index 312195ec0..2325d40f7 100644 --- a/packages/react-native-audio-api/android/build.gradle +++ b/packages/react-native-audio-api/android/build.gradle @@ -17,21 +17,42 @@ def reactNativeArchitectures() { return value ? value.split(",") : ["armeabi-v7a", "arm64-v8a", "x86_64", "x86"] } -def isPropertyTrue(name) { - return rootProject.hasProperty(name) && rootProject.getProperty(name) == "true" +def isNewArchitectureEnabled() { + return !rootProject.hasProperty("newArchEnabled") || rootProject.getProperty("newArchEnabled") != "false" } -def isNewArchitectureEnabled() { - return isPropertyTrue("newArchEnabled") +// Build options come from the app's package.json: +// "react-native-audio-api": { "enableFFmpeg": true, "disableStaticExternalLibs": false }. +// Bare apps write the section by hand; in Expo apps `expo prebuild` writes it from the plugin options. +// The Gradle root project is the app's android/, so the app root is its parent. +def readAudioApiBuildOptions() { + def packageJsonFile = new File(rootProject.projectDir.parentFile, "package.json") + if (!packageJsonFile.exists()) { + return [:] + } + return new groovy.json.JsonSlurper().parse(packageJsonFile)["react-native-audio-api"] ?: [:] +} + +// `false` when the key is absent; anything but a JSON boolean fails the build rather than +// silently building the wrong configuration. +def booleanBuildOption(key) { + def options = readAudioApiBuildOptions() + if (!options.containsKey(key)) { + return false + } + def value = options[key] + if (!(value instanceof Boolean)) { + throw new GradleException("[AudioAPI] \"${key}\" in package.json must be true or false, got ${value.inspect()}.") + } + return value } -// FFmpeg is opt-in: `enableAudioapiFFmpeg=true` in the app's gradle.properties turns it on. def isFFmpegEnabled() { - return isPropertyTrue("enableAudioapiFFmpeg") + return booleanBuildOption("enableFFmpeg") } def isStaticExternalLibsDisabled() { - return isPropertyTrue("disableAudioapiStaticExternalLibs") + return booleanBuildOption("disableStaticExternalLibs") } apply plugin: "com.android.library" diff --git a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/relinking.md b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/relinking.md index 97360031f..64391ef1b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/relinking.md +++ b/packages/react-native-audio-api/common/cpp/audioapi/libs/ffmpeg/relinking.md @@ -4,17 +4,19 @@ you can modify script in scripts/download-prebuilt-binaries.sh -- ios dynamic frameworks are in `ffmpeg_ios.zip` directory +- ios dynamic frameworks are one zip per library (`libavcodec.xcframework.zip`, `libavformat.xcframework.zip`, `libavutil.xcframework.zip`, `libswresample.xcframework.zip`), each unzipped straight into `common/cpp/audioapi/external/xcframeworks/` - android shared libraries are in `jniLibs.zip` just replace way of downloading them that links to your binaries +SwiftPM builds skip the script: edit the `binaryTarget` URLs and checksums in `Package.swift` instead. + ### Option B) -directly modify libraries in source code +replace the binaries in place -- ios dynamic frameworks are placed in `common/cpp/audioapi/external` -- android shared libraries are placed in `android/src/main/jniLibs` +- ios: `common/cpp/audioapi/external/xcframeworks/*.xcframework` +- android: `android/src/main/jniLibs//*.so` --- `USED_LIBRARIES`: diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h index c5701d4da..61507bfe9 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h @@ -1,6 +1,6 @@ #pragma once -#import +#import #include #include diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 6cc5e7642..1b4aaa787 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm @@ -1,7 +1,6 @@ #import -#import -#import #import +#import #include #include diff --git a/packages/react-native-audio-api/ios/spm/dsp-include/.gitkeep b/packages/react-native-audio-api/ios/spm/dsp-include/.gitkeep new file mode 100644 index 000000000..79cce4d66 --- /dev/null +++ b/packages/react-native-audio-api/ios/spm/dsp-include/.gitkeep @@ -0,0 +1,4 @@ +// The RNAudioAPIDSP SwiftPM target exposes no headers, but SwiftPM fails to resolve the package when +// a target's public headers directory is missing, so Package.swift points publicHeadersPath here. +// It cannot share ios/spm/include: SwiftPM builds a module over each target's public headers +// directory, and two modules over one directory fail to compile. diff --git a/packages/react-native-audio-api/ios/spm/include/.gitkeep b/packages/react-native-audio-api/ios/spm/include/.gitkeep new file mode 100644 index 000000000..a1f46e202 --- /dev/null +++ b/packages/react-native-audio-api/ios/spm/include/.gitkeep @@ -0,0 +1,3 @@ +// The RNAudioAPI SwiftPM target exposes no headers, but SwiftPM fails to resolve the package when +// a target's public headers directory is missing, so Package.swift points publicHeadersPath here. +// Pointing it at common/cpp instead would make SwiftPM build a module over every C++ header there. diff --git a/packages/react-native-audio-api/ios/spm/sources/AudioThreadGuardDebug.cpp b/packages/react-native-audio-api/ios/spm/sources/AudioThreadGuardDebug.cpp new file mode 100644 index 000000000..cb30453ed --- /dev/null +++ b/packages/react-native-audio-api/ios/spm/sources/AudioThreadGuardDebug.cpp @@ -0,0 +1,5 @@ +// AudioThreadGuard replaces the global operator new/delete, so it must stay out of +// Release builds. SwiftPM cannot exclude a file per configuration, hence the guard. +#ifdef DEBUG +#include +#endif diff --git a/packages/react-native-audio-api/ios/spm/sources/MiniaudioImplementation.mm b/packages/react-native-audio-api/ios/spm/sources/MiniaudioImplementation.mm new file mode 100644 index 000000000..9eaa11546 --- /dev/null +++ b/packages/react-native-audio-api/ios/spm/sources/MiniaudioImplementation.mm @@ -0,0 +1,3 @@ +// SwiftPM has no per-file compiler flags, so this wrapper compiles the miniaudio +// implementation as Objective-C++ (CocoaPods uses `-x objective-c++` instead). +#include diff --git a/packages/react-native-audio-api/package.json b/packages/react-native-audio-api/package.json index aecfc6f84..505ed916a 100644 --- a/packages/react-native-audio-api/package.json +++ b/packages/react-native-audio-api/package.json @@ -67,6 +67,7 @@ "android/", "ios", "RNAudioAPI.podspec", + "Package.swift", "metro-config", "!ios/build", "!android/build", @@ -85,6 +86,10 @@ "!common/cpp/test", "!android/src/main/jniLibs", "!common/cpp/audioapi/external/include_ffmpeg", + "!common/cpp/audioapi/external/xcframeworks", + "!common/cpp/audioapi/external/android", + "ios/spm/include/.gitkeep", + "ios/spm/dsp-include/.gitkeep", "scripts/setup-rn-audio-api-web.js", "scripts/rnaa_utils.rb", "scripts/download-prebuilt-binaries.sh", @@ -251,6 +256,9 @@ "javaPackageName": "com.swmansion.audioapi" } }, + "swiftpmConfig": { + "name": "ReactNativeAudioApi" + }, "create-react-native-library": { "type": "module-legacy", "languages": "cpp", diff --git a/packages/react-native-audio-api/scripts/download-prebuilt-binaries.sh b/packages/react-native-audio-api/scripts/download-prebuilt-binaries.sh index d19c92176..bed6b75b8 100755 --- a/packages/react-native-audio-api/scripts/download-prebuilt-binaries.sh +++ b/packages/react-native-audio-api/scripts/download-prebuilt-binaries.sh @@ -2,40 +2,24 @@ # Script to download and unzip prebuilt native binaries for React Native. MAIN_DOWNLOAD_URL="https://github.com/software-mansion-labs/rn-audio-libs/releases/download" -TAG="v3.2.0" +# Package.swift pins the same tag for its SwiftPM binary targets; bump both together. +TAG="v4.0.0" FFMPEG_HEADERS_ARCHIVE="include_ffmpeg.zip" -ANDROID_DOWNLOAD_NAMES=( - "android.zip" - "jniLibs.zip" - "$FFMPEG_HEADERS_ARCHIVE" -) -IOS_DOWNLOAD_NAMES=( - "ffmpeg_ios.zip" - "iphoneos.zip" - "iphonesimulator.zip" - "macosx.zip" - "$FFMPEG_HEADERS_ARCHIVE" -) STATIC_LIB_NAMES=( - "libopusfile.a" - "libopus.a" - "libogg.a" - "libvorbis.a" - "libvorbisenc.a" - "libvorbisfile.a" -) -FFMPEG_IOS_FRAMEWORKS=( - "libavcodec.xcframework" - "libavformat.xcframework" - "libavutil.xcframework" - "libswresample.xcframework" + "libopusfile" + "libopus" + "libogg" + "libvorbis" + "libvorbisenc" + "libvorbisfile" ) -FFMPEG_ANDROID_SO_LIBS=( - "libavcodec.so" - "libavformat.so" - "libavutil.so" - "libswresample.so" +FFMPEG_LIB_NAMES=( + "libavcodec" + "libavformat" + "libavutil" + "libswresample" ) +# iOS binaries ship as one zipped .xcframework per library. # One umbrella header per library proves the header set was extracted. FFMPEG_HEADER_MARKERS=( "libavcodec/avcodec.h" @@ -63,12 +47,6 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" TEMP_DOWNLOAD_DIR="${SCRIPT_DIR}/audioapi-binaries-temp" mkdir -p "$TEMP_DOWNLOAD_DIR" -if [ "$PLATFORM" == "android" ]; then - DOWNLOAD_NAMES=("${ANDROID_DOWNLOAD_NAMES[@]}") -else - DOWNLOAD_NAMES=("${IOS_DOWNLOAD_NAMES[@]}") -fi - if [ "$2" == "skipffmpeg" ]; then SKIP_FFMPEG=true else @@ -83,54 +61,39 @@ fi JNILIBS_DESTINATION="${PROJECT_ROOT}/android/src/main" NORMAL_DESTINATION="${PROJECT_ROOT}/common/cpp/audioapi/external" +XCFRAMEWORKS_DESTINATION="${NORMAL_DESTINATION}/xcframeworks" -restore_versioned_framework_symlinks() { - local ffmpeg_ios_dir="${NORMAL_DESTINATION}/ffmpeg_ios" - - if [ ! -d "$ffmpeg_ios_dir" ]; then - return +DOWNLOAD_NAMES=() +if [ "$PLATFORM" == "android" ]; then + if [ "$SKIP_STATIC" == false ]; then + DOWNLOAD_NAMES+=("android.zip") fi - - find "$ffmpeg_ios_dir" -path "*/Versions/A" -type d | while read -r version_dir; do - local framework_dir - framework_dir="$(cd "${version_dir}/../.." && pwd)" - local framework_name - framework_name="$(basename "$framework_dir" .framework)" - - if [ -f "${version_dir}/${framework_name}" ]; then - ( - cd "$framework_dir" - ln -sfn A Versions/Current - ln -sfn "Versions/Current/${framework_name}" "$framework_name" - - if [ -d "Versions/A/Resources" ]; then - ln -sfn "Versions/Current/Resources" Resources - fi - - if [ -d "Versions/A/Headers" ]; then - ln -sfn "Versions/Current/Headers" Headers - fi - ) - fi - done -} + if [ "$SKIP_FFMPEG" == false ]; then + DOWNLOAD_NAMES+=("jniLibs.zip" "$FFMPEG_HEADERS_ARCHIVE") + fi +else + if [ "$SKIP_STATIC" == false ]; then + for lib in "${STATIC_LIB_NAMES[@]}"; do + DOWNLOAD_NAMES+=("${lib}.xcframework.zip") + done + fi + if [ "$SKIP_FFMPEG" == false ]; then + for lib in "${FFMPEG_LIB_NAMES[@]}"; do + DOWNLOAD_NAMES+=("${lib}.xcframework.zip") + done + # The FFmpeg frameworks carry no headers; CocoaPods adds this directory to the search path. + DOWNLOAD_NAMES+=("$FFMPEG_HEADERS_ARCHIVE") + fi +fi artifacts_present() { - local extracted_dir_name="$1" + local extracted_name="$1" local final_check_path="$2" local abi local lib - local framework local so_lib - case "$extracted_dir_name" in - ffmpeg_ios) - for framework in "${FFMPEG_IOS_FRAMEWORKS[@]}"; do - if [ ! -d "${final_check_path}/${framework}" ]; then - return 1 - fi - done - ;; + case "$extracted_name" in include_ffmpeg) for header in "${FFMPEG_HEADER_MARKERS[@]}"; do if [ ! -f "${final_check_path}/${header}" ]; then @@ -140,8 +103,8 @@ artifacts_present() { ;; jniLibs) for abi in "${ANDROID_JNI_ABIS[@]}"; do - for so_lib in "${FFMPEG_ANDROID_SO_LIBS[@]}"; do - if [ ! -f "${final_check_path}/${abi}/${so_lib}" ]; then + for so_lib in "${FFMPEG_LIB_NAMES[@]}"; do + if [ ! -f "${final_check_path}/${abi}/${so_lib}.so" ]; then return 1 fi done @@ -150,18 +113,15 @@ artifacts_present() { android) for abi in "${ANDROID_JNI_ABIS[@]}"; do for lib in "${STATIC_LIB_NAMES[@]}"; do - if [ ! -f "${final_check_path}/${abi}/${lib}" ]; then + if [ ! -f "${final_check_path}/${abi}/${lib}.a" ]; then return 1 fi done done ;; - iphoneos|iphonesimulator|macosx) - for lib in "${STATIC_LIB_NAMES[@]}"; do - if [ ! -f "${final_check_path}/${lib}" ]; then - return 1 - fi - done + *.xcframework) + [ -f "${final_check_path}/Info.plist" ] + return ;; *) [ -d "$final_check_path" ] @@ -176,26 +136,20 @@ for name in "${DOWNLOAD_NAMES[@]}"; do ARCH_URL="${MAIN_DOWNLOAD_URL}/${TAG}/${name}" ZIP_FILE_PATH="${TEMP_DOWNLOAD_DIR}/${name}" - # Get the directory name from the zip name (e.g., "armeabi-v7a.zip" -> "armeabi-v7a") - EXTRACTED_DIR_NAME="${name%.zip}" + # Each archive unpacks to one top-level entry named after it (e.g. "android.zip" -> "android", + # "libopus.xcframework.zip" -> "libopus.xcframework"). + EXTRACTED_NAME="${name%.zip}" - if [[ ("$EXTRACTED_DIR_NAME" == "ffmpeg_ios" || "$EXTRACTED_DIR_NAME" == "jniLibs" || "$EXTRACTED_DIR_NAME" == "include_ffmpeg") && "$SKIP_FFMPEG" == true ]]; then - continue - fi - - if [[ ("$EXTRACTED_DIR_NAME" == "android" || "$EXTRACTED_DIR_NAME" == "iphoneos" || "$EXTRACTED_DIR_NAME" == "iphonesimulator" || "$EXTRACTED_DIR_NAME" == "macosx") && "$SKIP_STATIC" == true ]]; then - continue - fi - - # Determine the final output path if [[ "$name" == "jniLibs.zip" ]]; then OUTPUT_DIR="${JNILIBS_DESTINATION}" + elif [[ "$EXTRACTED_NAME" == *.xcframework ]]; then + OUTPUT_DIR="${XCFRAMEWORKS_DESTINATION}" else OUTPUT_DIR="${NORMAL_DESTINATION}" fi - FINAL_CHECK_PATH="${OUTPUT_DIR}/${EXTRACTED_DIR_NAME}" + FINAL_CHECK_PATH="${OUTPUT_DIR}/${EXTRACTED_NAME}" - if artifacts_present "$EXTRACTED_DIR_NAME" "$FINAL_CHECK_PATH"; then + if artifacts_present "$EXTRACTED_NAME" "$FINAL_CHECK_PATH"; then continue fi @@ -214,6 +168,10 @@ for name in "${DOWNLOAD_NAMES[@]}"; do echo "Unzipping ${name} to ${OUTPUT_DIR}" mkdir -p "$OUTPUT_DIR" + # A stale bundle from an interrupted run would otherwise mix with the fresh one. + if [[ "$EXTRACTED_NAME" == *.xcframework ]]; then + rm -rf "${OUTPUT_DIR:?}/${EXTRACTED_NAME}" + fi unzip -o "$ZIP_FILE_PATH" -d "$OUTPUT_DIR" # Clean up any __MACOSX directories that may have been created @@ -221,8 +179,4 @@ for name in "${DOWNLOAD_NAMES[@]}"; do done -if [ "$PLATFORM" == "ios" ]; then - restore_versioned_framework_symlinks -fi - rm -rf "$TEMP_DOWNLOAD_DIR" diff --git a/packages/react-native-audio-api/scripts/rnaa_utils.rb b/packages/react-native-audio-api/scripts/rnaa_utils.rb index 9b2d28a77..33f417a08 100644 --- a/packages/react-native-audio-api/scripts/rnaa_utils.rb +++ b/packages/react-native-audio-api/scripts/rnaa_utils.rb @@ -6,6 +6,30 @@ def try_to_parse_react_native_package_json(node_modules_dir) return JSON.parse(File.read(react_native_package_json_path)) end +# Build options come from the app's package.json: +# "react-native-audio-api": { "enableFFmpeg": true, "disableStaticExternalLibs": false } +# Bare apps write the section by hand; in Expo apps `expo prebuild` writes it from the plugin options. +# The Podfile directory is the app's ios/, so the app root is its parent. +def read_audio_api_build_options() + package_json_path = File.join(Pod::Config.instance.installation_root.to_s, '..', 'package.json') + options = File.exist?(package_json_path) ? (JSON.parse(File.read(package_json_path))['react-native-audio-api'] || {}) : {} + return { + :ffmpeg_enabled => boolean_build_option(options, 'enableFFmpeg'), + :static_external_libs_disabled => boolean_build_option(options, 'disableStaticExternalLibs'), + } +end + +# `false` when the key is absent; anything but a JSON boolean fails the install rather than +# silently building the wrong configuration. +def boolean_build_option(options, key) + return false unless options.key?(key) + value = options[key] + unless value == true || value == false + raise "[AudioAPI] \"#{key}\" in package.json must be true or false, got #{value.inspect}." + end + return value +end + def find_audio_api_config() result = { :react_native_common_dir => nil, diff --git a/packages/react-native-audio-api/src/plugin/withAudioAPI.ts b/packages/react-native-audio-api/src/plugin/withAudioAPI.ts index eb4ad7e1f..4a6979d8c 100644 --- a/packages/react-native-audio-api/src/plugin/withAudioAPI.ts +++ b/packages/react-native-audio-api/src/plugin/withAudioAPI.ts @@ -3,10 +3,11 @@ import { ConfigPlugin, createRunOncePlugin, withAndroidManifest, - withGradleProperties, + withDangerousMod, withInfoPlist, - withPodfile, } from '@expo/config-plugins'; +const fs = require('fs'); +const path = require('path'); const pkg = require('react-native-audio-api/package.json'); interface Options { @@ -16,11 +17,15 @@ interface Options { androidForegroundService: boolean; androidFSTypes: string[]; androidFSStopWithTask: boolean; - /** Links FFmpeg for remote URL streaming / HLS. Off by default. */ - enableFFmpeg: boolean; - disableStaticExternalLibs: boolean; + enableFFmpeg?: boolean; + disableStaticExternalLibs?: boolean; } +const BUILD_OPTION_KEYS = [ + 'enableFFmpeg', + 'disableStaticExternalLibs', +] as const; + const withDefaultOptions = (options: Partial): Options => { return { iosBackgroundMode: true, @@ -31,37 +36,10 @@ const withDefaultOptions = (options: Partial): Options => { androidForegroundService: true, androidFSTypes: ['mediaPlayback'], androidFSStopWithTask: true, - enableFFmpeg: false, - disableStaticExternalLibs: false, ...options, }; }; -/** - * Keeps exactly one `key = value` line in the Podfile when `value` is set, and - * removes any existing line for that key when it is not. - */ -const upsertPodfileEnvLine = ( - contents: string, - key: string, - value: string | null -): string => { - const lineRegex = new RegExp(`^.*ENV\\['${key}'\\].*$`, 'gm'); - const line = value === null ? '' : `ENV['${key}'] = '${value}'`; - - if (contents.search(lineRegex) !== -1) { - return contents.replace(lineRegex, line); - } - - if (line === '') { - return contents; - } - - return contents.endsWith('\n') - ? `${contents}${line}` - : `${contents}\n${line}`; -}; - const withBackgroundAudio: ConfigPlugin = (config) => { return withInfoPlist(config, (iosConfig) => { iosConfig.modResults.UIBackgroundModes = [ @@ -130,97 +108,62 @@ const withForegroundService: ConfigPlugin = ( }); }; -const withFFmpegConfig: ConfigPlugin = (config, options) => { - const iosConf = withPodfile(config, (mod) => { - mod.modResults.contents = upsertPodfileEnvLine( - mod.modResults.contents, - 'ENABLE_AUDIOAPI_FFMPEG', - options.enableFFmpeg ? '1' : null - ); - return mod; - }); - - const finalConf = withGradleProperties(iosConf, (mod) => { - const gradleProperties = mod.modResults; - - const existingIndex = gradleProperties.findIndex( - (prop) => prop.type === 'property' && prop.key === 'enableAudioapiFFmpeg' - ); - if (existingIndex !== -1) { - gradleProperties.splice(existingIndex, 1); - } - - if (options.enableFFmpeg) { - gradleProperties.push({ - type: 'property', - key: 'enableAudioapiFFmpeg', - value: 'true', - }); - } - - return mod; - }); - - return finalConf; -}; - -const withStaticExternalLibsConfig: ConfigPlugin = ( - config, - options +/** + * Writes the given build options into the `react-native-audio-api` section of + * the app's package.json, which is where CocoaPods, Swift Package Manager and + * Gradle read them from. + */ +const writeBuildOptionsToPackageJson = ( + projectRoot: string, + options: Options ) => { - const iosConf = withPodfile(config, (mod) => { - let contents = mod.modResults.contents; - const staticLibsRegex = - /^.*ENV\['DISABLE_AUDIOAPI_STATIC_EXTERNAL_LIBS'\].*$/gm; - const podfileString = options.disableStaticExternalLibs - ? `ENV['DISABLE_AUDIOAPI_STATIC_EXTERNAL_LIBS'] = '1'` - : ''; - // No existing setting - if (contents.search(staticLibsRegex) === -1) { - if (options.disableStaticExternalLibs) { - if (contents.endsWith('\n')) { - contents = `${contents}${podfileString}`; - } else { - contents = `${contents}\n${podfileString}`; - } - mod.modResults.contents = contents; - } - } else { - // Existing setting found, will replace - contents = contents.replace(staticLibsRegex, podfileString); + const packageJsonPath = path.join(projectRoot, 'package.json'); + const source: string = fs.readFileSync(packageJsonPath, 'utf8'); + const packageJson = JSON.parse(source); + + const section = { ...(packageJson[pkg.name] ?? {}) }; + for (const key of BUILD_OPTION_KEYS) { + const value = options[key]; + if (value === undefined) { + continue; } - - mod.modResults.contents = contents; - return mod; - }); - - const finalConf = withGradleProperties(iosConf, (mod) => { - const gradleProperties = mod.modResults; - - const existingIndex = gradleProperties.findIndex( - (prop) => - prop.type === 'property' && - prop.key === 'disableAudioapiStaticExternalLibs' - ); - if (existingIndex !== -1) { - gradleProperties.splice(existingIndex, 1); - } else if (!options.disableStaticExternalLibs) { - // No existing setting and static external libs are enabled, do nothing. - return mod; + if (typeof value !== 'boolean') { + throw new Error( + `[react-native-audio-api] plugin option "${key}" must be true or false, got ${JSON.stringify(value)}.` + ); } + section[key] = value; + } + packageJson[pkg.name] = section; - if (options.disableStaticExternalLibs) { - gradleProperties.push({ - type: 'property', - key: 'disableAudioapiStaticExternalLibs', - value: options.disableStaticExternalLibs ? 'true' : 'false', - }); - } + const indent = source.match(/^[ \t]+/m)?.[0] ?? ' '; + const trailingNewline = source.endsWith('\n') ? '\n' : ''; + const updated = JSON.stringify(packageJson, null, indent) + trailingNewline; + if (updated !== source) { + fs.writeFileSync(packageJsonPath, updated); + } +}; - return mod; - }); +// Registered on both platforms so a single-platform prebuild writes the file +// too; the second run finds the file already up to date. +const withBuildOptions: ConfigPlugin = (config, options) => { + const isAnyBuildOptionGiven = BUILD_OPTION_KEYS.some( + (key) => options[key] !== undefined + ); + if (!isAnyBuildOptionGiven) { + return config; + } - return finalConf; + for (const platform of ['ios', 'android'] as const) { + config = withDangerousMod(config, [ + platform, + (mod) => { + writeBuildOptionsToPackageJson(mod.modRequest.projectRoot, options); + return mod; + }, + ]); + } + return config; }; const withAudioAPI: ConfigPlugin = (config, optionsIn) => { @@ -240,11 +183,7 @@ const withAudioAPI: ConfigPlugin = (config, optionsIn) => { config = withIosMicrophonePermission(config, options); } - config = withFFmpegConfig(config, options); - - if (options.disableStaticExternalLibs !== undefined) { - config = withStaticExternalLibsConfig(config, options); - } + config = withBuildOptions(config, options); return config; }; diff --git a/packages/react-native-audio-api/src/utils/flags.ts b/packages/react-native-audio-api/src/utils/flags.ts index 13e54903c..205f1ff83 100644 --- a/packages/react-native-audio-api/src/utils/flags.ts +++ b/packages/react-native-audio-api/src/utils/flags.ts @@ -2,9 +2,9 @@ import { NativeAudioAPIModule } from '../specs'; /** * Returns whether the native build includes FFmpeg. FFmpeg is an opt-in build - * flag (`enableFFmpeg` in the Expo plugin, `ENABLE_AUDIOAPI_FFMPEG` in the - * Podfile, `enableAudioapiFFmpeg` in gradle.properties), so this is `false` - * unless the app turned it on. + * option, `enableFFmpeg`, set in the Expo plugin options or, in a bare React + * Native app, in the `"react-native-audio-api"` section of the app's + * package.json. This is `false` unless the app turned it on. * * When `false`, remote URL streaming / HLS and remote URL metadata are * unavailable. Batch decoding, encoding, and concatenation use OS APIs / diff --git a/scripts/collect-knowledge-changes.sh b/scripts/collect-knowledge-changes.sh index df2aa9637..5834c24d6 100755 --- a/scripts/collect-knowledge-changes.sh +++ b/scripts/collect-knowledge-changes.sh @@ -114,7 +114,7 @@ EXCLUDE=( # Prebuilt / vendored external libraries — not our code ":(exclude)**/external/**" ":(exclude)**/prebuilt_libs/**" - ":(exclude)**/ffmpeg_ios/**" + ":(exclude)**/xcframeworks/**" ":(exclude)**/openssl-prebuilt/**" ":(exclude)**/jniLibs/**" ":(exclude)**/vendor/**" From 7314dfa7f6acda7304bd84fa76589de51696dfd5 Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 22 Oct 2026 10:11:25 +0200 Subject: [PATCH 2/2] docs: reword some section --- packages/audiodocs/docs/other/audio-api-plugin.mdx | 14 ++++++++++---- .../docs/other/prebuilt-libraries-support.mdx | 2 +- .../audiodocs/docs/other/swift-package-manager.mdx | 5 +++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/audiodocs/docs/other/audio-api-plugin.mdx b/packages/audiodocs/docs/other/audio-api-plugin.mdx index 6abb2e3bc..330b8d224 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -149,12 +149,18 @@ Set it to `false` to emit `android:stopWithTask="false"` on the service entry The Foreground Service only exists while a library notification is shown, so this option has an effect only if a notification (e.g. via `RecordingNotificationManager.show()`) is displayed before the user closes the app. See [keeping the recording alive when the app is closed](../inputs/audio-recorder.mdx#keeping-the-recording-alive-when-the-app-is-closed) for the full set of requirements. ::: -### `enableFFmpeg` / `disableStaticExternalLibs` {#build-options} +### `enableFFmpeg` -Optional. The [prebuilt libraries](./prebuilt-libraries-support.mdx) build options. +Defaults to `false`. -Every build (CocoaPods, Swift Package Manager, Gradle) reads them from the `react-native-audio-api` section of the app's `package.json`. When given here, `expo prebuild` writes them into that section for you, so an Expo app can keep its whole configuration in `app.json`. Options you leave out are not touched in `package.json`. +Links the prebuilt FFmpeg libraries into the app, which enables remote URL streaming, HLS (`.m3u8`) playback and remote URL metadata. See [Prebuilt libraries support](./prebuilt-libraries-support.mdx) for what FFmpeg adds and what it costs. + +### `disableStaticExternalLibs` + +Defaults to `false`. + +Removes the bundled Opus / Ogg / Vorbis static libraries. See [Prebuilt libraries support](./prebuilt-libraries-support.mdx#disabling-other-external-libraries). :::note -A value given here overwrites the `package.json` one on every prebuild, so keep each option in one place only. +`expo prebuild` writes `enableFFmpeg` and `disableStaticExternalLibs` into the `react-native-audio-api` section of the app's `package.json`, which every build (CocoaPods, Swift Package Manager, Gradle) reads. The one given here overwrites it on every prebuild, so keep each option in one place only. ::: diff --git a/packages/audiodocs/docs/other/prebuilt-libraries-support.mdx b/packages/audiodocs/docs/other/prebuilt-libraries-support.mdx index 3987f6d58..3781514f2 100644 --- a/packages/audiodocs/docs/other/prebuilt-libraries-support.mdx +++ b/packages/audiodocs/docs/other/prebuilt-libraries-support.mdx @@ -28,7 +28,7 @@ Apps that would like to decode HLS or make use of URL streaming have to enable i Every build (CocoaPods, [Swift Package Manager](./swift-package-manager.mdx) and Gradle) reads the options from a `react-native-audio-api` section of the app's `package.json`. In an Expo app, pass -them as [Expo plugin options](./audio-api-plugin.mdx#build-options) instead and `expo prebuild` +them as [Expo plugin options](./audio-api-plugin.mdx#enableffmpeg) instead and `expo prebuild` writes that section for you. diff --git a/packages/audiodocs/docs/other/swift-package-manager.mdx b/packages/audiodocs/docs/other/swift-package-manager.mdx index c0ccd233e..748694f85 100644 --- a/packages/audiodocs/docs/other/swift-package-manager.mdx +++ b/packages/audiodocs/docs/other/swift-package-manager.mdx @@ -28,6 +28,7 @@ rm -rf ~/Library/Caches/org.swift.swiftpm/manifests # or Xcode ▸ File ▸ Pa cd ios && npx react-native spm ``` -[`isFfmpegEnabled()`](./runtime-flags#isffmpegenabled) tells you at runtime which configuration -the app was built with. +To confirm an `enableFFmpeg` change took effect, check +[`isFfmpegEnabled()`](./runtime-flags#isffmpegenabled) at runtime. There is no runtime flag for +`disableStaticExternalLibs`. :::