Skip to content

Restore mic-capable audio session after in-recorder preview - #125

Merged
morepriyam merged 5 commits into
mainfrom
fix/preview-audio-session
Jul 27, 2026
Merged

Restore mic-capable audio session after in-recorder preview#125
morepriyam merged 5 commits into
mainfrom
fix/preview-audio-session

Conversation

@morepriyam

@morepriyam morepriyam commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Addresses the preview-induced silent-mic failure from #124 (the .playback session flip named in that issue's title). #124 also documents a second, separate failure mode — the micBlocked latch in use-call-state.ts staying 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's VideoManager.setAudioSession() forces the shared AVAudioSession to .playback / .moviePlayback whenever one of its players changes state. .playback has 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 on heldRef ("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:
    • the audio-focus effect keys on previewing, so closing the preview re-runs acquire() and restores .playAndRecord
    • record start awaits a session re-assert before the mic attaches (reacquireThen wrapping onToggle / onHoldStart) — cheap, idempotent insurance against any other session-stealer

Testing

  • tsc, eslint, full jest suite pass
  • Diagnosed on device: instrumented session reproduced silent clips after preview; per-segment ffprobe confirmed full-length silent audio tracks with identical encoder params

Repro to verify: record a clip → preview it → record again → second clip should now have audio.

Related upstream reports

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/app/recorder.tsx Outdated
@morepriyam
morepriyam requested a review from horner July 26, 2026 00:40
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.
Copilot AI review requested due to automatic review settings July 26, 2026 00:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in recorder.tsx via void ... (not awaited), so their async bodies can overlap. If release() is in-flight and an acquire() happens (e.g. focus/preview/mute toggles close together), the tail of release() can complete after acquire() and re-apply allowsRecording: 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,

Comment thread src/app/recorder.tsx
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.
Copilot AI review requested due to automatic review settings July 26, 2026 17:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread src/features/recorder/use-audio-focus.ts
Comment thread src/app/recorder.tsx Outdated
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.
Copilot AI review requested due to automatic review settings July 26, 2026 17:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 later acquire() because the heldRef check happens after await setIsAudioActiveAsync(false). If an acquire() starts while release() is awaiting, release() may deactivate the session and then return early when it notices heldRef is 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({

Comment thread src/app/recorder.tsx Outdated
…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.
Copilot AI review requested due to automatic review settings July 26, 2026 17:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • reacquireThen returns an async callback. useRecorderGestures triggers onToggle via runOnJS without awaiting, so rapid taps can start multiple overlapping toggles and make start/stop ordering depend on when the awaited acquireFocus() 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],
  );

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 checks heldRef before awaiting setIsAudioActiveAsync(true). If release() runs while that await is in flight, the native calls can complete out-of-order and leave audio active even though heldRef is now false (violating the "last requested state wins" intent described in the comment). Add a post-await guard that reverts to the released state when heldRef flipped 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 with acquire() while awaiting setIsAudioActiveAsync(false) / setAudioModeAsync(...). In particular, if acquire() re-flips heldRef to true during the await, release() currently returns without ensuring audio is active again, and if heldRef flips during the setAudioModeAsync await there’s no post-await guard to restore recording mode. Add after-await checks that (a) re-activate audio when heldRef became 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,

@morepriyam
morepriyam enabled auto-merge July 26, 2026 18:54
@morepriyam
morepriyam added this pull request to the merge queue Jul 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Jul 26, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@horner
horner added this pull request to the merge queue Jul 26, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Jul 26, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@morepriyam
morepriyam added this pull request to the merge queue Jul 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Jul 27, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@kadenhorner kadenhorner moved this from Ready to In Progress in Scrum Team Jerry Jul 27, 2026
@morepriyam
morepriyam added this pull request to the merge queue Jul 27, 2026
Merged via the queue into main with commit f278018 Jul 27, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Scrum Team Jerry Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants