Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2c35596
fix: issue #1220 suggested onInterruptionEnd update
SomePersonFromMars Aug 20, 2026
0547b09
fix: minor tweaks to previous flawed patch
SomePersonFromMars Aug 24, 2026
b9fd96e
fix: add mixWithOthers to Recorder demo
SomePersonFromMars Aug 27, 2026
b93c5c1
fix: generalize handleInputConfigurationChange() to handle errors
SomePersonFromMars Aug 27, 2026
523e5a4
fix: retry AudioEngine resume from interrupt when back foreground
SomePersonFromMars Aug 27, 2026
1bfa489
fix: reopen a new rotating segment after capture loss instead of rota…
SomePersonFromMars Aug 27, 2026
6283fb7
fix: retry AudioEngine resume only happens when interruption ended wa…
SomePersonFromMars Aug 28, 2026
bc74d58
fix: always perform native recovery after interruption notification
SomePersonFromMars Aug 31, 2026
f12b250
feat: observeAudioInterruptions(true) in the Record demo
SomePersonFromMars Aug 31, 2026
86a9ae7
feat: document resuming recording after an interruption
SomePersonFromMars Sep 2, 2026
673d326
fix: detach source node in player on failed start similarly to what r…
SomePersonFromMars Sep 2, 2026
d648901
fix: don't poison state on a no-op deactivation
SomePersonFromMars Sep 3, 2026
33c3753
fix: recorder resume() only updates state on successful engine start
SomePersonFromMars Sep 3, 2026
bcadbb3
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Sep 17, 2026
32c2906
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Sep 17, 2026
6b17b0f
fix: workaround to mitigate infinite loop when starting recording
SomePersonFromMars Sep 18, 2026
d9268b3
fix: schedule rebuild on configuration change instead of doing it int…
SomePersonFromMars Sep 18, 2026
3249d2d
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Sep 22, 2026
41dbdd5
Merge branch 'main' into fix/ios-recording-interrupted and verified o…
SomePersonFromMars Sep 28, 2026
c4601b4
fix: explicitely expressed logic of playback and recording resume
SomePersonFromMars Sep 29, 2026
f5c49c4
fix: cache the intended `shouldResume` value for retry
SomePersonFromMars Sep 29, 2026
a6acc6b
fix: wording in apps/CLAUDE.md
SomePersonFromMars Sep 29, 2026
02f7a69
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Oct 1, 2026
3c491fc
fix: broken relative audiodocs links
SomePersonFromMars Oct 1, 2026
ba9e6b5
style: cleanups
SomePersonFromMars Oct 9, 2026
aa49c2b
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Oct 12, 2026
a623c95
chore: extract docs badges to docs-theme
SomePersonFromMars Oct 15, 2026
df1c080
docs: simplify audiodocs and add detailed library behavior and OS res…
SomePersonFromMars Oct 15, 2026
dba0ad8
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Oct 19, 2026
f2a4713
fix: remove path unnecessary after encoder and minor logic tweaks
SomePersonFromMars Oct 20, 2026
2adedef
style: extract engine resume policy; docs tweak
SomePersonFromMars Oct 20, 2026
ae1adc6
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Oct 22, 2026
65ac4d7
chore: update lockfile
SomePersonFromMars Oct 22, 2026
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/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ That flag is **not** what keeps the audio thread out of the pool being freed, an

Never hold a lock the worker takes while joining it: `finishCurrentFile()` drains and joins, and only then takes `fileMutex_` to retire the encoder — otherwise the join deadlocks against whatever the worker is doing under that lock.

