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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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 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<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.
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
106 changes: 89 additions & 17 deletions apps/common-app/src/demos/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
AudioBuffer,
AudioBufferSourceNode,
AudioManager,
AudioRecorder,
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used by the commented-out concat flow above
concatAudioFiles,
FileFormat,
RecordingNotificationManager,
Expand All @@ -21,7 +23,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.
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 @@ -52,6 +64,10 @@ const Record: FC = () => {
paused,
smallIconResourceName: 'logo',
color: 0xff6200,
showStopAction: true,
stopIconResourceName: 'stop',
deepLinkUri: 'audioapi-example://record',
usesChronometer: true,
});
};

Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
}
}
})();
}, []);

Expand All @@ -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]);

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.
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>;
};

export default RecordingTime;
Expand Down
Loading
Loading