From 7bda48f7050f48f8873a8ea7515e6d07df7a7f45 Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 17 Aug 2026 13:57:42 +0200 Subject: [PATCH 1/2] feat: let a fresh js context resync with an ongoing native recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With stopWithTask=false a recording outlives the app UI, but a remounted screen (or relaunched app) had no way to learn about it — recorder state was only reachable through the instance that started it: - new JSI globals backed by ActiveRecorderHandle, surfaced as statics: AudioRecorder.isRecordingOngoing() and the consume-once AudioRecorder.takeLastRecordingResult() for files finalized by the notification stop action (mock parity + jest coverage included) - Record demo mounts directly in the live recorder's state, picks up natively stopped files, keeps the recording alive across screen exits and only enables file output when no session is ongoing (re-enabling mid-recording replaces the writer and resets the duration) - deep-link routing for the notification tap (react-navigation linking), duration displays seeded from the recorder instead of assuming a fresh session, and RecordingTime rewritten to plain state — the animated-prop binding went stale on the frozen value while paused and showed zeros Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- apps/common-app/src/App.tsx | 13 +- apps/common-app/src/demos/Record/Record.tsx | 106 ++++++++-- .../src/demos/Record/RecordingTime.tsx | 72 +++---- .../demos/Record/RecordingVisualization.tsx | 193 ++---------------- .../src/demos/Record/TimeStream.tsx | 14 +- .../common-app/src/demos/Record/constants.tsx | 2 - .../audiodocs/docs/inputs/audio-recorder.mdx | 48 ++++- .../cpp/audioapi/AudioAPIModuleInstaller.h | 44 ++++ .../src/AudioAPIModule/globals.d.ts | 5 + .../src/core/AudioRecorder.ts | 21 ++ .../react-native-audio-api/src/mock/index.ts | 15 +- .../react-native-audio-api/tests/mock.test.ts | 17 ++ 13 files changed, 300 insertions(+), 252 deletions(-) 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 @@ -54,6 +66,10 @@ const Record: FC = () => { pauseIconResourceName: 'pause', resumeIconResourceName: 'resume', color: 0xff6200, + showStopAction: true, + stopIconResourceName: 'stop', + deepLinkUri: 'audioapi-example://record', + usesChronometer: true, }); }; @@ -118,6 +134,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(); @@ -130,15 +163,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) { @@ -229,11 +269,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'); + } + } })(); }, []); @@ -254,22 +302,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/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index 5609be69c..407f443c5 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -6,7 +6,7 @@ toc_max_heading_level: 5 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -import { Optional } from '@site/src/components/Badges'; +import { Android, Optional } from '@site/src/components/Badges'; # AudioRecorder @@ -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`](/docs/inputs/audio-recorder#androidflushintervalms) and [`rotateIntervalBytes`](/docs/inputs/audio-recorder#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#the-stop-action) (`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 @@ -495,12 +510,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](/docs/inputs/audio-recorder#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` + +**Static.** Returns the [`FileInfo`](/docs/inputs/audio-recorder#fileinfo) of a recording that was stopped natively — through the [recording notification's stop action](/docs/system/recording-notification-manager#the-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 [`stop`](/docs/inputs/audio-recorder#stop) resolve their promise with the file info instead and never appear here. + +#### Returns [`FileInfo`](/docs/inputs/audio-recorder#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/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h index e4dc7cbce..62fc6d355 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 @@ -16,6 +17,7 @@ #include #include +#include namespace audioapi { using namespace facebook; @@ -35,6 +37,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); @@ -43,6 +47,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); @@ -126,6 +133,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 655cde36b..7d361eeff 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -7,6 +7,7 @@ import type { IAudioBuffer, IOfflineAudioContext, } from '../jsi-interfaces'; +import type { FileInfo } from '../types'; /* eslint-disable no-var */ declare global { @@ -19,6 +20,10 @@ declare global { var createAudioRecorder: () => 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 7c9256cfb..d23523ea4 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -55,6 +55,27 @@ export default class AudioRecorder { this.recorder = globalThis.createAudioRecorder(); } + /** + * 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 7b85feed3..cc637ee96 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -851,6 +851,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; @@ -860,7 +862,18 @@ class AudioRecorderMock { private onAudioReadySubscription: MockEventSubscription | null = null; private onErrorSubscription: MockEventSubscription | null = null; - constructor() {} + constructor() { + 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(); From e5ded9241ec2af05f5b3ae903e8e360050e5f3dd Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 20 Aug 2026 13:46:20 +0200 Subject: [PATCH 2/2] fix: ci --- packages/audiodocs/CLAUDE.md | 7 +++++++ packages/audiodocs/docs/inputs/audio-recorder.mdx | 12 ++++++------ .../docs/system/recording-notification-manager.mdx | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) 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 9977821ac..07c95dafa 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -135,7 +135,7 @@ 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](/docs/system/recording-notification-manager#the-stop-action) (`showStopAction: true`), which stops the recording natively even when no JS is reachable. +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: @@ -521,7 +521,7 @@ 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](/docs/inputs/audio-recorder#keeping-the-recording-alive-when-the-app-is-closed). +**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`. @@ -531,13 +531,13 @@ if (AudioRecorder.isRecordingOngoing()) { } ``` -### `takeLastRecordingResult` +### `takeLastRecordingResult` {#takelastrecordingresult} -**Static.** Returns the [`FileInfo`](/docs/inputs/audio-recorder#fileinfo) of a recording that was stopped natively — through the [recording notification's stop action](/docs/system/recording-notification-manager#the-stop-action) — or `null` if there is none. Consume-once: the result is cleared on read, so a second call returns `null`. +**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`](/docs/inputs/audio-recorder#stop) resolve their promise with the file info instead and never appear here. +Recordings stopped through [`stop`](#stop) resolve their promise with the file info instead and never appear here. -#### Returns [`FileInfo`](/docs/inputs/audio-recorder#fileinfo) or `null`. +#### Returns [`FileInfo`](#fileinfo) or `null`. ```tsx const info = AudioRecorder.takeLastRecordingResult(); 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