Skip to content

[Bug]: iPhone voice input says a transcription is already active after leaving a thread mid-dictation instead of cancelling it #13743

Description

@MatthewFeroz

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/mobile (shared logic in packages/client-runtime/src/voice-input)

Summary

On T3 Code for iPhone, if you leave a thread while voice input is still going and then try to dictate again, the app says a transcription or recording is already in progress. It should cancel the abandoned session and start a new one.

Steps to reproduce

  1. Open a thread in T3 Code on iPhone and tap the composer microphone.
  2. Speak, then stop so the recording goes to transcribing. Or navigate away while it is still preparing or recording.
  3. Before transcription finishes, scroll/navigate back to an older thread (another composer owner).
  4. Tap the microphone again.

Observed by the reporter during normal use. It has not yet been reproduced on a second device.

Expected behavior

Starting voice input again should cancel the abandoned session, which the user no longer cares about, and start a new recording right away. It should not block.

Actual behavior

The new attempt fails with an "already active/in progress" error. There are two candidate messages in the source:

  • Another voice recording is already active.: acquireSession() returns null.
  • Voice transcription is still finishing. Try again shortly.: runTranscriptionOperation() throws voice-operation-busy.

Impact

Minor bug or occasional failure. Dictation is blocked until the previous native transcription finishes on its own.

Version or commit

Installed iPhone app: 1.2.1 (build 80). The voice-input code is identical between the 1.2.1 version bump (76cc9b08f1) and main @ ed809f7ad2.

Environment

iPhone, iOS 26.6.1 (Darwin 25.6.0), on-device Apple transcription (@react-native-ai/apple). Connected to a Windows 10 T3 Code server.

Investigation: suspected cause (inferred from source, not instrumented)

controller.ts keeps two module-global locks shared by every VoiceInputController instance:

let activeSession: symbol | null = null;
let activeTranscriptionOperation: Promise<unknown> | null = null;

When the old composer's controller is cancelled (ownerChanged() → cancel(), or blur → dispose()) during preparing/transcribing, it only calls invalidateOperation() and sets idle. The locks are released later: releaseSession() runs in releaseResources(), which sits in the finally of start()/finishRecording(). That code only runs after the awaited native call settles.

voiceTranscription.ios.ts checks the AbortSignal only between awaits. AppleTranscription.prepare()/.transcribe() cannot be interrupted, so an abandoned transcription holds both locks until the native call returns. The UI shows the new composer as idle, but start() is still rejected.

Suggested fix (for a follow-up PR)

  1. Preempt instead of reject. Track the owning controller rather than an opaque symbol. In start(), if another controller holds the session, call its cancel()/dispose() and take ownership, instead of calling setError("Another voice recording is already active.").
  2. Release the session synchronously on cancel. cancel()/dispose()/discardRecording() should call releaseSession(this.sessionToken) immediately. File/audio cleanup can stay asynchronous.
  3. Serialize native calls instead of throwing. In runTranscriptionOperation, wait for (and ignore the result of) an abandoned in-flight operation before starting the next one, rather than throwing voice-operation-busy. The native module still sees one call at a time.
  4. Add controller tests: controller A in transcribing with a never-resolving transcribe, A cancelled, then controller B start() reaches recording. Also cover the same case from preparing.

Workaround

Wait for the abandoned transcription to finish (a few seconds), or background and reopen the app, then retry.

