diff --git a/.claude/skills/turbo-modules/SKILL.md b/.claude/skills/turbo-modules/SKILL.md index 3c6372582..68257e1e2 100644 --- a/.claude/skills/turbo-modules/SKILL.md +++ b/.claude/skills/turbo-modules/SKILL.md @@ -218,7 +218,7 @@ void AudioAPIModule::registerNatives() { `registerNatives()` is called from `android/src/main/cpp/audioapi/android/OnLoad.cpp` at `.so` load time. -**`invokeHandlerWithEventNameAndEventBody`**: called from Kotlin (MediaSessionManager callbacks) to fire events into JS. Takes a Java `Map` and converts to `std::unordered_map`. +**`invokeHandlerWithEventNameAndEventBody`**: called from Kotlin (MediaSessionManager callbacks) to fire events into JS. Takes a Java `Map` and converts it to a typed `AudioEventPayload` in `buildPayloadFromJniMap` (`android/src/main/cpp/audioapi/android/JniEventPayloadParser.cpp`), which switches on the event. An event without a case there falls to `EmptyPayload{}`: the event still reaches JS, but with an empty body. When Kotlin starts sending a new event, add its case, building the same payload type the iOS side dispatches (e.g. `ROUTE_CHANGE` → `StringPayload{.name = "reason", ...}`). --- diff --git a/CLAUDE.md b/CLAUDE.md index 9c0545389..d8096f0cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ packages/custom-node-generator/ # Code generation tooling - **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. The recording notification's swipe policy (`dismissible` option) is fixed when the notification is built and travels as an extra of its delete intent, so `RecordingNotificationReceiver` stays stateless: a pinned notification is re-posted after a swipe (Android 14+ lets users swipe `setOngoing(true)` notifications; 13 honours the flag; 12 and older pin every foreground-service notification), a dismissible one stops the recording through the same path as the stop action - **JS-Independent Recorder Control**: `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` published by `tryStart`, released by a successful stop) is the process-global occupant. Android notification actions reach it through static-JNI `NativeRecorderControl` (no HybridData/React context — the reverse of `NativeFileInfo`). iOS `setAudioSessionActivity(false)` stops a live recording through the same handle before deactivating the session. Session methods take the handle mutex before `AudioRecorder`. `pause` / `resume` / `stopAndReturnState` / `currentState` return `RecorderState` (`core/inputs/RecorderState.h`, Kotlin ordinal mirror) so `RecordingNotificationReceiver` can render from that value. `stopAndReturnInfo` returns the recorder's file-info `Result` (JS `stop`). Successful stops with paths stash consume-once for `AudioRecorder.consumeLastRecordingResult()`; `AudioRecorder.isRecordingOngoing()` probes a session that outlived the UI +- **Android input device routing**: `MediaSessionManager.inputRouter` (`AudioInputRouter`) owns the selected capture device, the automatic external-mic choice, and the acquire/release count a recorder balances around its stream; `BluetoothScoLink` brings a headset mic's SCO link up and tears it down (the platform only does that for the communication device). The C++ recorder reaches the router through static-JNI `NativeInputRouting`, and the router reaches the recorder back through `NativeInputRouting.rerouteActiveCapture` - **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/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index aa1720b01..6b734d11f 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -7,10 +7,11 @@ import { concatAudioFiles, FileFormat, RecordingNotificationManager, + useAudioInput, } from 'react-native-audio-api'; import { Alert, StyleSheet, View } from 'react-native'; -import { Container } from '../../components'; +import { Container, Select } from '../../components'; import { Easing, useSharedValue, withTiming } from 'react-native-reanimated'; import { audioRecorder as Recorder, audioContext } from '../../singletons'; @@ -48,6 +49,28 @@ const Record: FC = () => { ); const currentPositionSV = useSharedValue(0); const playbackSourceRef = useRef(null); + const { availableInputs, currentInput, selectInput } = useAudioInput(); + + const inputLabels = availableInputs.map( + (device) => `${device.name} · ${device.category} #${device.id}` + ); + const currentInputLabel = currentInput + ? inputLabels[availableInputs.indexOf(currentInput)] + : 'System default'; + + const onSelectInputLabel = async (label: string) => { + const input = availableInputs[inputLabels.indexOf(label)]; + + if (!input) { + return; + } + + try { + await selectInput(input); + } catch (error) { + Alert.alert('Input Device Error', `${error}`); + } + }; const stopPlayback = useCallback(() => { const source = playbackSourceRef.current; @@ -294,6 +317,16 @@ const Record: FC = () => { })(); }, []); + useEffect(() => { + Recorder.onError((error) => { + Alert.alert('Recorder Error', error.message); + }); + + return () => { + Recorder.clearOnError(); + }; + }, []); + useEffect(() => { const pauseListener = RecordingNotificationManager.addEventListener( 'recordingNotificationPause', @@ -358,6 +391,13 @@ const Record: FC = () => { return ( + +