Restore mic-capable audio session after in-recorder preview - #125
Conversation
expo-video's VideoManager forces the shared AVAudioSession to .playback (no mic input) whenever its players change state, so clips recorded after previewing captured a silent audio track. acquire() short-circuited on 'already held' and never restored .playAndRecord. - useAudioFocus.acquire() now always re-applies the session config - closing the preview re-runs acquire - record start awaits a re-assert before the mic attaches Fixes #124
There was a problem hiding this comment.
Pull request overview
This PR addresses an iOS recording reliability issue where using the in-recorder preview (expo-video) could flip the shared AVAudioSession to a playback-only category, causing subsequent clips to record silent microphone audio until the session was rebuilt.
Changes:
- Updates
useAudioFocus.acquire()to always re-apply the recording-friendly audio session configuration (even when focus is already “held”). - Adjusts the recorder screen’s audio-focus effect to re-run acquisition when leaving preview mode.
- Adds a pre-capture “reacquire then record” wrapper to re-assert the recording session config immediately before starting capture.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/features/recorder/use-audio-focus.ts | Removes the “already held” short-circuit so acquire() can re-assert the audio session after expo-video alters it. |
| src/app/recorder.tsx | Re-keys audio-focus behavior around preview state and adds a pre-record reacquire wrapper to reduce silent-mic risk. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The early 'if (previewing) return' skipped BOTH acquire and release while a preview was open, so blurring the screen, muting, or a call becoming active during preview couldn't release focus until unmount — holding other apps paused / fighting telephony. Keep skipping acquire during preview (expo-video owns the session's category), but still run release whenever focus/mute/call/prefs say we shouldn't hold it.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/features/recorder/use-audio-focus.ts:37
acquire()/release()are invoked inrecorder.tsxviavoid ...(not awaited), so their async bodies can overlap. Ifrelease()is in-flight and anacquire()happens (e.g. focus/preview/mute toggles close together), the tail ofrelease()can complete afteracquire()and re-applyallowsRecording: false/mixWithOthers, leaving the session in a non-recording category and potentially reintroducing silent clips.
const acquire = useCallback(async () => {
heldRef.current = true;
try {
await setAudioModeAsync({
allowsRecording: true,
onHoldStart awaited audioFocus.acquire() before startHoldRecording(), while the gesture's release fires synchronously via runOnJS — a quick press-release could run endHoldRecording() first (holdInitiatedRef still false, nothing to stop) and then start recording AFTER the gesture ended, leaving an unintended in-progress recording. Each hold edge now bumps a sequence number and the delayed start is dropped when its hold is no longer the live one.
With acquire() no longer short-circuiting on 'already held', an acquire can overlap a release (effect-driven release on blur/call while a pre-record acquire is mid-await) and vice versa — a slow stale continuation could re-seize focus after a release, or a slow release could tear down a fresh acquire's session config. Both now re-check heldRef after each await and bail when the other call flipped it meanwhile, so the last requested state always wins.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/features/recorder/use-audio-focus.ts:59
release()can still clobber a lateracquire()because theheldRefcheck happens afterawait setIsAudioActiveAsync(false). If anacquire()starts whilerelease()is awaiting,release()may deactivate the session and then return early when it noticesheldRefis true, leaving the “held” state with audio inactive (and making the doc claim “LAST requested state wins” untrue). This needs a stronger serialization/token approach so stale async continuations can’t apply side effects after a newer request.
heldRef.current = false;
try {
await setIsAudioActiveAsync(false);
if (heldRef.current) return; // acquire() won while we awaited — leave its config alone
await setAudioModeAsync({
…he object useAudioFocus() returns a fresh object each render, and the focus effect depended on that object identity — so it re-ran (and, with acquire no longer short-circuiting, re-applied the session config via setAudioModeAsync/setIsAudioActiveAsync) on every render. Destructure the useCallback-stable acquire/release and depend on those instead, in the effect, reacquireThen, and onHoldStart.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/app/recorder.tsx:219
reacquireThenreturns anasynccallback.useRecorderGesturestriggersonToggleviarunOnJSwithout awaiting, so rapid taps can start multiple overlapping toggles and make start/stop ordering depend on when the awaitedacquireFocus()resolves. Serializing the reacquire+action work avoids overlapping toggles and keeps tap order deterministic.
const reacquireThen = useCallback(
(action: () => void) => async () => {
if (!muted && !callActive) await acquireFocus();
action();
},
[muted, callActive, acquireFocus],
);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/features/recorder/use-audio-focus.ts:48
acquire()only checksheldRefbefore awaitingsetIsAudioActiveAsync(true). Ifrelease()runs while that await is in flight, the native calls can complete out-of-order and leave audio active even thoughheldRefis now false (violating the "last requested state wins" intent described in the comment). Add a post-await guard that reverts to the released state whenheldRefflipped during the await.
interruptionMode: 'doNotMix',
});
if (!heldRef.current) return; // release() won while we awaited — don't re-seize focus
await setIsAudioActiveAsync(true);
} catch {
src/features/recorder/use-audio-focus.ts:60
release()can race withacquire()while awaitingsetIsAudioActiveAsync(false)/setAudioModeAsync(...). In particular, ifacquire()re-flipsheldRefto true during the await,release()currently returns without ensuring audio is active again, and ifheldRefflips during thesetAudioModeAsyncawait there’s no post-await guard to restore recording mode. Add after-await checks that (a) re-activate audio whenheldRefbecame true during deactivation, and (b) restore recording mode if it became true during the mode reset.
try {
await setIsAudioActiveAsync(false);
if (heldRef.current) return; // acquire() won while we awaited — leave its config alone
await setAudioModeAsync({
allowsRecording: false,
Addresses the preview-induced silent-mic failure from #124 (the
.playbacksession flip named in that issue's title). #124 also documents a second, separate failure mode — themicBlockedlatch inuse-call-state.tsstaying set after non-call interruptions — which this PR does NOT touch; the issue stays open to track that.Problem
Clips recorded after using the in-recorder preview captured a silent audio track. ffprobe/volumedetect on exported segments showed the dead clips contain a flat ~-66 to -70 dB noise floor for their full duration — the mic input delivered digital silence with no error surfaced.
Root cause
expo-video'sVideoManager.setAudioSession()forces the sharedAVAudioSessionto.playback/.moviePlaybackwhenever one of its players changes state..playbackhas no microphone input, and the preview player (use-preview.ts) lives on the recorder screen — so previewing clips mid-session silently flipped the session out of.playAndRecord. VisionCamera kept capturing without error.The app couldn't recover because
useAudioFocus.acquire()short-circuited onheldRef("already held"), so nothing ever re-applied the recording config until something rebuilt the session (mute toggle, camera flip, screen leave). Same failure mode as mrousavy/react-native-vision-camera#3560.Fix
use-audio-focus.ts:acquire()always re-applies the session config instead of returning early when focus is already held.recorder.tsx:previewing, so closing the preview re-runsacquire()and restores.playAndRecordreacquireThenwrappingonToggle/onHoldStart) — cheap, idempotent insurance against any other session-stealerTesting
tsc,eslint, full jest suite passRepro to verify: record a clip → preview it → record again → second clip should now have audio.
Related upstream reports
disableAudioSessionManagementin react-native-video — expo-video exposes no equivalent)