Skip to content

iOS: stop pausing background music on text submit - #6290

Merged
lawrencecchen merged 2 commits into
mainfrom
feat-ios-dictation-no-audio-poke
Jun 17, 2026
Merged

lawrencecchen merged 2 commits into
mainfrom
feat-ios-dictation-no-audio-poke

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On iOS, submitting text in the terminal composer momentarily paused (then resumed) the user's background music (Spotify/Apple Music). The composer's send() was interacting with the microphone/audio session on every submit even when voice dictation was never used.

Root cause

send() (and field blur / terminal switch) call dictation.cancel() unconditionally. The send-path comment even claimed cancel() is "a no-op" when idle, but it isn't:

cancel() → teardown() → stopEngineAndSession(), which always:

  • accessed audioEngine.inputNode.removeTap(onBus: 0) — touching AVAudioEngine.inputNode powers up the mic input route, and
  • called AVAudioSession.setActive(false, options: .notifyOthersOnDeactivation) on the shared session.

Both poke the system audio session on every submit, which interrupts other apps' playback. Hence music pausing + resuming each time you hit send.

Fix

Track whether beginRecognition() actually activated the session (didActivateSession, set right after setActive(true) succeeds) and make stopEngineAndSession() a true no-op when we never activated it. A send with no dictation in flight now never touches the audio system. A session we did activate is torn down exactly as before, including the failed-start path (the flag is set before any post-setActive setup that could throw).

Principled fix: it removes the entire class of "teardown touches audio when nothing was started" rather than special-casing the send path. Only deactivate a session we own.

Test

