diff --git a/CLAUDE.md b/CLAUDE.md index f1715d44e..3d6d4bed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ packages/custom-node-generator/ # Code generation tooling - **Optional FFmpeg**: Audio decoding via FFmpeg can be conditionally compiled out - **Audio Worklets**: JavaScript runs on the audio thread via React Native Worklets - **Notification-Driven Foreground Service (Android)**: `NotificationRegistry.showNotification` → `ForegroundServiceManager.subscribe` → `CentralizedForegroundService`; service lifetime follows notification visibility, never recorder/player state. The library manifest is empty — consuming apps declare the `` (Expo plugin `withAudioAPI.ts` or manually), where `android:stopWithTask` (plugin option `androidFSStopWithTask`) decides whether the service and an in-progress recording survive task removal -- **JS-Independent Recorder Control (Android)**: the recording notification's stop action must work after task removal, when no JS listener is reachable. `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` registered by `AudioRecorderHostObject`) exposes the live recorder process-globally; Kotlin reaches it through the static-JNI `NativeRecorderControl` object (no HybridData/React context needed — the reverse of the `NativeFileInfo` pattern). Results of a native stop are stashed consume-once in the handle +- **JS-Independent Recorder Control (Android)**: the recording notification's stop action must work after task removal, when no JS listener is reachable. `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` registered by `AudioRecorderHostObject`) exposes the live recorder process-globally; Kotlin reaches it through the static-JNI `NativeRecorderControl` object (no HybridData/React context needed — the reverse of the `NativeFileInfo` pattern). Results of a native stop are stashed consume-once for `AudioRecorder.takeLastRecordingResult()`; `AudioRecorder.isRecordingOngoing()` probes for a recording that outlived the UI - **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`). ### Native Module Entry Points diff --git a/apps/common-app/src/App.tsx b/apps/common-app/src/App.tsx index ec6f13d15..aad636d1d 100644 --- a/apps/common-app/src/App.tsx +++ b/apps/common-app/src/App.tsx @@ -181,10 +181,21 @@ const MainTabsScreen: FC = () => { ); }; +// Routes notification taps (e.g. the recording notification's `deepLinkUri`) +// straight to the right screen instead of the app's entry screen. +const linking = { + prefixes: ['audioapi-example://'], + config: { + screens: { + RecordDemo: 'record', + }, + }, +}; + const App: FC = () => { return ( - + { - const [state, setState] = useState(RecordingState.Idle); + // A recording can outlive this screen (and, with `stopWithTask: false`, the whole + // app UI). Mounting directly in the right state lets every child initialize from + // the live recorder instead of transitioning out of a transient Idle render. + const [state, setState] = useState(() => { + if (!AudioRecorder.isRecordingOngoing()) { + return RecordingState.Idle; + } + return Recorder.isPaused() + ? RecordingState.Paused + : RecordingState.Recording; + }); const [hasPermissions, setHasPermissions] = useState(false); const [recordedBuffer, setRecordedBuffer] = useState( null @@ -52,6 +64,10 @@ const Record: FC = () => { paused, smallIconResourceName: 'logo', color: 0xff6200, + showStopAction: true, + stopIconResourceName: 'stop', + deepLinkUri: 'audioapi-example://record', + usesChronometer: true, }); }; @@ -116,6 +132,23 @@ const Record: FC = () => { setState(RecordingState.Recording); }, []); + const loadRecordedAudio = useCallback( + async (paths: string[]) => { + setState(RecordingState.Loading); + + // const outputPath = paths[0].replace(/[^/]+$/, 'recording.wav'); + + // const finalPath = await concatAudioFiles(paths, outputPath); + const finalPath = paths[0]; + const audioBuffer = await audioContext.decodeAudioData(finalPath); + setRecordedBuffer(audioBuffer); + + setState(RecordingState.ReadyToPlay); + currentPositionSV.value = 0; + }, + [currentPositionSV] + ); + const onStopRecording = useCallback(async () => { const info = await Recorder.stop(); RecordingNotificationManager.hide(); @@ -128,15 +161,22 @@ const Record: FC = () => { return; } - const outputPath = info.paths[0].replace(/[^/]+$/, 'recording.m4a'); + await loadRecordedAudio(info.paths); + }, [loadRecordedAudio]); - const finalPath = await concatAudioFiles(info.paths, outputPath); - const audioBuffer = await audioContext.decodeAudioData(finalPath); - setRecordedBuffer(audioBuffer); + // The stop action already stopped the recorder natively and hid the notification; + // here we only pick up the resulting files and sync the UI. + const onStopRecordingFromNotification = useCallback(async () => { + const info = AudioRecorder.takeLastRecordingResult(); - setState(RecordingState.ReadyToPlay); - currentPositionSV.value = 0; - }, []); + if (!info || info.paths.length === 0) { + setRecordedBuffer(null); + setState(RecordingState.Idle); + return; + } + + await loadRecordedAudio(info.paths); + }, [loadRecordedAudio]); const onPlayRecording = useCallback(() => { if (state !== RecordingState.ReadyToPlay) { @@ -227,11 +267,19 @@ const Record: FC = () => { useEffect(() => { (async () => { - const permissionStatus = await AudioManager.checkRecordingPermissions(); + const recordingPermissionStatus = await AudioManager.checkRecordingPermissions(); - if (permissionStatus === 'Granted') { + if (recordingPermissionStatus === 'Granted') { setHasPermissions(true); } + + const notificationPermissionStatus = await AudioManager.checkNotificationPermissions(); + if (notificationPermissionStatus !== 'Granted') { + const result = await AudioManager.requestNotificationPermissions(); + if (result !== 'Granted') { + console.warn('Notification permissions are not granted'); + } + } })(); }, []); @@ -252,22 +300,46 @@ const Record: FC = () => { } ); + const stopListener = RecordingNotificationManager.addEventListener( + 'recordingNotificationStop', + () => { + console.log('Notification stop action received'); + onStopRecordingFromNotification(); + } + ); + return () => { pauseListener.remove(); resumeListener.remove(); - RecordingNotificationManager.hide(); + stopListener.remove(); }; - }, [onPauseRecording, onResumeRecording]); + }, [onPauseRecording, onResumeRecording, onStopRecordingFromNotification]); + // An ongoing recording is picked up by the state initializer above; here we only + // collect the files of a recording that was stopped natively (notification stop + // action) while this screen was unmounted. useEffect(() => { - Recorder.enableFileOutput({ rotateIntervalBytes: 1_000_000, format: FileFormat.M4A }); + if (AudioRecorder.isRecordingOngoing()) { + return; + } + + const info = AudioRecorder.takeLastRecordingResult(); + if (info && info.paths.length > 0) { + loadRecordedAudio(info.paths); + } + }, [loadRecordedAudio]); + + useEffect(() => { + // Re-enabling file output during an ongoing recording replaces the file writer, + // which starts a new file and resets the duration — skip it when resyncing. + if (!AudioRecorder.isRecordingOngoing()) { + Recorder.enableFileOutput({ format: FileFormat.Wav }); + } return () => { + // The recording and its notification intentionally stay alive when leaving this + // screen; they can be stopped from the notification or after coming back. stopPlayback(); - Recorder.disableFileOutput(); - Recorder.stop(); - AudioManager.setAudioSessionActivity(false); - RecordingNotificationManager.hide(); }; }, [stopPlayback]); diff --git a/apps/common-app/src/demos/Record/RecordingTime.tsx b/apps/common-app/src/demos/Record/RecordingTime.tsx index d354b1b08..5f1cd31ac 100644 --- a/apps/common-app/src/demos/Record/RecordingTime.tsx +++ b/apps/common-app/src/demos/Record/RecordingTime.tsx @@ -1,71 +1,53 @@ -import React, { useEffect } from 'react'; -import { StyleSheet, TextInput } from 'react-native'; -import Animated, { - useAnimatedProps, - useSharedValue, -} from 'react-native-reanimated'; +import React, { useEffect, useState } from 'react'; +import { StyleSheet, Text } from 'react-native'; import { audioRecorder as Recorder } from '../../singletons'; import { colors } from '../../styles'; import { RecordingState } from './types'; -const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); +const IDLE_DURATION = '00:00:000'; + +function formatDuration(elapsedSeconds: number) { + const minutes = Math.floor((elapsedSeconds % 3600) / 60) + .toString() + .padStart(2, '0'); + const seconds = Math.floor(elapsedSeconds % 60) + .toString() + .padStart(2, '0'); + const milliseconds = Math.floor((elapsedSeconds % 1) * 1000) + .toString() + .padStart(3, '0'); + + return `${minutes}:${seconds}:${milliseconds}`; +} interface RecordingTimeProps { state: RecordingState; } const RecordingTime: React.FC = ({ state }) => { - const durationStringSV = useSharedValue('00:00:000'); - const isMountedSV = useSharedValue(true); + const [durationString, setDurationString] = useState(IDLE_DURATION); useEffect(() => { - isMountedSV.value = true; if (![RecordingState.Recording, RecordingState.Paused].includes(state)) { - durationStringSV.value = '00:00:00'; + setDurationString(IDLE_DURATION); return; } - const interval = setInterval(() => { - if (!isMountedSV.value) { - return; - } - - const elapsedSeconds = Recorder.getCurrentDuration(); + const refreshDuration = () => + setDurationString(formatDuration(Recorder.getCurrentDuration())); - const minutes = Math.floor((elapsedSeconds % 3600) / 60) - .toString() - .padStart(2, '0'); - const seconds = Math.floor(elapsedSeconds % 60) - .toString() - .padStart(2, '0'); - const milliseconds = Math.floor((elapsedSeconds % 1) * 1000) - .toString() - .padStart(3, '0'); - - durationStringSV.value = `${minutes}:${seconds}:${milliseconds}`; - }, 100); + // Also refresh immediately so a paused or resynced screen shows the real + // duration before the first interval tick. + refreshDuration(); + const interval = setInterval(refreshDuration, 100); return () => { - isMountedSV.value = false; clearInterval(interval); }; - }, [state, durationStringSV, isMountedSV]); - - const animatedText = useAnimatedProps(() => { - return { - text: durationStringSV.value, - defaultValue: '00:00:000', - }; - }); + }, [state]); - return ( - - ); + return {durationString}; }; export default RecordingTime; diff --git a/apps/common-app/src/demos/Record/RecordingVisualization.tsx b/apps/common-app/src/demos/Record/RecordingVisualization.tsx index 747026742..cc07718fa 100644 --- a/apps/common-app/src/demos/Record/RecordingVisualization.tsx +++ b/apps/common-app/src/demos/Record/RecordingVisualization.tsx @@ -22,7 +22,6 @@ import { withTiming, } from 'react-native-reanimated'; -import { Spacer } from '../../components'; import { audioRecorder as Recorder } from '../../singletons'; import constants from './constants'; import TimeStream from './TimeStream'; @@ -32,18 +31,10 @@ const { width: windowWidth } = Dimensions.get('window'); const defaultNumBars = Math.floor(windowWidth / constants.barStep); -const historyNumBars = Math.floor( - windowWidth / (constants.historyBarWidth + constants.historyBarGap) -); - function getInitialWaveform() { return new Array(defaultNumBars * 2).fill(-1); } -function getInitialHistory() { - return new Array(historyNumBars * 10).fill(-1); -} - interface RecordingVisualizationProps { state: RecordingState; } @@ -57,15 +48,6 @@ interface DrawDefaultWaveformParams { numBars: number; } -interface DrawHistoryWaveformParams { - normalized: number; - lifetimeCanvasHeight: number; - history: number[]; - historyHead: SharedValue; - durationMS: SharedValue; - historyMidpointMS: SharedValue; -} - function drawDefaultWaveform(params: DrawDefaultWaveformParams) { 'worklet'; const { normalized, canvasHeight, barHeights, translateX, lastIndex, numBars } = @@ -108,63 +90,23 @@ function drawDefaultWaveform(params: DrawDefaultWaveformParams) { return barHeights; } -function drawHistoryWaveform(params: DrawHistoryWaveformParams) { - 'worklet'; - - const { - history, - normalized, - lifetimeCanvasHeight, - historyHead, - durationMS, - historyMidpointMS, - } = params; - - if (lifetimeCanvasHeight <= 0) { - return history; - } - - const value = normalized * lifetimeCanvasHeight * 0.8; - history[historyHead.value] = value; - historyHead.value += 1; - - // downsample if needed - if (historyHead.value >= history.length) { - const halfLength = history.length / 2; - - for (let i = 0; i < halfLength; i++) { - history[i] = Math.max(history[2 * i], history[2 * i + 1]); - } - - historyHead.value = halfLength; - historyMidpointMS.value = durationMS.value; - } - - return history; -} - const RecordingVisualization: React.FC = ({ state, }) => { const canvasRef = useCanvasRef(); - const lifetimeCanvasRef = useCanvasRef(); const { size } = useCanvasSize(canvasRef); - const { size: lifetimeSize } = useCanvasSize(lifetimeCanvasRef); const barHeights = useSharedValue(getInitialWaveform()); - const history = useSharedValue(getInitialHistory()); - const historyHead = useSharedValue(0); - const historyMidpointMS = useSharedValue(0); - const historyRenderer = useSharedValue( - new Array(historyNumBars).fill(-1) - ); - const translateX = useSharedValue(0); const lastIndex = useSharedValue(-1); - const durationMS = useSharedValue(0); + // The worklet only accumulates duration from buffers it sees while this component + // is mounted; when the screen re-attaches to an already-running recording, start + // from the recorder's real elapsed time. Seeding here (not in an effect) matters: + // TimeStream's children position their ticks from this value during their own + // mount, which happens before any parent effect could run. + const durationMS = useSharedValue(Recorder.getCurrentDuration() * 1000); const canvasHeightSV = useSharedValue(0); - const lifetimeCanvasHeightSV = useSharedValue(0); const numBarsSV = useSharedValue(0); const stateRef = useRef(state); @@ -205,66 +147,6 @@ const RecordingVisualization: React.FC = ({ return path; }, [size, numBars]); - const historyWaveformPath = useDerivedValue(() => { - const path = Skia.PathBuilder.Make().build(); - const canvasHeight = lifetimeSize.height; - const values = historyRenderer.value; - - if (historyHead.value < historyNumBars) { - // render as it is - for (let i = 0; i < historyHead.value; i++) { - values[i] = history.value[i]; - - if (values[i] < 0) { - continue; - } - - const x = - i * (constants.historyBarWidth + constants.historyBarGap) + - constants.historyBarWidth / 2; - const y1 = (canvasHeight - values[i]) / 2; - const y2 = (canvasHeight + values[i]) / 2; - - path.moveTo(x, y1); - path.lineTo(x, y2); - } - - return path; - } - - const ratio = historyHead.value / historyNumBars; - - // render rest - for (let i = 0; i < historyNumBars; i++) { - let maxVal = -1; - const startIndex = Math.floor(i * ratio); - const endIndex = Math.floor((i + 1) * ratio); - - for (let j = startIndex; j < endIndex; j++) { - if (history.value[j] > maxVal) { - maxVal = history.value[j]; - } - } - - values[i] = maxVal; - - if (values[i] < 0) { - continue; - } - - const x = - i * (constants.historyBarWidth + constants.historyBarGap) + - constants.historyBarWidth / 2; - const y1 = (canvasHeight - values[i]) / 2; - const y2 = (canvasHeight + values[i]) / 2; - - path.moveTo(x, y1); - path.lineTo(x, y2); - } - - return path; - }, [lifetimeSize]); - useEffect(() => { stateRef.current = state; }, [state]); @@ -272,8 +154,7 @@ const RecordingVisualization: React.FC = ({ useEffect(() => { numBarsSV.value = numBars; canvasHeightSV.value = size.height; - lifetimeCanvasHeightSV.value = lifetimeSize.height; - }, [numBars, size.height, lifetimeSize.height, numBarsSV, canvasHeightSV, lifetimeCanvasHeightSV]); + }, [numBars, size.height, numBarsSV, canvasHeightSV]); useEffect(() => { if (numBars <= 0) { @@ -299,7 +180,6 @@ const RecordingVisualization: React.FC = ({ 'worklet'; const canvasHeight = canvasHeightSV.value; - const lifetimeCanvasHeight = lifetimeCanvasHeightSV.value; const activeNumBars = numBarsSV.value; if (canvasHeight <= 0 || activeNumBars <= 0) { @@ -335,19 +215,6 @@ const RecordingVisualization: React.FC = ({ numBars: activeNumBars, }) as T; }); - - history.modify((hist: T) => { - 'worklet'; - - return drawHistoryWaveform({ - normalized, - lifetimeCanvasHeight, - history: hist, - historyHead, - durationMS, - historyMidpointMS, - }) as T; - }); }, { domain: 'time-domain', @@ -419,6 +286,13 @@ const RecordingVisualization: React.FC = ({ useEffect(() => { if (state === RecordingState.Recording) { + if (size.width === 0) { + // Canvas not measured yet (mounting straight into an ongoing recording). + // Starting the scroll animation now would pin translateX at 0 and draw the + // waveform off-screen; this effect re-runs once the size arrives. + return; + } + const animationTarget = -size.width; const animationDuration = 1000 * (size.width / constants.pixelsPerSecond); @@ -450,26 +324,10 @@ const RecordingVisualization: React.FC = ({ cancelAnimation(translateX); translateX.value = 0; barHeights.value = Array(numBars).fill(-1); - historyRenderer.value = Array(historyNumBars).fill(-1); - history.value = Array(historyNumBars * 10).fill(-1); - historyHead.value = 0; - historyMidpointMS.value = 0; durationMS.value = 0; lastIndex.value = -1; } - }, [ - state, - size, - translateX, - barHeights, - numBars, - durationMS, - lastIndex, - history, - historyHead, - historyMidpointMS, - historyRenderer, - ]); + }, [state, size, translateX, barHeights, numBars, durationMS, lastIndex]); const transformPath = useDerivedValue(() => [ { @@ -498,20 +356,6 @@ const RecordingVisualization: React.FC = ({ durationMS={durationMS} /> - - - - - - - - ); }; @@ -531,11 +375,4 @@ const styles = StyleSheet.create({ height: 20, marginTop: 8, }, - lifetimeContainer: { - marginTop: 16, - height: 75, - width: '100%', - backgroundColor: 'rgba(0, 0, 0, 0.15)', - flexDirection: 'column', - }, }); diff --git a/apps/common-app/src/demos/Record/TimeStream.tsx b/apps/common-app/src/demos/Record/TimeStream.tsx index 0fd4ff022..4b16925a8 100644 --- a/apps/common-app/src/demos/Record/TimeStream.tsx +++ b/apps/common-app/src/demos/Record/TimeStream.tsx @@ -23,10 +23,12 @@ interface TimeStreamProps { durationMS: SharedValue; } -function generateInitialTimestamps() { +// Seconds around `baseSecond` so the visible window is fully populated even when +// the stream starts mid-recording (screen re-attached to a live recorder). +function generateInitialTimestamps(baseSecond: number) { const timestamps: number[] = []; - for (let i = -5; i < 15; i++) { + for (let i = baseSecond - 5; i < baseSecond + 15; i++) { timestamps.push(i); } @@ -34,14 +36,14 @@ function generateInitialTimestamps() { } const TimeStream: React.FC = ({ isRecording, durationMS }) => { - const [timestamps, setTimestamps] = useState( - generateInitialTimestamps() + const [timestamps, setTimestamps] = useState(() => + generateInitialTimestamps(Math.floor(durationMS.value / 1000)) ); - const intervalRef = useRef(null); + const intervalRef = useRef | null>(null); useEffect(() => { if (isRecording) { - setTimestamps(generateInitialTimestamps()); + setTimestamps(generateInitialTimestamps(Math.floor(durationMS.value / 1000))); intervalRef.current = setInterval(() => { const elapsedSeconds = durationMS.value / 1000; diff --git a/apps/common-app/src/demos/Record/constants.tsx b/apps/common-app/src/demos/Record/constants.tsx index 4bd332c91..ba3b26063 100644 --- a/apps/common-app/src/demos/Record/constants.tsx +++ b/apps/common-app/src/demos/Record/constants.tsx @@ -9,8 +9,6 @@ const constants = { barGap: 2, minDb: -40, maxDb: 0, - historyBarWidth: 2, - historyBarGap: 2, get barStep() { return this.barWidth + this.barGap; }, diff --git a/packages/audiodocs/CLAUDE.md b/packages/audiodocs/CLAUDE.md index fee64e75e..0eb4be6c5 100644 --- a/packages/audiodocs/CLAUDE.md +++ b/packages/audiodocs/CLAUDE.md @@ -97,6 +97,13 @@ Docusaurus collects link targets from heading ids. A hand-rolled `` +becomes `#takelastrecordingresult-`, with a trailing hyphen. Pin the anchor explicitly instead: + +```mdx +### `takeLastRecordingResult` {#takelastrecordingresult} +``` + ## Sidebar / Navigation Sidebar is **fully autogenerated** from the folder structure — no edits to `sidebars.js` needed. diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 5793052e0..07c95dafa 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -135,6 +135,21 @@ For the recording to actually survive, all of the following must hold: 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](../system/recording-notification-manager#native-action-handling) (`showStopAction: true`), which stops 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.takeLastRecordingResult(); + if (info) { + // the recording was stopped from the notification; info.paths holds the files + } +} +``` + ## Examples @@ -504,12 +519,43 @@ Returns the current recording duration when file output is enabled. const duration = audioRecorder.getCurrentDuration(); ``` +### `isRecordingOngoing` + +**Static.** Returns `true` while any recording session is ongoing (recording or paused), regardless of which `AudioRecorder` instance started it. Use it after an app relaunch to detect a recording that [outlived the app UI](#keeping-the-recording-alive-when-the-app-is-closed). + +#### Returns `boolean`. + +```tsx +if (AudioRecorder.isRecordingOngoing()) { + // re-attach the UI to the still-running recording +} +``` + +### `takeLastRecordingResult` {#takelastrecordingresult} + +**Static.** Returns the [`FileInfo`](#fileinfo) of a recording that was stopped natively — through the [recording notification's stop action](../system/recording-notification-manager.mdx#native-action-handling) — or `null` if there is none. Consume-once: the result is cleared on read, so a second call returns `null`. + +Recordings stopped through [`stop`](#stop) resolve their promise with the file info instead and never appear here. + +#### Returns [`FileInfo`](#fileinfo) or `null`. + +```tsx +const info = AudioRecorder.takeLastRecordingResult(); +if (info) { + // the recording was stopped from the notification; info.paths holds the files +} +``` + ### `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. For further information, see [`AudioRecorderFileOptions`](#audiorecorderfileoptions). +:::caution +Calling `enableFileOutput` while a recording is ongoing replaces the file writer: output continues into a new file and [`getCurrentDuration`](#getcurrentduration) resets. When re-mounting a screen that may be resyncing with a still-running recording, guard the call with [`isRecordingOngoing`](#isrecordingongoing). +::: + | Parameter | Type | Description | | :---: | :---: | :---- | | `options` | [`AudioRecorderFileOptions`](#audiorecorderfileoptions) | File output configuration. | diff --git a/packages/audiodocs/docs/system/recording-notification-manager.mdx b/packages/audiodocs/docs/system/recording-notification-manager.mdx index 8c24c6e30..9b7472676 100644 --- a/packages/audiodocs/docs/system/recording-notification-manager.mdx +++ b/packages/audiodocs/docs/system/recording-notification-manager.mdx @@ -50,7 +50,7 @@ RecordingNotificationManager.hide(); 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](../inputs/audio-recorder.mdx#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.takeLastRecordingResult()`), 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.takeLastRecordingResult()` instead. +- **Stop** (`showStopAction: true`) stops the recorder and finalizes the output files (their info becomes available through [`AudioRecorder.takeLastRecordingResult()`](../inputs/audio-recorder#takelastrecordingresult)), 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.takeLastRecordingResult()` instead. ## Routing the notification tap diff --git a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h index 33068db7d..14465785e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,8 @@ class AudioAPIModuleInstaller { auto createAudioBuffer = getCreateAudioBufferFunction(jsiRuntime); auto createAudioDecoder = getCreateAudioDecoderFunction(jsiRuntime, jsCallInvoker); auto createAudioFileUtils = getCreateAudioFileUtilsFunction(jsiRuntime, jsCallInvoker); + auto isRecordingOngoing = getIsRecordingOngoingFunction(jsiRuntime); + auto takeLastRecordingResult = getTakeLastRecordingResultFunction(jsiRuntime); jsiRuntime->global().setProperty(*jsiRuntime, "createAudioContext", createAudioContext); jsiRuntime->global().setProperty(*jsiRuntime, "createAudioRecorder", createAudioRecorder); @@ -46,6 +49,9 @@ class AudioAPIModuleInstaller { jsiRuntime->global().setProperty(*jsiRuntime, "createAudioBuffer", createAudioBuffer); jsiRuntime->global().setProperty(*jsiRuntime, "createAudioDecoder", createAudioDecoder); jsiRuntime->global().setProperty(*jsiRuntime, "createAudioFileUtils", createAudioFileUtils); + jsiRuntime->global().setProperty(*jsiRuntime, "isRecordingOngoing", isRecordingOngoing); + jsiRuntime->global().setProperty( + *jsiRuntime, "takeLastRecordingResult", takeLastRecordingResult); auto audioEventHandlerRegistryHostObject = std::make_shared(audioEventHandlerRegistry); @@ -132,6 +138,43 @@ class AudioAPIModuleInstaller { }); } + static jsi::Function getIsRecordingOngoingFunction(jsi::Runtime *jsiRuntime) { + return jsi::Function::createFromHostFunction( + *jsiRuntime, + jsi::PropNameID::forAscii(*jsiRuntime, "isRecordingOngoing"), + 0, + [](jsi::Runtime &runtime, const jsi::Value &thisValue, const jsi::Value *args, size_t count) + -> jsi::Value { + return jsi::Value(ActiveRecorderHandle::global().isRecordingOngoing()); + }); + } + + static jsi::Function getTakeLastRecordingResultFunction(jsi::Runtime *jsiRuntime) { + return jsi::Function::createFromHostFunction( + *jsiRuntime, + jsi::PropNameID::forAscii(*jsiRuntime, "takeLastRecordingResult"), + 0, + [](jsi::Runtime &runtime, const jsi::Value &thisValue, const jsi::Value *args, size_t count) + -> jsi::Value { + auto result = ActiveRecorderHandle::global().takeLastRecordingResult(); + if (!result.has_value()) { + return jsi::Value::null(); + } + + auto jsResult = jsi::Object(runtime); + auto pathsArray = jsi::Array(runtime, result->paths.size()); + for (size_t i = 0; i < result->paths.size(); ++i) { + pathsArray.setValueAtIndex( + runtime, i, jsi::String::createFromUtf8(runtime, result->paths[i])); + } + jsResult.setProperty(runtime, "paths", pathsArray); + jsResult.setProperty(runtime, "size", result->size); + jsResult.setProperty(runtime, "duration", result->duration); + + return jsi::Value(std::move(jsResult)); + }); + } + static jsi::Function getCreateAudioDecoderFunction( jsi::Runtime *jsiRuntime, const std::shared_ptr &jsCallInvoker) { diff --git a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts index 5bd2be419..6a061530c 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -7,7 +7,7 @@ import type { IAudioBuffer, IOfflineAudioContext, } from '../jsi-interfaces'; -import type { AudioRecorderOptions } from '../types'; +import type { FileInfo, AudioRecorderOptions } from '../types'; /* eslint-disable no-var */ declare global { @@ -20,6 +20,10 @@ declare global { var createAudioRecorder: (options: AudioRecorderOptions) => IAudioRecorder; + var isRecordingOngoing: () => boolean; + + var takeLastRecordingResult: () => FileInfo | null; + var createAudioBuffer: ( numberOfChannels: number, length: number, diff --git a/packages/react-native-audio-api/src/core/AudioRecorder.ts b/packages/react-native-audio-api/src/core/AudioRecorder.ts index f1cd377f1..ef5c6bca4 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -56,6 +56,27 @@ export default class AudioRecorder { this.recorder = globalThis.createAudioRecorder(options ?? {}); } + /** + * Checks whether any recording session is ongoing (recording or paused), + * regardless of which `AudioRecorder` instance started it. Use it after an + * app relaunch to detect a recording that outlived the UI (Android foreground + * service with `stopWithTask: false`). + */ + static isRecordingOngoing(): boolean { + return globalThis.isRecordingOngoing?.() ?? false; + } + + /** + * Returns the file info of a recording that was stopped natively (e.g. via + * the recording notification stop action), or `null` if there is none. + * Consume-once: the result is cleared on read, so a second call returns + * `null`. Recordings stopped through {@link stop} resolve their promise with + * the file info instead and never appear here. + */ + static takeLastRecordingResult(): FileInfo | null { + return globalThis.takeLastRecordingResult?.() ?? null; + } + /** * Enables writing recorded audio to a file using the provided options. * diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index 5d596e392..ec482830e 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -852,6 +852,8 @@ class OfflineAudioContextMock extends BaseAudioContextMock { } class AudioRecorderMock { + private static lastCreated: AudioRecorderMock | null = null; + private _isRecording: boolean = false; private _isPaused: boolean = false; private _currentDuration: number = 0; @@ -861,8 +863,18 @@ class AudioRecorderMock { private onAudioReadySubscription: MockEventSubscription | null = null; private onErrorSubscription: MockEventSubscription | null = null; - // Options only configure the native capture chain, so the mock ignores them. - constructor(_options?: AudioRecorderOptions) {} + constructor(_options?: AudioRecorderOptions) { + AudioRecorderMock.lastCreated = this; + } + + static isRecordingOngoing(): boolean { + const recorder = AudioRecorderMock.lastCreated; + return recorder != null && (recorder._isRecording || recorder._isPaused); + } + + static takeLastRecordingResult(): FileInfo | null { + return null; + } enableFileOutput( options?: AudioRecorderFileOptions diff --git a/packages/react-native-audio-api/tests/mock.test.ts b/packages/react-native-audio-api/tests/mock.test.ts index 166a91b3d..5a6507c9d 100644 --- a/packages/react-native-audio-api/tests/mock.test.ts +++ b/packages/react-native-audio-api/tests/mock.test.ts @@ -253,6 +253,23 @@ describe('React Native Audio API Mocks', () => { expect(recorder.isRecording()).toBe(false); }); + it('should report an ongoing recording through the static probe', async () => { + expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(false); + + await recorder.start(); + expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(true); + + recorder.pause(); + expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(true); + + await recorder.stop(); + expect(MockAPI.AudioRecorder.isRecordingOngoing()).toBe(false); + }); + + it('should expose the consume-once native stop result as null', () => { + expect(MockAPI.AudioRecorder.takeLastRecordingResult()).toBeNull(); + }); + it('should support RecorderAdapterNode connection', () => { const context = new MockAPI.AudioContext(); const adapter = context.createRecorderAdapter();