Add configurable audio resumption delay for Bluetooth headphones

Fixes #459
This commit is contained in:
Beingpax 2025-12-31 16:23:13 +05:45
parent 3a2721e150
commit 8bfaf88f9b
7 changed files with 217 additions and 95 deletions

View File

@ -10,21 +10,35 @@ class MediaController: ObservableObject {
private var didMuteAudio = false
private var wasAudioMutedBeforeRecording = false
private var currentMuteTask: Task<Bool, Never>?
private var unmuteTask: Task<Void, Never>?
@Published var isSystemMuteEnabled: Bool = UserDefaults.standard.bool(forKey: "isSystemMuteEnabled") {
didSet {
UserDefaults.standard.set(isSystemMuteEnabled, forKey: "isSystemMuteEnabled")
}
}
@Published var audioResumptionDelay: Double = {
let value = UserDefaults.standard.double(forKey: "audioResumptionDelay")
return value < 1.0 ? 1.0 : value
}() {
didSet {
UserDefaults.standard.set(audioResumptionDelay, forKey: "audioResumptionDelay")
}
}
private init() {
// Set default if not already set
if !UserDefaults.standard.contains(key: "isSystemMuteEnabled") {
UserDefaults.standard.set(true, forKey: "isSystemMuteEnabled")
}
if !UserDefaults.standard.contains(key: "audioResumptionDelay") {
UserDefaults.standard.set(1.0, forKey: "audioResumptionDelay")
} else if audioResumptionDelay < 1.0 {
audioResumptionDelay = 1.0
}
}
/// Checks if the system audio is currently muted using AppleScript
private func isSystemAudioMuted() -> Bool {
let pipe = Pipe()
let task = Process()
@ -39,58 +53,58 @@ class MediaController: ObservableObject {
if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) {
return output == "true"
}
} catch {
// Silently fail
}
} catch { }
return false
}
/// Mutes system audio during recording
func muteSystemAudio() async -> Bool {
guard isSystemMuteEnabled else { return false }
// Cancel any existing mute task and create a new one
unmuteTask?.cancel()
unmuteTask = nil
currentMuteTask?.cancel()
let task = Task<Bool, Never> {
// First check if audio is already muted
wasAudioMutedBeforeRecording = isSystemAudioMuted()
// If already muted, no need to mute it again
if wasAudioMutedBeforeRecording {
return true
}
// Otherwise mute the audio
let success = executeAppleScript(command: "set volume with output muted")
didMuteAudio = success
return success
}
currentMuteTask = task
return await task.value
}
/// Restores system audio after recording
func unmuteSystemAudio() async {
guard isSystemMuteEnabled else { return }
// Wait for any pending mute operation to complete first
if let muteTask = currentMuteTask {
_ = await muteTask.value
}
// Only unmute if we actually muted it (and it wasn't already muted)
if didMuteAudio && !wasAudioMutedBeforeRecording {
let delay = audioResumptionDelay
let shouldUnmute = didMuteAudio && !wasAudioMutedBeforeRecording
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
if Task.isCancelled {
return
}
if shouldUnmute {
_ = executeAppleScript(command: "set volume without output muted")
}
didMuteAudio = false
currentMuteTask = nil
}
/// Executes an AppleScript command
private func executeAppleScript(command: String) -> Bool {
let task = Process()
task.launchPath = "/usr/bin/osascript"
@ -114,9 +128,14 @@ extension UserDefaults {
func contains(key: String) -> Bool {
return object(forKey: key) != nil
}
var isSystemMuteEnabled: Bool {
get { bool(forKey: "isSystemMuteEnabled") }
set { set(newValue, forKey: "isSystemMuteEnabled") }
}
var audioResumptionDelay: Double {
get { double(forKey: "audioResumptionDelay") }
set { set(newValue, forKey: "audioResumptionDelay") }
}
}

View File

@ -10,12 +10,12 @@ class PlaybackController: ObservableObject {
private var isMediaPlaying = false
private var lastKnownTrackInfo: TrackInfo?
private var originalMediaAppBundleId: String?
private var resumeTask: Task<Void, Never>?
@Published var isPauseMediaEnabled: Bool = UserDefaults.standard.bool(forKey: "isPauseMediaEnabled") {
didSet {
UserDefaults.standard.set(isPauseMediaEnabled, forKey: "isPauseMediaEnabled")
if isPauseMediaEnabled {
startMediaTracking()
} else {
@ -26,13 +26,13 @@ class PlaybackController: ObservableObject {
private init() {
mediaController = MediaRemoteAdapter.MediaController()
if !UserDefaults.standard.contains(key: "isPauseMediaEnabled") {
UserDefaults.standard.set(false, forKey: "isPauseMediaEnabled")
}
setupMediaControllerCallbacks()
if isPauseMediaEnabled {
startMediaTracking()
}
@ -60,53 +60,60 @@ class PlaybackController: ObservableObject {
}
func pauseMedia() async {
resumeTask?.cancel()
resumeTask = nil
wasPlayingWhenRecordingStarted = false
originalMediaAppBundleId = nil
guard isPauseMediaEnabled,
guard isPauseMediaEnabled,
isMediaPlaying,
lastKnownTrackInfo?.payload.isPlaying == true,
let bundleId = lastKnownTrackInfo?.payload.bundleIdentifier else {
return
}
wasPlayingWhenRecordingStarted = true
originalMediaAppBundleId = bundleId
// Add a small delay to ensure state is set before sending command
try? await Task.sleep(nanoseconds: 50_000_000)
try? await Task.sleep(nanoseconds: 50_000_000)
mediaController.pause()
}
func resumeMedia() async {
let shouldResume = wasPlayingWhenRecordingStarted
let originalBundleId = originalMediaAppBundleId
let delay = MediaController.shared.audioResumptionDelay
defer {
wasPlayingWhenRecordingStarted = false
originalMediaAppBundleId = nil
}
guard isPauseMediaEnabled,
shouldResume,
let bundleId = originalBundleId else {
return
}
guard isAppStillRunning(bundleId: bundleId) else {
return
}
guard let currentTrackInfo = lastKnownTrackInfo,
let currentBundleId = currentTrackInfo.payload.bundleIdentifier,
currentBundleId == bundleId,
currentTrackInfo.payload.isPlaying == false else {
return
}
try? await Task.sleep(nanoseconds: 50_000_000)
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
if Task.isCancelled {
return
}
mediaController.play()
}

View File

@ -15,6 +15,7 @@ class Recorder: NSObject, ObservableObject {
@Published var audioMeter = AudioMeter(averagePower: 0, peakPower: 0)
private var audioLevelCheckTask: Task<Void, Never>?
private var audioMeterUpdateTask: Task<Void, Never>?
private var audioRestorationTask: Task<Void, Never>?
private var hasDetectedAudioInCurrentSession = false
enum RecorderError: Error {
@ -95,6 +96,9 @@ class Recorder: NSObject, ObservableObject {
logger.info("✅ AudioEngineRecorder started successfully")
audioRestorationTask?.cancel()
audioRestorationTask = nil
Task { [weak self] in
guard let self = self else { return }
await self.playbackController.pauseMedia()
@ -146,9 +150,8 @@ class Recorder: NSObject, ObservableObject {
recorder = nil
audioMeter = AudioMeter(averagePower: 0, peakPower: 0)
Task {
audioRestorationTask = Task {
await mediaController.unmuteSystemAudio()
try? await Task.sleep(nanoseconds: 100_000_000)
await playbackController.resumeMedia()
}
deviceManager.isRecordingActive = false

View File

@ -23,6 +23,7 @@ struct GeneralSettings: Codable {
let isSoundFeedbackEnabled: Bool?
let isSystemMuteEnabled: Bool?
let isPauseMediaEnabled: Bool?
let audioResumptionDelay: Double?
let isTextFormattingEnabled: Bool?
let isExperimentalFeaturesEnabled: Bool?
let restoreClipboardAfterPaste: Bool?
@ -116,6 +117,7 @@ class ImportExportService {
isSoundFeedbackEnabled: soundManager.isEnabled,
isSystemMuteEnabled: mediaController.isSystemMuteEnabled,
isPauseMediaEnabled: playbackController.isPauseMediaEnabled,
audioResumptionDelay: mediaController.audioResumptionDelay,
isTextFormattingEnabled: UserDefaults.standard.object(forKey: keyIsTextFormattingEnabled) as? Bool ?? true,
isExperimentalFeaturesEnabled: UserDefaults.standard.bool(forKey: "isExperimentalFeaturesEnabled"),
restoreClipboardAfterPaste: UserDefaults.standard.bool(forKey: "restoreClipboardAfterPaste"),
@ -323,6 +325,9 @@ class ImportExportService {
if let pauseMedia = general.isPauseMediaEnabled {
playbackController.isPauseMediaEnabled = pauseMedia
}
if let audioDelay = general.audioResumptionDelay {
mediaController.audioResumptionDelay = audioDelay
}
if let experimentalEnabled = general.isExperimentalFeaturesEnabled {
UserDefaults.standard.set(experimentalEnabled, forKey: "isExperimentalFeaturesEnabled")
if experimentalEnabled == false {

View File

@ -0,0 +1,59 @@
import SwiftUI
struct ExpandableToggleSection<Content: View>: View {
let title: String
let helpText: String
@Binding var isEnabled: Bool
@Binding var isExpanded: Bool
let content: Content
init(
title: String,
helpText: String,
isEnabled: Binding<Bool>,
isExpanded: Binding<Bool>,
@ViewBuilder content: () -> Content
) {
self.title = title
self.helpText = helpText
self._isEnabled = isEnabled
self._isExpanded = isExpanded
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Toggle(isOn: $isEnabled) {
Text(title)
}
.toggleStyle(.switch)
.help(helpText)
if isEnabled {
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.animation(.easeInOut(duration: 0.2), value: isExpanded)
}
}
.contentShape(Rectangle())
.onTapGesture {
if isEnabled {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
}
}
if isEnabled && isExpanded {
content
.transition(.opacity.combined(with: .move(edge: .top)))
.padding(.top, 4)
}
}
}
}

View File

@ -3,6 +3,8 @@ import SwiftUI
struct ExperimentalFeaturesSection: View {
@AppStorage("isExperimentalFeaturesEnabled") private var isExperimentalFeaturesEnabled = false
@ObservedObject private var playbackController = PlaybackController.shared
@ObservedObject private var mediaController = MediaController.shared
@State private var isPauseMediaExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@ -36,13 +38,37 @@ struct ExperimentalFeaturesSection: View {
Divider()
.padding(.vertical, 4)
.transition(.opacity.combined(with: .move(edge: .top)))
Toggle(isOn: $playbackController.isPauseMediaEnabled) {
Text("Pause Media during recording")
ExpandableToggleSection(
title: "Pause Media during recording",
helpText: "Automatically pause active media playback during recordings and resume afterward.",
isEnabled: $playbackController.isPauseMediaEnabled,
isExpanded: $isPauseMediaExpanded
) {
HStack(spacing: 8) {
Text("Resumption Delay")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
Picker("", selection: $mediaController.audioResumptionDelay) {
Text("1s").tag(1.0)
Text("2s").tag(2.0)
Text("3s").tag(3.0)
Text("4s").tag(4.0)
Text("5s").tag(5.0)
}
.pickerStyle(.menu)
.frame(width: 80)
InfoTip(
title: "Audio Resumption Delay",
message: "Delay before resuming media playback after recording stops. Useful for Bluetooth headphones that need time to switch from microphone mode back to high-quality audio mode. Recommended: 2s for AirPods/Bluetooth headphones, 1s for wired headphones."
)
Spacer()
}
.padding(.leading, 16)
}
.toggleStyle(.switch)
.help("Automatically pause active media playback during recordings and resume afterward.")
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.easeInOut(duration: 0.3), value: isExperimentalFeaturesEnabled)

View File

@ -18,11 +18,12 @@ struct SettingsView: View {
@AppStorage("autoUpdateCheck") private var autoUpdateCheck = true
@AppStorage("enableAnnouncements") private var enableAnnouncements = true
@AppStorage("restoreClipboardAfterPaste") private var restoreClipboardAfterPaste = false
@AppStorage("clipboardRestoreDelay") private var clipboardRestoreDelay = 1.5
@AppStorage("clipboardRestoreDelay") private var clipboardRestoreDelay = 2.0
@State private var showResetOnboardingAlert = false
@State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder)
@State private var isCustomCancelEnabled = false
@State private var isCustomSoundsExpanded = false
@State private var isSystemMuteExpanded = false
var body: some View {
@ -220,44 +221,49 @@ struct SettingsView: View {
subtitle: "Customize app & system feedback"
) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Toggle(isOn: $soundManager.isEnabled) {
Text("Sound feedback")
}
.toggleStyle(.switch)
if soundManager.isEnabled {
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
.rotationEffect(.degrees(isCustomSoundsExpanded ? 90 : 0))
.animation(.easeInOut(duration: 0.2), value: isCustomSoundsExpanded)
}
}
.contentShape(Rectangle())
.onTapGesture {
if soundManager.isEnabled {
withAnimation(.easeInOut(duration: 0.2)) {
isCustomSoundsExpanded.toggle()
}
}
}
if soundManager.isEnabled && isCustomSoundsExpanded {
ExpandableToggleSection(
title: "Sound feedback",
helpText: "Play sounds when recording starts and stops",
isEnabled: $soundManager.isEnabled,
isExpanded: $isCustomSoundsExpanded
) {
CustomSoundSettingsView()
.transition(.opacity.combined(with: .move(edge: .top)))
.padding(.top, 4)
}
Divider()
Toggle(isOn: $mediaController.isSystemMuteEnabled) {
Text("Mute system audio during recording")
ExpandableToggleSection(
title: "Mute system audio during recording",
helpText: "Automatically mute system audio when recording starts and restore when recording stops",
isEnabled: $mediaController.isSystemMuteEnabled,
isExpanded: $isSystemMuteExpanded
) {
HStack(spacing: 8) {
Text("Resumption Delay")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
Picker("", selection: $mediaController.audioResumptionDelay) {
Text("1s").tag(1.0)
Text("2s").tag(2.0)
Text("3s").tag(3.0)
Text("4s").tag(4.0)
Text("5s").tag(5.0)
}
.pickerStyle(.menu)
.frame(width: 80)
InfoTip(
title: "Audio Resumption Delay",
message: "Delay before unmuting system audio after recording stops. Useful for Bluetooth headphones that need time to switch from microphone mode back to high-quality audio mode. Recommended: 2s for AirPods/Bluetooth headphones, 1s for wired headphones."
)
Spacer()
}
.padding(.leading, 16)
}
.toggleStyle(.switch)
.help("Automatically mute system audio when recording starts and restore when recording stops")
Divider()
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
@ -277,17 +283,14 @@ struct SettingsView: View {
.foregroundColor(.secondary)
Picker("", selection: $clipboardRestoreDelay) {
Text("0.5s").tag(0.5)
Text("1.0s").tag(1.0)
Text("1.5s").tag(1.5)
Text("2.0s").tag(2.0)
Text("2.5s").tag(2.5)
Text("3.0s").tag(3.0)
Text("4.0s").tag(4.0)
Text("5.0s").tag(5.0)
Text("1s").tag(1.0)
Text("2s").tag(2.0)
Text("3s").tag(3.0)
Text("4s").tag(4.0)
Text("5s").tag(5.0)
}
.pickerStyle(.menu)
.frame(width: 90)
.frame(width: 80)
Spacer()
}