diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index cb6d6d963..dc4022654 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -141,7 +141,6 @@ classDiagram AudioNode <|-- WorkletNode AudioNode <|-- AnalyserNode AudioNode <|-- AudioDestinationNode - AudioNode <|-- AudioRecorder AudioScheduledSourceNode <|-- AudioBufferBaseSourceNode AudioScheduledSourceNode <|-- OscillatorNode @@ -152,6 +151,29 @@ classDiagram AudioBufferBaseSourceNode <|-- AudioBufferQueueSourceNode ``` +### AudioRecorder (not an AudioNode) + +`core/inputs/AudioRecorder` is a standalone base class, not part of the `AudioNode` hierarchy — it +feeds the graph through a `RecorderAdapterNode` instead of being processed by it. + +The split between it and `IOSAudioRecorder` / `AndroidAudioRecorder` is: the base owns everything +that happens to recorded frames (file writer, JS callback, adapter node — `enableFileOutput`, +`setupFileWriter`, `setOnAudioReadyCallback`, `connect`, `detachSideEffects`/`finalizeSideEffects`), the +subclasses own only the platform input stream. The one thing the base needs from the platform is +`resolveStreamFormat()`, returning sample rate, channel count and max frames per buffer; iOS reads +it from `NativeAudioRecorder` on every call (a route change invalidates it), Android returns values +cached when the Oboe stream opened. Add shared recorder behavior to the base, not to one platform; +state only one platform touches (Android's `streamMutex_`, iOS's `inputChannelCount_`) lives in +that subclass. State a consumer already owns stays with the consumer: the adapter node's ring +layout (`RecorderAdapterNode::writeFrames`) and the session's file paths (returned by +`AudioFileWriter::closeFile()`) are not mirrored in the recorder. + +Pitfall: never redeclare a base member (`streamSampleRate_`, `fileWriter_`, +`lastCallbackFrameCount_`) in a platform recorder. The shadowing copy compiles fine, but the base's +audio-thread fan-out reads its own member and silently drops that output. + +--- + ### AudioScheduledSourceNode (internal only — not exposed to JS directly) Base class for source nodes that have a scheduled start and stop time. **Not instantiated directly.** diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 505ed2a7f..635462481 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -30,7 +30,8 @@ react-native-audio-api/ │ │ └── src/main/cpp/audioapi/ │ │ └── CMakeLists.txt # Actual Android C++ build target │ ├── common/cpp/audioapi/ # Shared C++ (used by all platforms) -│ │ ├── decoding/ # Decoder factory, backends, SeekDecoderDaemon, AudioDecoding, AudioFileConcatenator +│ │ ├── decoding/ # Decoder factory, backends, SeekDecoderDaemon, AudioDecoding +│ │ ├── encoding/ # AudioEncoder interface, EncoderCapabilities, OS encoder/remux selector headers, AudioFileConcatenator │ │ ├── libs/ # Third-party wrappers (FFmpeg, miniaudio, pffft, …) │ │ └── external/ # Prebuilt binaries per platform │ │ ├── android/ # .a static libs (Opus, Ogg, Vorbis, OpenSSL) @@ -316,6 +317,7 @@ CI runs a parallel `cpp-coverage` job via `.github/workflows/cpp-coverage-job.ym - Compile definitions: `RN_AUDIO_API_ENABLE_WORKLETS=0`, `RN_AUDIO_API_TEST=1`, `RN_AUDIO_API_FFMPEG_DISABLED=1` - Google Test auto-fetched via `FetchContent` if not installed locally - New test files in `test/src/**/*.cpp` are picked up automatically by glob — no CMakeLists edit needed +- `jsi.cpp` is compiled into the static lib so library members that reference JSI symbols (e.g. `AudioFileProperties::CreateFromJSIValue`) link when a test first pulls them in; a static-lib member costs nothing unless demanded. If a new test triggers `Undefined symbols: facebook::jsi::...`, the referenced runtime source is missing from the lib — add it there rather than stubbing the symbol For `MockAudioEventHandlerRegistry`, `TestableXxx` pattern, and full CMakeLists analysis see [build-details.md](build-details.md#c-test-build--commoncpptestcmakeliststxt--detailed-analysis). @@ -354,6 +356,8 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | `HAVE_ACCELERATE` | Not set | `GCC_PREPROCESSOR_DEFINITIONS` | Not set | | `RN_AUDIO_API_TEST` | Not set | Not set | Always set to 1 | +**OS-API selector headers** (`decoding/OSDecoding.h`, `encoding/OSEncoding.h`, `encoding/OSRemux.h`, `encoding/OSFilePath.h`): common code reaches platform implementations through `#if defined(__ANDROID__)` / `#elif defined(__APPLE__) && !defined(RN_AUDIO_API_TEST) && !defined(RN_AUDIO_API_NODE)` dispatch. The Apple branch must exclude **both** desktop defines: the gtest build (`RN_AUDIO_API_TEST`) and the WPT node addon (`RN_AUDIO_API_NODE`) run on macOS (where `__APPLE__` is defined) but do not compile or link the `ios/` ObjC++ sources. The node build cannot borrow `RN_AUDIO_API_TEST` instead — that flag also switches on gtest-only code (`gtest_prod.h` includes, test `ArrayBuffer` shims). When adding a new OS-selector header, copy the full three-clause guard; an incremental `wpt_tests/build` dir can mask a missing clause for a long time, so verify with a clean `yarn node:build`. Platform glue selected this way lives in `android/src/main/cpp/audioapi/android/` (e.g. `AndroidDecoding`, `AndroidEncoder`, `AndroidRemux`) and `ios/audioapi/ios/core/utils/` (e.g. `IOSDecoding`, `IOSEncoder`, `IOSRemux`) — both picked up automatically by the CMake glob / podspec glob, no build-file edits needed. + --- ## Common Build Failure Patterns @@ -368,6 +372,7 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | 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` | +| 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`. | | 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` | ## clangd compile database diff --git a/.claude/skills/expressive-code/SKILL.md b/.claude/skills/expressive-code/SKILL.md index 03c1045af..83ed30004 100644 --- a/.claude/skills/expressive-code/SKILL.md +++ b/.claude/skills/expressive-code/SKILL.md @@ -36,6 +36,13 @@ If the **semantics are complex**, it might be due to one of the following: Generally, prefer standard, idiomatic names. +C++ namespaces are `snake_case` with every word separated (`encoder_capabilities`, +`recording_file_name`, `ios_file_path`) — never PascalCase, never run-together (`filepath`). + +C++ constants are `SCREAMING_CASE` (`POOL_SIZE`, `DRAIN_TIMEOUT_US`). A constant only one class uses is +a private `static constexpr` member of that class — no class-name prefix, the scope already says it. +Only a constant shared by free functions stays file-private in the `.cpp`'s anonymous namespace. + ### Frequency of Comments Use **comments only when necessary**. Add them only when something cannot be expressed easily in code. Their purpose is to make complex fragments easier to understand. Most code fragments are relatively easy to understand simply by **reading them like prose** (as explained above). diff --git a/.claude/skills/post-work-checks/SKILL.md b/.claude/skills/post-work-checks/SKILL.md index 18e51ac9b..57bde88f9 100644 --- a/.claude/skills/post-work-checks/SKILL.md +++ b/.claude/skills/post-work-checks/SKILL.md @@ -134,7 +134,9 @@ enum mirrored between C++ and Kotlin — currently `AudioEvent` and `RecorderSta cross JNI as plain ints and Kotlin maps them back by ordinal, so entry *order* is part of the contract; the check compares ignoring case and underscores, since the two languages name entries differently by convention. Add a new pair to the `MIRRORED_ENUMS` table at -the top of the script. +the top of the script. It then runs `check-audio-file-properties-enum-sync.sh`, which does +the same for the `AudioFileProperties` enums that cross JSI into TypeScript (`FileFormat`, +`FileDirectory`, `BitDepth`, `IOSAudioQuality`). **When**: only when you modify one of those enums or any file that maps event names across C++/Kotlin/TypeScript. Skip this step if you already ran `validate:fast` (it includes enum sync). diff --git a/.claude/skills/post-work-checks/maintenance.md b/.claude/skills/post-work-checks/maintenance.md index b4863c5e2..35a6b7db6 100644 --- a/.claude/skills/post-work-checks/maintenance.md +++ b/.claude/skills/post-work-checks/maintenance.md @@ -10,5 +10,5 @@ Review this skill when `pre-push-update` reports changes in: | `packages/react-native-audio-api/package.json` scripts | Package-level command changes (including per-language lint/format) | | `lefthook.yml` | Pre-commit / commit-msg hook changes | | `scripts/validate.sh` | Tier behavior (`--fast` / `--cpp-extended` / `--android` / `--ios` / `--full`), skip rules | -| `scripts/check-audio-enum-sync*` or `packages/react-native-audio-api/scripts/check-audio-events-sync.sh` | Enum sync check details | +| `scripts/check-audio-enum-sync*` or `packages/react-native-audio-api/scripts/check-*-enum-sync.sh` / `check-enum-sync.sh` | Enum sync check details (AudioEvent + AudioFileProperties) | | `.github/workflows/ci.yml`, `tests.yml`, `cpp-job.yml` | What CI covers vs local validation tiers | diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 8d4d6b222..2fc2d98d0 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -150,7 +150,27 @@ offloader.scheduleTask(std::move(workItem)); See the `utilities` skill for full API. -**Pitfall — file writer / recorder shutdown:** `TaskOffloader::shutdown()` drains the SPSC queue before joining the worker. Call it (or destroy the offloader) only after `isFileOpen_` is cleared so the audio thread stops enqueueing. Otherwise rotated or closed M4A segments lose seconds of buffered audio. Types with a `.slot` member use `slot == size_t max` as the shutdown sentinel. +**Pitfall — file writer / recorder shutdown:** `TaskOffloader::shutdown()` drains the SPSC queue before joining the worker, and it drains by *running the task* for each pending item. So drain **while the file still counts as open**. `runWriterTask()` gates its encode on `isFileOpen_`, so clearing that flag first makes every drained buffer take the false branch and be dropped, costing a rotated or closed M4A segment seconds of buffered audio. `finishCurrentFile()` therefore destroys the offloader first and clears the flag after, not the other way round. Types with a `.slot` member use `slot == size_t max` as the shutdown sentinel. + +That flag is **not** what keeps the audio thread out of the pool being freed, and it cannot be: `writeAudioData()` reads it and then dereferences the pool pointers, so no ordering of those two lines closes the window between the two steps. The caller closes it. The audio thread only enters the writer under the recorder's `fileWriterMutex_`, and every close path either moves the writer out of `fileWriter_` under that mutex first (`disableFileOutput`, `detachSideEffects`) or holds it across the call (the iOS format-change path). That includes the platform recorder destructors: both call `stop()` first, which detaches under the mutexes, and only then close the native stream. A writer driven without that discipline would need a guard of its own. + +**Android ownership cycle.** `AndroidAudioRecorder::openAudioStream()` hands Oboe `shared_from_this()`, and Oboe keeps that `shared_ptr` for the lifetime of the stream *object* (`AudioStreamBase::mSharedDataCallback`), not just while the stream is open. Since `mStream_` is a member, an opened recorder owns itself through its stream until `cleanup()` resets `mStream_`. Two consequences. The destructor can only run after `cleanup()` has already closed the stream, so no callback can race it — which is why the pre-`stop()` destructor got away with an unlocked `closeFile()`. And `stop()` only calls `requestStop()`, never `cleanup()`, so once JS drops a normally stopped recorder nothing breaks the cycle: the recorder and its AAudio stream leak until a disconnect error, and a recorder dropped mid-recording keeps recording. Any change that breaks the cycle (closing in `stop()`, releasing from the HostObject, or a weak-pointer proxy as Oboe's callback) makes the destructor order above load-bearing. + +Never hold a lock the worker takes while joining it: `finishCurrentFile()` drains and joins, and only then takes `fileMutex_` to retire the encoder — otherwise the join deadlocks against whatever the worker is doing under that lock. + +**iOS lock order: engine lock before consumer mutexes.** `AudioEngine` invokes `onInputConfigurationChange` while holding `_engineLock` (interruption end, engine rebuild), and that handler takes the recorder's consumer mutexes (`fileWriterMutex_`, `callbackMutex_`, `adapterNodeMutex_`) one at a time. So no recorder method may call into the native recorder (`start`, `stop`, `detachInputNode`, anything that takes `_engineLock`) while holding a consumer mutex. `IOSAudioRecorder::stop()` therefore stores `Idle`, disarms and stops the native side first, and only then locks to detach the side effects; a handler that fires in between sees `Idle` and returns. The same gate is why `resume()` must store `Recording` *before* the native resume: the native side re-arms input only through that handler, and the handler arms only while the state reads `Recording` (issue #1294). + +**Pattern — reacting to audio without blocking on it.** When a decision depends on data the audio thread produced but costs blocking work to make (rotating a recording once its file outgrows a cap), do not make it in `writeAudioData()`. Measuring a file can be a `stat`, and acting on it closes and opens an encoder — both forbidden on the audio thread. `AudioFileWriter` makes that decision **on its own worker thread**, in the task that just encoded a buffer (`rotateIfFileOutgrowsCap()`), and only when the file properties carry a non-zero `rotateIntervalBytes`, so a session that never rotates never pays for the `stat`. One mutex (`fileMutex_`) guards the open `RecordingFile` (encoder, path, and the frames it holds — a plain counter, since only the worker encodes and only under this lock) together with the session bookkeeping the rotation advances (file count, finished-file totals); the JS thread reads those under the same lock. `RecordingFile` itself is not thread-safe; rotation is swapping one for the next. The same lock guards the session's file list, which `closeFile()` hands back after joining the worker, so the recorder needs no lock or callback of its own to learn which files a rotation produced. Anything that reaches back out of the writer — the error event — is invoked **after** that lock is released, so a handler may call straight back in (`getFilePath()`) without re-entering a non-recursive mutex. The join-versus-lock ordering is the shutdown pitfall above: `finishCurrentFile()` stops the worker first, then takes the lock to retire the encoder. + +Swapping the output file must not restart the worker: a rotation exchanges the encoder underneath the live offloader, so a rotating recording costs one thread per file open, not one per segment. Only `reprepareStreamFormat()` (a stream-format change) stops the worker, because the buffer pool is sized from the format. It does **not** swap the file: the file's settings come from the file properties, not the input, so the same encoder is pointed at the new input through the backend's `reprepareEncoderInput` hook (iOS only — `IOSEncoder::reprepareInput` rebuilds the `AVAudioConverter`; Android's stream keeps its format, so `AudioEncoder` has no such virtual). Joining the worker first is what makes the in-place switch safe: no `encode()` is in flight when the converter is replaced. Duration bookkeeping needs care here, since `framesWritten_` counts in the current stream rate: the earlier formats' share is folded into `currentFileEarlierFormatsDurationSec_`, which the encoder's whole-file report on close must not double count. + +**Test seam — inject, do not derive.** `resolveOsFilePath` and `createOsEncoder` both fail in the desktop test build, so a real writer cannot open a file there. Those steps are bundled into a `PlatformFileBackend` struct of `std::function`s (output spec, path, encoder, and the optional in-place input re-prepare). The writer borrows it by `const &` — the constructor overload taking a temporary is deleted, so it cannot dangle — and defaults to `osFileBackend()`, one static instance shared by every writer. The test fixture owns a backend returning bare file names and a fake `AudioEncoder`, declared before the writer that borrows it; everything else — pool, worker, rotation, totals — runs for real, on a real `AudioFileWriter` rather than a subclass. For name-collision tests the fake backend prefixes a scratch directory, so `stat()` sees real files. + +Prefer this shape over `protected virtual` hooks for a class the production code instantiates directly. Virtual hooks make the class polymorphic, which then forces a virtual destructor for anyone holding it by base pointer, and they let a test assert against a subclass instead of the real type. With injection the writer is `final` with a non-virtual destructor, and a misconstructed backend is the one thing to guard: calling an empty `std::function` throws, and on the worker thread that terminates, so `openEncoderForNextFile()` checks both slots and returns an error instead. + +Tests stay deterministic by writing at most a pool's worth of buffers (32) per open and asserting only after `closeFile()`, which drains and joins the worker. + +**Pitfall — the task type cannot be a nested struct.** `TaskOffloader` constrains `T` with `std::default_initializable`. A task struct carrying default member initializers (which the `.slot` sentinel requires) does *not* satisfy that constraint while its enclosing class is still incomplete, so `using Offloader = TaskOffloader;` inside the class fails to compile with "constraints not satisfied". Making the struct `public` does not help — it is not an access problem. Declare the task type at **namespace scope** instead (`PendingFileWrite`, `PendingCallbackFrames`). Dropping the initializers to satisfy the constraint is worse: `T{}` would then produce `slot == 0`, a valid slot index, making the shutdown sentinel indistinguishable from real work. --- @@ -232,6 +252,7 @@ suspend.then→suspended, event→suspended`. - **Copying `shared_ptr` inside `processNode()`** — increments atomic refcount; capture before entering hot path. - **Locking `initialize()` or graph factory methods** — `initialize()` runs synchronously during HostObject construction on the JS thread; node factories and `createMediaElementSource()` are synchronous JS calls. Only lifecycle methods that touch the driver or offline render thread need `driverMutex_`. - **Locking only `AudioContext`** — iOS recorder, session, and interruption paths mutate the shared `AVAudioEngine` outside `AudioContext`; keep the `AudioEngine` mutex on those entry points. Offline render uses the same `driverMutex_` on `BaseAudioContext`. +- **Duplicating recorder fan-out in platform code** — `AudioRecorder::onAudioFrames(interleavedFrames, numFrames)` (base class, `common/cpp/audioapi/core/inputs/`) is the single audio-thread fan-out to file writer, JS callback, and adapter node, using tryLock-and-drop per consumer. Platform recorders (e.g. `IOSAudioRecorder`) only normalize the platform buffer to interleaved float32 and call it — adding per-consumer writes in the platform receiver block double-writes every buffer. The interleave config (`inputChannelCount_`, scratch buffer) is read unlocked by the audio thread, so it may only be mutated while the input is disarmed (start/stop/input-format-change paths). - **Re-entering `driverMutex_` or the `AudioEngine` mutex on the same thread** — call `tryStartDriver()` directly from `resume()` instead of `start()`; use lock-free `isStreamRunning()` from `isDriverRunning()`. `AudioContext::start()` does not acquire `driverMutex_`; it asserts the lock is already held when the driver is not initialized (via `scheduleAudioEvent` synchronous path). When already initialized, `start()` is a lock-free no-op so `source.start()` on the audio thread does not take the mutex. --- diff --git a/.claude/skills/thread-safety-itc/maintenance.md b/.claude/skills/thread-safety-itc/maintenance.md index b3b16dd31..0e06742d9 100644 --- a/.claude/skills/thread-safety-itc/maintenance.md +++ b/.claude/skills/thread-safety-itc/maintenance.md @@ -11,4 +11,5 @@ Review this skill when `pre-push-update` reports changes in: | `common/cpp/audioapi/utils/CrossThreadEventScheduler.hpp` | Scheduler API changes — update decision table | | `common/cpp/audioapi/core/AudioNode.*` | Audio thread contract changes | | `common/cpp/audioapi/core/utils/AudioGraphManager.*` | Graph mutation queue changes | +| `common/cpp/audioapi/core/utils/AudioFileWriter.*` | The "reacting to audio without blocking on it" pattern: rotation decided in the worker task, the callbacks-outside-the-lock rule, the never-join-while-holding rule, the injected `PlatformFileBackend` test seam | | Any new cross-thread primitive in `utils/` | Document in the decision table | diff --git a/.claude/skills/utilities/SKILL.md b/.claude/skills/utilities/SKILL.md index 807183c65..cf9dd22ce 100644 --- a/.claude/skills/utilities/SKILL.md +++ b/.claude/skills/utilities/SKILL.md @@ -142,6 +142,27 @@ For full API see [api.md](api.md#benchmarkhpp--timing-utilities-devdebug-only). --- +### `Path.h` — path strings and file:// URLs + +```cpp +audioapi::path::lowercaseExtension(path) // "wav" for "/tmp/Take.WAV", "" when none +audioapi::path::hasExtension(path, {"m4a", "mp4"}) // lowercase, no leading dot +audioapi::path::hasNonFileProtocol(path) // http://, content://, ... +audioapi::path::normalizeFilePath(pathOrFileUrl) // strips file:// and percent-decodes +``` + +Pure string work, never touches the disk (that is `FileSystem.hpp`). The extension comes from the file name only, so a dot in a directory name is not mistaken for one. `decoding::pathHasExtension` is a different, older suffix match used by the decoder. + +### `FileSystem.hpp` — path queries without `stat` + +```cpp +audioapi::file_system::fileExists(path) // false also when the path cannot be inspected +audioapi::file_system::fileSizeBytes(path) // 0 when missing or unreadable +audioapi::file_system::removeFile(path) // no-op when missing +``` + +Both wrap `std::filesystem` with an `error_code`, so they never throw. Use them instead of a local `::stat` helper; `fstat` on an fd you already opened is a different job and stays inline. + ### `UnitConversion.h` — byte unit constants ```cpp @@ -221,9 +242,15 @@ For full API see [api.md](api.md#audioutilshpp--inline-dsp-math). --- +### `AudioBufferPool.hpp` — preallocated planar buffers by pointer + +`AudioBufferPool` owns N `AudioBuffer`s and hands them out as `AudioBufferLease`s, a `unique_ptr` whose deleter returns the buffer to the pool through a lock-free `SlotFreeList` (any thread, never blocks, no allocation). Use it wherever the audio thread fills a buffer for a worker (`AudioFileWriter`, `AudioRecorderCallback`): the lease travels through the `TaskOffloader` message by move, dropping it anywhere returns the buffer, and a null lease is the shutdown message, so the message struct needs a defaulted `operator==`. Not `shared_ptr`: its control block would allocate on the audio thread. + +--- + ### `VectorMath.h` — SIMD-optimized vector math -SIMD-accelerated array operations (ARM NEON / x86 SSE2). Use for per-channel hot-path processing. Read the header for available functions before writing manual loops. +SIMD-accelerated array operations (Apple Accelerate/vDSP when `HAVE_ACCELERATE` is set by the podspec, otherwise ARM NEON / x86 SSE2). Use for per-channel hot-path processing. Read the header for available functions before writing manual loops. `interleave`/`deinterleave` handle any channel count (planar pointers <-> channel-interleaved); on Accelerate stereo goes through `vDSP_ctoz`/`vDSP_ztoc` and N channels through one strided `vDSP_vsadd` per channel, so platform code should call these rather than hand-roll a repack. The recorder pipeline is planar end to end (`AudioRecorder::onAudioFrames`, `AudioFileWriter`, `AudioRecorderCallback`, `AudioEncoder::encode` all take one pointer per channel); the only repacks are Oboe's interleaved input on Android and the encoder backends' fused interleave-while-quantize. --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fa0a3fb1..ce859aadf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: check-audio-enum-sync: uses: ./.github/workflows/ci-check.yml with: - name: Check AudioEvent enum sync + name: Check enum sync run: yarn check-audio-enum-sync build-audio-api: diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 37ddb0e2b..aa1720b01 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -21,6 +21,17 @@ import RecordingVisualization from './RecordingVisualization'; import Status from './Status'; import { RecordingState } from './types'; +// concatAudioFiles supports WAV, M4A, and FLAC — the formats recordable on +// both iOS and Android. +const RECORDING_EXTENSION = FileFormat.M4A; +const ROTATING_SIZE = 250_000; + +const RECORDING_EXTENSION_NAME_MAP = { + [FileFormat.Wav]: 'wav', + [FileFormat.M4A]: 'm4a', + [FileFormat.Flac]: 'flac', +}; + const Record: FC = () => { // Recover from "app disabled" state - recording can survive the app kill (android) const [state, setState] = useState(() => { @@ -100,9 +111,7 @@ const Record: FC = () => { return; } - const result = await Recorder.start({ - fileNameOverride: `overridden_name_${Date.now()}`, - }); + const result = await Recorder.start(); setupNotification(false); @@ -132,11 +141,12 @@ const Record: FC = () => { async (paths: string[]) => { setState(RecordingState.Loading); + const extension = RECORDING_EXTENSION_NAME_MAP[RECORDING_EXTENSION]; const finalPath = paths.length > 1 ? await concatAudioFiles( paths, - paths[0].replace(/[^/]+$/, 'recording.wav') + paths[0].replace(/[^/]+$/, `recording.${extension}`) ) : paths[0]; @@ -330,7 +340,11 @@ const Record: FC = () => { useEffect(() => { if (!AudioRecorder.isRecordingOngoing()) { - Recorder.enableFileOutput({ format: FileFormat.Wav }); + Recorder.enableFileOutput({ + rotateIntervalBytes: ROTATING_SIZE, + format: RECORDING_EXTENSION, + fileName: 'my_recording', + }); } return () => { diff --git a/apps/common-app/src/other/AudioPipelineStress/AudioPipelineStress.tsx b/apps/common-app/src/other/AudioPipelineStress/AudioPipelineStress.tsx index 98740874b..16c95cc18 100644 --- a/apps/common-app/src/other/AudioPipelineStress/AudioPipelineStress.tsx +++ b/apps/common-app/src/other/AudioPipelineStress/AudioPipelineStress.tsx @@ -281,13 +281,11 @@ const AudioPipelineStress: FC = () => { } }; - const performCleanRecording = async ( - fileNameOverride: string - ): Promise => { + const performCleanRecording = async (): Promise => { await activateRecordingSession(); resourcesRef.current.configureRecorderTap(); - await resourcesRef.current.startRecording(fileNameOverride); + await resourcesRef.current.startRecording(); await waitForRecordingCallbacks(1); await sleep(SHORT_RECORDING_MS); @@ -327,7 +325,7 @@ const AudioPipelineStress: FC = () => { }; const performCleanRecordPlaybackCycle = async (label: string) => { - const capture = await performCleanRecording(`${label}-${Date.now()}`); + const capture = await performCleanRecording(); await performCleanPlayback( capture.decodedBuffer, Math.min(capture.decodedBuffer.duration, 1.4), @@ -474,9 +472,7 @@ const AudioPipelineStress: FC = () => { 'record-and-decode', 'Record briefly, then decode output', async () => { - const capture = await performCleanRecording( - `record-warmup-${Date.now()}` - ); + const capture = await performCleanRecording(); addInfoStep( steps, 'recorded-file', @@ -499,9 +495,7 @@ const AudioPipelineStress: FC = () => { `cycle-${cycle}`, `Cycle ${cycle}: record, decode, and play recorded audio`, async () => { - const capture = await performCleanRecording( - `record-to-playback-${cycle}-${Date.now()}` - ); + const capture = await performCleanRecording(); const playbackStats = await performCleanPlayback( capture.decodedBuffer, @@ -551,9 +545,7 @@ const AudioPipelineStress: FC = () => { `Cycle ${cycle} pre-record playback engine timing`, formatPlaybackProgressStats(playbackStats) ); - await performCleanRecording( - `playback-to-record-${cycle}-${Date.now()}` - ); + await performCleanRecording(); } ); } @@ -691,7 +683,7 @@ const AudioPipelineStress: FC = () => { 'clean-recovery-cycle', 'Run one clean record and decode cycle after recovery', async () => { - await performCleanRecording(`post-record-recovery-${Date.now()}`); + await performCleanRecording(); } ); } @@ -716,9 +708,7 @@ const AudioPipelineStress: FC = () => { await AudioManager.setAudioSessionActivity(true); resourcesRef.current.configureRecorderTap(); - const result = await resourcesRef.current.tryStartRecording( - `wrong-category-${Date.now()}` - ); + const result = await resourcesRef.current.tryStartRecording(); if (result.status === 'success') { throw new Error( @@ -743,9 +733,7 @@ const AudioPipelineStress: FC = () => { 'clean-recovery-record', 'Switch back to playAndRecord and confirm clean recording works', async () => { - await performCleanRecording( - `wrong-category-recovery-${Date.now()}` - ); + await performCleanRecording(); } ); } diff --git a/apps/common-app/src/other/AudioPipelineStress/StressResourceOwner.ts b/apps/common-app/src/other/AudioPipelineStress/StressResourceOwner.ts index af0f293e3..0903f7a44 100644 --- a/apps/common-app/src/other/AudioPipelineStress/StressResourceOwner.ts +++ b/apps/common-app/src/other/AudioPipelineStress/StressResourceOwner.ts @@ -48,7 +48,7 @@ export default class StressResourceOwner { const fileOutputResult = recorder.enableFileOutput({ channelCount: 1, directory: FileDirectory.Cache, - fileNamePrefix: 'audio-pipeline-stress', + fileName: 'audio-pipeline-stress', format: FileFormat.M4A, subDirectory: 'AudioPipelineStress', }); @@ -115,18 +115,18 @@ export default class StressResourceOwner { } } - async startRecording(fileNameOverride: string): Promise { + async startRecording(): Promise { const { recorder } = this.getReadyResources(); - const result = await recorder.start({ fileNameOverride }); + const result = await recorder.start(); if (result.status === 'error') { throw new Error(`Failed to start recording: ${result.message}`); } } - tryStartRecording(fileNameOverride: string) { + tryStartRecording() { const { recorder } = this.getReadyResources(); - return recorder.start({ fileNameOverride }); + return recorder.start(); } async stopRecordingAndDecode(): Promise { diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index f9e29f45f..9a08cb9c6 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -263,6 +263,7 @@ - (void)setUp { self.audioEngine = [[TestableAudioEngine alloc] init]; AVAudioFormat *inputFormat = [self testInputFormat]; self.audioEngine.defaultCreatedEngineInputFormat = inputFormat; + [self.audioEngine createAudioEngineIfNeeded]; self.audioEngine.currentFakeAudioEngine.fakeInputNode.outputFormat = inputFormat; self.sessionManager = [[FakeAudioSessionManager alloc] init]; diff --git a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm index 9283624b8..b3fedd24f 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -7,6 +7,7 @@ #import #import #import +#import #import #import #import @@ -188,8 +189,9 @@ - (void)pause { self.pauseCallCount += 1; } -- (void)resume { +- (BOOL)resume { self.resumeCallCount += 1; + return YES; } - (void)cleanup { @@ -217,30 +219,33 @@ void setRecorderState(RecorderState state) { state_.store(state, std::memory_order_release); } - std::string currentFilePath() const { return filePath_; } + std::string currentFilePath() const { + std::scoped_lock lock(fileWriterMutex_); + return fileWriter_ != nullptr ? fileWriter_->getFilePath() : ""; + } bool fileOutputEnabledIntent() const { - return fileOutputEnabled_.load(std::memory_order_acquire); + return wantsFileOutput(); } bool fileOutputConfigured() const { - return fileOutputConfigured_.load(std::memory_order_acquire); + return usesFileOutput(); } bool callbackOutputEnabledIntent() const { - return callbackOutputEnabled_.load(std::memory_order_acquire); + return wantsCallback(); } bool callbackOutputConfigured() const { - return callbackOutputConfigured_.load(std::memory_order_acquire); + return usesCallback(); } bool connectionEnabledIntent() const { - return isConnected_.load(std::memory_order_acquire); + return wantsConnection(); } bool connectionConfigured() const { - return connectedConfigured_.load(std::memory_order_acquire); + return isConnected(); } }; @@ -290,10 +295,23 @@ - (void)tearDown { - (std::shared_ptr)validFileProperties { return std::make_shared( - AudioFileProperties::FileDirectory::Cache, "fabric-example-tests", - "ios-recorder-test", 2, 0, AudioFileProperties::Format::WAV, 44100, - 128000, AudioFileProperties::BitDepth::Bit16, 0, 0, - AudioFileProperties::IOSAudioQuality::High); + AudioFileProperties::PathConfig{ + .directory = AudioFileProperties::FileDirectory::Cache, + .subDirectory = "fabric-example-tests", + .fileName = "ios-recorder-test", + }, + AudioFileProperties::StreamConfig{.sampleRate = 44100, .channelCount = 2}, + AudioFileProperties::EncodingConfig{ + .format = AudioFileProperties::FileFormat::WAV, + .bitRate = 128000, + .bitDepth = AudioFileProperties::BitDepth::Bit16, + .flacCompressionLevel = 0, + .iosAudioQuality = AudioFileProperties::IOSAudioQuality::High, + }, + AudioFileProperties::WriterConfig{ + .rotateIntervalBytes = 0, + .androidFlushIntervalMs = 0, + }); } - (id)invalidFormat { @@ -316,7 +334,7 @@ - (id)validMultichannelFormat { - (void)testStartReturnsErrorWhenRecorderIsNotIdle { _recorder->setRecorderState(RecorderState::Paused); - auto result = _recorder->start(""); + auto result = _recorder->start(); XCTAssertTrue(result.is_err()); XCTAssertEqualObjects(NSStringFromStdString(result.unwrap_err()), @@ -326,7 +344,7 @@ - (void)testStartReturnsErrorWhenRecorderIsNotIdle { - (void)testStartReturnsErrorWhenRecordingPermissionIsDenied { self.sessionManager.recordingPermissions = @"Denied"; - auto result = _recorder->start(""); + auto result = _recorder->start(); XCTAssertTrue(result.is_err()); XCTAssertEqualObjects(NSStringFromStdString(result.unwrap_err()), @@ -340,7 +358,7 @@ - (void)testStartReturnsErrorWhenSessionActivationFails { code:7 userInfo:@{NSLocalizedDescriptionKey : @"boom"}]; - auto result = _recorder->start(""); + auto result = _recorder->start(); XCTAssertTrue(result.is_err()); NSString *message = NSStringFromStdString(result.unwrap_err()); @@ -355,7 +373,7 @@ - (void)testStartReturnsErrorWhenNativeRecorderThrows { reason:@"attempt-wrong-category-record" userInfo:nil]; - auto result = _recorder->start(""); + auto result = _recorder->start(); XCTAssertTrue(result.is_err()); XCTAssertEqual(self.nativeRecorder.startCallCount, 1); @@ -374,7 +392,7 @@ - (void)testStartReturnsErrorWhenEngineInputFormatIsUnavailable { self.sessionManager.diagnosticInputChannels = 0; self.sessionManager.routeReady = NO; - auto result = _recorder->start(""); + auto result = _recorder->start(); XCTAssertTrue(result.is_err()); XCTAssertEqual(self.nativeRecorder.startCallCount, 1); @@ -393,7 +411,7 @@ - (void)testStartSucceedsWhenResolvedInputFormatIsAvailable { self.audioEngine.state = AudioEngineStateRunning; self.nativeRecorder.mockResolvedInputFormat = [self validMultichannelFormat]; - auto result = _recorder->start(""); + auto result = _recorder->start(); XCTAssertTrue(result.is_ok()); XCTAssertEqual(self.nativeRecorder.startCallCount, 1); @@ -411,7 +429,7 @@ - (void)testStartPreparesMonoCallbackAgainstResolvedMultichannelInputFormat { auto callbackResult = _recorder->setOnAudioReadyCallback(48000, 256, 1, 99); XCTAssertTrue(callbackResult.is_ok()); - auto startResult = _recorder->start(""); + auto startResult = _recorder->start(); XCTAssertTrue(startResult.is_ok()); XCTAssertTrue(_recorder->usesCallback()); @@ -452,7 +470,7 @@ - (void)testConnectWhileIdleTracksIntentWithoutLiveConnection { - (void)testStartDoesNotAttemptToManageSessionWhenOwnershipIsExternal { self.sessionManager.shouldManageSession = NO; - auto result = _recorder->start(""); + auto result = _recorder->start(); XCTAssertTrue(result.is_ok()); XCTAssertEqual(self.nativeRecorder.startCallCount, 1); @@ -488,7 +506,7 @@ - (void)testStopReturnsErrorWhileIdle { - (void)testStopSucceedsAfterStartAndResetsState { self.audioEngine.state = AudioEngineStateRunning; - auto startResult = _recorder->start(""); + auto startResult = _recorder->start(); XCTAssertTrue(startResult.is_ok()); auto stopResult = _recorder->stop(); @@ -497,9 +515,9 @@ - (void)testStopSucceedsAfterStartAndResetsState { XCTAssertEqual(self.nativeRecorder.stopCallCount, 1); XCTAssertTrue(_recorder->isIdle()); XCTAssertEqual(_recorder->currentFilePath(), ""); - XCTAssertTrue(std::get<0>(stopResult.unwrap()).empty()); - XCTAssertEqual(std::get<1>(stopResult.unwrap()), 0); - XCTAssertEqual(std::get<2>(stopResult.unwrap()), 0); + XCTAssertTrue(stopResult.unwrap().paths.empty()); + XCTAssertEqual(stopResult.unwrap().size, 0); + XCTAssertEqual(stopResult.unwrap().duration, 0); } - (void)testStopClearsConfiguredStateButPreservesConfiguredIntent { @@ -511,7 +529,7 @@ - (void)testStopClearsConfiguredStateButPreservesConfiguredIntent { XCTAssertTrue(_recorder->setOnAudioReadyCallback(48000, 256, 1, 99).is_ok()); _recorder->connect(adapterFixture.handle); - auto startResult = _recorder->start(""); + auto startResult = _recorder->start(); XCTAssertTrue(startResult.is_ok()); XCTAssertTrue(_recorder->fileOutputConfigured()); XCTAssertTrue(_recorder->callbackOutputConfigured()); @@ -536,10 +554,10 @@ - (void)testRestartAfterStopReusesConfiguredCallback { self.nativeRecorder.mockResolvedInputFormat = [self validMultichannelFormat]; XCTAssertTrue(_recorder->setOnAudioReadyCallback(48000, 256, 1, 99).is_ok()); - XCTAssertTrue(_recorder->start("").is_ok()); + XCTAssertTrue(_recorder->start().is_ok()); XCTAssertTrue(_recorder->stop().is_ok()); - auto restartResult = _recorder->start(""); + auto restartResult = _recorder->start(); XCTAssertTrue(restartResult.is_ok()); XCTAssertTrue(_recorder->callbackOutputEnabledIntent()); @@ -548,14 +566,16 @@ - (void)testRestartAfterStopReusesConfiguredCallback { - (void)testFileOutputSmokeTest { self.audioEngine.state = AudioEngineStateRunning; - auto enableResult = _recorder->enableFileOutput([self validFileProperties]); - XCTAssertTrue(enableResult.is_ok()); NSString *uuid = [[NSUUID UUID] UUIDString]; std::string fileName = [[NSString stringWithFormat:@"ios-recorder-smoke-%@", uuid] UTF8String]; + auto properties = [self validFileProperties]; + properties->path.fileName = fileName; + auto enableResult = _recorder->enableFileOutput(properties); + XCTAssertTrue(enableResult.is_ok()); - auto startResult = _recorder->start(fileName); + auto startResult = _recorder->start(); XCTAssertTrue(startResult.is_ok()); NSString *path = NSStringFromStdString(_recorder->currentFilePath()); @@ -564,12 +584,12 @@ - (void)testFileOutputSmokeTest { auto stopResult = _recorder->stop(); XCTAssertTrue(stopResult.is_ok()); - const auto &outputPaths = std::get<0>(stopResult.unwrap()); + const auto &outputPaths = stopResult.unwrap().paths; XCTAssertEqual(outputPaths.size(), 1U); XCTAssertEqualObjects(NSStringFromStdString(outputPaths.front()), [@"file://" stringByAppendingString:path]); - XCTAssertGreaterThanOrEqual(std::get<1>(stopResult.unwrap()), 0.0); - XCTAssertGreaterThanOrEqual(std::get<2>(stopResult.unwrap()), 0.0); + XCTAssertGreaterThanOrEqual(stopResult.unwrap().size, 0.0); + XCTAssertGreaterThanOrEqual(stopResult.unwrap().duration, 0.0); XCTAssertEqual(_recorder->currentFilePath(), ""); [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; @@ -579,11 +599,11 @@ - (void)testStartReturnsCallbackPreparationFailureForInvalidCallbackFormat { auto callbackResult = _recorder->setOnAudioReadyCallback(0, 256, 2, 99); XCTAssertTrue(callbackResult.is_ok()); - auto result = _recorder->start(""); + auto result = _recorder->start(); XCTAssertTrue(result.is_err()); XCTAssertTrue([NSStringFromStdString(result.unwrap_err()) - containsString:@"Failed to prepare callback: Invalid callback format"]); + containsString:@"Failed to prepare callback: Invalid callback"]); } - (void)testConnectWhileActiveInitializesAdapterAndDisconnectClearsIt { diff --git a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm index 149c07371..fe97c1202 100644 --- a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm @@ -255,6 +255,7 @@ - (void)setUp self.sessionManager = [[FakeRecorderAudioSessionManager alloc] init]; self.audioEngine = [[FakeRecorderAudioEngine alloc] init]; + [self.audioEngine createAudioEngineIfNeeded]; self.sharedSession = [[FakeRecorderSharedAVAudioSession alloc] init]; self.sharedSession.IOBufferDuration = 0.01; self.sharedSession.sampleRate = 48000; @@ -430,12 +431,21 @@ - (void)testPauseAndResumeDelegateToAudioEngine initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} voiceProcessingEnabled:NO]; + self.audioEngine.startIfNecessaryResult = YES; + __block NSInteger configurationChangeCallCount = 0; + recorder.onInputConfigurationChange = ^{ + configurationChangeCallCount += 1; + }; + [recorder pause]; - [recorder resume]; + BOOL resumed = [recorder resume]; XCTAssertEqual(self.audioEngine.pauseIfNecessaryCallCount, 1); XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); - XCTAssertTrue(recorder.inputArmed); + XCTAssertTrue(resumed); + // Re-arming is the owner's decision, taken in the configuration-change handler. + XCTAssertEqual(configurationChangeCallCount, 1); + XCTAssertFalse(recorder.inputArmed); } - (void)testStartAfterSessionDeactivationUsesRecoveryRebuildPath @@ -462,7 +472,8 @@ - (void)testStartAfterSessionDeactivationUsesRecoveryRebuildPath XCTAssertEqual(self.audioEngine.rebuildAfterDeactivationCallCount, 1); XCTAssertFalse(self.audioEngine.sessionDeactivationInvalidatedGraph); XCTAssertEqualObjects([recorder getResolvedInputFormat], recoveredFormat); - XCTAssertEqual([recorder getResolvedBufferSize], 16384); + // 0.2 s at the recovered 32 kHz rate, rounded up to a power of two. + XCTAssertEqual([recorder getResolvedBufferSize], 8192); } @end diff --git a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm index 2f1d1b0f3..9c2161429 100644 --- a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm @@ -118,6 +118,13 @@ - (void)restartAudioEngine self.restartAudioEngineCallCount += 1; } +/// The engine is created lazily, so a fresh fake reads as not in use and the manager would +/// ignore every notification meant for it. +- (bool)isInUse +{ + return true; +} + @end @interface SNMFakeAudioSessionManager : AudioSessionManager @@ -508,6 +515,8 @@ - (void)testHandleRouteChangeMapsReasonsAndFallsBackToUnknown - (void)testHandleMediaServicesResetReactivatesSessionAndRestartsEngine { + // Only a session that was active gets re-activated after the reset. + self.fakeSessionManager.isActive = true; [self.manager handleMediaServicesReset:nil]; [self flushMainQueue]; diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 2ae695f89..de58e7526 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -444,12 +444,9 @@ export default MyRecorder;
Starts the stream from the system audio input device. Returns a `Promise>` that resolves when recording has started. - You can pass an optional [AudioRecorderStartOptions](#audiorecorderstartoptions) object with a `fileNameOverride` string to provide your own file name.
```tsx - const result = await audioRecorder.start({ - fileNameOverride: `my_audio_${mySessionId}`, - }); + const result = await audioRecorder.start(); console.log(result.status); ``` @@ -850,18 +847,6 @@ type AndroidInputPreset = Names of Oboe's [`InputPreset`](https://github.com/google/oboe/blob/0da326e4ef878eac0c032e11ea84ca0a6811aafd/include/oboe/Definitions.h#L470) values, which select the preprocessing chain the capture stream is opened with. -#### `AudioRecorderStartOptions` - -```tsx -interface AudioRecorderStartOptions { - fileNameOverride?: string; -} -``` - -| Parameter | Type | Description | -| :---: | :---: | :---- | -| `fileNameOverride` | `string` | Custom file name used when recording to file. | - #### AudioRecorderCallbackOptions ```tsx @@ -918,13 +903,13 @@ interface AudioRecorderFileOptions { directory?: FileDirectory; subDirectory?: string; - fileNamePrefix?: string; + fileName?: string; androidFlushIntervalMs?: number; } ``` -- `channelCount` - The desired channel count in the resulting file. not all file formats supports all possible channel counts. -- `rotateIntervalBytes` - The threshold size (in bytes) at which the recorder will start writing to a new file. If set to `0` (default), file output rotation is disabled. When active, new files are named with the original prefix appended with a timestamp. You can join the rotated files after recording with [`concatAudioFiles`](../utils/file-concatenation.mdx#concataudiofiles). +- `channelCount` - The channel count of the resulting file: `1` (mono) or `2` (stereo). +- `rotateIntervalBytes` - The threshold size (in bytes) at which the recorder will start writing to a new file. If set to `0` (default), file output rotation is disabled. When active, each segment carries a three-digit index (`_001`, `_002`, …) — see [File naming](#file-naming). You can join the rotated files after recording with [`concatAudioFiles`](../utils/file-concatenation.mdx#concataudiofiles). - Use a large enough value for your format. Very small thresholds rotate often, which increases the chance of audible gaps or muffled joins after concatenation — especially for **M4A**, where each segment is a separate AAC encode. - Practical starting points: **≥ 1 MB** for WAV, **≥ 200 KB** for M4A (adjust upward if you still hear artifacts at segment boundaries). - This option controls segment file size, not RAM usage. For crash-resilience tuning on Android, use `androidFlushIntervalMs` instead. @@ -932,26 +917,89 @@ interface AudioRecorderFileOptions { - `preset` - The desired recorder file properties, you can use either one of built-in properties or tweak low-level parameters yourself. Check [FilePresetType](#filepresettype) for more details. - `directory` - Either `FileDirectory.Cache` or `FileDirectory.Document` (default: `FileDirectory.Cache`). Determines the system directory that the file will be saved to. - `subDirectory` - If configured it will create the recording inside requested directory (default: `undefined`). -- `fileNamePrefix` - Prefix of the recording files without the unique ID (default: `recording`). +- `fileName` - Names the output file outright. Left unset, the library generates `recording_`. See [File naming](#file-naming) below. - `androidFlushIntervalMs` - How often the recorder should force the system to write data to the device storage (default: `500`). - Lower values are good for crash-resilience and are more memory friendly. - Higher values are more battery - and storage-efficient. +#### File naming + +`fileName` names the output file outright. Left unset, the library generates a name from the +start time, and keeps it clear of existing files. Rotation appends a segment index either way, +because one recording then spans several files. + +| `fileName` | `rotateIntervalBytes` | Resulting file(s) | +| :--- | :--- | :--- | +| — | `0` | `recording_20260909_101500.wav` | +| `session-42` | `0` | `session-42.wav` | +| — | `> 0` | `recording_20260909_101500_001.wav`, `_002`, … | +| `session-42` | `> 0` | `session-42_001.wav`, `session-42_002.wav`, … | + +The generated timestamp is taken once per recording, in local time, so every segment of one +rotated recording shares it and only the index differs. + +`fileName` has to be a bare name: no extension (it follows from `format`), no path +separators and no `..`. + +The segment index is padded to three digits and keeps counting beyond that (`_999`, +`_1000`, …). Names stay unique; only their lexicographic order breaks past 999 segments — +`stop()` still returns `paths` in recording order. + +An audio route change mid-session (headphones connecting, say) alters the input format on +iOS, but the recording carries on in the same file: only the conversion from the input to +the file's format is rebuilt. A recording without rotation is always a single file. + +A generated name never overwrites: when two recordings start within the same second and +would share a timestamp, the later one gets a counter appended +(`recording_20260909_101500_1.wav`, `_2`, …). + +:::caution +`fileName` puts you in charge of telling recordings apart. An existing file of the same name +is **overwritten**, with a warning in the native log, and two recordings started under the +same `fileName` overwrite each other — with rotation, segment by segment, since numbering +restarts at `_001` every time. Make the name unique per recording yourself. +::: + #### FileFormat Describes desired file extension as well as codecs, containers (and muxers!) used to encode the file. +All encoding is done with platform system APIs — iOS AVFoundation and Android MediaCodec/MediaMuxer. Because each platform exposes a different set of system encoders, format support is platform-specific. + ```tsx enum FileFormat { Wav, Caf, M4A, Flac, + Aiff, + Alac, + OpusOgg, + OpusWebm, + VorbisWebm, + Ulaw, + Alaw, } ``` -:::caution Android + FFmpeg -On Android, encoded file output for `M4A`, `FLAC`, and `CAF` uses FFmpeg. When FFmpeg is disabled in the build, only **WAV** recording to file is supported. iOS uses system AVFoundation for all listed formats. See [Runtime flags](../other/runtime-flags.mdx#where-ffmpeg-is-used). +The table below lists which formats each platform can encode with its system APIs: + +| `FileFormat` | Container / codec | iOS | Android | +| :--- | :--- | :---: | :---: | +| `Wav` | WAV / PCM | ✅ | ✅ | +| `M4A` | M4A / AAC-LC | ✅ | ✅ | +| `Flac` | FLAC / FLAC | ✅ | ✅ | +| `Caf` | CAF / PCM | ✅ | ❌ | +| `Aiff` | AIFF / PCM | ✅ | ❌ | +| `Alac` | M4A / Apple Lossless | ✅ | ❌ | +| `Ulaw` | WAV / µ-law | ✅ | ❌ | +| `Alaw` | WAV / a-law | ✅ | ❌ | +| `OpusOgg` | OGG / Opus | ❌ | ✅ | +| `OpusWebm` | WebM / Opus | ❌ | ✅ | +| `VorbisWebm` | WebM / Vorbis | ❌ | ✅ | + +:::caution Platform support +Selecting a format the current platform cannot encode (for example `Caf` on Android or `OpusOgg` on iOS) fails when file output is enabled, with a descriptive error. Some Android formats also depend on the device OS version (Opus/OGG muxing requires newer releases); if a device lacks a system encoder for the requested format, recording start returns an error. Use `Wav`, `M4A`, or `Flac` for the widest cross-platform support. ::: #### FileInfo diff --git a/packages/audiodocs/docs/other/disabling-prebuilt-libraries.mdx b/packages/audiodocs/docs/other/disabling-prebuilt-libraries.mdx index 6cd9faa22..39d986252 100644 --- a/packages/audiodocs/docs/other/disabling-prebuilt-libraries.mdx +++ b/packages/audiodocs/docs/other/disabling-prebuilt-libraries.mdx @@ -17,7 +17,7 @@ The available flags are independent and can be combined: | Flag | What it removes | What stops working | | :---: | :---- | :---- | -| `disableFFmpeg` | FFmpeg shared libraries (`libavcodec`, `libavformat`, `libavutil`, `libswresample`) | Remote URL streaming / HLS, remote URL metadata, M4A concat, **Android** non-WAV recording — see [Runtime flags](./runtime-flags.mdx#where-ffmpeg-is-used) | +| `disableFFmpeg` | FFmpeg shared libraries (`libavcodec`, `libavformat`, `libavutil`, `libswresample`) | Remote URL streaming / HLS, remote URL metadata — see [Runtime flags](./runtime-flags#where-ffmpeg-is-used). | | `disableStaticExternalLibs` | Static libs: `libopus`, `libopusfile`, `libogg`, `libvorbis`, `libvorbisenc`, `libvorbisfile` | Decoding `ogg`, `opus`, `oga` files | :::info diff --git a/packages/audiodocs/docs/other/runtime-flags.mdx b/packages/audiodocs/docs/other/runtime-flags.mdx index c65709891..6aa079f5b 100644 --- a/packages/audiodocs/docs/other/runtime-flags.mdx +++ b/packages/audiodocs/docs/other/runtime-flags.mdx @@ -2,7 +2,7 @@ These helpers let you check at runtime which optional native features are compiled into your app. They are synchronous and safe to call from JavaScript after the library has been installed. -Use them to branch UI or loading logic — for example, skip remote metadata preload when FFmpeg is disabled, disable hls streaming, or offer only WAV recording on Android. +Use them to branch UI or loading logic — for example, skip remote metadata preload when FFmpeg is disabled or disable hls streaming. :::info Build-time vs runtime To **disable** optional libraries at build time (and reduce app size), see [Disabling prebuilt libraries](./disabling-prebuilt-libraries.mdx). Runtime flags only tell you what ended up in the binary you are running. @@ -20,7 +20,7 @@ Returns whether the native build includes [`FFmpeg`](https://github.com/FFmpeg/F import { isFfmpegEnabled } from 'react-native-audio-api'; if (!isFfmpegEnabled()) { - console.warn('Remote URL metadata, streaming, and Android M4A recording require an FFmpeg build.'); + console.warn('Remote URL metadata and streaming require an FFmpeg build.'); } ``` @@ -29,14 +29,12 @@ if (!isFfmpegEnabled()) { | Area | API | Requires FFmpeg for | | :---: | :---: | :---- | -| Streaming | [`Audio tag`](../sources/audio-tag.mdx), `createFileSource` | Remote URL streaming (HTTP byte ranges) and HLS (`.m3u8`) | -| Metadata | [`getAudioDuration`](../utils/decoding.mdx#getaudioduration), [`