diff --git a/VoiceInk/MediaController.swift b/VoiceInk/MediaController.swift index 07db267..e47bf83 100644 --- a/VoiceInk/MediaController.swift +++ b/VoiceInk/MediaController.swift @@ -1,126 +1,152 @@ -import AppKit -import Combine import Foundation -import SwiftUI import CoreAudio -/// Controls system audio management during recording -class MediaController: ObservableObject { +final class MediaController: ObservableObject { + static let shared = MediaController() + private var didMuteAudio = false private var wasAudioMutedBeforeRecording = false - private var currentMuteTask: Task? private var unmuteTask: Task? + private var muteGeneration: Int = 0 @Published var isSystemMuteEnabled: Bool = UserDefaults.standard.bool(forKey: "isSystemMuteEnabled") { - didSet { - UserDefaults.standard.set(isSystemMuteEnabled, 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") - } + didSet { UserDefaults.standard.set(audioResumptionDelay, forKey: "audioResumptionDelay") } } private init() { - if !UserDefaults.standard.contains(key: "isSystemMuteEnabled") { + if UserDefaults.standard.object(forKey: "isSystemMuteEnabled") == nil { UserDefaults.standard.set(true, forKey: "isSystemMuteEnabled") } - - if !UserDefaults.standard.contains(key: "audioResumptionDelay") { + if UserDefaults.standard.object(forKey: "audioResumptionDelay") == nil { UserDefaults.standard.set(0.0, forKey: "audioResumptionDelay") } } - - private func isSystemAudioMuted() -> Bool { - let pipe = Pipe() - let task = Process() - task.launchPath = "/usr/bin/osascript" - task.arguments = ["-e", "output muted of (get volume settings)"] - task.standardOutput = pipe - - do { - try task.run() - task.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { - return output == "true" - } - } catch { } - - return false - } - + func muteSystemAudio() async -> Bool { guard isSystemMuteEnabled else { return false } unmuteTask?.cancel() unmuteTask = nil - currentMuteTask?.cancel() + muteGeneration += 1 - let task = Task { - wasAudioMutedBeforeRecording = isSystemAudioMuted() + let currentlyMuted = isSystemAudioMuted() - if wasAudioMutedBeforeRecording { - return true + if currentlyMuted { + if didMuteAudio { + // We muted it previously, stay responsible for unmuting + wasAudioMutedBeforeRecording = false + } else { + // User muted it, don't unmute when done + wasAudioMutedBeforeRecording = true + didMuteAudio = false } - - let success = executeAppleScript(command: "set volume with output muted") - didMuteAudio = success - return success + return true } - currentMuteTask = task - return await task.value + wasAudioMutedBeforeRecording = false + let success = setSystemMuted(true) + didMuteAudio = success + return success } - + func unmuteSystemAudio() async { guard isSystemMuteEnabled else { return } - if let muteTask = currentMuteTask { - _ = await muteTask.value - } - let delay = audioResumptionDelay let shouldUnmute = didMuteAudio && !wasAudioMutedBeforeRecording + let myGeneration = muteGeneration - let task = Task { - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - - if Task.isCancelled { - return + let task = Task { [weak self] in + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } + guard let self = self else { return } + guard !Task.isCancelled else { return } + guard self.muteGeneration == myGeneration else { return } + if shouldUnmute { - _ = executeAppleScript(command: "set volume without output muted") + _ = self.setSystemMuted(false) } - didMuteAudio = false - currentMuteTask = nil + self.didMuteAudio = false } unmuteTask = task await task.value } - - private func executeAppleScript(command: String) -> Bool { - let task = Process() - task.launchPath = "/usr/bin/osascript" - task.arguments = ["-e", command] - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - - do { - try task.run() - task.waitUntilExit() - return task.terminationStatus == 0 - } catch { - return false + + private func getDefaultOutputDevice() -> AudioDeviceID? { + var deviceID = AudioDeviceID(0) + var propertySize = UInt32(MemoryLayout.size) + + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &propertySize, + &deviceID + ) + + return status == noErr ? deviceID : nil + } + + private func isSystemAudioMuted() -> Bool { + guard let deviceID = getDefaultOutputDevice() else { return false } + + var muted: UInt32 = 0 + var propertySize = UInt32(MemoryLayout.size) + + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + + if !AudioObjectHasProperty(deviceID, &address) { + address.mElement = 0 + if !AudioObjectHasProperty(deviceID, &address) { return false } } + + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &propertySize, &muted) + return status == noErr && muted != 0 + } + + private func setSystemMuted(_ muted: Bool) -> Bool { + guard let deviceID = getDefaultOutputDevice() else { return false } + + var muteValue: UInt32 = muted ? 1 : 0 + let propertySize = UInt32(MemoryLayout.size) + + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + + if !AudioObjectHasProperty(deviceID, &address) { + address.mElement = 0 + if !AudioObjectHasProperty(deviceID, &address) { return false } + } + + var isSettable: DarwinBoolean = false + var status = AudioObjectIsPropertySettable(deviceID, &address, &isSettable) + if status != noErr || !isSettable.boolValue { return false } + + status = AudioObjectSetPropertyData(deviceID, &address, 0, nil, propertySize, &muteValue) + return status == noErr } }