Fix speakers staying muted after double-tap lock recording#221
Fix speakers staying muted after double-tap lock recording#221domain80 wants to merge 3 commits into
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdd a guard in ChangesAudio Mute State Management Fix
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
Hex/Clients/RecordingClient.swift (2)
1090-1102: ⚡ Quick winCore 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.7before itsstopRecording()enters the grace period sleep.- Session 2 starts during that sleep,
recordingSessionIDis reassigned; the guard seespreviousVolumeis still0.7(non-nil) and bails, preventing0from being snapshotted as the "original" volume.- Session 1's stop detects the session mismatch and returns
makeIgnoredStopURL()without callingresumeMediaIfNeeded(), sopreviousVolumeis 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 tradeoffPre-existing edge case:
clearMediaState()runs asynchronously, leaving a narrow window where a rapid new session can skip muting.
resumeMediaIfNeeded()capturespreviousVolumeinto a local but clears the actor property only inside an unstructuredTask { }(Line 1291). IfstartRecording()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 insideresumeMediaIfNeeded()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 ifrestoreSystemVolumecompleting before a new session's mute Task is acceptable. If Session 3 starts and mutes to0afterclearMediaState()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
📒 Files selected for processing (2)
.changeset/9782fb11.mdHex/Clients/RecordingClient.swift
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
.changeset/9782fb11.mdHex/Clients/RecordingClient.swift
✅ Files skipped from review due to trivial changes (1)
- .changeset/9782fb11.md
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 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
Summary by CodeRabbit
Bug Fixes
Chores