Merge pull request #460 from Beingpax/fix/bluetooth-audio-delay

Fix/bluetooth audio delay
This commit is contained in:
Prakash Joshi Pax 2026-01-01 15:21:58 +05:45 committed by GitHub
commit 06496f6d5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 315 additions and 182 deletions

View File

@ -10,21 +10,30 @@ 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 = UserDefaults.standard.double(forKey: "audioResumptionDelay") {
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(0.0, forKey: "audioResumptionDelay")
}
}
/// Checks if the system audio is currently muted using AppleScript
private func isSystemAudioMuted() -> Bool {
let pipe = Pipe()
let task = Process()
@ -39,58 +48,63 @@ 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 {
_ = executeAppleScript(command: "set volume without output muted")
let delay = audioResumptionDelay
let shouldUnmute = didMuteAudio && !wasAudioMutedBeforeRecording
let task = Task {
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
}
didMuteAudio = false
currentMuteTask = nil
unmuteTask = task
await task.value
}
/// 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,54 +60,66 @@ 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)
mediaController.play()
let task = Task {
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
if Task.isCancelled {
return
}
mediaController.play()
}
resumeTask = task
await task.value
}
private func isAppStillRunning(bundleId: String) -> Bool {

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
@ -210,6 +213,7 @@ class Recorder: NSObject, ObservableObject {
deinit {
audioLevelCheckTask?.cancel()
audioMeterUpdateTask?.cancel()
audioRestorationTask?.cancel()
if let observer = deviceObserver {
NotificationCenter.default.removeObserver(observer)
}

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,88 @@
import SwiftUI
enum ExpandableSection: Hashable {
case soundFeedback
case systemMute
case pauseMedia
case clipboardRestore
case customCancel
case middleClick
}
struct ExpandableToggleSection<Content: View>: View {
let section: ExpandableSection
let title: String
let helpText: String
@Binding var isEnabled: Bool
@Binding var expandedSections: Set<ExpandableSection>
let content: Content
init(
section: ExpandableSection,
title: String,
helpText: String,
isEnabled: Binding<Bool>,
expandedSections: Binding<Set<ExpandableSection>>,
@ViewBuilder content: () -> Content
) {
self.section = section
self.title = title
self.helpText = helpText
self._isEnabled = isEnabled
self._expandedSections = expandedSections
self.content = content()
}
private var isExpanded: Bool {
expandedSections.contains(section)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Toggle(isOn: $isEnabled) {
Text(title)
}
.toggleStyle(.switch)
.help(helpText)
.onChange(of: isEnabled) { _, newValue in
withAnimation(.easeInOut(duration: 0.2)) {
if newValue {
_ = expandedSections.insert(section)
} else {
expandedSections.remove(section)
}
}
}
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)) {
if isExpanded {
expandedSections.remove(section)
} else {
_ = expandedSections.insert(section)
}
}
}
}
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 expandedSections: Set<ExpandableSection> = []
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@ -36,13 +38,38 @@ struct ExperimentalFeaturesSection: View {
Divider()
.padding(.vertical, 4)
.transition(.opacity.combined(with: .move(edge: .top)))
Toggle(isOn: $playbackController.isPauseMediaEnabled) {
Text("Pause Media during recording")
ExpandableToggleSection(
section: .pauseMedia,
title: "Pause Media during recording",
helpText: "Automatically pause active media playback during recordings and resume afterward.",
isEnabled: $playbackController.isPauseMediaEnabled,
expandedSections: $expandedSections
) {
HStack(spacing: 8) {
Text("Resume Delay")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
Picker("", selection: $mediaController.audioResumptionDelay) {
Text("0s").tag(0.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: 80)
InfoTip(
title: "Audio Resume 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, 0s for wired headphones."
)
Spacer()
}
}
.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,11 @@ 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 expandedSections: Set<ExpandableSection> = []
var body: some View {
@ -134,81 +134,62 @@ struct SettingsView: View {
Divider()
// Custom Cancel Shortcut
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Toggle(isOn: $isCustomCancelEnabled.animation()) {
Text("Custom Cancel Shortcut")
}
.toggleStyle(.switch)
.onChange(of: isCustomCancelEnabled) { _, newValue in
if !newValue {
KeyboardShortcuts.setShortcut(nil, for: .cancelRecorder)
}
}
InfoTip(
title: "Dismiss Recording",
message: "Shortcut for cancelling the current recording session. Default: double-tap Escape."
)
ExpandableToggleSection(
section: .customCancel,
title: "Custom Cancel Shortcut",
helpText: "Shortcut for cancelling the current recording session. Default: double-tap Escape.",
isEnabled: $isCustomCancelEnabled,
expandedSections: $expandedSections
) {
HStack(spacing: 12) {
Text("Cancel Shortcut")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
KeyboardShortcuts.Recorder(for: .cancelRecorder)
.controlSize(.small)
Spacer()
}
if isCustomCancelEnabled {
HStack(spacing: 12) {
Text("Cancel Shortcut")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
KeyboardShortcuts.Recorder(for: .cancelRecorder)
.controlSize(.small)
Spacer()
}
.padding(.leading, 16)
.transition(.opacity.combined(with: .move(edge: .top)))
}
.onChange(of: isCustomCancelEnabled) { _, newValue in
if !newValue {
KeyboardShortcuts.setShortcut(nil, for: .cancelRecorder)
}
}
Divider()
// Middle-Click Toggle
VStack(alignment: .leading, spacing: 12) {
ExpandableToggleSection(
section: .middleClick,
title: "Enable Middle-Click Toggle",
helpText: "Use middle mouse button to toggle VoiceInk recording.",
isEnabled: $hotkeyManager.isMiddleClickToggleEnabled,
expandedSections: $expandedSections
) {
HStack(spacing: 8) {
Toggle("Enable Middle-Click Toggle", isOn: $hotkeyManager.isMiddleClickToggleEnabled)
.toggleStyle(.switch)
Text("Activation Delay")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
InfoTip(
title: "Middle-Click Toggle",
message: "Use middle mouse button to toggle VoiceInk recording."
)
}
TextField("", value: $hotkeyManager.middleClickActivationDelay, formatter: {
let formatter = NumberFormatter()
formatter.numberStyle = .none
formatter.minimum = 0
return formatter
}())
.textFieldStyle(PlainTextFieldStyle())
.padding(EdgeInsets(top: 3, leading: 6, bottom: 3, trailing: 6))
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(5)
.frame(width: 70)
if hotkeyManager.isMiddleClickToggleEnabled {
HStack(spacing: 8) {
Text("Activation Delay")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
Text("ms")
.foregroundColor(.secondary)
TextField("", value: $hotkeyManager.middleClickActivationDelay, formatter: {
let formatter = NumberFormatter()
formatter.numberStyle = .none
formatter.minimum = 0
return formatter
}())
.textFieldStyle(PlainTextFieldStyle())
.padding(EdgeInsets(top: 3, leading: 6, bottom: 3, trailing: 6))
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(5)
.frame(width: 70)
Text("ms")
.foregroundColor(.secondary)
Spacer()
}
.padding(.leading, 16)
Spacer()
}
}
}
@ -220,78 +201,75 @@ 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(
section: .soundFeedback,
title: "Sound feedback",
helpText: "Play sounds when recording starts and stops",
isEnabled: $soundManager.isEnabled,
expandedSections: $expandedSections
) {
CustomSoundSettingsView()
.transition(.opacity.combined(with: .move(edge: .top)))
.padding(.top, 4)
}
Divider()
Toggle(isOn: $mediaController.isSystemMuteEnabled) {
Text("Mute system audio during recording")
}
.toggleStyle(.switch)
.help("Automatically mute system audio when recording starts and restore when recording stops")
VStack(alignment: .leading, spacing: 12) {
ExpandableToggleSection(
section: .systemMute,
title: "Mute system audio during recording",
helpText: "Automatically mute system audio when recording starts and restore when recording stops",
isEnabled: $mediaController.isSystemMuteEnabled,
expandedSections: $expandedSections
) {
HStack(spacing: 8) {
Toggle("Restore clipboard after paste", isOn: $restoreClipboardAfterPaste)
.toggleStyle(.switch)
Text("Resume Delay")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
Picker("", selection: $mediaController.audioResumptionDelay) {
Text("0s").tag(0.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: 80)
InfoTip(
title: "Restore Clipboard",
message: "When enabled, VoiceInk will restore your original clipboard content after pasting the transcription."
title: "Audio Resume 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, 0s for wired headphones."
)
Spacer()
}
}
if restoreClipboardAfterPaste {
HStack(spacing: 8) {
Text("Restore Delay")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
Divider()
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)
}
.pickerStyle(.menu)
.frame(width: 90)
ExpandableToggleSection(
section: .clipboardRestore,
title: "Restore clipboard after paste",
helpText: "When enabled, VoiceInk will restore your original clipboard content after pasting the transcription.",
isEnabled: $restoreClipboardAfterPaste,
expandedSections: $expandedSections
) {
HStack(spacing: 8) {
Text("Restore Delay")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
Spacer()
Picker("", selection: $clipboardRestoreDelay) {
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)
}
.padding(.leading, 16)
.pickerStyle(.menu)
.frame(width: 80)
Spacer()
}
}