Skip to content
Merged
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/build-compilation-dependencies/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<format>` 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
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/turbo-modules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ interface Spec extends TurboModule {
install(): boolean; // synchronous — MUST run first
getDevicePreferredSampleRate(): number; // synchronous
setAudioSessionActivity(enabled: boolean): Promise<boolean>;
setAudioSessionOptions(...): void;
setSystemOptions(...): void;
observeAudioInterruptions(focusType, enabled): void;
requestRecordingPermissions(): Promise<PermissionStatus>;
// ... audio devices, notifications ...
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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. 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<I…>`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`).

### Native Module Entry Points
Expand Down
12 changes: 7 additions & 5 deletions apps/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
```
Expand Down
62 changes: 44 additions & 18 deletions apps/common-app/src/demos/Crossfade/Crossfade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -118,7 +126,7 @@ const Crossfade: FC = () => {
if (isPlayingShared.value === 1) {
scheduleOnRN(applyGainFromProgress, p);
}
},
}
);

useAnimatedReaction(
Expand All @@ -128,7 +136,7 @@ const Crossfade: FC = () => {
scheduleOnRN(commitSwipeEnd, snapTo);
swipeEndSnapTo.value = -1;
}
},
}
);

useEffect(() => {
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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) => {
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -338,9 +366,7 @@ const Crossfade: FC = () => {
<Text style={styles.timeText}>
{formatTime(playbackPosition)}
</Text>
<Text style={styles.timeText}>
{formatTime(trackDuration)}
</Text>
<Text style={styles.timeText}>{formatTime(trackDuration)}</Text>
</View>
</View>

Expand Down
57 changes: 34 additions & 23 deletions apps/common-app/src/demos/PedalBoard/PedalBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -112,7 +124,7 @@ export default function PedalBoard() {
sourceNodeRef.current.start();
setIsPlaying(true);
}
}
};

const toggleRecording = async () => {
if (isRecording) {
Expand All @@ -139,10 +151,9 @@ export default function PedalBoard() {
'Insufficient permissions!',
'You need to grant audio recording permissions to use this feature.'
);
};
}
}
}

};

return (
<Container centered>
Expand All @@ -151,7 +162,7 @@ export default function PedalBoard() {
) : (
<>
<ScrollView>
{PEDALS.map((pedal, index) => {
{PEDALS.map((pedal, index) => {
const PedalComponent = pedal.component;
return (
<View key={pedal.id} style={styles.container}>
Expand All @@ -165,18 +176,18 @@ export default function PedalBoard() {
})}
</ScrollView>
<View style={styles.controls}>
<View style= {{ display: isRecording ? 'none' : 'flex' }}>
<Button
title={isPlaying ? 'Stop' : 'Play'}
onPress={togglePlayback}
/>
<View style={{ display: isRecording ? 'none' : 'flex' }}>
<Button
title={isPlaying ? 'Stop' : 'Play'}
onPress={togglePlayback}
/>
</View>

<View style= {{ display: isPlaying ? 'none' : 'flex' }}>
<Button
title={isRecording ? 'Stop' : 'Record'}
onPress={toggleRecording}
/>
<View style={{ display: isPlaying ? 'none' : 'flex' }}>
<Button
title={isRecording ? 'Stop' : 'Record'}
onPress={toggleRecording}
/>
</View>
</View>
</>
Expand Down
10 changes: 6 additions & 4 deletions apps/common-app/src/demos/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,12 @@ const Record: FC = () => {
setHasPermissions(true);
}

AudioManager.setAudioSessionOptions({
iosCategory: 'playAndRecord',
iosMode: 'default',
iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'],
AudioManager.setSystemOptions({
ios: {
category: 'playAndRecord',
mode: 'default',
options: ['defaultToSpeaker', 'allowBluetoothA2DP'],
},
});

try {
Expand Down
20 changes: 9 additions & 11 deletions apps/common-app/src/examples/AudioFile/AudioFile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ const AudioFile: FC = () => {
setPositionPercentage(offset);
});

AudioManager.setAudioSessionOptions({
iosCategory: 'playback',
iosMode: 'default',
iosOptions: [],
AudioManager.setSystemOptions({
ios: {
category: 'playback',
mode: 'default',
options: [],
},
});

try {
Expand Down Expand Up @@ -87,7 +89,6 @@ const AudioFile: FC = () => {
await PlaybackNotificationManager.enableControl('pause', true);
await PlaybackNotificationManager.enableControl('play', true);
await PlaybackNotificationManager.enableControl('seekTo', true);

} catch (error) {
console.error('Failed to setup notification:', error);
}
Expand Down Expand Up @@ -178,12 +179,9 @@ const AudioFile: FC = () => {
}
);

const duckListener = AudioManager.addSystemEventListener(
'duck',
() => {
AudioPlayer.setVolume(0.2);
}
);
const duckListener = AudioManager.addSystemEventListener('duck', () => {
AudioPlayer.setVolume(0.2);
});

const seekToListener = PlaybackNotificationManager.addEventListener(
'playbackNotificationSeekTo',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ const INITIAL_GAINS = [0.3, 0.6, 0.1, 1.0, 0.7, 0.7];
const labelWidth = 56;

/**
* Graph:
* AudioBufferSourceNode (5.1) → ChannelSplitter(6)
* → per-channel GainNodes
* → ChannelMerger(2) stereo fold-down → destination
* Graph: AudioBufferSourceNode (5.1) → ChannelSplitter(6) → per-channel
* GainNodes → ChannelMerger(2) stereo fold-down → destination
*
* Fold-down: L/C/LFE/Ls → left, R/C/LFE/Rs → right
*/
Expand Down Expand Up @@ -64,10 +62,12 @@ const ChannelMergerSplitter: FC = () => {
return;
}

AudioManager.setAudioSessionOptions({
iosCategory: 'playback',
iosMode: 'default',
iosOptions: [],
AudioManager.setSystemOptions({
ios: {
category: 'playback',
mode: 'default',
options: [],
},
});
await AudioManager.setAudioSessionActivity(true);

Expand Down Expand Up @@ -178,7 +178,8 @@ const ChannelMergerSplitter: FC = () => {
<Container>
<ScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}>
showsVerticalScrollIndicator={false}
>
<Text style={styles.description}>
Plays a 5.1 surround test (L R C LFE Ls Rs) through
AudioBufferSourceNode → ChannelSplitter → per-channel gains →
Expand Down
Loading
Loading