diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index 39300f21c..cb6d6d963 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -169,7 +169,11 @@ enum class PlaybackState { Subclasses call `updatePlaybackInfo(currentTime, framesToProcess)` at the top of `processNode()` to transition the state machine and handle sample-accurate start/stop. -When the node finishes, fire the `ENDED` event to JS via `audioEventHandlerRegistry_->invokeHandlerWithEventBody(AudioEvent::ENDED, {})`. +When the node finishes, `AudioScheduledSourceNode::disable()` fires `ENDED` through its `EventCaller` (`dispatchEmptyFromAudioThread`); a node that will never render (stop-before-start) defers it with `EventCaller::deferEmpty` instead — see `thread-safety-itc`. + +Pitfalls: +- `start(double)` in this base class is a separate overload from `AudioBufferSourceNode::start(when, offset, duration)`. Calling `node->start(when)` on an ABSN skips the derived logic; the HostObject always passes three arguments, and so must tests. +- `AudioBufferBaseSourceNode::processNode` is `final` and returns early on `isEmpty()`. Whether a started-but-empty source ends there (`ended` fires) is `endsWhenStartedEmpty()`: the base default is true (spec: a null buffer forces `stop = currentTime`); `AudioBufferQueueSourceNode` overrides it to false because an empty queue is waiting for the next enqueue. The check runs per quantum on the audio thread on purpose, because the buffer setter legitimately acquires content assigned after `start()` but before the first render. --- diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 1a5910083..505ed2a7f 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -368,6 +368,39 @@ 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` | +| 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 + +`packages/react-native-audio-api/common/cpp/clangd/` generates the repo-root +`compile_commands.json` that clangd reads (`yarn setup:clangd`, or +`yarn setup:clangd:clean` after a native dependency bump). It is editor tooling +only — it never participates in a real build. See its `SETUP.md`. + +The iOS half reuses whatever `pod install` resolved for `apps/fabric-example`, +reading **both** `HEADER_SEARCH_PATHS` and `FRAMEWORK_SEARCH_PATHS` out of +`Pods/Target Support Files/Pods-FabricExample/Pods-FabricExample.debug.xcconfig`. + +Framework search paths matter because React-Core ships prebuilt as +`React.xcframework` since RN 0.87: `#import ` resolves +via `-F`, not `-I`, so an `-I`-only database fails every `.mm` that imports +React. Xcode's own `-F` points at `PODS_XCFRAMEWORKS_BUILD_DIR`, which only +exists after Xcode has unpacked the slices — the CMakeLists globs the +`ios-*simulator*` slices inside the `.xcframework` bundles instead, so a bare +`pod install` suffices. + +To check the database rather than guessing at clangd, replay an entry directly: + +```bash +python3 -c " +import json,shlex,subprocess +e=[x for x in json.load(open('compile_commands.json')) if x['file'].endswith('ios/AudioAPIModule.mm')][0] +toks=[t for t in shlex.split(e['command']) if t!='-c'] +subprocess.run(toks[:toks.index('-o')]+toks[toks.index('-o')+2:]+['-fsyntax-only'], cwd=e['directory']) +" +``` + +If that reproduces the error, the database is wrong — not the editor's LSP setup. --- diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 21674fa6a..8d4d6b222 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -95,7 +95,7 @@ Throttled events (e.g. `positionChanged`) use `PositionChangedDispatcher`, which - Clear callbacks in the **most-derived HostObject first** (each layer clears its own events; base destructors run afterward and clear parent events) - Do **not** inherit `EventCaller` on multiple node bases (ambiguous API); add member `EventCaller` fields per event instead -**Time-delayed dispatch outside the node's lifetime** (e.g. firing `ended` at a context time the render path will not reach for this node): use `BaseAudioContext::deferEmptyEventDispatch(event, callbackId, dueTime)`. The context keeps a preallocated pending list drained each quantum in `processAudioEvents()`; the context clock is the timer (suspend pauses it, offline fires at render speed) and the context holds no node reference. Never use a detached timer thread for this — spawning a thread from an audio-event body runs on the audio thread (allocation + syscalls), and every capture choice has been a real bug in `AudioScheduledSourceNode::stop()`: raw `this` was a use-after-free, `weak_from_this().lock()` ran the node destructor on the timer thread (bypassing the graph's disposal path), and `std::move`-ing the `EventCaller` member raced the JS/GC-thread `assignCallbackId` paths (the registry `shared_ptr` inside `EventCaller` is only safe for concurrent access while it is never mutated — which is why `EventCaller` is non-movable). +**Time-delayed dispatch outside the node's lifetime** (e.g. firing `ended` at a context time the render path will not reach for this node): use `EventCaller::deferEmpty(context->getDeferredEvents(), dueTime)`. The context keeps a preallocated `DeferredEventQueue` drained each quantum in `processAudioEvents()`; the context clock is the timer (suspend pauses it, offline fires at render speed) and the context holds no node reference. Never use a detached timer thread for this — spawning a thread from an audio-event body runs on the audio thread (allocation + syscalls), and every capture choice has been a real bug in `AudioScheduledSourceNode::stop()`: raw `this` was a use-after-free, `weak_from_this().lock()` ran the node destructor on the timer thread (bypassing the graph's disposal path), and `std::move`-ing the `EventCaller` member raced the JS/GC-thread `assignCallbackId` paths (the registry `shared_ptr` inside `EventCaller` is only safe for concurrent access while it is never mutated — which is why `EventCaller` is non-movable). Low-level registry API (used internally by `EventCaller`): diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 461eb19d6..1ba7a71ea 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -155,6 +155,7 @@ const Record: FC = () => { const onStopRecording = useCallback(async () => { const info = await Recorder.stop(); + AudioRecorder.consumeLastRecordingResult(); RecordingNotificationManager.hide(); setIsInterrupted(false); setState(RecordingState.Loading); diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 3dbab261e..78691f710 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2516,4 +2516,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 2fbbdb3fb754264aa345dc912d51c3c6d295cdd9 -COCOAPODS: 1.17.0 +COCOAPODS: 1.16.2 diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 0f7980f91..2ae695f89 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -401,261 +401,418 @@ export default MyRecorder; -## Constructor +## API -```tsx -constructor(options?: AudioRecorderOptions) -``` - -Creates a new `AudioRecorder` instance, optionally configuring the platform capture chain - see [`AudioRecorderOptions`](#audiorecorderoptions). - -:::info -It is preferred to create only a single instance of `AudioRecorder` for the best performance, memory, and battery consumption. While an idle recorder has minimal impact, switching between separate recorder instances might have a noticeable impact on the device. -::: - -```tsx -import { AudioRecorder } from 'react-native-audio-api'; - -const audioRecorder = new AudioRecorder(); -``` - -Full-duplex voice apps (playing audio while recording) need platform echo cancellation on both platforms, otherwise the speaker output leaks back into the microphone: - -```tsx -const audioRecorder = new AudioRecorder({ - androidInputPreset: 'voiceCommunication', - iosVoiceProcessing: true, -}); -``` - -## Properties - -| Name | Type | Description | -| :--- | :--- | :--- | -| `options` | [`AudioRecorderFileOptions`](#audiorecorderfileoptions) \| `null` | Active file-output configuration after [`enableFileOutput()`](#enablefileoutput), or `null` when file output is disabled. | -| `inputLatency` | `number` | Returns the estimated **full** input latency in seconds (from analog input to app delivery). Mirrors [`AudioContext.outputLatency`](../core/audio-context.mdx#properties) semantics. Calculated using underlying platform properties (`AVAudioSession` on iOS; Oboe timestamps/burst sizes on Android). Value may change when the audio route changes. Returns `0` when idle. | - -## Methods - -### `start` - -Starts the stream from the system audio input device. - -| Parameter | Type | Description | -| :---: | :---: | :---- | -| `options` | [`AudioRecorderStartOptions`](#audiorecorderstartoptions) | Optional recording start configuration. | - -###### Returns `Promise>`. - -```tsx -const result = await audioRecorder.start({ - fileNameOverride: `my_audio_${mySessionId}`, -}); - -console.log(result.status); -``` - -### `stop` - -Stops the input stream and cleans up each input access method. - -For details on the returned file information, see [`FileInfo`](#fileinfo). - -###### Returns `Promise>`. - -```tsx -const result = await audioRecorder.stop(); - -if (result.status === 'success') { - const { paths, duration, size } = result; -} else if (result.status === 'error') { - console.error(result.message); -} -``` - -### `pause` - -Pauses the recording. This is useful when recording to file is active, but you do not want to finalize the file. - -```tsx -audioRecorder.pause(); -``` - -### `resume` - -Resumes the recording if it was previously paused; otherwise does nothing. - -```tsx -audioRecorder.resume(); -``` - -### `isRecording` - -Returns `true` if the recorder is in an active recording state. - -###### Returns `boolean`. - -```tsx -const isRecording = audioRecorder.isRecording(); -``` - -### `isPaused` - -Returns `true` if the recorder is in a paused state. - -###### Returns `boolean`. - -```tsx -const isPaused = audioRecorder.isPaused(); -``` - -### `getCurrentDuration` - -Returns the current recording duration when file output is enabled. - -###### Returns `number`. - -```tsx -const duration = audioRecorder.getCurrentDuration(); -``` - -### `isRecordingOngoing` - -**Static.** Returns `true` while a recording session is ongoing (recording or paused). Native source of truth that needs no reference to the recorder instance, so use it from a remounted screen — e.g. after reopening an app whose recording [outlived the app UI](#keeping-the-recording-alive-when-the-app-is-closed) — to seed the UI state. It reflects the recorder that acquired the active session on a successful [`start`](#start); a second `start()` does not displace it. - -#### Returns `boolean`. - -```tsx -if (AudioRecorder.isRecordingOngoing()) { - // re-attach the UI to the still-running recording -} -``` - -### `consumeLastRecordingResult` {#consumelastrecordingresult} - -**Static.** Returns the [`FileInfo`](#fileinfo) stashed by the last successful stop that produced output files, or `null` if there is none. That includes JS [`stop`](#stop), the Android [recording notification](../system/recording-notification-manager.mdx#native-action-handling), and [deactivating the iOS audio session](../system/audio-manager.mdx#setaudiosessionactivity) mid-recording. Consume-once: the stash is cleared on read, so a second call returns `null`. + + + + + + + + + -#### Returns [`FileInfo`](#fileinfo) or `null`. + -```tsx -const info = AudioRecorder.consumeLastRecordingResult(); -if (info) { - // the recording was stopped from the notification; info.paths holds the files -} -``` + + + + -Configures and enables file output with the given options and stream properties. By default, the recorder writes to the cache directory using a high-quality `M4A` file. + -Must be called while the recorder is idle: the file is created by the next [`start`](#start). During an active (recording or paused) session the call returns an error result and the session keeps the output it started with. + + + + -###### Returns `Result<{}>`. + -```tsx -audioRecorder.enableFileOutput(); -``` + + + + -### `disableFileOutput` + -Disables file output and finalizes the currently recorded file if the recorder is active. + + + + -```tsx -audioRecorder.disableFileOutput(); -``` + -### `onAudioReady` + + + + -Registers a callback that receives raw audio buffers during an active recording session. + -The callback is periodically invoked with audio buffers that match the preferred configuration provided in `options`. These parameters guide how audio data is chunked and delivered, though the exact values may vary depending on device capabilities. + + + + -For further information, see [`AudioRecorderCallbackOptions`](#audiorecordercallbackoptions) and [`OnAudioReadyEventType`](#onaudioreadyeventtype). + -| Parameter | Type | Description | -| :---: | :---: | :---- | -| `options` | [`AudioRecorderCallbackOptions`](#audiorecordercallbackoptions) | Preferred callback buffer configuration. | -| `callback` | `(event: OnAudioReadyEventType) => void` | Function invoked when a new audio buffer is available. | + + + + -###### Returns `Result`. + -```tsx -const sampleRate = 16000; + + + + -### `clearOnAudioReady` + -Removes the audio data callback and flushes any remaining buffered data through `onAudioReady`. + + + + -### `connect` + -Routes captured audio into an audio graph by creating a recorder adapter with [`BaseAudioContext.createRecorderAdapter()`](../core/base-audio-context.mdx#createrecorderadapter), connecting the recorder to it, and wiring it to `destination`. + + + + -| Parameter | Type | Description | -| :---: | :---: | :---- | -| `context` | [`BaseAudioContext`](../core/base-audio-context.mdx) | Audio context used to create the recorder adapter. | -| `destination` | [`AudioNode`](../core/audio-node.mdx) | Destination node in the audio graph. | + +
Method
Description
+ ##### Constructor +
+
+ Creates a new `AudioRecorder` instance, optionally configuring the platform capture chain - see [AudioRecorderOptions](#audiorecorderoptions). It is preferred to create only a single instance of `AudioRecorder` for the best performance, memory, and battery consumption. While an idle recorder has minimal impact, switching between separate recorder instances might have a noticeable impact on the device. +
+ ```tsx + import { AudioRecorder } from 'react-native-audio-api'; -If the recording was already finalized natively, calling [`stop`](#stop) on an `AudioRecorder` instance does not return those files — it reports that the recorder is not recording. Read the stashed [`FileInfo`](#fileinfo) from this method instead. + const audioRecorder = new AudioRecorder(); + ``` +
+ Full-duplex voice apps (playing audio while recording) need platform echo cancellation on both platforms, otherwise the speaker output leaks back into the microphone: +
+ ```tsx + const audioRecorder = new AudioRecorder({ + androidInputPreset: 'voiceCommunication', + iosVoiceProcessing: true, + }); + ``` +
+ ##### start +
+ (asynchronous) +
+
+ 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}`, + }); -### `enableFileOutput` + console.log(result.status); + ``` +
+ ##### stop +
+ (asynchronous) +
+
+ Stops the input stream and cleans up each input access method. Returns a `Promise>` that resolves when recording has stopped. -For further information, see [`AudioRecorderFileOptions`](#audiorecorderfileoptions). + For details on the returned file information, see [FileInfo](#fileinfo). +
+ ```tsx + const result = await audioRecorder.stop(); -| Parameter | Type | Description | -| :---: | :---: | :---- | -| `options` | [`AudioRecorderFileOptions`](#audiorecorderfileoptions) | File output configuration. | + if (result.status === 'success') { + const { paths, duration, size } = result; + } else if (result.status === 'error') { + console.error(result.message); + } + ``` +
+ ##### pause +
+
+ Pauses the recording. This is useful when recording to file is active, but you do not want to finalize the file. +
+ ```tsx + audioRecorder.pause(); + ``` +
+ ##### resume +
+
+ Resumes the recording if it was previously paused; otherwise does nothing. +
+ ```tsx + audioRecorder.resume(); + ``` +
+ ##### isRecording +
+
+ Returns `true` if the recorder is in an active recording state. +
+ ```tsx + const isRecording = audioRecorder.isRecording(); + ``` +
+ ##### isPaused +
+
+ Returns `true` if the recorder is in a paused state. +
+ ```tsx + const isPaused = audioRecorder.isPaused(); + ``` +
+ ##### isRecordingOngoing +
+ (static) +
+
+ Returns `true` while a recording session is ongoing (recording or paused). Native source of truth that needs no reference to the recorder instance, so use it from a remounted screen — e.g. after reopening an app whose recording [outlived the app UI](#keeping-the-recording-alive-when-the-app-is-closed) — to seed the UI state. It reflects the recorder that acquired the active session on a successful [start](#start); a second `start()` does not displace it. +
+ ```tsx + if (AudioRecorder.isRecordingOngoing()) { + // re-attach the UI to the still-running recording + } + ``` +
+ ##### consumeLastRecordingResult +
+ (static) +
+
+ Returns the [FileInfo](#fileinfo) stashed by the last successful stop that produced output files, or `null` if there is none. That includes JS [stop](#stop), the Android [recording notification](../system/recording-notification-manager.mdx#native-action-handling), and [deactivating the iOS audio session](../system/audio-manager.mdx#setaudiosessionactivity) mid-recording. Consume-once: the stash is cleared on read, so a second call returns `null`. -audioRecorder.onAudioReady( - { - sampleRate, - bufferLength: 0.1 * sampleRate, // 0.1s of data - channelCount: 1, - }, - ({ buffer, numFrames, when }) => { - // do something with the data - } -); -``` + If the recording was already finalized natively, calling [stop](#stop) on an `AudioRecorder` instance does not return those files — it reports that the recorder is not recording. Read the stashed [FileInfo](#fileinfo) from this method instead. +
+ ```tsx + const info = AudioRecorder.consumeLastRecordingResult(); + if (info) { + // the recording was stopped from the notification; info.paths holds the files + } + ``` +
+ ##### onError +
+
+ Sets an error callback for internal errors that might happen during file writing, callback invocation, or adapter access. -```tsx -audioRecorder.clearOnAudioReady(); -``` + For details, see [OnRecorderErrorEventType](#onrecordererroreventtype). +
+ ```tsx + audioRecorder.onError((error: OnRecorderErrorEventType) => { + console.log(error); + }); + ``` +
+ ##### clearOnError +
+
+ Removes the error callback. +
+ ```tsx + audioRecorder.clearOnError(); + ``` +
-#### Returns [`AudioNode`](../core/audio-node.mdx). +### Properties -```tsx -audioRecorder.connect(audioContext, audioContext.destination); -``` + + + + + + + + + + + + + + + + +
Property
Description
+ ##### options +
+
+ `AudioRecorderFileOptions | null` - the active file-output configuration after [enableFileOutput()](#enablefileoutput), or `null` when file output is disabled. See [AudioRecorderFileOptions](#audiorecorderfileoptions). +
+ ```tsx + const fileOptions = audioRecorder.options; + ``` +
+ ##### inputLatency +
+
+ `number` - the estimated **full** input latency in seconds (from analog input to app delivery). Mirrors [AudioContext.outputLatency](../core/audio-context.mdx#properties) semantics. Calculated using underlying platform properties (`AVAudioSession` on iOS; Oboe timestamps/burst sizes on Android). Value may change when the audio route changes. Returns `0` when idle. +
+ ```tsx + const latency = audioRecorder.inputLatency; + ``` +
-### `disconnect` +### Recording to file -Disconnects `AudioRecorder` from the audio graph. + + + + + + + + + + + + + + + + + + + + -Sets an error callback for internal errors that might happen during file writing, callback invocation, or adapter access. + +
Method
Description
+ ##### enableFileOutput +
+
+ Configures and enables file output with the given options and stream properties. By default, the recorder writes to the cache directory using a high-quality `M4A` file. Returns `Result<{}>`. -```tsx -audioRecorder.disconnect(); -``` + Must be called while the recorder is idle: the file is created by the next [start](#start). During an active (recording or paused) session the call returns an error result and the session keeps the output it started with. -### `onError` + For further information, see [AudioRecorderFileOptions](#audiorecorderfileoptions). +
+ ```tsx + audioRecorder.enableFileOutput(); + ``` +
+ ##### disableFileOutput +
+
+ Disables file output and finalizes the currently recorded file if the recorder is active. +
+ ```tsx + audioRecorder.disableFileOutput(); + ``` +
+ ##### getCurrentDuration +
+
+ Returns the current recording duration (`number`) when file output is enabled. +
+ ```tsx + const duration = audioRecorder.getCurrentDuration(); + ``` +
-For details, see [`OnRecorderErrorEventType`](#onrecordererroreventtype). +### Data callback -| Parameter | Type | Description | -| :---: | :---: | :---- | -| `callback` | `(error: OnRecorderErrorEventType) => void` | Error handler invoked when recording fails. | + + + + + + + + + + + + + + + + +
Method
Description
+ ##### onAudioReady +
+
+ Registers a callback that receives raw audio buffers during an active recording session. Returns `Result`. -```tsx -audioRecorder.onError((error) => { - console.log(error); -}); -``` + The callback is periodically invoked with audio buffers that match the preferred configuration provided in `options`. These parameters guide how audio data is chunked and delivered, though the exact values may vary depending on device capabilities. -### `clearOnError` + For further information, see [AudioRecorderCallbackOptions](#audiorecordercallbackoptions) and [OnAudioReadyEventType](#onaudioreadyeventtype). +
+ ```tsx + const sampleRate = 16000; + + audioRecorder.onAudioReady( + { + sampleRate, + bufferLength: 0.1 * sampleRate, // 0.1s of data + channelCount: 1, + }, + ({ buffer, numFrames, when }) => { + // do something with the data + } + ); + ``` +
+ ##### clearOnAudioReady +
+
+ Removes the audio data callback and flushes any remaining buffered data through `onAudioReady`. +
+ ```tsx + audioRecorder.clearOnAudioReady(); + ``` +
-Removes the error callback. +### Graph processing -```tsx -audioRecorder.clearOnError(); -``` + + + + + + + + + + + + + + + + +
Method
Description
+ ##### connect +
+
+ Routes captured audio into an audio graph by creating a recorder adapter with [BaseAudioContext.createRecorderAdapter()](../core/base-audio-context.mdx#createrecorderadapter), connecting the recorder to it, and wiring it to `destination`. Takes the [BaseAudioContext](../core/base-audio-context.mdx) used to create the recorder adapter and the destination [AudioNode](../core/audio-node.mdx) in the audio graph. Returns [AudioNode](../core/audio-node.mdx). +
+ ```tsx + audioRecorder.connect(audioContext, audioContext.destination); + ``` +
+ ##### disconnect +
+
+ Disconnects `AudioRecorder` from the audio graph. +
+ ```tsx + audioRecorder.disconnect(); + ``` +
## Types diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/AuthorAvatar/index.tsx b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/AuthorAvatar/index.tsx new file mode 100644 index 000000000..68f42b615 --- /dev/null +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/AuthorAvatar/index.tsx @@ -0,0 +1,56 @@ +import clsx from 'clsx'; +import React, { useState } from 'react'; + +import styles from './styles.module.css'; + +interface ImageProps { + alt: string; + src: string; +} + +interface Props { + author: string; + image?: ImageProps; +} + +const initialsOf = (author: string): string => { + const nameParts = author.trim().split(/\s+/).filter(Boolean); + + if (nameParts.length === 0) { + return ''; + } + + const firstInitial = nameParts[0][0]; + const lastInitial = + nameParts.length > 1 ? nameParts[nameParts.length - 1][0] : ''; + + return (firstInitial + lastInitial).toUpperCase(); +}; + +const AuthorAvatar = ({ author, image }: Props) => { + const [hasImageFailedToLoad, setHasImageFailedToLoad] = useState(false); + + if (!image || hasImageFailedToLoad) { + return ( +
+ +
+ ); + } + + return ( +
+ {image.alt} setHasImageFailedToLoad(true)} + /> +
+ ); +}; + +export default AuthorAvatar; diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/AuthorAvatar/styles.module.css b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/AuthorAvatar/styles.module.css new file mode 100644 index 000000000..94b476715 --- /dev/null +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/AuthorAvatar/styles.module.css @@ -0,0 +1,33 @@ +.authorAvatar { + flex-shrink: 0; + border-radius: 50%; + width: 4rem; + height: 4rem; + background-color: black; +} + +.authorAvatar > img { + border-radius: 50%; + width: 4rem; + height: 4rem; + object-fit: cover; + object-position: top center; + opacity: 1; + transition: 0.2s ease-in-out; +} + +.authorInitials { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--swm-red-light-100); + color: var(--swm-off-white); + font-size: 20px; + font-weight: 600; + letter-spacing: 0.05em; + user-select: none; +} + +[data-theme='dark'] .authorInitials { + background-color: var(--swm-red-dark-100); +} diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/index.tsx b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/index.tsx index 53c35d9ab..97f197d91 100644 --- a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/index.tsx +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/index.tsx @@ -1,6 +1,7 @@ import { useColorMode } from '@docusaurus/theme-common'; import React, { PropsWithChildren } from 'react'; +import AuthorAvatar from './AuthorAvatar'; import QuoteIcon from './QuoteIcon'; import styles from './styles.module.css'; @@ -11,8 +12,8 @@ interface ImageProps { interface Props extends PropsWithChildren { author: string; company?: string; - link: string; - image: ImageProps; + link?: string; + image?: ImageProps; } const TestimonialItem = ({ author, company, image, link, children }: Props) => { @@ -27,9 +28,7 @@ const TestimonialItem = ({ author, company, image, link, children }: Props) => { } />
-
- {image.alt} -
+
{author}
diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/styles.module.css b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/styles.module.css index ed3bf87e3..840cd9696 100644 --- a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/styles.module.css +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialItem/styles.module.css @@ -62,25 +62,6 @@ font-weight: 600; } -.testimonialAuthorPhoto { - flex-shrink: 0; - background-color: black; -} - -.testimonialAuthorPhoto, -.testimonialAuthorPhoto > img { - border-radius: 50%; - width: 4rem; - height: 4rem; -} - -.testimonialAuthorPhoto > img { - object-fit: cover; - opacity: 1; - transition: 0.2s ease-in-out; - object-position: top center; -} - .testimonialCompany { font-weight: 500; font-size: 14px; diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/index.tsx b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/index.tsx index 1488a8ea3..de39b667b 100644 --- a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/index.tsx +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/index.tsx @@ -1,5 +1,13 @@ import clsx from 'clsx'; -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; + +import Icon from '@site/src/ui/Icon'; import TestimonialItem from '../TestimonialItem'; import testimonials from './testimonials'; @@ -7,103 +15,181 @@ import styles from './styles.module.css'; import { Testimonial } from './types'; import useDrag from './useDrag'; +const SLIDE_VERTICAL_ALLOWANCE = 50; +const SLIDE_TRANSITION = 'transform 300ms ease-out'; + +const loopedTestimonials = [ + testimonials[testimonials.length - 1], + ...testimonials, + testimonials[0], +]; + +const FIRST_REAL_SLIDE = 1; +const LAST_REAL_SLIDE = testimonials.length; +const LEADING_COPY = 0; +const TRAILING_COPY = testimonials.length + 1; + +const wrapSlideIndex = (index: number) => + ((index % testimonials.length) + testimonials.length) % testimonials.length; + const TestimonialList = () => { - const [activeIndex, setActiveIndex] = useState(0); - const [currentOffset, setCurrentOffset] = useState(0); - const testimonialListRef = useRef(null); + const [trackIndex, setTrackIndex] = useState(FIRST_REAL_SLIDE); + const [slideWidth, setSlideWidth] = useState(0); + const [trackHeight, setTrackHeight] = useState(0); + const [isJumpingToCopiedSlide, setIsJumpingToCopiedSlide] = useState(false); + const viewportRef = useRef(null); + const trackRef = useRef(null); - const onSetActiveSlide = useCallback((index: number) => { - let offsetX = 0; + const activeIndex = wrapSlideIndex(trackIndex - FIRST_REAL_SLIDE); - testimonials.forEach((_t, i) => { - const testimonialContainer = document.querySelector(`.testimonialContainer-${i}`); + const goToRelativeSlide = useCallback((step: number) => { + setTrackIndex((current) => { + const next = current + step; - if (testimonialContainer && i < index) { - offsetX += testimonialContainer.offsetWidth; - } + return next < LEADING_COPY || next > TRAILING_COPY ? current : next; }); + }, []); + const goToPreviousSlide = useCallback( + () => goToRelativeSlide(-1), + [goToRelativeSlide] + ); - setActiveIndex(index); - setCurrentOffset(offsetX); - }, []); + const goToNextSlide = useCallback( + () => goToRelativeSlide(1), + [goToRelativeSlide] + ); - const renderTestimonial = useCallback((testimonial: Testimonial, index: number) => ( -
- - {testimonial.body} - -
- ), []); + const onSetActiveSlide = useCallback( + (index: number) => setTrackIndex(index + FIRST_REAL_SLIDE), + [] + ); - useEffect(() => { - function handleResize() { - if (!testimonialListRef.current) { + const onSlideSettled = useCallback( + (event: React.TransitionEvent) => { + const hasSlideMoved = + event.target === event.currentTarget && + event.propertyName === 'transform'; + + if (!hasSlideMoved) { return; } - const { offsetWidth } = testimonialListRef.current; - - let offsetX = 0; + if (trackIndex === TRAILING_COPY) { + setTrackIndex(FIRST_REAL_SLIDE); + setIsJumpingToCopiedSlide(true); + } else if (trackIndex === LEADING_COPY) { + setTrackIndex(LAST_REAL_SLIDE); + setIsJumpingToCopiedSlide(true); + } + }, + [trackIndex] + ); + useEffect(() => { + if (!isJumpingToCopiedSlide) { + return; + } - testimonials.forEach((_t, index) => { - const testimonialContainer = document.querySelector(`.testimonialContainer-${index}`); + let paintedFrame = 0; + const jumpedFrame = requestAnimationFrame(() => { + paintedFrame = requestAnimationFrame(() => + setIsJumpingToCopiedSlide(false) + ); + }); - if (testimonialContainer) { - testimonialContainer.style.width = `${offsetWidth}px`; - } + return () => { + cancelAnimationFrame(jumpedFrame); + cancelAnimationFrame(paintedFrame); + }; + }, [isJumpingToCopiedSlide]); - if (index < activeIndex) { - offsetX += testimonialContainer ? testimonialContainer.offsetWidth : 0; - } - }) + useLayoutEffect(() => { + function measureSlideWidth() { + if (viewportRef.current) { + setSlideWidth(viewportRef.current.offsetWidth); + } + } - setCurrentOffset(offsetX); + measureSlideWidth(); + window.addEventListener('resize', measureSlideWidth); - const activeSlide = document.querySelector(`.testimonialContainer-${activeIndex}`); + return () => window.removeEventListener('resize', measureSlideWidth); + }, []); - if (!activeSlide) { - return; - } + useEffect(() => { + const track = trackRef.current; - testimonialListRef.current.style.height = `${activeSlide.offsetHeight + 50}px`; + if (!track) { + return; } + const observer = new ResizeObserver(() => setTrackHeight(track.offsetHeight)); + observer.observe(track); - handleResize(); - window.addEventListener('resize', handleResize); + return () => observer.disconnect(); + }, []); - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [activeIndex]); + const { dragOffset, isDragging, dragHandlers } = useDrag( + slideWidth, + goToRelativeSlide + ); - const dragHandlers = useDrag(currentOffset, setCurrentOffset, activeIndex, onSetActiveSlide); + const renderTestimonial = useCallback( + (testimonial: Testimonial, index: number) => ( +
+ + {testimonial.body} + +
+ ), + [slideWidth] + ); return (
-
-
+
- {testimonials.map(renderTestimonial)} + {loopedTestimonials.map(renderTestimonial)}
-
+
+
{testimonials.map((_, index) => ( ))}
+
); diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/styles.module.css b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/styles.module.css index 197b005c4..88ec57d9e 100644 --- a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/styles.module.css +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/styles.module.css @@ -6,11 +6,73 @@ transition: all 150ms linear; } +.testimonialTrack { + position: absolute; + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: flex-start; +} + +.testimonialSlide { + display: flex; + flex-shrink: 0; + padding: 20px; + box-sizing: border-box; +} + +.controlsContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.arrowButton { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + background: none; + border: none; + border-radius: 50%; + cursor: pointer; + color: var(--swm-navy-light-60); + transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out; +} + +.arrowButton:hover { + color: var(--swm-red-light-100); + background-color: var(--swm-red-light-20); +} + +.arrowButton:focus-visible { + outline: 2px solid var(--swm-red-light-100); + outline-offset: 2px; +} + +.previousArrow > svg { + transform: rotate(90deg); +} + +.nextArrow > svg { + transform: rotate(-90deg); +} + +[data-theme='dark'] .arrowButton { + color: var(--swm-navy-light-20); +} + +[data-theme='dark'] .arrowButton:hover { + color: var(--swm-off-white); + background-color: var(--swm-red-dark-140); +} + .dotsContainer { display: flex; flex-direction: row; gap: 1rem; - width: 100%; justify-content: center; } diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/testimonials.tsx b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/testimonials.tsx index f09dfcfa7..4c719fc47 100644 --- a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/testimonials.tsx +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/testimonials.tsx @@ -7,7 +7,7 @@ const testimonials: Testimonial[] = [ body: 'Perch has been using react-native-audio-api for streaming generated audio in our app and it’s been fantastic. The audio quality is crisp and features like variable playback speed work seamlessly without any hiccups. We’ve replaced all other audio libraries we were using with it.', image: { alt: 'Matt McGuiness', - src: 'https://perch-app-prod.s3.us-east-1.amazonaws.com/media/matt-mcguiness.png', + src: '/react-native-audio-api/img/matt-mcguiness.jpg', }, }, { diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/types.ts b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/types.ts index 647d9fe61..f2f914e55 100644 --- a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/types.ts +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/types.ts @@ -4,7 +4,7 @@ export interface Testimonial { company: string; body: string | React.ReactNode; link?: string; - image: { + image?: { alt: string; src: string; }; diff --git a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/useDrag.ts b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/useDrag.ts index 8570b5cc7..00888493a 100644 --- a/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/useDrag.ts +++ b/packages/audiodocs/src/landingComponents/Testimonials/TestimonialList/useDrag.ts @@ -1,112 +1,60 @@ +import { TouchEvent, useCallback, useRef, useState } from 'react'; -import { TouchEvent, useCallback, useEffect, useRef, useState } from 'react'; -import testimonials from './testimonials'; - -function parseTranslateX(transform: string): number | null { - const match = transform.match(/translateX\((-?\d+.?\d*)px\)/); - return match ? parseFloat(match[1]) : null; -} - -function minMax(value: number, min: number, max: number): number { - return Math.max(min, Math.min(value, max)); -} - -export default function useDrag(currentOffset: number, setCurrentOffset: (offset: number) => void, activeIndex: number, onSetActiveSlide: (index: number) => void) { - const draggableRef = useRef(null); +const SWIPE_DISTANCE_RATIO = 0.3; +export default function useDrag( + slideWidth: number, + onSwipe: (step: number) => void +) { + const [dragOffset, setDragOffset] = useState(0); const [isDragging, setIsDragging] = useState(false); const touchStartRef = useRef(null); - const touchEndRef = useRef(null); - const [initialOffset, setInitialOffset] = useState(0); - const onTouchStart = useCallback((e: TouchEvent) => { - if (!draggableRef.current) { - return; - } + const endDrag = useCallback(() => { + touchStartRef.current = null; + setDragOffset(0); + setIsDragging(false); + document.body.style.overflowX = ''; + }, []); - touchStartRef.current = e.targetTouches[0].clientX; - touchEndRef.current = null; + const onTouchStart = useCallback((event: TouchEvent) => { + touchStartRef.current = event.targetTouches[0].clientX; + setDragOffset(0); setIsDragging(true); - - draggableRef.current.style.transition = 'none'; - setInitialOffset(parseTranslateX(draggableRef.current.style.transform) || 0); - document.body.style.overflowX = 'hidden'; }, []); - const onTouchMove = useCallback((e: TouchEvent) => { - if (!isDragging || !touchStartRef.current || !draggableRef.current) { + const onTouchMove = useCallback((event: TouchEvent) => { + if (touchStartRef.current === null) { return; } - const currentX = e.targetTouches[0].clientX; - touchEndRef.current = currentX; - - const maxOffset = draggableRef.current.clientWidth - (document.querySelector(`.testimonialContainer-${activeIndex}`)?.clientWidth || 0); - - const offset = minMax(currentX - touchStartRef.current + initialOffset, -maxOffset, 0); - const container = draggableRef.current; - - if (container) { - setCurrentOffset(-offset); - } - }, [isDragging, initialOffset, activeIndex, setCurrentOffset]); + setDragOffset(event.targetTouches[0].clientX - touchStartRef.current); + }, []); const onTouchEnd = useCallback(() => { - if (!isDragging || !touchStartRef.current || !touchEndRef.current) { + if (touchStartRef.current === null) { return; } - const distance = touchStartRef.current - touchEndRef.current; - const minDistance = 0.30 * (document.querySelector(`.testimonialContainer-0`)?.clientWidth || 0); - const isLeftSwipe = distance >= minDistance; - const isRightSwipe = distance <= -minDistance; + const isSwipe = + slideWidth > 0 && Math.abs(dragOffset) >= SWIPE_DISTANCE_RATIO * slideWidth; - let newIndex = activeIndex; - - if (isLeftSwipe) { - newIndex = Math.min(activeIndex + 1, testimonials.length - 1); - } else if (isRightSwipe) { - newIndex = Math.max(activeIndex - 1, 0); - } - - - document.body.style.overflowX = ''; - draggableRef.current!.style.transition = 'transform 300ms ease-out'; - - if (newIndex !== activeIndex) { - onSetActiveSlide(newIndex); - } else { - setCurrentOffset(initialOffset); - } - - touchStartRef.current = null; - touchEndRef.current = null; - setInitialOffset(0); - - setIsDragging(false); - }, [isDragging, activeIndex, onSetActiveSlide, setCurrentOffset, initialOffset, currentOffset]); - - const onTouchCancel = useCallback(() => { - setIsDragging(false); - document.body.style.overflowX = ''; - - touchStartRef.current = null; - touchEndRef.current = null; - - if (draggableRef.current) { - draggableRef.current.style.transition = 'transform 300ms ease-out'; - setCurrentOffset(initialOffset); + if (isSwipe) { + onSwipe(dragOffset < 0 ? 1 : -1); } - setInitialOffset(0); - }, []); + endDrag(); + }, [dragOffset, slideWidth, onSwipe, endDrag]); return { - ref: draggableRef, - onTouchStart, - onTouchMove, - onTouchEnd, - onTouchCancel, + dragOffset, + isDragging, + dragHandlers: { + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel: endDrag, + }, }; } diff --git a/packages/audiodocs/src/pages/index.tsx b/packages/audiodocs/src/pages/index.tsx index ea5a40ad9..f0cc7f3cb 100644 --- a/packages/audiodocs/src/pages/index.tsx +++ b/packages/audiodocs/src/pages/index.tsx @@ -38,8 +38,8 @@ function Home() {
- - + +
+### Keeping the recording alive when the app is closed {#keeping-the-recording-alive-when-the-app-is-closed} + +By default the foreground service stops when the user swipes the app away from the recents screen (`android:stopWithTask="true"`), which kills the app process and ends any in-progress recording. You can opt into letting the service — and therefore the process, the JS runtime, and the active recording — survive task removal: + + + + Set the `androidFSStopWithTask` option of the [expo plugin](/docs/other/audio-api-plugin#androidfsstopwithtask) to `false`: + + ```json + { + "plugins": [ + [ + "react-native-audio-api", + { + "androidFSStopWithTask": false + } + ] + ] + } + ``` + + + + In a bare react-native application, set `android:stopWithTask="false"` on the service entry in your `AndroidManifest.xml`: + + ```xml + + ``` + + + + +For the recording to actually survive, all of the following must hold: + +- The foreground service only exists while a library notification is shown. Call [`RecordingNotificationManager.show()`](/docs/system/recording-notification-manager#show) while the app is still in the foreground — before the user leaves the app — otherwise there is no service to keep alive. +- `androidFSTypes` must include `"microphone"` (manifest `foregroundServiceType="microphone"`), and on Android 14+ (API 34) the app needs the `android.permission.FOREGROUND_SERVICE_MICROPHONE` permission. +- Android's while-in-use rule applies: microphone access must begin while the app is in the foreground. Starting a recording from the background is not possible. + +:::caution +Even with `stopWithTask="false"`, the system can still kill the process (memory pressure, OEM battery managers). Recording cannot self-restart from the background — the user has to reopen the app. To limit data loss in that case, tune the file-output options [`androidFlushIntervalMs`](#audiorecorderfileoptions) and [`rotateIntervalBytes`](#audiorecorderfileoptions). +::: + +A recording that survives task removal can only be controlled from the notification, so give the user a way out: enable the notification's [stop action](/docs/system/recording-notification-manager#native-action-handling) (`showStopAction: true`) or let them [swipe the notification away](/docs/system/recording-notification-manager#swiping-the-notification-away) (`dismissible: true`). Both stop the recording natively even when no JS is reachable. + +When the app is opened again, reconcile the UI with what happened while it was away: + +```tsx +if (AudioRecorder.isRecordingOngoing()) { + // the recording is still running — re-attach the UI to it +} else { + const info = AudioRecorder.consumeLastRecordingResult(); + if (info) { + // the recording was stopped from the notification; info.paths holds the files + } +} +``` + ## Usage @@ -480,6 +537,49 @@ export default MyRecorder; + + + +
+ ##### isRecordingOngoing +
+ (static) +
+ +
+ Returns `true` while a recording session is ongoing (recording or paused). Native source of truth that needs no reference to the recorder instance, so use it from a remounted screen — e.g. after reopening an app whose recording [outlived the app UI](#keeping-the-recording-alive-when-the-app-is-closed) — to seed the UI state. It reflects the recorder that acquired the active session on a successful [start](#start); a second `start()` does not displace it. +
+ ```tsx + if (AudioRecorder.isRecordingOngoing()) { + // re-attach the UI to the still-running recording + } + ``` + + + + + + +
+ ##### consumeLastRecordingResult +
+ (static) +
+ +
+ Returns the [FileInfo](#fileinfo) stashed by the last successful stop that produced output files, or `null` if there is none. That includes JS [stop](#stop), the Android [recording notification](/docs/system/recording-notification-manager#native-action-handling), and [deactivating the iOS audio session](/docs/system/audio-manager#setaudiosessionactivity) mid-recording. Consume-once: the stash is cleared on read, so a second call returns `null`. + + If the recording was already finalized natively, calling [stop](#stop) on an `AudioRecorder` instance does not return those files — it reports that the recorder is not recording. Read the stashed [FileInfo](#fileinfo) from this method instead. +
+ ```tsx + const info = AudioRecorder.consumeLastRecordingResult(); + if (info) { + // the recording was stopped from the notification; info.paths holds the files + } + ``` + + + @@ -535,6 +635,8 @@ export default MyRecorder;
Configures and enables the file output with defined options and stream properties. Options property allows for configuration of the output file structure and quality. By default the recorder writes to cache directory using high-quality `M4A` file. + Must be called while the recorder is idle: the file is created by the next [start](#start). During an active (recording or paused) session the call returns an error result and the session keeps the output it started with. + For further information check: [AudioRecorderFileOptions](#audiorecorderfileoptions)
```tsx diff --git a/packages/audiodocs/versioned_docs/version-latest/other/audio-api-plugin.mdx b/packages/audiodocs/versioned_docs/version-latest/other/audio-api-plugin.mdx index 85dfd13b6..1b47d34c5 100644 --- a/packages/audiodocs/versioned_docs/version-latest/other/audio-api-plugin.mdx +++ b/packages/audiodocs/versioned_docs/version-latest/other/audio-api-plugin.mdx @@ -21,6 +21,7 @@ interface Options { androidPermissions: string[]; androidForegroundService: boolean; androidFSTypes: string[]; + androidFSStopWithTask: boolean; } ``` @@ -145,3 +146,15 @@ Allows user to specify appropriate Foreground Service type. Runtime prerequisites: - Request and be granted the RECORD_AUDIO runtime permission. + +### androidFSStopWithTask + +Defaults to true + +Controls the `android:stopWithTask` attribute of the Foreground Service injected by the plugin. With the default value (`true`), the service stops when the user swipes the app away from the recents screen. + +Set it to `false` to emit `android:stopWithTask="false"` on the service entry — on task removal the service keeps running, which keeps the app process (and e.g. an in-progress recording) alive. + +:::info +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](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed) for the full set of requirements. +::: diff --git a/packages/audiodocs/versioned_docs/version-latest/system/audio-manager.mdx b/packages/audiodocs/versioned_docs/version-latest/system/audio-manager.mdx index 4ab0790c2..22703be9b 100644 --- a/packages/audiodocs/versioned_docs/version-latest/system/audio-manager.mdx +++ b/packages/audiodocs/versioned_docs/version-latest/system/audio-manager.mdx @@ -58,7 +58,7 @@ Always verify valid category and option combinations in [Apple's AVAudioSession #### Returns `undefined` -### `setAudioSessionActivity` +### `setAudioSessionActivity` {#setaudiosessionactivity} | Parameter | Type | Description | | :---: | :---: | :---- | @@ -66,6 +66,8 @@ Always verify valid category and option combinations in [Apple's AVAudioSession #### Returns `Promise`, which resolves when the audio session activity was set successfully. On failure the promise rejects with a `SessionActivationError` that carries the native error details (`nativeErrorInfo`) when available. +Deactivating the session while a recording is in progress or paused stops that recording first, since an inactive session would corrupt its output. The finalized files are then available through [`AudioRecorder.consumeLastRecordingResult()`](/docs/inputs/audio-recorder#consumelastrecordingresult). Prefer calling [`AudioRecorder.stop()`](/docs/inputs/audio-recorder#stop) yourself before deactivating, so the file info arrives through its promise. + ### `disableSessionManagement` #### Returns `undefined`. diff --git a/packages/audiodocs/versioned_docs/version-latest/system/recording-notification-manager.mdx b/packages/audiodocs/versioned_docs/version-latest/system/recording-notification-manager.mdx index 9209fd1c8..c808b0b6c 100644 --- a/packages/audiodocs/versioned_docs/version-latest/system/recording-notification-manager.mdx +++ b/packages/audiodocs/versioned_docs/version-latest/system/recording-notification-manager.mdx @@ -10,7 +10,7 @@ import { # RecordingNotificationManager The `RecordingNotificationManager` provides system integration with [`Recorder`](/docs/inputs/audio-recorder). -It can send events about pausing and resuming to your application. +It shows a standard notification with pause/resume and (optionally) stop actions, can route notification taps to a specific screen, and sends action events to your application. ## Example ```typescript @@ -19,9 +19,10 @@ RecordingNotificationManager.show({ contentText: 'Recording...', paused: false, smallIconResourceName: 'icon_to_display', - pauseIconResourceName: 'pause_icon', - resumeIconResourceName: 'resume_icon', color: 0xff6200, + showStopAction: true, + deepLinkUri: 'myapp://record', + usesChronometer: true, }); const pauseEventListener = RecordingNotificationManager.addEventListener('recordingNotificationPause', () => { @@ -30,12 +31,44 @@ const pauseEventListener = RecordingNotificationManager.addEventListener('record const resumeEventListener = RecordingNotificationManager.addEventListener('recordingNotificationResume', () => { console.log('Notification resume action received'); }); +const stopEventListener = RecordingNotificationManager.addEventListener('recordingNotificationStop', () => { + console.log('Notification stop action received'); +}); pauseEventListener.remove(); resumeEventListener.remove(); +stopEventListener.remove(); RecordingNotificationManager.hide(); ``` +## Native action handling + +All notification actions act on the recorder **natively**, without a JS round-trip. This matters when the recording outlives the app UI (see [keeping the recording alive when the app is closed](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed)) — pause, resume and stop keep working even after the app task has been removed and no JS listener is reachable. + +- **Pause / resume** pause or resume the recorder and flip the notification's action button. The matching event (`recordingNotificationPause` / `recordingNotificationResume`) still fires so a live app can sync its UI — handlers calling `AudioRecorder.pause()` / `resume()` again are harmless, the recorder ignores same-state transitions. +- **Stop** (`showStopAction: true`) stops the recorder and finalizes the output files (their info becomes available through [`AudioRecorder.consumeLastRecordingResult()`](/docs/inputs/audio-recorder#consumelastrecordingresult)), emits `recordingNotificationStop`, then hides the notification, which also stops the foreground service. Unlike pause/resume, your `recordingNotificationStop` listener should **not** call `AudioRecorder.stop()` — the recording is already stopped. Collect the files with `AudioRecorder.consumeLastRecordingResult()` instead. + +## Swiping the notification away + +By default the notification is pinned: it cannot be swiped away while a recording is in progress or paused. Android 13 and older honour this through the ongoing flag; on Android 14 and newer, where the system lets users dismiss ongoing notifications, it is re-posted immediately after a swipe. Once the recorder is idle, a swipe is honoured — call `hide()` when you are done so the foreground service stops as well. + +Set `dismissible: true` to let the user swipe the notification away instead. A swipe then behaves exactly like the stop action: the recording is stopped natively and its files finalized, `recordingNotificationStop` is emitted, and the foreground service stops. Android 12 and older never let the user dismiss a foreground-service notification, so the option only takes effect on Android 13 and newer. + +## Routing the notification tap + +By default, tapping the notification opens the app's launcher activity. Set `deepLinkUri` to attach a URI to the tap intent; React Native delivers it through [`Linking`](https://reactnative.dev/docs/linking) (`getInitialURL()` on cold start, the `url` event otherwise). With React Navigation, map it to a screen with a [`linking` config](https://reactnavigation.org/docs/deep-linking/): + +```tsx +const linking = { + prefixes: ['myapp://'], + config: { screens: { RecordScreen: 'record' } }, +}; + + +``` + +No `AndroidManifest.xml` changes are needed — the notification uses an explicit launch intent, so the URI does not go through intent filters. (If you also want the same URI to work from a browser or `adb`, declare your own scheme intent filter, e.g. via Expo's `scheme` option.) + ## Methods ### `show` @@ -59,8 +92,7 @@ Resource name is a path to resource plased in res/drawable folder. It has to be ::: :::caution -If nothing is displayed, even though your name is correct, try decreasing size of your resource. -Notification can look vastly different on different android devices. +The notification uses the standard Android template, so its exact look varies between devices and Android versions. ::: ### `hide` @@ -99,9 +131,14 @@ interface RecordingNotificationInfo { paused?: boolean; smallIconResourceName?: string; largeIconResourceName?: string; - pauseIconResourceName?: string; - resumeIconResourceName?: string; color?: number; + showStopAction?: boolean; + dismissible?: boolean; // a swipe stops the recording instead of re-posting the notification, default: false + pauseActionTitle?: string; // default: 'Pause' + resumeActionTitle?: string; // default: 'Resume' + stopActionTitle?: string; // default: 'Stop' + deepLinkUri?: string; // URI attached to the notification tap intent + usesChronometer?: boolean; // shows the elapsed recording time, default: false } ``` @@ -114,6 +151,7 @@ interface RecordingNotificationInfo { interface RecordingNotificationEvent { recordingNotificationPause: EventEmptyType; recordingNotificationResume: EventEmptyType; + recordingNotificationStop: EventEmptyType; } ``` diff --git a/packages/docs-theme/css/overrides.css b/packages/docs-theme/css/overrides.css index 4232a1c74..1666ff343 100644 --- a/packages/docs-theme/css/overrides.css +++ b/packages/docs-theme/css/overrides.css @@ -235,6 +235,7 @@ button[class*='DocSearch-Button'] { .table-vertical-top { width: 100%; + table-layout: fixed; border-collapse: collapse; border-spacing: 0; box-sizing: border-box; @@ -267,15 +268,15 @@ button[class*='DocSearch-Button'] { margin: -1px; } -.table-vertical-top tbody tr td:first-child { - min-width: 200px; +.table-vertical-top tr td:first-child { + width: 28%; text-wrap: wrap; word-break: normal; overflow-wrap: break-word; } -.table-vertical-top tbody tr td:last-child { - width: 100%; +.table-vertical-top td:first-child h5 { + font-size: 15px; } .table-vertical-top tbody tr:nth-child(2n) code { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp index 54affc0a2..374122b46 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp @@ -19,6 +19,7 @@ BaseAudioContext::BaseAudioContext( : state_(ContextState::SUSPENDED), sampleRate_(sampleRate), audioEventHandlerRegistry_(audioEventHandlerRegistry), + audioEventProducer_(audioEventHandlerRegistry->createAudioEventProducer()), stateChangeEvent_(audioEventHandlerRegistry), pendingPromisesOffloader_( std::make_unique>(AUDIO_SCHEDULER_CAPACITY)), graph_(std::make_shared(AUDIO_SCHEDULER_CAPACITY, disposer_.get())), - deferredEvents_(audioEventHandlerRegistry) {} + deferredEvents_(audioEventHandlerRegistry, audioEventProducer_) {} void BaseAudioContext::initialize(const AudioDestinationNode *destination) { destination_ = destination; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h index 2c3f9d173..a76384631 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h @@ -99,6 +99,13 @@ class BaseAudioContext : public std::enable_shared_from_this { return deferredEvents_; } + /// @brief This context's dispatch lane, for nodes that emit events from the render thread. + /// Every node of a context shares it because they all render on that one thread; a node of + /// another context must never use it. + [[nodiscard]] std::shared_ptr getAudioEventProducer() const { + return audioEventProducer_; + } + template bool scheduleAudioEvent(F &&event) noexcept { // NOLINT(cppcoreguidelines-missing-std-forward) std::scoped_lock lock(driverMutex_); @@ -176,6 +183,8 @@ class BaseAudioContext : public std::enable_shared_from_this { private: std::atomic sampleRate_; std::shared_ptr audioEventHandlerRegistry_; + /// context's own lane into the registry's dispatch queue, shared with every node it owns. + std::shared_ptr audioEventProducer_; EventCaller stateChangeEvent_; /// Ledger backing dispatchStateChange()'s dedupe; contexts start suspended. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp index 7b87876ca..d2724b7ff 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp @@ -38,6 +38,7 @@ AudioBufferBaseSourceNode::AudioBufferBaseSourceNode( detuneParam_)), positionChanged_( context->getAudioEventHandlerRegistry(), + context->getAudioEventProducer(), static_cast(context->getSampleRate())) { setOnPositionChangedInterval(options.onpositionchangedInterval); } @@ -69,6 +70,10 @@ void AudioBufferBaseSourceNode::assignOnPositionChangedCallbackId(uint64_t callb void AudioBufferBaseSourceNode::processNode(int framesToProcess) { if (isEmpty()) { audioBuffer_->zero(); + if (isScheduled() && endsWhenStartedEmpty()) { + playbackState_ = PlaybackState::FINISHED; + disable(); + } return; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.h index 4650d9e80..d4f4d6bf5 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.h @@ -51,6 +51,12 @@ class AudioBufferBaseSourceNode : public AudioScheduledSourceNode { virtual bool isEmpty() const = 0; + /// @brief Whether a started source that is rendered while `isEmpty()` ends + /// immediately instead of staying scheduled until content arrives. + virtual bool endsWhenStartedEmpty() const { + return true; + } + virtual void runBufferProcessor( const std::shared_ptr &processingBuffer, size_t startOffset, diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp index 5bda58098..3a8b77164 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp @@ -19,7 +19,9 @@ AudioBufferQueueSourceNode::AudioBufferQueueSourceNode( const std::shared_ptr &context, const BaseAudioBufferSourceOptions &options) : AudioBufferBaseSourceNode(context, options), - onBufferEndedEvent_(context->getAudioEventHandlerRegistry()) { + onBufferEndedEvent_( + context->getAudioEventHandlerRegistry(), + context->getAudioEventProducer()) { if (options.pitchCorrection) { // If pitch correction is enabled, add extra frames at the end // to compensate for processing latency. @@ -169,6 +171,11 @@ bool AudioBufferQueueSourceNode::isEmpty() const { return buffers_.empty(); } +bool AudioBufferQueueSourceNode::endsWhenStartedEmpty() const { + // An empty queue is a source waiting for its next buffer, not a finished one. + return false; +} + void AudioBufferQueueSourceNode::runBufferProcessor( const std::shared_ptr &processingBuffer, size_t startOffset, diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h index 2574c0617..71ff621e9 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h @@ -59,6 +59,8 @@ class AudioBufferQueueSourceNode : public AudioBufferBaseSourceNode { bool isEmpty() const final; + bool endsWhenStartedEmpty() const final; + void runBufferProcessor( const std::shared_ptr &processingBuffer, size_t startOffset, diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp index 24dbf20f6..fd8bd7cf8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp @@ -24,7 +24,7 @@ AudioBufferSourceNode::AudioBufferSourceNode( loopSkip_(options.loopSkip), loopStart_(options.loopStart), loopEnd_(options.loopEnd), - onLoopEndedEvent_(context->getAudioEventHandlerRegistry()) { + onLoopEndedEvent_(context->getAudioEventHandlerRegistry(), context->getAudioEventProducer()) { auto onLoopEnded = [this]() { sendOnLoopEndedEvent(); }; @@ -61,6 +61,10 @@ void AudioBufferSourceNode::setBuffer( return; } + if (isFinished()) { + return; + } + if (buffer_ != nullptr) { context->getDisposer()->dispose(std::move(buffer_)); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp index e3b623f29..494e59c39 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp @@ -33,10 +33,12 @@ AudioFileSourceNode::AudioFileSourceNode( targetPlaybackRate_(options.playbackRate), positionChanged_( context->getAudioEventHandlerRegistry(), + context->getAudioEventProducer(), static_cast(context->getSampleRate() * ON_POSITION_CHANGED_INTERVAL), true), bufferingStateDispatcher_( context->getAudioEventHandlerRegistry(), + context->getAudioEventProducer(), static_cast(context->getSampleRate() * ON_BUFFERING_STATE_DEBOUNCE_INTERVAL)) { decoderState_->playbackRate.store(options.playbackRate, std::memory_order_release); decoderState_->preservesPitch.store(options.preservesPitch, std::memory_order_release); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp index f768d6b15..705d30a0c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp @@ -21,7 +21,7 @@ AudioScheduledSourceNode::AudioScheduledSourceNode( startTime_(-1.0), stopTime_(-1.0), playbackState_(PlaybackState::UNSCHEDULED), - onEndedEvent_(context->getAudioEventHandlerRegistry()) {} + onEndedEvent_(context->getAudioEventHandlerRegistry(), context->getAudioEventProducer()) {} void AudioScheduledSourceNode::start(double when) { playbackState_ = PlaybackState::SCHEDULED; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.cpp b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.cpp index 639dc7e84..c3bb9f441 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.cpp @@ -11,10 +11,7 @@ namespace audioapi { AudioEventHandlerRegistry::AudioEventHandlerRegistry( jsi::Runtime *runtime, const std::shared_ptr &callInvoker) - : callInvoker_(callInvoker), - runtime_(runtime), - dispatchQueue_(kDispatchCapacity), - audioProducerToken_(dispatchQueue_) { + : callInvoker_(callInvoker), runtime_(runtime), dispatchQueue_(kDispatchCapacity) { // Dispatch worker: wait for an item, dequeue it, then hop to the JS thread. workerThread_ = std::thread([this]() { while (true) { @@ -86,7 +83,12 @@ bool AudioEventHandlerRegistry::dispatchEvent( return true; } +std::shared_ptr AudioEventHandlerRegistry::createAudioEventProducer() { + return std::make_shared(dispatchQueue_); +} + bool AudioEventHandlerRegistry::dispatchEventFromAudioThread( + AudioEventProducer &producer, AudioEvent eventName, uint64_t listenerId, AudioEventPayload &&payload) noexcept { @@ -94,7 +96,7 @@ bool AudioEventHandlerRegistry::dispatchEventFromAudioThread( return false; } if (!dispatchQueue_.try_enqueue( - audioProducerToken_, + producer.token(), DispatchEvent{ .event = eventName, .listenerId = listenerId, .payload = std::move(payload)})) { return false; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.h index cd961ff18..48fad0b79 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -27,8 +28,8 @@ using namespace facebook; /// Both entry points enqueue a DispatchEvent and signal itemsAvailable_: /// /// - dispatchEventFromAudioThread() — real-time audio thread (e.g. `processNode()`). -/// Uses a pre-created moodycamel ProducerToken so try_enqueue() is wait-free and never -/// allocates. Drops the event if the queue is full. +/// Enqueues through the caller's own AudioEventProducer so try_enqueue() is wait-free and +/// never allocates. Drops the event if the queue is full. /// /// - dispatchEvent() — any other (non-RT) thread (worker, platform/JNI callbacks, recorder /// cleanup, AudioAPIModule, etc.). Uses the implicit (multi-producer) enqueue path. @@ -57,15 +58,26 @@ class AudioEventHandlerRegistry : public IAudioEventHandlerRegistry, uint64_t listenerId, AudioEventPayload &&payload) noexcept override; + std::shared_ptr createAudioEventProducer() override; + /// @brief Enqueue an event from the real-time audio thread. /// Wait-free and allocation-free on the calling thread; drops when the queue is full. bool dispatchEventFromAudioThread( + AudioEventProducer &producer, AudioEvent eventName, uint64_t listenerId, AudioEventPayload &&payload) noexcept override; private: +#ifdef RN_AUDIO_API_NODE + // The WPT harness runs dozens of test files, each with its own contexts, in + // one process. Queue blocks claimed by a producer are never recycled, so at + // the app-sized capacity the supply runs out mid-run and every later + // audio-thread event is dropped; the extra headroom keeps a run honest. + static constexpr size_t kDispatchCapacity = 8192; +#else static constexpr size_t kDispatchCapacity = 256; +#endif struct DispatchEvent { AudioEvent event{}; @@ -84,12 +96,9 @@ class AudioEventHandlerRegistry : public IAudioEventHandlerRegistry, std::unordered_map>> eventHandlers_; - // Single producer-to-consumer channel for every thread. Declared before - // audioProducerToken_ so the token can bind to it during construction. + // Single producer-to-consumer channel for every thread. Audio threads bind their own + // AudioEventProducer to it; every other thread uses the implicit-producer path. moodycamel::ConcurrentQueue dispatchQueue_; - // Dedicated token for the audio thread; lets it enqueue without the implicit-producer - // lookup/allocation that the first enqueue from a new thread would otherwise trigger. - moodycamel::ProducerToken audioProducerToken_; // Counts queued items; workerThread_ waits on it instead of busy-spinning. moodycamel::LightweightSemaphore itemsAvailable_; std::atomic isExiting_{false}; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventProducer.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventProducer.h new file mode 100644 index 000000000..bb19407aa --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventProducer.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +namespace audioapi { + +/// @brief One audio thread's private lane into the event registry's dispatch queue. +/// +/// A moodycamel ProducerToken is single-producer by construction: do not share tokens between threads. +/// +/// every context owns a producer and hands it to +/// IAudioEventHandlerRegistry::dispatchEventFromAudioThread. +/// +/// @note A producer may move between threads over time — an OfflineAudioContext spawns a +/// fresh render thread on every resume — but never concurrently +class AudioEventProducer { + public: + /// @param queue The registry's dispatch queue; the token binds to it for its whole life. + template + explicit AudioEventProducer(TQueue &queue) : token_(queue) {} + + ~AudioEventProducer() = default; + + DELETE_COPY_AND_MOVE(AudioEventProducer); + + [[nodiscard]] moodycamel::ProducerToken &token() noexcept { + return token_; + } + + private: + moodycamel::ProducerToken token_; +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/DeferredEventQueue.hpp b/packages/react-native-audio-api/common/cpp/audioapi/events/DeferredEventQueue.hpp index 429ad0cb3..d411d8411 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/DeferredEventQueue.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/DeferredEventQueue.hpp @@ -27,8 +27,12 @@ namespace audioapi { /// `scheduleAudioEvent` path) — no lock, so no other thread may touch it. class DeferredEventQueue { public: - explicit DeferredEventQueue(std::shared_ptr registry) - : registry_(std::move(registry)) {} + /// @param audioEventProducer The owning context's dispatch lane — `dispatchDue` always runs + /// on that context's render thread. + DeferredEventQueue( + std::shared_ptr registry, + std::shared_ptr audioEventProducer) + : registry_(std::move(registry)), audioEventProducer_(std::move(audioEventProducer)) {} /// @brief Queues @p event for dispatch once the clock reaches @p dueTime. /// @return False when there is nothing to queue (@p callbackId unset) or no @@ -74,16 +78,20 @@ class DeferredEventQueue { }; void dispatch(const DeferredEvent &deferred) const { - if (registry_ == nullptr) { + if (registry_ == nullptr || audioEventProducer_ == nullptr) { return; } registry_->dispatchEventFromAudioThread( - deferred.event, deferred.callbackId, AudioEventPayload{EmptyPayload{}}); + *audioEventProducer_, + deferred.event, + deferred.callbackId, + AudioEventPayload{EmptyPayload{}}); } BoundedPriorityQueue pending_; std::shared_ptr registry_; + std::shared_ptr audioEventProducer_; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/EventCaller.hpp b/packages/react-native-audio-api/common/cpp/audioapi/events/EventCaller.hpp index 405bcd92a..2ad64eed2 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/EventCaller.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/EventCaller.hpp @@ -20,6 +20,15 @@ class EventCaller { explicit EventCaller(const std::shared_ptr &audioEventHandlerRegistry) : eventHandlerRegistry_(audioEventHandlerRegistry) {} + /// @param audioEventProducer The owning context's dispatch lane. Required to dispatch from + /// the audio thread; without it `dispatchFromAudioThread` reports failure instead of + /// enqueueing through a lane that belongs to another thread. + EventCaller( + const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer) + : eventHandlerRegistry_(audioEventHandlerRegistry), + audioEventProducer_(std::move(audioEventProducer)) {} + ~EventCaller() { unregisterCallback(); } @@ -91,16 +100,18 @@ class EventCaller { requires EventPayloadFor bool dispatchFromAudioThread(Payload &&payload) const noexcept { const auto callbackId = getCallbackId(); - if (eventHandlerRegistry_ == nullptr || callbackId == 0) { + if (eventHandlerRegistry_ == nullptr || audioEventProducer_ == nullptr || callbackId == 0) { return false; } return eventHandlerRegistry_->dispatchEventFromAudioThread( - Event, callbackId, AudioEventPayload{std::forward(payload)}); + *audioEventProducer_, Event, callbackId, AudioEventPayload{std::forward(payload)}); } private: std::shared_ptr eventHandlerRegistry_; + /// Null for events only ever dispatched NOT on the audio thread + std::shared_ptr audioEventProducer_; std::atomic callbackId_{0}; }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/IAudioEventHandlerRegistry.h b/packages/react-native-audio-api/common/cpp/audioapi/events/IAudioEventHandlerRegistry.h index 933eef0ea..7fac46615 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/IAudioEventHandlerRegistry.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/IAudioEventHandlerRegistry.h @@ -13,6 +13,8 @@ namespace audioapi { +class AudioEventProducer; + class IAudioEventHandlerRegistry { public: IAudioEventHandlerRegistry() = default; @@ -31,7 +33,14 @@ class IAudioEventHandlerRegistry { uint64_t listenerId, AudioEventPayload &&payload) noexcept = 0; + /// @brief Creates a dispatch lane for one audio thread. Every owner of an audio + /// thread needs its own — see AudioEventProducer for why sharing one corrupts the queue. + virtual std::shared_ptr createAudioEventProducer() = 0; + + /// @param producer The calling audio thread's own producer, never one shared with + /// another thread. virtual bool dispatchEventFromAudioThread( + AudioEventProducer &producer, AudioEvent eventName, uint64_t listenerId, AudioEventPayload &&payload) noexcept = 0; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.cpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.cpp index 356b13dc6..c06f9c098 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.cpp @@ -1,13 +1,15 @@ #include #include +#include namespace audioapi { BufferingStateDispatcher::BufferingStateDispatcher( const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer, int startThresholdFrames) - : bufferingStateChangeEvent_(audioEventHandlerRegistry), + : bufferingStateChangeEvent_(audioEventHandlerRegistry, std::move(audioEventProducer)), startThresholdFrames_(startThresholdFrames) {} void BufferingStateDispatcher::assignCallbackId(uint64_t callbackId) noexcept { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.h b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.h index 3ababb45b..e099a4932 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.h @@ -16,6 +16,7 @@ class BufferingStateDispatcher { public: BufferingStateDispatcher( const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer, int startThresholdFrames); void assignCallbackId(uint64_t callbackId) noexcept; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.cpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.cpp index 0bfb857ec..02637eada 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.cpp @@ -3,14 +3,16 @@ #include #include +#include namespace audioapi { PositionChangedDispatcher::PositionChangedDispatcher( const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer, int intervalInFrames, bool shouldFlush) - : positionChangedEvent_(audioEventHandlerRegistry), + : positionChangedEvent_(audioEventHandlerRegistry, std::move(audioEventProducer)), shouldFlush_(shouldFlush), intervalInFrames_(intervalInFrames) {} diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.h b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.h index 392c899e3..85f3d7d20 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.h @@ -14,6 +14,7 @@ class PositionChangedDispatcher { public: PositionChangedDispatcher( const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer, int intervalInFrames, bool shouldFlush = false); diff --git a/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt b/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt index 96fc1b904..211a16a47 100644 --- a/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt +++ b/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt @@ -146,19 +146,59 @@ if(APPLE) set(PODS_ROOT "${FABRIC_EXAMPLE_IOS_DIR}/Pods") set(PODS_XCCONFIG "${FABRIC_EXAMPLE_IOS_DIR}/Pods/Target Support Files/Pods-FabricExample/Pods-FabricExample.debug.xcconfig") + # Xcode writes each search path setting as one long line of quoted, + # space-separated entries referencing its own build variables; expand the two + # PODS_ROOT spellings CocoaPods emits and hand back a plain CMake list. + function(read_pods_xcconfig_paths OUT_VAR SETTING) + file(STRINGS "${PODS_XCCONFIG}" SETTING_LINE REGEX "^${SETTING}") + string(REGEX REPLACE "^${SETTING} *= *" "" SETTING_LINE "${SETTING_LINE}") + string(REPLACE "\"" "" SETTING_LINE "${SETTING_LINE}") + string(REPLACE "$(inherited)" "" SETTING_LINE "${SETTING_LINE}") + string(REPLACE "\${PODS_ROOT}" "${PODS_ROOT}" SETTING_LINE "${SETTING_LINE}") + string(REPLACE "$(PODS_ROOT)" "${PODS_ROOT}" SETTING_LINE "${SETTING_LINE}") + separate_arguments(SETTING_PATHS UNIX_COMMAND "${SETTING_LINE}") + set(${OUT_VAR} "${SETTING_PATHS}" PARENT_SCOPE) + endfunction() + set(IOS_POD_INCLUDE_DIRS "") + set(IOS_POD_FRAMEWORK_DIRS "") if(EXISTS "${PODS_XCCONFIG}") - file(STRINGS "${PODS_XCCONFIG}" HEADER_SEARCH_LINE REGEX "^HEADER_SEARCH_PATHS") - string(REGEX REPLACE "^HEADER_SEARCH_PATHS *= *" "" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - string(REPLACE "\"" "" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - string(REPLACE "$(inherited)" "" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - string(REPLACE "\${PODS_ROOT}" "${PODS_ROOT}" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - string(REPLACE "$(PODS_ROOT)" "${PODS_ROOT}" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - separate_arguments(IOS_POD_INCLUDE_DIRS UNIX_COMMAND "${HEADER_SEARCH_LINE}") + read_pods_xcconfig_paths(IOS_POD_INCLUDE_DIRS HEADER_SEARCH_PATHS) + read_pods_xcconfig_paths(IOS_POD_FRAMEWORK_DIRS FRAMEWORK_SEARCH_PATHS) else() message(WARNING "clangd/CMakeLists.txt: ${PODS_XCCONFIG} not found — run `pod install` in apps/fabric-example/ios to resolve React/Pod headers for iOS files.") endif() + # React-Core ships prebuilt as React.xcframework since RN 0.87, so + # `#import ` resolves through a framework search + # path (-F) rather than -I — without one, every .mm importing React fails + # with "'React/RCTBridgeModule.h' file not found". Xcode's own -F points at + # PODS_XCFRAMEWORKS_BUILD_DIR, which exists only after Xcode has unpacked the + # slices, so target the slices inside the .xcframework bundles directly and + # keep a bare `pod install` sufficient. + execute_process( + COMMAND find "${PODS_ROOT}" -maxdepth 8 -type d -name "ios-*simulator*" -path "*.xcframework/*" + OUTPUT_VARIABLE IOS_XCFRAMEWORK_SLICES + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + string(REPLACE "\n" ";" IOS_XCFRAMEWORK_SLICES "${IOS_XCFRAMEWORK_SLICES}") + list(APPEND IOS_POD_FRAMEWORK_DIRS ${IOS_XCFRAMEWORK_SLICES}) + + # Entries naming build-time-only Xcode variables (PODS_XCFRAMEWORKS_BUILD_DIR, + # PODS_CONFIGURATION_BUILD_DIR) survive the expansion above unresolved; drop + # anything that isn't a real directory so the compile commands stay clean. + set(IOS_FRAMEWORK_FLAGS "") + foreach(framework_dir IN LISTS IOS_POD_FRAMEWORK_DIRS) + if(IS_DIRECTORY "${framework_dir}") + list(APPEND IOS_FRAMEWORK_FLAGS "-F${framework_dir}") + endif() + endforeach() + list(REMOVE_DUPLICATES IOS_FRAMEWORK_FLAGS) + + target_compile_options(rnaudioapi_cursor_ios PRIVATE ${IOS_FRAMEWORK_FLAGS}) + target_compile_options(rnaudioapi_cursor_ios_objc PRIVATE ${IOS_FRAMEWORK_FLAGS}) + set(IOS_EXTRA_INCLUDES "${COMMON_CPP_DIR}" "${IOS_DIR}" @@ -187,6 +227,7 @@ if(APPLE) RN_AUDIO_API_TEST=${RN_AUDIO_API_TEST} ) target_include_directories(rnaudioapi_cursor_ios_headers PRIVATE ${IOS_EXTRA_INCLUDES}) + target_compile_options(rnaudioapi_cursor_ios_headers PRIVATE ${IOS_FRAMEWORK_FLAGS}) if(IOS_SDK_PATH) target_compile_options(rnaudioapi_cursor_ios_headers PRIVATE -isysroot "${IOS_SDK_PATH}") endif() diff --git a/packages/react-native-audio-api/common/cpp/clangd/SETUP.md b/packages/react-native-audio-api/common/cpp/clangd/SETUP.md index 6e72784be..34c628891 100644 --- a/packages/react-native-audio-api/common/cpp/clangd/SETUP.md +++ b/packages/react-native-audio-api/common/cpp/clangd/SETUP.md @@ -11,7 +11,10 @@ Generates entries for `common/cpp` (sources + headers), the iOS Objective-C++/Ob **Prerequisites** - **Android**: needs a real Gradle build's `android/.cxx` output — this CMakeLists.txt can't safely reproduce NDK cross-compile flags itself. -- **iOS**: needs `apps/fabric-example/ios/Pods` — reuses its generated xcconfig for React/Pod header paths. +- **iOS**: needs `apps/fabric-example/ios/Pods` — reuses its generated xcconfig for React/Pod header + *and framework* search paths. Since RN 0.87 React-Core ships prebuilt as `React.xcframework`, so + `#import ` resolves via `-F`, not `-I`; missing framework paths show up as + `'React/RCTBridgeModule.h' file not found` in every `.mm` that imports React. If either is missing or stale (e.g. after a dependency bump), refresh both first: diff --git a/packages/react-native-audio-api/common/cpp/test/src/MockAudioEventHandlerRegistry.h b/packages/react-native-audio-api/common/cpp/test/src/MockAudioEventHandlerRegistry.h index ea71407fb..5cd12cae4 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/MockAudioEventHandlerRegistry.h +++ b/packages/react-native-audio-api/common/cpp/test/src/MockAudioEventHandlerRegistry.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -24,6 +25,19 @@ class MockAudioEventHandlerRegistry : public IAudioEventHandlerRegistry { MOCK_METHOD( bool, dispatchEventFromAudioThread, - (AudioEvent eventName, uint64_t listenerId, AudioEventPayload &&payload), + (AudioEventProducer & producer, + AudioEvent eventName, + uint64_t listenerId, + AudioEventPayload &&payload), (noexcept, override)); + + /// Real producers, not mocked: the expectations above never touch the token, but callers + /// refuse to dispatch from the audio thread without one. + std::shared_ptr createAudioEventProducer() override { + return std::make_shared(producerQueue_); + } + + private: + /// Only ever a binding target for the producers handed out above; nothing is enqueued. + moodycamel::ConcurrentQueue producerQueue_; }; diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp index 931b99aac..7b34f7b6f 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,7 @@ class TestableAudioBufferSourceNode : public AudioBufferSourceNode { using AudioBufferSourceNode::getCurrentPosition; using AudioBufferSourceNode::initStretch; + using AudioBufferSourceNode::isEmpty; using AudioBufferSourceNode::setBuffer; }; @@ -122,6 +124,15 @@ class AudioBufferSourceNodeTest : public ::testing::Test { node->start(0.0); return node; } + + std::unique_ptr makeNodeWithoutBuffer() { + return std::make_unique( + context, AudioBufferSourceOptions(BaseAudioBufferSourceOptions())); + } + + std::shared_ptr makeOutputBuffer() { + return std::make_shared(QUANTUM, 1, static_cast(SAMPLE_RATE)); + } }; /// With pitchCorrection on, processNode() used to call processKRateParam twice per @@ -165,4 +176,81 @@ TEST_F(AudioBufferSourceNodeTest, WithoutPitchCorrectionPlaybackRateModulationAd EXPECT_NEAR(node->getCurrentPosition(), EXPECTED_POSITION, TOLERANCE); } +/// Spec ("Playback of AudioBuffer Contents"): a null buffer forces the stop time to +/// currentTime, so a started source with no buffer ends in the first rendered quantum, +/// not when `when` arrives. Without this, processNode() bails on the empty buffer +/// forever and `ended` never fires. +TEST_F(AudioBufferSourceNodeTest, StartedWithNullBufferEndsOnFirstRenderInsteadOfAtStartTime) { + static constexpr uint64_t ENDED_CALLBACK_ID = 51; + static constexpr double START_BEYOND_RENDERED_RANGE = 4.0; + + auto *node = addNode(makeNodeWithoutBuffer()); + node->assignOnEndedCallbackId(ENDED_CALLBACK_ID); + EXPECT_CALL(*eventRegistry, unregisterHandler(AudioEvent::ENDED, ENDED_CALLBACK_ID)) + .Times(testing::AnyNumber()); + EXPECT_CALL( + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) + .WillOnce(testing::Return(true)); + + node->start(START_BEYOND_RENDERED_RANGE, /*offset=*/0.0); + EXPECT_TRUE(node->isScheduled()); + + processGraph(); + EXPECT_TRUE(node->isFinished()); + + processGraph(); // `ended` must not fire a second time +} + +/// The setter acquires a buffer assigned after start(), so content that lands before +/// the first render is played (WPT audiobuffersource-grain "set buffer after start"). +TEST_F(AudioBufferSourceNodeTest, BufferAssignedAfterStartButBeforeFirstRenderIsPlayed) { + static constexpr uint64_t ENDED_CALLBACK_ID = 52; + + auto *node = addNode(makeNodeWithoutBuffer()); + node->assignOnEndedCallbackId(ENDED_CALLBACK_ID); + EXPECT_CALL(*eventRegistry, unregisterHandler(AudioEvent::ENDED, ENDED_CALLBACK_ID)) + .Times(testing::AnyNumber()); + EXPECT_CALL( + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) + .Times(0); + + node->start(/*when=*/0.0, /*offset=*/0.0); + node->setBuffer(makeSilentBuffer(NUM_QUANTA * QUANTUM), makeOutputBuffer()); + + processGraph(); + EXPECT_TRUE(node->isPlaying()); + EXPECT_FALSE(node->isEmpty()); +} + +/// Once a null-buffer source has ended, a buffer assigned later must be dropped instead +/// of resurrecting the node into playing (and ending a second time) at `when`. +TEST_F(AudioBufferSourceNodeTest, BufferAssignedAfterNullBufferSourceEndedIsIgnored) { + static constexpr uint64_t ENDED_CALLBACK_ID = 53; + + auto *node = addNode(makeNodeWithoutBuffer()); + node->assignOnEndedCallbackId(ENDED_CALLBACK_ID); + EXPECT_CALL(*eventRegistry, unregisterHandler(AudioEvent::ENDED, ENDED_CALLBACK_ID)) + .Times(testing::AnyNumber()); + EXPECT_CALL( + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) + .WillOnce(testing::Return(true)); + + node->start(/*when=*/0.0, /*offset=*/0.0); + processGraph(); + EXPECT_TRUE(node->isFinished()); + + node->setBuffer(makeSilentBuffer(NUM_QUANTA * QUANTUM), makeOutputBuffer()); + EXPECT_TRUE(node->isEmpty()); + + for (size_t q = 0; q < NUM_QUANTA; ++q) { + processGraph(); + } + + EXPECT_TRUE(node->isFinished()); + EXPECT_TRUE(node->isEmpty()); +} + // NOLINTEND diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioScheduledSourceTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioScheduledSourceTest.cpp index df746bf2a..7ffabd6ce 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioScheduledSourceTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioScheduledSourceTest.cpp @@ -142,13 +142,13 @@ TEST_F(AudioScheduledSourceTest, StopBeforeStartFiresEndedWhenContextTimeReaches EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) .Times(0); sourceNode.playFrames(RENDER_QUANTUM); // context time is still before the stop time EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); sourceNode.playFrames(RENDER_QUANTUM); // context time reaches the stop time @@ -171,21 +171,24 @@ TEST_F(AudioScheduledSourceTest, DeferredEndedEventsFireInDueTimeOrderNotInserti earlyNode.stop(RENDER_QUANTUM_TIME); EXPECT_CALL( - *eventRegistry, dispatchEventFromAudioThread(AudioEvent::ENDED, testing::_, testing::_)) + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, testing::_, testing::_)) .Times(0); lateNode.playFrames(RENDER_QUANTUM); // context time is still 0 EXPECT_CALL( - *eventRegistry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATE_CALLBACK_ID, testing::_)) + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATE_CALLBACK_ID, testing::_)) .Times(0); EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, EARLY_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, EARLY_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); lateNode.playFrames(RENDER_QUANTUM); // context time reaches the earlier stop time EXPECT_CALL( - *eventRegistry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATE_CALLBACK_ID, testing::_)) + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATE_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); lateNode.playFrames(RENDER_QUANTUM); // context time reaches the later stop time @@ -214,12 +217,12 @@ TEST_F(AudioScheduledSourceTest, DeferredEndedEventsDueInTheSameQuantumFireInDue testing::Sequence dueTimeOrder; EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) .InSequence(dueTimeOrder) .WillOnce(testing::Return(true)); EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) .InSequence(dueTimeOrder) .WillOnce(testing::Return(true)); laterNode.playFrames(RENDER_QUANTUM); // both due times have passed diff --git a/packages/react-native-audio-api/common/cpp/test/src/events/DeferredEventQueueTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/events/DeferredEventQueueTest.cpp index a77f6f65c..ecd4e2e00 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/events/DeferredEventQueueTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/events/DeferredEventQueueTest.cpp @@ -18,7 +18,7 @@ class DeferredEventQueueTest : public ::testing::Test { protected: std::shared_ptr registry = std::make_shared(); - DeferredEventQueue queue{registry}; + DeferredEventQueue queue{registry, registry->createAudioEventProducer()}; }; TEST_F(DeferredEventQueueTest, IgnoresEventsWithoutACallback) { @@ -29,7 +29,9 @@ TEST_F(DeferredEventQueueTest, IgnoresEventsWithoutACallback) { TEST_F(DeferredEventQueueTest, HoldsEventsBackUntilTheirDueTime) { EXPECT_TRUE(queue.defer(AudioEvent::ENDED, SOONER_CALLBACK_ID, SOONER_DUE_TIME)); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_)).Times(0); + EXPECT_CALL( + *registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_, testing::_)) + .Times(0); queue.dispatchDue(SOONER_DUE_TIME - 0.001); EXPECT_EQ(queue.pendingCount(), 1); } @@ -38,7 +40,8 @@ TEST_F(DeferredEventQueueTest, DispatchesEventsDueExactlyNow) { queue.defer(AudioEvent::ENDED, SOONER_CALLBACK_ID, SOONER_DUE_TIME); EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); queue.dispatchDue(SOONER_DUE_TIME); EXPECT_EQ(queue.pendingCount(), 0); @@ -50,11 +53,13 @@ TEST_F(DeferredEventQueueTest, DispatchesInDueTimeOrderNotInsertionOrder) { testing::Sequence dueTimeOrder; EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) .InSequence(dueTimeOrder) .WillOnce(testing::Return(true)); EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) .InSequence(dueTimeOrder) .WillOnce(testing::Return(true)); @@ -67,13 +72,15 @@ TEST_F(DeferredEventQueueTest, LeavesNotYetDueEventsQueuedAfterASweep) { queue.defer(AudioEvent::ENDED, LATER_CALLBACK_ID, LATER_DUE_TIME); EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); queue.dispatchDue(SOONER_DUE_TIME); EXPECT_EQ(queue.pendingCount(), 1); EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); queue.dispatchDue(LATER_DUE_TIME); EXPECT_EQ(queue.pendingCount(), 0); @@ -89,9 +96,12 @@ TEST_F(DeferredEventQueueTest, DropsEventsOnceFullInsteadOfGrowing) { // The dropped event never fires; everything accepted before it still does. EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) .Times(0); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(AudioEvent::ENDED, testing::_, testing::_)) + EXPECT_CALL( + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, testing::_, testing::_)) .Times(DeferredEventQueue::MAX_PENDING_EVENTS) .WillRepeatedly(testing::Return(true)); queue.dispatchDue(LATER_DUE_TIME); @@ -99,7 +109,7 @@ TEST_F(DeferredEventQueueTest, DropsEventsOnceFullInsteadOfGrowing) { } TEST_F(DeferredEventQueueTest, ToleratesAMissingRegistry) { - DeferredEventQueue queueWithoutRegistry{nullptr}; + DeferredEventQueue queueWithoutRegistry{nullptr, nullptr}; EXPECT_TRUE(queueWithoutRegistry.defer(AudioEvent::ENDED, SOONER_CALLBACK_ID, SOONER_DUE_TIME)); queueWithoutRegistry.dispatchDue(SOONER_DUE_TIME); EXPECT_EQ(queueWithoutRegistry.pendingCount(), 0); diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/BufferingStateDispatcherTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/BufferingStateDispatcherTest.cpp index f085d155f..288b2d087 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/utils/BufferingStateDispatcherTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/utils/BufferingStateDispatcherTest.cpp @@ -17,9 +17,12 @@ constexpr int kThresholdFrames = 6615; // 150ms @ 44100Hz, matching production u TEST(BufferingStateDispatcherTest, NoCallbackMeansNoDispatchRegardlessOfStarvation) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_)).Times(0); + EXPECT_CALL( + *registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_, testing::_)) + .Times(0); // Well past the debounce threshold, but no listener is registered. dispatcher.advance(/* hasData */ false, kThresholdFrames * 2); @@ -29,12 +32,14 @@ TEST(BufferingStateDispatcherTest, NoCallbackMeansNoDispatchRegardlessOfStarvati TEST(BufferingStateDispatcherTest, StarvationBelowThresholdDoesNotDispatch) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); EXPECT_CALL( *registry, - dispatchEventFromAudioThread(AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) + dispatchEventFromAudioThread( + testing::_, AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) .Times(0); dispatcher.advance(/* hasData */ false, kThresholdFrames - 1); @@ -44,19 +49,23 @@ TEST(BufferingStateDispatcherTest, StarvationBelowThresholdDoesNotDispatch) { TEST(BufferingStateDispatcherTest, StarvationCrossingThresholdDispatchesTrueExactlyOnce) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); EXPECT_CALL( *registry, - dispatchEventFromAudioThread(AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) + dispatchEventFromAudioThread( + testing::_, AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) .Times(1) - .WillOnce(testing::Invoke([](AudioEvent, uint64_t, AudioEventPayload payload) { - auto *boolPayload = std::get_if(&payload); - EXPECT_NE(boolPayload, nullptr); - EXPECT_TRUE(boolPayload->value); - return true; - })); + .WillOnce( + testing::Invoke( + [](AudioEventProducer &, AudioEvent, uint64_t, AudioEventPayload payload) { + auto *boolPayload = std::get_if(&payload); + EXPECT_NE(boolPayload, nullptr); + EXPECT_TRUE(boolPayload->value); + return true; + })); // Accumulates across calls, like consecutive starved render quanta would. dispatcher.advance(false, kThresholdFrames / 2); @@ -68,19 +77,23 @@ TEST(BufferingStateDispatcherTest, StarvationCrossingThresholdDispatchesTrueExac TEST(BufferingStateDispatcherTest, RecoveryDispatchesFalseImmediatelyWithNoDebounce) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); EXPECT_CALL( *registry, - dispatchEventFromAudioThread(AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) + dispatchEventFromAudioThread( + testing::_, AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) .WillOnce(testing::Return(true)) - .WillOnce(testing::Invoke([](AudioEvent, uint64_t, AudioEventPayload payload) { - auto *boolPayload = std::get_if(&payload); - EXPECT_NE(boolPayload, nullptr); - EXPECT_FALSE(boolPayload->value); - return true; - })); + .WillOnce( + testing::Invoke( + [](AudioEventProducer &, AudioEvent, uint64_t, AudioEventPayload payload) { + auto *boolPayload = std::get_if(&payload); + EXPECT_NE(boolPayload, nullptr); + EXPECT_FALSE(boolPayload->value); + return true; + })); dispatcher.advance(false, kThresholdFrames * 2); ASSERT_TRUE(dispatcher.isBuffering()); @@ -93,10 +106,13 @@ TEST(BufferingStateDispatcherTest, RecoveryDispatchesFalseImmediatelyWithNoDebou TEST(BufferingStateDispatcherTest, HasDataWhileNotBufferingNeverDispatches) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_)).Times(0); + EXPECT_CALL( + *registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_, testing::_)) + .Times(0); for (int i = 0; i < 10; ++i) { dispatcher.advance(true, 128); @@ -107,10 +123,13 @@ TEST(BufferingStateDispatcherTest, HasDataWhileNotBufferingNeverDispatches) { TEST(BufferingStateDispatcherTest, IntermittentDataResetsStarvationCounter) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_)).Times(0); + EXPECT_CALL( + *registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_, testing::_)) + .Times(0); // Never accumulates enough consecutive starvation to cross the threshold, // because a data quantum resets the counter each time — mirrors ordinary @@ -125,7 +144,8 @@ TEST(BufferingStateDispatcherTest, IntermittentDataResetsStarvationCounter) { TEST(BufferingStateDispatcherTest, AssignCallbackIdUnregistersPreviousCallback) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); testing::InSequence sequence; EXPECT_CALL(*registry, unregisterHandler(AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId)) diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h index 88049e9d9..ee6e35375 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h @@ -61,10 +61,21 @@ typedef NS_ENUM(NSInteger, AudioEngineInterruptionEndOutcome) { /// Records that hardware format may have changed while the engine must not rebuild /// yet (`Interrupted`). The next start or interruption-end resume rebuilds the graph. - (void)markGraphNeedsRebuild; +/// Responds to an audio route change without restarting the engine. A running engine is left +/// as it is: when a route change alters the hardware sample rate or channel count, the engine +/// stops itself and posts `AVAudioEngineConfigurationChangeNotification`, whose handler +/// rebuilds it. A stopped engine with a tracked graph is marked for rebuild instead, because +/// whether a stopped engine observes the change and posts that notification is not +/// documented; the next start then builds the graph against the current hardware. +- (void)onRouteChange; - (AudioEngineState)getState; - (bool)isEngineRunning; - (bool)isInUse; +/// True when @p engine is the AVAudioEngine this object currently drives. False for nil, for +/// engines owned by other code in the process, and for engines this object has already +/// replaced during a rebuild. +- (bool)isCurrentEngine:(AVAudioEngine *)engine; - (bool)startIfNecessary; - (void)pauseIfNecessary; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm index cdd597ae4..4bc6f4914 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm @@ -26,8 +26,22 @@ @implementation AudioEngineInputRegistration @end @interface AudioEngine () { - std::recursive_mutex _engineLock; - BOOL _isRebuildingAudioEngine; + /// Serialises every mutation of the engine and its tracked graph. Never taken on a thread + /// AVFoundation owns: `-[AVAudioEngine dealloc]`, `stop`, `reset`, `prepare` and `start` + /// can all wait on the engine's own queue while this is held, so an observer that locked + /// from that queue would close the cycle. Notification handlers hop to the main queue + /// first, and the registered input's handler runs only after the lock is released. + std::mutex _engineLock; + /// Handler captured under `_engineLock` when the graph changes in a way the registered + /// input has to hear about, invoked only once the lock is released. Running it inside the + /// locked region would re-enter `_engineLock` (the handler reads the live input format + /// back) and would take the recorder's own mutexes in the opposite order from the + /// recorder's start path, which takes them before calling into the engine. + /// Set and taken within one locked region, so it never holds a value while the lock is + /// free: `restartAudioEngine` also runs on the thread that stops the recorder, and that + /// thread must not pick up a notification another thread queued. + void (^_pendingInputNotification)(AudioEngineInputNotification); + AudioEngineInputNotification _pendingInputNotificationKind; /// Tracks whether voice processing is currently engaged on the system input /// node of the live engine instance. Reset whenever the engine is recreated. BOOL _voiceProcessingApplied; @@ -43,13 +57,19 @@ - (BOOL)hasTrackedGraph; - (AVAudioFormat *)currentInputConnectionFormat; - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId; - (BOOL)materializeInputNodeIfNeeded; +- (void)connectVoiceProcessingOutputWithFormat:(AVAudioFormat *)format; - (void)applyVoiceProcessing; - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; - (void)resetInputNode; +- (BOOL)graphRequiresRebuild; +- (void)rebuildGraphForCurrentHardware; +- (void)parkAfterFailedStart; - (void)rebuildAudioEngineAndResumeIfNeeded; -- (void)notifyInput:(AudioEngineInputNotification)notification; +- (void)queueInputNotification:(AudioEngineInputNotification)notification; +- (dispatch_block_t)takePendingInputNotification; +- (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume; @end @@ -168,10 +188,11 @@ - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId [self.audioEngine connect:sourceNode to:self.audioEngine.mainMixerNode format:format]; } +/// @brief Reads the format the input node currently produces. +/// @discussion Expects `_engineLock` to be held: the other caller is the graph +/// materialization, which runs inside a locked rebuild. - (AVAudioFormat *)liveInputFormat { - std::scoped_lock lock(_engineLock); - if (self.audioEngine == nil) { return nil; } @@ -231,6 +252,10 @@ - (BOOL)materializeInputNodeIfNeeded return NO; } + if (_voiceProcessingApplied) { + [self connectVoiceProcessingOutputWithFormat:inputFormat]; + } + self.inputNode = [[AVAudioSinkNode alloc] initWithReceiverBlock:self.inputRegistration.receiverBlock]; [self.audioEngine attachNode:self.inputNode]; @@ -238,6 +263,22 @@ - (BOOL)materializeInputNodeIfNeeded return YES; } +/// @brief Gives the voice-processing unit's output bus a graph to pull from. +/// @discussion Voice processing runs a single I/O unit in both directions, even when only +/// the microphone is used. A recorder-only graph never creates the main mixer, so the output +/// bus has no node behind it and the first render fails. Once the mixer exists, its +/// connection to the output node is formatted by the engine unless made explicitly, and that +/// format can differ from the input's (observed: 44.1 kHz against a 48 kHz input). Either +/// way the engine stops itself shortly after starting. Voice processing requires the input +/// node's output format and the output node's input format to match, so the connection is +/// made with the input's format; the mixer converts whatever sources are attached to it. +- (void)connectVoiceProcessingOutputWithFormat:(AVAudioFormat *)format +{ + [self.audioEngine connect:self.audioEngine.mainMixerNode + to:self.audioEngine.outputNode + format:format]; +} + // Apple's voice-processing I/O (echo cancellation, noise suppression, AGC) is // opt-in per recorder. Without it, full-duplex apps (VoIP, voice agents) hear // their own speaker output looped back into the microphone. Toggling it is only @@ -383,6 +424,7 @@ - (void)detachInputNode - (AVAudioFormat *)getLiveInputFormat { + std::scoped_lock lock(_engineLock); return [self liveInputFormat]; } @@ -437,9 +479,36 @@ - (void)markGraphNeedsRebuild self.graphNeedsRebuild = true; } -- (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume +- (void)onRouteChange { std::scoped_lock lock(_engineLock); + if (![self hasTrackedGraph] || [self.audioEngine isRunning]) { + return; + } + + self.graphNeedsRebuild = true; +} + +- (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume +{ + AudioEngineInterruptionEndOutcome outcome; + dispatch_block_t notifyInput; + + { + std::scoped_lock lock(_engineLock); + outcome = [self resumeAfterInterruption:shouldResume]; + notifyInput = [self takePendingInputNotification]; + } + + if (notifyInput != nil) { + notifyInput(); + } + + return outcome; +} + +- (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume +{ NSError *error = nil; if (self.state != AudioEngineState::AudioEngineStateInterrupted) { @@ -450,7 +519,7 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume [self stopEngine]; [self rebuildAudioEngine]; self.state = AudioEngineState::AudioEngineStatePaused; - [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; return AudioEngineInterruptionEndOutcomePaused; } @@ -466,7 +535,7 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume NSLog( @"Error while materializing the audio input node after interruption: missing live input format"); self.state = AudioEngineState::AudioEngineStateInterrupted; - [self notifyInput:AudioEngineInputNotificationCaptureLost]; + [self queueInputNotification:AudioEngineInputNotificationCaptureLost]; return AudioEngineInterruptionEndOutcomeStillInterrupted; } @@ -478,21 +547,43 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); self.state = AudioEngineState::AudioEngineStateInterrupted; - [self notifyInput:AudioEngineInputNotificationCaptureLost]; + [self queueInputNotification:AudioEngineInputNotificationCaptureLost]; return AudioEngineInterruptionEndOutcomeStillInterrupted; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; - [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; return AudioEngineInterruptionEndOutcomeRunning; } -- (void)notifyInput:(AudioEngineInputNotification)notification +- (void)queueInputNotification:(AudioEngineInputNotification)notification { - if (self.inputRegistration != nil && self.inputRegistration.onInputNotification != nil) { - self.inputRegistration.onInputNotification(notification); + if (self.inputRegistration == nil || self.inputRegistration.onInputNotification == nil) { + return; } + + // The handler is captured, not looked up at delivery time: the registration may be + // detached between releasing the lock and delivering, and the recorder that asked for + // this notification is the one that must receive it. + _pendingInputNotification = self.inputRegistration.onInputNotification; + _pendingInputNotificationKind = notification; +} + +/// @brief Removes the notification queued by the last graph change and returns it ready to run. +/// @discussion Must be called with `_engineLock` held, in the same locked region that queued +/// it; the caller runs the result after releasing the lock. Returns nil when nothing is queued. +- (dispatch_block_t)takePendingInputNotification +{ + void (^handler)(AudioEngineInputNotification) = _pendingInputNotification; + + if (handler == nil) { + return nil; + } + + AudioEngineInputNotification notification = _pendingInputNotificationKind; + _pendingInputNotification = nil; + return ^{ handler(notification); }; } - (AudioEngineState)getState @@ -513,31 +604,69 @@ - (bool)isInUse return [self hasTrackedGraph] || self.audioEngine != nil; } +- (bool)isCurrentEngine:(AVAudioEngine *)engine +{ + std::scoped_lock lock(_engineLock); + return engine != nil && engine == self.audioEngine; +} + - (void)rebuildAudioEngineAndResumeIfNeeded { - if (_isRebuildingAudioEngine) { - return; + const BOOL shouldResume = self.state == AudioEngineState::AudioEngineStateRunning; + + [self rebuildGraphForCurrentHardware]; + + BOOL didStartEngine = NO; + if (shouldResume) { + didStartEngine = [self startEngine]; + + if (!didStartEngine) { + [self parkAfterFailedStart]; + } } - _isRebuildingAudioEngine = YES; + if (didStartEngine) { + [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; + } +} + +/// @brief Reports whether the graph must be torn down and rebuilt before it can run. +/// @discussion An interruption, a session deactivation and a node registered while the +/// engine was gone all leave connections that no longer match the hardware. Starting such +/// a graph would either fail or capture nothing. +- (BOOL)graphRequiresRebuild +{ + return self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild || + self.sessionDeactivationInvalidatedGraph; +} +/// @brief Rebuilds the graph against the current hardware, leaving the engine stopped. +/// @discussion Separate from starting so that no path can start an engine and rebuild it in +/// the same call: the two used to be mutually recursive, and termination depended on +/// `rebuildAudioEngine` clearing `graphNeedsRebuild` between the two frames. +- (void)rebuildGraphForCurrentHardware +{ if ([self.audioEngine isRunning]) { [self.audioEngine stop]; } [self rebuildAudioEngine]; self.sessionDeactivationInvalidatedGraph = false; +} - BOOL didStartEngine = NO; - if (self.state == AudioEngineState::AudioEngineStateRunning) { - didStartEngine = [self startEngine]; - } - - if (didStartEngine) { - [self notifyInput:AudioEngineInputNotificationHardwareChanged]; +/// @brief Records that the engine is not running after a start that was expected to succeed. +/// @discussion Leaving `Running` behind makes `startIfNecessary` early-out on a dead engine +/// and makes `getState` report a recorder that is capturing nothing. `Interrupted` is kept +/// as-is: the interruption, not the failed start, is still the reason the engine is down, +/// and the interruption-end path is what recovers it. +- (void)parkAfterFailedStart +{ + if (self.state == AudioEngineState::AudioEngineStateInterrupted) { + return; } - _isRebuildingAudioEngine = NO; + self.state = [self hasTrackedGraph] ? AudioEngineState::AudioEngineStatePaused + : AudioEngineState::AudioEngineStateIdle; } - (void)rebuildAudioEngine @@ -565,12 +694,10 @@ - (bool)startEngine return false; } - if (self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild || - self.sessionDeactivationInvalidatedGraph) { - [self rebuildAudioEngineAndResumeIfNeeded]; - } else { - [self materializeTrackedNodesIfNeeded]; - } + // Materializing attaches nodes registered while the engine was stopped; it never + // rebuilds. A graph that needs rebuilding is the caller's business, so that starting + // cannot silently tear the engine down - see `graphRequiresRebuild`. + [self materializeTrackedNodesIfNeeded]; if (self.inputRegistration != nil && self.inputNode == nil) { NSLog(@"Error while materializing the audio input node: missing live input format"); @@ -612,10 +739,19 @@ - (bool)startIfNecessary return true; } - if ([self hasTrackedGraph]) { - return [self startEngine]; + if (![self hasTrackedGraph]) { + return false; } + if ([self graphRequiresRebuild]) { + [self rebuildGraphForCurrentHardware]; + } + + if ([self startEngine]) { + return true; + } + + [self parkAfterFailedStart]; return false; } @@ -656,16 +792,25 @@ - (void)stopIfPossible - (void)restartAudioEngine { - std::scoped_lock lock(_engineLock); + dispatch_block_t notifyInput = nil; - // The engine is created lazily on first node attach. Apps that only use - // session management and notifications never have one, and a system-driven - // restart (media services reset, configuration change) must not create it. - if (![self hasTrackedGraph] && self.audioEngine == nil) { - return; + { + std::scoped_lock lock(_engineLock); + + // The engine is created lazily on first node attach. Apps that only use + // session management and notifications never have one, and a system-driven + // restart (media services reset, configuration change) must not create it. + if (![self hasTrackedGraph] && self.audioEngine == nil) { + return; + } + + [self rebuildAudioEngineAndResumeIfNeeded]; + notifyInput = [self takePendingInputNotification]; } - [self rebuildAudioEngineAndResumeIfNeeded]; + if (notifyInput != nil) { + notifyInput(); + } } - (void)logAudioEngineState diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm index 0becf9a1b..b6f19e24d 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm @@ -268,9 +268,15 @@ - (void)handleRouteChange:(NSNotification *)notification switch (routeChangeReason) { case AVAudioSessionRouteChangeReasonNewDeviceAvailable: case AVAudioSessionRouteChangeReasonOldDeviceUnavailable: - case AVAudioSessionRouteChangeReasonRouteConfigurationChange: - [self handleEngineConfigurationChange:nil]; + case AVAudioSessionRouteChangeReasonRouteConfigurationChange: { + // Not a restart: rebuilding the engine reconfigures its I/O unit, which itself posts + // RouteConfigurationChange, so restarting here would restart without end. A route + // change that alters the hardware format also arrives as + // AVAudioEngineConfigurationChangeNotification, which does restart the engine. + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onRouteChange]; }); break; + } default: break; } @@ -305,15 +311,20 @@ - (void)handleEngineConfigurationChange:(NSNotification *)notification AudioEngine *audioEngine = self.audioAPIModule.audioEngine; AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; - // This notification is registered with object:nil, so it also fires for - // AVAudioEngine instances owned by other libraries in the host app. Without - // an engine of our own there is nothing to restart, and marking the session - // inactive would corrupt bookkeeping for apps that only manage the session. - if (![audioEngine isInUse]) { - return; - } + // Weak, so a queued handler does not keep a replaced engine alive; one that is gone by the + // time the handler runs is no longer ours to restart. + __weak AVAudioEngine *changedEngine = notification.object; dispatch_async(dispatch_get_main_queue(), ^{ + // Registered with object:nil because the engine is replaced on every rebuild, so this + // also fires for AVAudioEngine instances owned by other libraries in the host app and + // for engines we have already replaced. Only a change on the engine we currently drive + // calls for a restart; acting on any other would rebuild ours for nothing and mark the + // session inactive behind the back of an app that only manages the session. + if (![audioEngine isCurrentEngine:changedEngine]) { + return; + } + // A configuration change is an I/O-unit stop, not a resume trigger. Restarting // while Interrupted races with onInterruptionEnd and the foreground retry: it // marks the session inactive and rebuilds a graph that cannot start. Leave @@ -323,7 +334,6 @@ - (void)handleEngineConfigurationChange:(NSNotification *)notification [audioEngine markGraphNeedsRebuild]; return; } - [sessionManager markInactive]; [audioEngine restartAudioEngine]; });