**iOS lock order: engine lock before consumer mutexes.** `AudioEngine` invokes `onInputConfigurationChange` while holding `_engineLock` (interruption end, engine rebuild), and that handler takes the recorder's consumer mutexes (`fileWriterMutex_`, `callbackMutex_`, `adapterNodeMutex_`) one at a time. So no recorder method may call into the native recorder (`start`, `stop`, `detachInputNode`, anything that takes `_engineLock`) while holding a consumer mutex. `IOSAudioRecorder::stop()` therefore stores `Idle`, disarms and stops the native side first, and only then locks to detach the side effects; a handler that fires in between sees `Idle` and returns. The same gate is why `resume()` must store `Recording` *before* the native resume: the native side re-arms input only through that handler, and the handler arms only while the state reads `Recording` (issue #1294).
**iOS lock order: engine lock before consumer mutexes.** `AudioEngine` invokes the recorder's `onInputNotification` handler while holding `_engineLock` (interruption end, engine rebuild), and that handler takes the recorder's consumer mutexes (`fileWriterMutex_`, `callbackMutex_`, `adapterNodeMutex_`) one at a time. So no recorder method may call into the native recorder (`start`, `stop`, `detachInputNode`, anything that takes `_engineLock`) while holding a consumer mutex. `IOSAudioRecorder::stop()` therefore stores `Idle`, disarms and stops the native side first, and only then locks to detach the side effects; a handler that fires in between sees `Idle` and returns. The same gate is why `resume()` stores `Recording` before re-arming through `handleHardwareChange()`: the native resume does not re-arm input, and the handler arms only while the state reads `Recording` (issue #1294).

**Pattern — reacting to audio without blocking on it.** When a decision depends on data the audio thread produced but costs blocking work to make (rotating a recording once its file outgrows a cap), do not make it in `writeAudioData()`. Measuring a file can be a `stat`, and acting on it closes and opens an encoder — both forbidden on the audio thread. `AudioFileWriter` makes that decision **on its own worker thread**, in the task that just encoded a buffer (`rotateIfFileOutgrowsCap()`), and only when the file properties carry a non-zero `rotateIntervalBytes`, so a session that never rotates never pays for the `stat`. One mutex (`fileMutex_`) guards the open `RecordingFile` (encoder, path, and the frames it holds — a plain counter, since only the worker encodes and only under this lock) together with the session bookkeeping the rotation advances (file count, finished-file totals); the JS thread reads those under the same lock. `RecordingFile` itself is not thread-safe; rotation is swapping one for the next. The same lock guards the session's file list, which `closeFile()` hands back after joining the worker, so the recorder needs no lock or callback of its own to learn which files a rotation produced. Anything that reaches back out of the writer — the error event — is invoked **after** that lock is released, so a handler may call straight back in (`getFilePath()`) without re-entering a non-recursive mutex. The join-versus-lock ordering is the shutdown pitfall above: `finishCurrentFile()` stops the worker first, then takes the lock to retire the encoder.

Expand Down
6 changes: 6 additions & 0 deletions apps/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,10 @@ if (status !== 'Granted') return;

### Interruption handling

Enable emission with `AudioManager.observeAudioInterruptions(true)`, then listen.

**Playback:** native pause on `began`, but does not resume any audio contexts. The AudioFile example resumes on `ended` when it had been playing.

```tsx
useEffect(() => {
const sub = AudioManager.addSystemEventListener('interruption', (event) => {
Expand All @@ -292,6 +296,8 @@ useEffect(() => {
}, []);
```

**Recording:** native always resumes the engine. Do not `Recorder.pause()` on `began`. iOS has already stopped I/O; the engine is `Interrupted`, not `Paused`. Only `Interrupted` is retried on `ended` / foreground — `Recorder.pause()` would move the engine to `Paused` and disable that retry. JS only freezes UI that still looks live (the Record demo's scrolling waveform). Unfreeze only on `ended` (failed resume does not emit `ended`).

## Shared UI Components

All in `apps/common-app/src/components/`:
Expand Down
38 changes: 36 additions & 2 deletions apps/common-app/src/demos/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ const Record: FC = () => {
: RecordingState.Recording;
});
const [hasPermissions, setHasPermissions] = useState<boolean>(false);
const [isInterrupted, setIsInterrupted] = useState(false);
const [recordedBuffer, setRecordedBuffer] = useState<AudioBuffer | null>(
null
);
const currentPositionSV = useSharedValue(0);
const playbackSourceRef = useRef<AudioBufferSourceNode | null>(null);
const stateRef = useRef(state);
const { availableInputs, currentInput, selectInput } = useAudioInput();

const inputLabels = availableInputs.map(
Expand Down Expand Up @@ -123,7 +125,7 @@ const Record: FC = () => {
ios: {
category: 'playAndRecord',
mode: 'default',
options: ['defaultToSpeaker', 'allowBluetoothA2DP'],
options: ['defaultToSpeaker', 'allowBluetoothA2DP', 'mixWithOthers'],
},
});

Expand All @@ -141,6 +143,7 @@ const Record: FC = () => {
setupNotification(false);

if (result.status === 'success') {
setIsInterrupted(false);
setState(RecordingState.Recording);
return;
}
Expand All @@ -153,6 +156,7 @@ const Record: FC = () => {
const onPauseRecording = useCallback(() => {
Recorder.pause();
updateNotification(true);
setIsInterrupted(false);
setState(RecordingState.Paused);
}, []);

Expand Down Expand Up @@ -188,6 +192,7 @@ const Record: FC = () => {
const info = await Recorder.stop();
AudioRecorder.consumeLastRecordingResult();
RecordingNotificationManager.hide();
setIsInterrupted(false);
setState(RecordingState.Loading);

if (info.status !== 'success') {
Expand Down Expand Up @@ -299,6 +304,10 @@ const Record: FC = () => {
]
);

useEffect(() => {
stateRef.current = state;
}, [state]);

useEffect(() => {
(async () => {
const recordingPermissionStatus =
Expand All @@ -319,6 +328,31 @@ const Record: FC = () => {
})();
}, []);

useEffect(() => {
AudioManager.observeAudioInterruptions(true);

const interruptionSubscription = AudioManager.addSystemEventListener(
'interruption',
(event) => {
if (event.type === 'began') {
if (stateRef.current === RecordingState.Recording) {
setIsInterrupted(true);
}
return;
}

if (event.type === 'ended') {
setIsInterrupted(false);
}
}
);

return () => {
interruptionSubscription.remove();
AudioManager.observeAudioInterruptions(false);
};
}, []);

useEffect(() => {
Recorder.onError((error) => {
Alert.alert('Recorder Error', error.message);
Expand Down Expand Up @@ -414,7 +448,7 @@ const Record: FC = () => {
<>
<RecordingTime state={state} />
<View style={styles.spacerS} />
<RecordingVisualization state={state} />
<RecordingVisualization state={state} isInterrupted={isInterrupted} />
</>
)}
<View style={styles.spacerM} />
Expand Down
Loading
Loading