Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/03f45a3f.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hex-app": patch
---

Defer media pause and volume mute until recording hold threshold (#239)
158 changes: 113 additions & 45 deletions Hex/Clients/RecordingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ actor RecordingClientLive {
private var deferredCaptureRestartReason: String?
private var environmentChangeDebounceTask: Task<Void, Never>?
private var mediaControlTask: Task<Void, Never>?
private var resumeMediaTask: Task<Void, Never>?
private static let resumeMediaDebounce: Duration = .milliseconds(200)
private let recorderSettings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatLinearPCM),
AVSampleRateKey: 16000.0,
Expand Down Expand Up @@ -377,6 +379,22 @@ actor RecordingClientLive {
/// Tracks previous system volume when muted for recording
private var previousVolume: Float?

private var hasActiveMediaControlState: Bool {
didPauseMedia || didPauseViaMediaRemote || previousVolume != nil || !pausedPlayers.isEmpty
}

private var isCaptureActive: Bool {
activeRecordingSession != nil || captureController.isRecording || recorder?.isRecording == true
}

/// Wait until a recording is likely intentional before pausing media or muting output.
private func mediaControlActivationDelay() -> TimeInterval {
if hexSettings.hotkey.key == nil {
return max(hexSettings.minimumKeyTime, RecordingDecisionEngine.modifierOnlyMinimumDuration)
}
return max(hexSettings.minimumKeyTime, 0.1)
}

/// Gets all available input devices on the system
func getAvailableInputDevices() async -> [AudioInputDevice] {
// Get all available audio devices
Expand Down Expand Up @@ -1031,60 +1049,50 @@ actor RecordingClientLive {
}

func startRecording() async {
resumeMediaTask?.cancel()
resumeMediaTask = nil

let sessionID = UUID()
recordingSessionID = sessionID
mediaControlTask?.cancel()
mediaControlTask = nil

// Handle audio behavior based on user preference
switch hexSettings.recordingAudioBehavior {
case .pauseMedia:
// Pause media in background - don't block recording from starting
mediaControlTask = Task { [sessionID] in
guard await self.isCurrentSession(sessionID) else { return }
if await self.pauseUsingMediaRemoteIfPossible(sessionID: sessionID) {
if isCaptureActive {
recordingLogger.notice("Waiting for prior capture to finish before starting new recording")
let deadline = Date().addingTimeInterval(
captureController.stopTimingEstimate.gracePeriod + 0.05
)
while isCaptureActive, Date() < deadline {
try? await Task.sleep(for: .milliseconds(5))
guard isCurrentSession(sessionID) else {
recordingLogger.notice("New recording session superseded while waiting for prior capture")
return
}
}
if isCaptureActive {
recordingLogger.notice("Prior capture still active; cannot start new recording")
return
}
}

// First, pause all media applications using their AppleScript interface.
let paused = await pauseAllMediaApplications()
guard await self.isCurrentSession(sessionID) else {
await resumeMediaApplications(paused)
return
}
await self.updatePausedPlayers(paused, sessionID: sessionID)

// If no specific players were paused, pause generic media using the media key.
guard await self.isCurrentSession(sessionID) else { return }
if paused.isEmpty {
if await isAudioPlayingOnDefaultOutput() {
guard await self.isCurrentSession(sessionID) else { return }
mediaLogger.notice("Detected active audio on default output; sending media pause")
await MainActor.run {
sendMediaKey()
}
await self.setDidPauseMedia(true, sessionID: sessionID)
mediaLogger.notice("Paused media via media key fallback")
}
} else {
mediaLogger.notice("Paused media players: \(paused.joined(separator: ", "))")
}
if hasActiveMediaControlState {
await resumeMediaImmediately()
}

// Defer pause/mute until the recording survives the minimum hold threshold.
// This avoids pause/play and volume flicker on accidental or fluttering hotkey presses.
switch hexSettings.recordingAudioBehavior {
case .pauseMedia:
scheduleMediaControl(sessionID: sessionID) { [sessionID] in
await self.applyPauseMedia(sessionID: sessionID)
}

case .mute:
// Mute system volume in background
mediaControlTask = Task { [sessionID] in
guard await self.isCurrentSession(sessionID) else { return }
let volume = await self.muteSystemVolume()
guard await self.isCurrentSession(sessionID) else {
await self.restoreSystemVolume(volume)
return
}
await self.setPreviousVolume(volume, sessionID: sessionID)
scheduleMediaControl(sessionID: sessionID) { [sessionID] in
await self.applyMute(sessionID: sessionID)
}

case .doNothing:
// No audio handling
break
}

Expand Down Expand Up @@ -1181,7 +1189,7 @@ actor RecordingClientLive {
}

await flushDeferredCaptureRestartIfNeeded()
await resumeMediaIfNeeded()
scheduleResumeMediaIfNeeded()
return captureURL
}

Expand All @@ -1200,7 +1208,7 @@ actor RecordingClientLive {
clearActiveRecordingMetadata()
lastRecordingEndedAt = stoppedAt
await flushDeferredCaptureRestartIfNeeded()
await resumeMediaIfNeeded()
scheduleResumeMediaIfNeeded()
return makeIgnoredStopURL()
}
recorder?.stop()
Expand All @@ -1224,12 +1232,72 @@ actor RecordingClientLive {
}

await flushDeferredCaptureRestartIfNeeded()
await resumeMediaIfNeeded()
scheduleResumeMediaIfNeeded()

return exportedURL
}

private func resumeMediaIfNeeded() async {
private func scheduleMediaControl(sessionID: UUID, action: @escaping @Sendable () async -> Void) {
let activationDelay = mediaControlActivationDelay()
mediaControlTask = Task {
if activationDelay > 0 {
try? await Task.sleep(for: .seconds(activationDelay))
}
guard await self.isCurrentSession(sessionID), !Task.isCancelled else { return }
await action()
}
}

private func applyPauseMedia(sessionID: UUID) async {
guard isCurrentSession(sessionID) else { return }
if await pauseUsingMediaRemoteIfPossible(sessionID: sessionID) {
return
}

let paused = await pauseAllMediaApplications()
guard isCurrentSession(sessionID) else {
await resumeMediaApplications(paused)
return
}
updatePausedPlayers(paused, sessionID: sessionID)

guard isCurrentSession(sessionID) else { return }
if paused.isEmpty {
if await isAudioPlayingOnDefaultOutput() {
guard isCurrentSession(sessionID) else { return }
mediaLogger.notice("Detected active audio on default output; sending media pause")
await MainActor.run {
sendMediaKey()
}
setDidPauseMedia(true, sessionID: sessionID)
mediaLogger.notice("Paused media via media key fallback")
}
} else {
mediaLogger.notice("Paused media players: \(paused.joined(separator: ", "))")
}
}

private func applyMute(sessionID: UUID) async {
guard isCurrentSession(sessionID) else { return }
let volume = await muteSystemVolume()
guard isCurrentSession(sessionID) else {
await restoreSystemVolume(volume)
return
}
setPreviousVolume(volume, sessionID: sessionID)
}

private func scheduleResumeMediaIfNeeded() {
guard hasActiveMediaControlState else { return }
resumeMediaTask?.cancel()
resumeMediaTask = Task {
try? await Task.sleep(for: Self.resumeMediaDebounce)
guard !Task.isCancelled else { return }
await self.resumeMediaImmediately()
}
}

private func resumeMediaImmediately() async {
let playersToResume = pausedPlayers
let shouldResumeMedia = didPauseMedia
let shouldResumeViaMediaRemote = didPauseViaMediaRemote
Expand Down Expand Up @@ -1446,7 +1514,7 @@ actor RecordingClientLive {
/// Release recorder resources. Call on app termination.
func cleanup() async {
endRecordingSession()
await resumeMediaIfNeeded()
await resumeMediaImmediately()
stopObservingSystemChanges()
stopCaptureController(reason: "cleanup")
releaseRecorder(reason: "cleanup")
Expand Down
1 change: 1 addition & 0 deletions Hex/Features/Transcription/TranscriptionFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ private extension TranscriptionFeature {

private extension TranscriptionFeature {
func handleStartRecording(_ state: inout State) -> Effect<Action> {
guard !state.isRecording, !state.isTranscribing else { return .none }
guard state.modelBootstrapState.isModelReady else {
return .merge(
.send(.modelMissing),
Expand Down