Activity

  1. juliusmarminge commented on Sep 26, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed on current main (ecd3237b18). This is a real iPhone dictation bug. Not already fixed. The session lock landed with offline voice input in #8614 and has not changed since. Installed 1.2.1 already has it. 88d0c40c85 is the 1.2.1 bump; 76cc9b08f1 is the later 1.3.0 bump, not 1.2.1. Nothing between 1.2.1 and main changes this lock. #12227 only keeps the screen awake, and #12534 only touches theme colors. Updating the app will not change this.

    No open issue covers it. #10140 describes the same error and is not the fix. That PR keeps the original recording alive across threads and still rejects a second one. This report is the opposite: the abandoned session should get out of the way.

    What you hit

    On iPhone, picking another thread pushes a new Thread screen. The one you left blurs, and useFocusEffect calls dispose(). The composer goes idle. The next microphone tap is a different VoiceInputController, but both share two module globals in packages/client-runtime/src/voice-input/controller.ts:

    let activeSession: symbol | null = null;
    let activeTranscriptionOperation: Promise<unknown> | null = null;
    
    function acquireSession(): symbol | null {
      if (activeSession) return null;
      // ...
    }
    
    async function runTranscriptionOperation<T>(operation: () => Promise<T>): Promise<T> {
      if (activeTranscriptionOperation) {
        throw new Error("voice-operation-busy");
      }
      // ...
    }

    cancel(), dispose(), and ownerChanged() only bump the operation token, abort the signal, and set idle. releaseSession() runs later, inside releaseResources(), from the finally of start() or finishRecording(). That finally waits until the native call returns.

    AppleTranscription.prepare() and .transcribe() cannot be interrupted. apps/mobile/src/native/voiceTranscription.ios.ts checks the signal only before and after those awaits. An abandoned prepare or transcribe holds activeSession until the native call settles. The new start() then fails here:

        const sessionToken = acquireSession();
        if (!sessionToken) {
          this.setError("Another voice recording is already active.", "retry");
          return;
        }

    That is the message this navigation path shows. "Voice transcription is still finishing" comes from voice-operation-busy, and this path does not reach it, because the session lock is still held for the whole native call. Dismissing the error only clears local state. It does not release the other controller’s lock.

    Leaving during recording is a short window: discardRecording() holds the lock until recorder.stop() and audio cleanup finish. The long block is preparing and transcribing. The lock does release when that call finishes, which matches waiting a few seconds. A permanent stuck state is not what this code does unless the native call never returns. Backgrounding during transcribing does not cancel it. appMovedToBackground() only handles preparing and recording.

    This rejection is the current contract, not an accident. controller.test.ts expects the next controller to see "already active" after cancel, dispose, and ownerChanged until a non-abortable transcription settles, and the same for a cancelled prepare. Changing the behavior means changing those tests.

    iPad split view is the same lock through ownerChanged() on one composer. Android does not use this transcriber.

    Direction

    Queue a start that follows an abandoned session. Do not show an error for a session the user already left. Keep one Apple call at a time. Do not overlap prepare or transcribe, and do not pretend the signal stops them.

    Releasing the session token immediately, while the old finally still runs releaseRecording(), is unsafe. That callback is process-wide (setAudioModeAsync({ allowsRecording: false }) and setIsAudioActiveAsync(false)). A late cleanup from the abandoned controller will tear down the new recording. The next start() has to wait until the abandoned controller has finished native work and audio cleanup, then proceed, showing Preparing rather than an error.

    If the owner is still actively preparing, recording, or transcribing and has not been cancelled or disposed, keep the single-session rejection.

    Constraints for a PR:

    • Only mobile voice input. Web does not use this controller.
    • An abandoned session must not leave the next microphone tap on "Another voice recording is already active."
    • A second native Apple call must not start until the abandoned one and its audio cleanup have finished.
    • A still-active, not-abandoned recording must still reject a second start.
    • Replace the two controller.test.ts cases that expect "already active" after cancel, dispose, owner change, and cancelled prepare. Add the case the report describes: controller A is in transcribing or preparing with a never-resolving native call, A is cancelled or disposed, then B start() reaches recording only after that call settles, with no error.
    • Do not take feat(mobile): keep voice recording active across apps and threads #10140’s background-audio or keep-recording behavior as part of this fix.

    Workaround. Wait until the abandoned transcription finishes, then tap the microphone again. Force-quitting the app clears the lock immediately because it lives in the JS heap. Backgrounding alone does not.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 26, 2026
  3. added 2 commits that reference this issue on Sep 27, 2026
    e145716
    57edbfb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions