Skip to content

Fix speakers staying muted after double-tap lock recording#221

Open
domain80 wants to merge 3 commits into
kitlangton:mainfrom
domain80:fix/double-tap-mute-restore
Open

Fix speakers staying muted after double-tap lock recording#221
domain80 wants to merge 3 commits into
kitlangton:mainfrom
domain80:fix/double-tap-mute-restore

Conversation

@domain80

@domain80 domain80 commented May 4, 2026

Copy link
Copy Markdown

Fixes #220

What was happening

When using double-tap lock with the "Mute" audio behavior, the speakers would stay muted after transcription ended. The bug is a race condition that plays out like this:

  • The first tap mutes the speakers and saves the original volume (e.g. 0.7)
  • The first release triggers an async stop with a short grace period (~100–300ms)
  • The second tap fires during that grace period, starting a new recording session
  • At this point the speakers are still at 0, so the new session snapshots 0 as the "original" volume — overwriting the real 0.7
  • The first session's stop detects a newer session is running and skips the restore entirely
  • When the user finally stops the lock recording, it tries to restore to 0 — a no-op — and the speakers stay muted

The fix

A single guard in the mute task inside startRecording(). Before snapshotting the current volume as the "original", it checks whether one has already been saved. If it has, that means a prior session muted the speakers and hasn't had a chance to restore them yet — so we leave that saved value alone. The real original volume survives through to the final restore and the speakers come back as expected.

Testing

  1. Set audio behavior to "Mute" in Settings
  2. Play audio so speakers are active
  3. Double-tap the hotkey quickly to enter lock mode
  4. Say something
  5. Tap hotkey once to stop
  6. Speakers should restore ✓

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where speakers could remain muted after a double-tap lock transcription session ends, ensuring system audio is restored reliably even when sessions overlap.
  • Chores

    • Included as a patch release entry to deliver the fix promptly.

When the audio behavior is set to "mute", using double-tap lock to
record caused the system volume to stay at 0 after transcription ended.
Speakers would remain silenced until the user manually adjusted volume.

Root cause — a race condition across three overlapping async operations:

1. First tap (press): startRecording() fires, mute task runs and saves
   previousVolume = 0.7, sets system volume to 0.

2. First tap (release): stopRecording() is called. The capture engine
   needs a grace period (100–300ms) before it can finalise the audio
   file, so it awaits Task.sleep. The actor suspends.

3. Second tap (press): Because the actor is suspended, startRecording()
   runs immediately for a new session. Its mute task calls
   muteSystemVolume(), but the system is already at 0 from step 1.
   It snapshots 0 as previousVolume, overwriting the real 0.7.

4. Second tap (release): HotKeyProcessor transitions to .doubleTapLock
   and emits nil — no action is sent to the feature, recording continues.

5. Step 2's grace period ends: shouldIgnoreStopRequest() detects a newer
   session is active and returns early via makeIgnoredStopURL() — skipping
   resumeMediaIfNeeded() entirely. previousVolume stays at 0.

6. User presses hotkey to stop lock: stopRecording() fires,
   resumeMediaIfNeeded() calls restoreSystemVolume(0) — a no-op.
   Speakers remain muted.

Fix — the mute task now skips saving if previousVolume is already set.
The new flow through the same steps:

1. First tap (press): mute task runs, previousVolume is nil so it saves
   0.7 and mutes the system to 0. Same as before.

2. First tap (release): stopRecording() enters its grace period. Same
   as before.

3. Second tap (press): new mute task runs, but previousVolume is already
   0.7 so it bails out early — the real level is preserved.

4. Second tap (release): .doubleTapLock entered, recording continues.
   previousVolume is still 0.7.

5. Step 2's grace period ends: returns early as before, skips restore.
   previousVolume is still 0.7.

6. User presses hotkey to stop lock: restoreSystemVolume(0.7) runs.
   Speakers come back.
@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a2b196d-a90d-49ca-ae8f-f51236862cb9

📥 Commits

Reviewing files that changed from the base of the PR and between 0160c89 and ad5ef07.

📒 Files selected for processing (1)
  • Hex/Clients/RecordingClient.swift

📝 Walkthrough

Walkthrough

Add a guard in RecordingClientLive.startRecording() for the .mute behavior to avoid overwriting a previously captured system volume during overlapping recordings, and add a changeset entry documenting a patch release for hex-app that fixes speakers remaining muted after double-tap lock transcription ends.

Changes

Audio Mute State Management Fix

Layer / File(s) Summary
Behavior Guard
Hex/Clients/RecordingClient.swift
In the .mute background media-control task, add guard self.previousVolume == nil else { /* log and return */ } to skip re-capturing/muting when previousVolume is already set, preventing overwrite from overlapping/double-start sessions.
Mute Action / Persist
Hex/Clients/RecordingClient.swift
When guard passes, continue to call muteSystemVolume() and setPreviousVolume(...) to save the muted session’s prior volume.
Release Notes
.changeset/9782fb11.md
Add a changeset entry for hex-app (patch) describing the fix for speakers staying muted after double-tap lock transcription ends (issue #220).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 I guarded the volume with one small hop,
Overlaps no longer make the speakers stop.
A tiny check, a gentle store,
Voices return — muted no more! 🎶

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix speakers staying muted after double-tap lock recording' clearly and concisely summarizes the main change: resolving the issue where speakers remain muted after double-tap lock recording with mute audio behavior.
Linked Issues check ✅ Passed The code changes address the core requirement from issue #220 by adding a guard in the mute task to prevent overwriting the original volume during overlapping sessions, ensuring speakers restore to the correct level.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the double-tap lock recording mute issue: the changeset entry documents the fix, and the RecordingClient modification implements the guard logic to preserve original volume across overlapping sessions.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
Hex/Clients/RecordingClient.swift (2)

1090-1102: ⚡ Quick win

Core fix is correct for the described double-tap race.

The guard self.previousVolume == nil else { return } guard correctly threads through the stop-grace-period race:

  • Session 1 mutes and saves previousVolume = 0.7 before its stopRecording() enters the grace period sleep.
  • Session 2 starts during that sleep, recordingSessionID is reassigned; the guard sees previousVolume is still 0.7 (non-nil) and bails, preventing 0 from being snapshotted as the "original" volume.
  • Session 1's stop detects the session mismatch and returns makeIgnoredStopURL() without calling resumeMediaIfNeeded(), so previousVolume is intentionally preserved.
  • Session 2's stop then correctly restores 0.7.

One minor observability note: the early-return path has no log line. Adding a recordingLogger.notice(...) entry (e.g., "Skipping mute – volume already saved from prior session") would make it much easier to confirm the fix fired during manual or automated testing.

📋 Suggested observability improvement
         guard self.previousVolume == nil else {
+            recordingLogger.notice("Skipping mute – previousVolume already captured from overlapping session; preserving original level")
             return
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Hex/Clients/RecordingClient.swift` around lines 1090 - 1102, Add an
observability log when the .mute branch bails out due to a prior session already
having saved volume: inside the mediaControlTask closure in the case .mute,
before the early return guarded by "guard self.previousVolume == nil else {
return }", call recordingLogger.notice(...) (include sessionID and a short
message like "Skipping mute – volume already saved from prior session") so test
logs show when the guard path is taken; keep the rest of the logic
(muteSystemVolume(), setPreviousVolume(sessionID:)) unchanged.

1254-1293: ⚖️ Poor tradeoff

Pre-existing edge case: clearMediaState() runs asynchronously, leaving a narrow window where a rapid new session can skip muting.

resumeMediaIfNeeded() captures previousVolume into a local but clears the actor property only inside an unstructured Task { } (Line 1291). If startRecording() for a new session is scheduled on the actor before that Task runs, the new session's mute guard (guard self.previousVolume == nil) sees the stale non-nil value and exits without muting — leaving the user recording at full volume.

This is pre-existing and not introduced by this PR, but the new guard makes the window observable in a way it wasn't before. One hardening option is to clear previousVolume (and the other state flags) synchronously inside resumeMediaIfNeeded() immediately after snapshotting the locals, before spawning the restore Task:

🔒 Hardening sketch (not required for this PR)
 private func resumeMediaIfNeeded() async {
   let playersToResume = pausedPlayers
   let shouldResumeMedia = didPauseMedia
   let shouldResumeViaMediaRemote = didPauseViaMediaRemote
   let volumeToRestore = previousVolume
+  // Clear state synchronously so any subsequent startRecording() call
+  // sees clean state immediately, regardless of Task scheduling order.
+  clearMediaState()

   if !playersToResume.isEmpty || shouldResumeMedia || shouldResumeViaMediaRemote || volumeToRestore != nil {
     Task {
       if let volume = volumeToRestore {
         await self.restoreSystemVolume(volume)
       }
       // ... resume logic ...
-      self.clearMediaState()
     }
   }
 }

⚠️ Note: this only works safely if restoreSystemVolume completing before a new session's mute Task is acceptable. If Session 3 starts and mutes to 0 after clearMediaState() but before the restore Task runs, the restore Task will unmute during Session 3's recording. Consider whether an additional session-ID check is needed inside the restore Task before this change is applied.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Hex/Clients/RecordingClient.swift` around lines 1254 - 1293, The
resumeMediaIfNeeded() function captures actor state into locals
(playersToResume, shouldResumeMedia, shouldResumeViaMediaRemote,
volumeToRestore) then spawns an unstructured Task and only clears actor state
via clearMediaState() inside that Task, leaving a race where a new
startRecording() can see stale previousVolume; fix by calling clearMediaState()
synchronously on the actor immediately after snapshotting those locals and
before creating the Task so the actor properties (previousVolume, pausedPlayers,
didPauseMedia, didPauseViaMediaRemote) are cleared deterministically; keep the
async restore logic (restoreSystemVolume, resumeMediaApplications,
mediaRemoteController.send(.play), sendMediaKey) inside the Task, and if needed
consider adding a session-id check inside the Task to avoid restoring for a
newer session.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/9782fb11.md:
- Line 5: The changeset .changeset/9782fb11.md is missing the required GitHub
issue/PR reference and user-facing impact text; update the description line "Fix
speakers staying muted after double-tap lock transcription ends" to the
project's required format by appending the issue or PR number and a concise
user-facing impact (e.g., "Fix speakers staying muted after double-tap lock
transcription ends (`#220`) — restores audio after transcription lock ends"),
ensuring the changeset content follows the pattern "User-facing summary
(#<issue>)".

---

Nitpick comments:
In `@Hex/Clients/RecordingClient.swift`:
- Around line 1090-1102: Add an observability log when the .mute branch bails
out due to a prior session already having saved volume: inside the
mediaControlTask closure in the case .mute, before the early return guarded by
"guard self.previousVolume == nil else { return }", call
recordingLogger.notice(...) (include sessionID and a short message like
"Skipping mute – volume already saved from prior session") so test logs show
when the guard path is taken; keep the rest of the logic (muteSystemVolume(),
setPreviousVolume(sessionID:)) unchanged.
- Around line 1254-1293: The resumeMediaIfNeeded() function captures actor state
into locals (playersToResume, shouldResumeMedia, shouldResumeViaMediaRemote,
volumeToRestore) then spawns an unstructured Task and only clears actor state
via clearMediaState() inside that Task, leaving a race where a new
startRecording() can see stale previousVolume; fix by calling clearMediaState()
synchronously on the actor immediately after snapshotting those locals and
before creating the Task so the actor properties (previousVolume, pausedPlayers,
didPauseMedia, didPauseViaMediaRemote) are cleared deterministically; keep the
async restore logic (restoreSystemVolume, resumeMediaApplications,
mediaRemoteController.send(.play), sendMediaKey) inside the Task, and if needed
consider adding a session-id check inside the Task to avoid restoring for a
newer session.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eb04942d-0423-4056-91e3-02cf24dd70e4

📥 Commits

Reviewing files that changed from the base of the PR and between f4764d5 and 569811a.

📒 Files selected for processing (2)
  • .changeset/9782fb11.md
  • Hex/Clients/RecordingClient.swift

Comment thread .changeset/9782fb11.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Hex/Clients/RecordingClient.swift`:
- Around line 1094-1098: Update the explanatory comment that explains preserving
previousVolume during overlapping recordings to include the issue reference in
the required format; locate the comment that mentions "If a prior session
already muted and hasn't been restored yet, don't overwrite previousVolume..."
(near the handling of previousVolume and the mute/restore logic) and append "
(`#220`)" to that comment so it reads with the GitHub issue reference.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 406b0842-4155-4d06-ab22-33350126d426

📥 Commits

Reviewing files that changed from the base of the PR and between 569811a and 0160c89.

📒 Files selected for processing (2)
  • .changeset/9782fb11.md
  • Hex/Clients/RecordingClient.swift
✅ Files skipped from review due to trivial changes (1)
  • .changeset/9782fb11.md

Comment thread Hex/Clients/RecordingClient.swift Outdated
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.

Speakers stay muted after double-tap lock recording (mute audio behavior)

1 participant