Add configurable audio resumption delay for Bluetooth headphones
Fixes #459
This commit is contained in:
parent
3a2721e150
commit
8bfaf88f9b
@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
59
VoiceInk/Views/Settings/ExpandableToggleSection.swift
Normal file
59
VoiceInk/Views/Settings/ExpandableToggleSection.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user