ComposerDictationController is iOS-only (#if os(iOS), AVFoundation/Speech) and not host-compilable, so the existing host tests cover only the pure text-merger and state machine; there is no host seam for the AVFoundation teardown. A meaningful automated test would require a large audio-session injection seam, so this is verified by on-device dogfood: with background music playing, submit text repeatedly in the iOS composer and confirm the music keeps playing uninterrupted, and that voice dictation still works.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Low Risk
Small, localized change to iOS dictation teardown; behavior when dictation actually ran is unchanged, with lower risk of unintended audio side effects when idle.

Overview
Fixes background music briefly pausing on every composer send when voice dictation was never used.

send(), blur, and terminal switches call dictation.cancel(), which always reached stopEngineAndSession() and touched AVAudioEngine.inputNode plus AVAudioSession.setActive(false, .notifyOthersOnDeactivation) even in idle state. ComposerDictationController now tracks didActivateSession (set right after a successful setActive(true) in beginRecognition()) and stopEngineAndSession() returns immediately when that flag is false, so teardown only runs for a session this controller actually activated. Active dictation and failed-start cleanup still deactivate as before.

Reviewed by Cursor Bugbot for commit bd6ed10. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Stop pausing background music on iOS when sending text in the composer. We now only touch the AVAudioSession if voice dictation actually started.

  • Bug Fixes

    • Track didActivateSession after setActive(true) and gate stopEngineAndSession() to no-op unless we activated the session, so sends/blur/switch without dictation no longer interrupt other apps’ audio. Teardown is unchanged when dictation runs.
  • Refactors

    • Trimmed comments to keep ComposerDictationController.swift under the 500-line limit. No behavior change.

Written for commit bd6ed10. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Fixed an issue where the shared audio session could be incorrectly deactivated when dictation fails to start or voice input is disabled. Dictation now only stops and deactivates the session if it was successfully activated, improving audio stability for other features.

The composer's send() (and field-blur / terminal-switch) unconditionally call
dictation.cancel() -> teardown() -> stopEngineAndSession(), which accessed
audioEngine.inputNode.removeTap(...) and called
AVAudioSession.setActive(false, .notifyOthersOnDeactivation) every time, even
when voice dictation was never started. Touching the engine input node powers up
the mic input route and deactivating the shared session interrupts other apps'
audio, so the user's background music paused and resumed on every text submit.

Track whether beginRecognition() actually activated the session
(didActivateSession) and make stopEngineAndSession() a true no-op otherwise, so a
send with no dictation in flight never touches the audio system. A session we did
activate is still torn down exactly as before, including on the failed-start path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 17, 2026 6:51am
cmux-staging Building Building Preview, Comment Jun 17, 2026 6:51am

@coderabbitai

coderabbitai Bot commented Jun 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1808376e-246c-46dc-a33f-36df6d1d81c9

📥 Commits

Reviewing files that changed from the base of the PR and between 7a9fe95 and bd6ed10.

📒 Files selected for processing (1)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift

📝 Walkthrough

Walkthrough

ComposerDictationController gains a private didActivateSession boolean. beginRecognition() sets it to true immediately after AVAudioSession.setActive(true) succeeds. stopEngineAndSession() returns early unless the flag is true, then resets it before executing teardown.

Changes

Audio Session Ownership Guard

Layer / File(s) Summary
didActivateSession flag, set on activation, checked on teardown
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift
Declares a private didActivateSession boolean; sets it true after AVAudioSession.setActive(true) succeeds in beginRecognition(); short-circuits stopEngineAndSession() when false and resets it to false before any teardown proceeds.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~5 minutes

Possibly related issues

Possibly related PRs

  • manaflow-ai/cmux#6197: Modifies the same AVAudioSession start/stop lifecycle in ComposerDictationController.swift, making it directly adjacent to this PR's activation-ownership guard.
  • manaflow-ai/cmux#6238: Modifies dictation start and engine setup flow in beginRecognition() around microphone/tap/session activation, making the changes closely connected at the session-management code level.

Poem

🐇 A session once torn down too soon,
Left audio ghosts beneath the moon.
A flag was raised — "did I start this?"
No flag? No teardown — nothing's amiss.
The rabbit hops with tidy ears,
No phantom setActive false appears!

🚥 Pre-merge checks | ✅ 20 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description provides a comprehensive explanation including problem statement, root cause analysis, the fix approach, and testing verification. However, it does not follow the repository's required description template structure. Restructure the description to match the required template with sections for Summary (what/why), Testing, Demo Video, Review Trigger, and Checklist. Move existing content into appropriate sections.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: preventing background music from pausing on iOS when submitting text in the composer, which is the core issue being fixed.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed The PR adds a didActivateSession flag as a private Bool property within the @MainActor-isolated ComposerDictationController class, with all accesses properly contained on the main actor. No act...
Cmux Swift Blocking Runtime ✅ Passed The PR adds only a boolean flag didActivateSession and simple guard checks/assignments to gate audio teardown. No blocking primitives (semaphores, sleeps, delays, locks, polling, main-queue sync)...
Cmux Expensive Synchronous Load ✅ Passed PR adds only a boolean flag and early-return guard to prevent audio system interactions when dictation wasn't used; no expensive synchronous loaders (disk reads, syscalls, JSON decoding) are introd...
Cmux Cache Substitution Correctness ✅ Passed The didActivateSession flag is a transient, non-persisted state tracker set/reset within a single session, not a cache substitution of authoritative data. No cold/stale cache concerns apply.
Cmux No Hacky Sleeps ✅ Passed PR changes only Swift file (ComposerDictationController.swift); check applies only to TypeScript/JavaScript/shell/build scripts, explicitly excluding Swift.
Cmux Algorithmic Complexity ✅ Passed PR adds only a simple boolean flag and early-return guard in stopEngineAndSession(); no loops, collections, sorting, or algorithmic operations that could violate complexity rules.
Cmux Swift Concurrency ✅ Passed This PR adds only a single boolean flag (didActivateSession) to track audio session activation and does not introduce or materially expand any legacy async patterns: no DispatchQueue, Combine, comp...
Cmux Swift @Concurrent ✅ Passed PR only adds a boolean flag and synchronous guard in @MainActor methods; no async methods, @concurrent annotations, or actor-isolation changes introduced.
Cmux Swift File And Package Boundaries ✅ Passed ComposerDictationController.swift (499→512 lines) is an existing iOS-specific dictation controller with clear single responsibility in appropriate SwiftPM location. +13-line focused bug fix adds di...
Cmux Swift Logging ✅ Passed No logging violations found. The PR only adds state tracking (didActivateSession flag) with no print, debugPrint, dump, NSLog, or ad-hoc logging statements.
Cmux User-Facing Error Privacy ✅ Passed No user-facing error messages, alerts, or sensitive data exposure. All changes are internal implementation details (didActivateSession flag, guard in stopEngineAndSession()) with no public API chan...
Cmux Full Internationalization ✅ Passed PR modifies only internal implementation: adds didActivateSession flag and developer comments to fix audio session handling. No user-facing text, localization catalogs, or Info.plist entries added.
Cmux Swiftui State Layout ✅ Passed ComposerDictationController already uses @Observable (modern pattern); new didActivateSession is a private Bool for audio state tracking, not a SwiftUI view pattern violation.
Cmux Architecture Rethink ✅ Passed This fix is a small, local correctness fix with clear owner (ComposerDictationController), explicit invariant ("only deactivate sessions we activate"), and no architectural violations. No timing, l...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed ComposerDictationController is an audio session management class with no NSWindow, NSPanel, NSWindowController, SwiftUI Window, or WindowGroup code. Changes are purely internal logic to track audio...
Cmux Source Artifacts ✅ Passed Changed file is hand-written Swift source code (ComposerDictationController.swift) in Sources/ directory, an intentional product file with no artifacts or generated output added.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-dictation-no-audio-poke

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a background-music interruption bug on iOS where submitting text in the terminal composer momentarily paused Spotify/Apple Music. The root cause was stopEngineAndSession() always accessing AVAudioEngine.inputNode and calling AVAudioSession.setActive(false, .notifyOthersOnDeactivation) even when voice dictation was never started.

  • Adds a didActivateSession boolean, set exactly once — immediately after AVAudioSession.setActive(true) succeeds in beginRecognition() — and cleared inside stopEngineAndSession() once teardown runs.
  • Gates stopEngineAndSession() with guard didActivateSession else { return }, making it a true no-op when no session was ever acquired; normal dictation teardown behavior is fully preserved on the active path.

Confidence Score: 5/5

Safe to merge — the change is tightly scoped to a single, well-understood resource-ownership invariant and does not alter any active-dictation code path.

The flag accurately models 'this controller owns the AVAudioSession': it is set only after setActive(true) returns successfully, cleared on the first teardown call, and all failure paths (category-set throw, format guard, engine-start throw) produce consistent flag state. The main-actor isolation of the entire class means no concurrent mutation of the flag is possible. The idle-send path (the bug) is now a guaranteed no-op without any audio-system side effects, and every scenario where dictation was actually started still runs the full teardown as before.

No files require special attention.

Important Files Changed

Filename Overview
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ComposerDictationController.swift Adds didActivateSession flag set only after AVAudioSession.setActive(true) succeeds; gates stopEngineAndSession() to skip all audio-system access when the session was never activated, fixing background-music interruption on every composer send.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["send / blur / cancel"] --> B["cancel()"]
    B --> C["teardown()"]
    C --> D["stopEngineAndSession()"]
    D --> E{"didActivateSession?"}
    E -->|No| F["return early - no audio system access"]
    E -->|Yes| G["didActivateSession = false"]
    G --> H["audioEngine.stop()"]
    H --> I["inputNode.removeTap"]
    I --> J["AVAudioSession.setActive false"]

    K["beginRecognition()"] --> L["setCategory + setActive true"]
    L --> M["didActivateSession = true"]
    M --> N["installTap + engine.start"]
    N --> O["state = .listening"]
    O --> P["stop() or cancel() later"]
    P --> D
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["send / blur / cancel"] --> B["cancel()"]
    B --> C["teardown()"]
    C --> D["stopEngineAndSession()"]
    D --> E{"didActivateSession?"}
    E -->|No| F["return early - no audio system access"]
    E -->|Yes| G["didActivateSession = false"]
    G --> H["audioEngine.stop()"]
    H --> I["inputNode.removeTap"]
    I --> J["AVAudioSession.setActive false"]

    K["beginRecognition()"] --> L["setCategory + setActive true"]
    L --> M["didActivateSession = true"]
    M --> N["installTap + engine.start"]
    N --> O["state = .listening"]
    O --> P["stop() or cancel() later"]
    P --> D
Loading

Reviews (2): Last reviewed commit: "Trim comments to stay under the 500-line..." | Re-trigger Greptile

Condense the didActivateSession doc/comments added in the prior commit so
ComposerDictationController.swift stays under the workflow-guard-tests 500-line
threshold (499 lines). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen
lawrencecchen merged commit b4ac9be into main Jun 17, 2026
33 of 36 checks passed
@lawrencecchen
lawrencecchen deleted the feat-ios-dictation-no-audio-poke branch June 17, 2026 07:44
@lawrencecchen
lawrencecchen restored the feat-ios-dictation-no-audio-poke branch July 18, 2026 10:24

This branch was successfully deployed

1 active deployment
Preview – cmux — bd6ed10a Deployed Jun 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant