Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is de
| Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) |
| CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread |
| Context lifecycle (`resume`/`suspend`/`close`) | `scheduleContextPromise` → `pendingPromisesOffloader_` |
| Platform code (Kotlin) must reach a C++ object with no JS runtime alive | Process-global handle (`ActiveRecorderHandle` — mutex + `weak_ptr`, registered by the HostObject ctor/dtor) + static-JNI `JavaClass` (`NativeRecorderControl`, no HybridData needed). Blocking calls run on a Kotlin executor (`goAsync()` in receivers), never a detached `std::thread` — Kotlin threads are already JNI-attached |

---

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ packages/custom-node-generator/ # Code generation tooling
- **New Architecture Ready**: Supports both old Bridge and new TurboModules/Fabric
- **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 `<service>` (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 for `AudioRecorder.takeLastRecordingResult()`; `AudioRecorder.isRecordingOngoing()` probes for a recording that outlived the UI
- **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr<I…>`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`).

### Native Module Entry Points
Expand Down
13 changes: 12 additions & 1 deletion apps/common-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +184 to +185

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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 (
<GestureHandlerRootView style={styles.container}>
<NavigationContainer>
<NavigationContainer linking={linking}>
<Stack.Navigator
screenOptions={{
headerShown: true,
Expand Down
109 changes: 89 additions & 20 deletions apps/common-app/src/demos/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
AudioBuffer,
AudioBufferSourceNode,
AudioManager,
concatAudioFiles,
AudioRecorder,
FileFormat,
RecordingNotificationManager,
} from 'react-native-audio-api';
Expand All @@ -21,7 +21,17 @@ import Status from './Status';
import { RecordingState } from './types';

const Record: FC = () => {
const [state, setState] = useState<RecordingState>(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.
Comment on lines +24 to +26

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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.
// Recover from "app disabled" state - recording can survive the app kill (android)

const [state, setState] = useState<RecordingState>(() => {
if (!AudioRecorder.isRecordingOngoing()) {
return RecordingState.Idle;
}
return Recorder.isPaused()
? RecordingState.Paused
: RecordingState.Recording;
});
const [hasPermissions, setHasPermissions] = useState<boolean>(false);
const [recordedBuffer, setRecordedBuffer] = useState<AudioBuffer | null>(
null
Expand Down Expand Up @@ -51,9 +61,10 @@ const Record: FC = () => {
contentText: paused ? 'Paused recording' : 'Recording...',
paused,
smallIconResourceName: 'logo',
pauseIconResourceName: 'pause',
resumeIconResourceName: 'resume',
color: 0xff6200,
showStopAction: true,
deepLinkUri: 'audioapi-example://record',
usesChronometer: true,
});
};

Expand Down Expand Up @@ -118,6 +129,19 @@ const Record: FC = () => {
setState(RecordingState.Recording);
}, []);

const loadRecordedAudio = useCallback(
async (paths: string[]) => {
setState(RecordingState.Loading);

const audioBuffer = await audioContext.decodeAudioData(paths[0]);
setRecordedBuffer(audioBuffer);

setState(RecordingState.ReadyToPlay);
currentPositionSV.value = 0;
},
[currentPositionSV]
);

const onStopRecording = useCallback(async () => {
const info = await Recorder.stop();
RecordingNotificationManager.hide();
Expand All @@ -130,15 +154,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.
Comment on lines +160 to +161

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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) {
Expand Down Expand Up @@ -229,11 +260,21 @@ 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');
}
}
})();
}, []);

Expand All @@ -254,22 +295,50 @@ 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.
Comment on lines +313 to +315

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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.
// Collect the files of a recording that was stopped natively while this screen was unmounted.

useEffect(() => {
if (AudioRecorder.isRecordingOngoing()) {
return;
}

const info = AudioRecorder.takeLastRecordingResult();
if (info && info.paths.length > 0) {
loadRecordedAudio(info.paths);
}
}, [loadRecordedAudio]);

useEffect(() => {
Recorder.enableFileOutput({ rotateIntervalBytes: 1_000_000, format: FileFormat.M4A });
// 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.
Comment on lines +328 to +329

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be also worth changing on the native side to handle that and make enable a no-op in that case

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree

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.
Comment on lines +335 to +336

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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();

if (!AudioRecorder.isRecordingOngoing()) {
AudioManager.setAudioSessionActivity(false);
}
Comment on lines +339 to +341

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

};
}, [stopPlayback]);

Expand Down
72 changes: 27 additions & 45 deletions apps/common-app/src/demos/Record/RecordingTime.tsx
Original file line number Diff line number Diff line change
@@ -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<RecordingTimeProps> = ({ 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.
Comment on lines +40 to +41

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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 (
<AnimatedTextInput
editable={false}
animatedProps={animatedText}
style={styles.text}
/>
);
return <Text style={styles.text}>{durationString}</Text>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why we moved to state driven timer?

};

export default RecordingTime;
Expand Down
Loading
Loading