diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 490fbe3a4..67427ff37 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -382,6 +382,7 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | iOS compile error `unknown type 'id'` | C++ file included ObjC-only header | Compile that file as ObjC++ (separate subspec with `-x objective-c++`) | | `RCT_NEW_ARCH_ENABLED` undefined on Android | Old RN gradle plugin | Ensure `newArchEnabled=true` in app's `gradle.properties` | | iOS: `'to_chars' is unavailable: introduced in iOS 16.3` from `formatter_floating_point.h`, instantiated by `std::format<...>` | `std::format` in code compiled for iOS. libc++ availability-gates the whole `` library to iOS 16.3; the podspec minimum is `ios_min_version = '14.0'`. The desktop C++ test build and Android NDK have no such gate, so `yarn test:cpp` passes and only the iOS build fails. | Use `std::string` concatenation / `std::to_string` in `common/cpp` and `ios/`. Zero-pad by hand (`insert(0, n, '0')`). Android-only files (`android/src/main/cpp`) may keep `std::format`. | +| Android incremental build only: `Unresolved reference` to a top-level Kotlin function, or `Cannot access '…': it is internal in file`, while `--rerun-tasks` passes | Incremental Kotlin rounds treat the module's own earlier classes as a foreign module (library is built through the `node_modules/react-native-audio-api` symlink) | Use only public classes/objects across files in `android/src/main/java`; no top-level functions, no `internal` | | clangd only: `'React/RCTBridgeModule.h' file not found` in `.mm` files | `compile_commands.json` has no framework search path | See *clangd compile database* below — regenerate with `yarn setup:clangd` | ## clangd compile database diff --git a/.claude/skills/turbo-modules/SKILL.md b/.claude/skills/turbo-modules/SKILL.md index 68257e1e2..3c885d6cc 100644 --- a/.claude/skills/turbo-modules/SKILL.md +++ b/.claude/skills/turbo-modules/SKILL.md @@ -81,7 +81,7 @@ interface Spec extends TurboModule { install(): boolean; // synchronous — MUST run first getDevicePreferredSampleRate(): number; // synchronous setAudioSessionActivity(enabled: boolean): Promise; - setAudioSessionOptions(...): void; + setSystemOptions(...): void; observeAudioInterruptions(focusType, enabled): void; requestRecordingPermissions(): Promise; // ... audio devices, notifications ... diff --git a/CLAUDE.md b/CLAUDE.md index d8096f0cc..78b5b16f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ packages/custom-node-generator/ # Code generation tooling - **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` +- **Android communication session**: `system/CommunicationSession.kt` owns the `inCommunication` state (mode preference, voice-communication focus, `MODE_IN_COMMUNICATION`, the device request, route-change callbacks) and does its own API-31 gating; `MediaSessionManager` only marshals calls onto the main thread and maps results. Keep new session state there, not on the manager - **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/CLAUDE.md b/apps/CLAUDE.md index 0132554e3..c6cbc77d7 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -33,7 +33,7 @@ apps/ Bottom Tabs ├── Tests → 2-column grid of Examples (simple feature demos) ├── Demo Apps → Single-column list of Demos (full mini-apps) -└── Other → Placeholder +└── Other → Internal tooling / repro screens, registered in `other/index.ts` (`otherScreens`); add the key to `NavigationParamList` in `examples/index.ts` ``` Each item navigates into a Stack screen. No manual stack registration needed — `examples/index.ts` and `demos/index.ts` drive it automatically. @@ -264,10 +264,12 @@ function MyPedal({ context, inputNode, outputNode }: PedalProps) { ```tsx import { AudioManager } from 'react-native-audio-api'; -AudioManager.setAudioSessionOptions({ - iosCategory: 'playback', // or 'playAndRecord' for mic input - iosMode: 'default', - iosOptions: [], +AudioManager.setSystemOptions({ + ios: { + category: 'playback', // or 'playAndRecord' for mic input + mode: 'default', + options: [], + }, }); await AudioManager.setAudioSessionActivity(true); ``` diff --git a/apps/common-app/src/demos/Crossfade/Crossfade.tsx b/apps/common-app/src/demos/Crossfade/Crossfade.tsx index d56641b42..11725db37 100644 --- a/apps/common-app/src/demos/Crossfade/Crossfade.tsx +++ b/apps/common-app/src/demos/Crossfade/Crossfade.tsx @@ -35,8 +35,16 @@ const TILE_DISTANCE = ARTWORK_SIZE + TILE_OFFSET; const MAX_GAIN = 0.5; const TRACKS = [ - { title: 'Up-Beat', cover: require('./images/image_1.jpeg'), uri: require('./tracks/track1.mp3') }, - { title: 'Chill', cover: require('./images/image_2.jpg'), uri: require('./tracks/track2.mp3') }, + { + title: 'Up-Beat', + cover: require('./images/image_1.jpeg'), + uri: require('./tracks/track1.mp3'), + }, + { + title: 'Chill', + cover: require('./images/image_2.jpg'), + uri: require('./tracks/track2.mp3'), + }, ] as const; function equalPowerGain1(progress: number): number { @@ -118,7 +126,7 @@ const Crossfade: FC = () => { if (isPlayingShared.value === 1) { scheduleOnRN(applyGainFromProgress, p); } - }, + } ); useAnimatedReaction( @@ -128,7 +136,7 @@ const Crossfade: FC = () => { scheduleOnRN(commitSwipeEnd, snapTo); swipeEndSnapTo.value = -1; } - }, + } ); useEffect(() => { @@ -140,8 +148,12 @@ const Crossfade: FC = () => { return; } - buffer1.current = await audioContext.current.decodeAudioData(TRACKS[0].uri); - buffer2.current = await audioContext.current.decodeAudioData(TRACKS[1].uri); + buffer1.current = await audioContext.current.decodeAudioData( + TRACKS[0].uri + ); + buffer2.current = await audioContext.current.decodeAudioData( + TRACKS[1].uri + ); setTrackDuration(buffer1.current.duration); setPlaybackPosition(0); setIsLoading(false); @@ -156,14 +168,21 @@ const Crossfade: FC = () => { }, []); const playAudio = useCallback(async () => { - if (!audioContext.current || !buffer1.current || !buffer2.current || isPlaying) { + if ( + !audioContext.current || + !buffer1.current || + !buffer2.current || + isPlaying + ) { return; } - AudioManager.setAudioSessionOptions({ - iosCategory: 'playback', - iosMode: 'default', - iosOptions: [], + AudioManager.setSystemOptions({ + ios: { + category: 'playback', + mode: 'default', + options: [], + }, }); await AudioManager.setAudioSessionActivity(true); @@ -188,12 +207,18 @@ const Crossfade: FC = () => { const now = audioContext.current.currentTime; const maxOffset = Math.max( 0, - Math.min(buffer1.current.duration, buffer2.current.duration) - 0.01, + Math.min(buffer1.current.duration, buffer2.current.duration) - 0.01 ); const startOffset = Math.min(Math.max(0, playbackPosition), maxOffset); - gainNode1.current.gain.setValueAtTime(visibleTrack === 1 ? MAX_GAIN : 0, now); - gainNode2.current.gain.setValueAtTime(visibleTrack !== 1 ? MAX_GAIN : 0, now); + gainNode1.current.gain.setValueAtTime( + visibleTrack === 1 ? MAX_GAIN : 0, + now + ); + gainNode2.current.gain.setValueAtTime( + visibleTrack !== 1 ? MAX_GAIN : 0, + now + ); progress.value = visibleTrack === 1 ? 0 : 1; sourceNode1.current.onpositionchanged = (event) => { @@ -249,7 +274,10 @@ const Crossfade: FC = () => { onUpdate: (event) => { const p = Math.max( 0, - Math.min(1, swipeStartProgress.value - event.translationX / ARTWORK_SIZE), + Math.min( + 1, + swipeStartProgress.value - event.translationX / ARTWORK_SIZE + ) ); progress.value = p; }, @@ -338,9 +366,7 @@ const Crossfade: FC = () => { {formatTime(playbackPosition)} - - {formatTime(trackDuration)} - + {formatTime(trackDuration)} diff --git a/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx b/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx index 82f7ec02c..8f4f86fc6 100644 --- a/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx +++ b/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx @@ -4,11 +4,19 @@ import { AudioBuffer, GainNode, AudioRecorder, - AudioManager + AudioManager, } from 'react-native-audio-api'; import { Container } from '../../components'; import { audioContext } from '../../singletons'; -import { ActivityIndicator, View, Button, StyleSheet, ScrollView, Dimensions, Alert } from 'react-native'; +import { + ActivityIndicator, + View, + Button, + StyleSheet, + ScrollView, + Dimensions, + Alert, +} from 'react-native'; import OverdrivePedal from './OverdrivePedal'; import ReverbPedal from './ReverbPedal'; import AutoWahPedal from './AutoWahPedal'; @@ -38,10 +46,12 @@ export default function PedalBoard() { useEffect(() => { const init = async () => { setIsLoading(true); - AudioManager.setAudioSessionOptions({ - iosCategory: 'playAndRecord', - iosMode: 'default', - }) + AudioManager.setSystemOptions({ + ios: { + category: 'playAndRecord', + mode: 'default', + }, + }); AudioManager.setAudioSessionActivity(true); try { @@ -95,8 +105,10 @@ export default function PedalBoard() { for (let i = 0; i < PEDALS.length - 1; i++) { pedalOutputNodesRef.current[i].connect(pedalInputNodesRef.current[i + 1]); } - pedalOutputNodesRef.current[PEDALS.length - 1].connect(audioContext.destination); - } + pedalOutputNodesRef.current[PEDALS.length - 1].connect( + audioContext.destination + ); + }; const togglePlayback = () => { if (isPlaying) { @@ -112,7 +124,7 @@ export default function PedalBoard() { sourceNodeRef.current.start(); setIsPlaying(true); } - } + }; const toggleRecording = async () => { if (isRecording) { @@ -139,10 +151,9 @@ export default function PedalBoard() { 'Insufficient permissions!', 'You need to grant audio recording permissions to use this feature.' ); - }; + } } - } - + }; return ( @@ -151,7 +162,7 @@ export default function PedalBoard() { ) : ( <> - {PEDALS.map((pedal, index) => { + {PEDALS.map((pedal, index) => { const PedalComponent = pedal.component; return ( @@ -165,18 +176,18 @@ export default function PedalBoard() { })} - -