diff --git a/VoiceInk.xcodeproj/project.pbxproj b/VoiceInk.xcodeproj/project.pbxproj index aa0d63f..78f1d25 100644 --- a/VoiceInk.xcodeproj/project.pbxproj +++ b/VoiceInk.xcodeproj/project.pbxproj @@ -50,7 +50,6 @@ E11473C32CBE0F0B00318EE4 /* VoiceInkTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VoiceInkTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E11473CD2CBE0F0B00318EE4 /* VoiceInkUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VoiceInkUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E129E77A2D943393009322D9 /* whisper.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = whisper.xcframework; path = "../whisper.cpp/build-apple/whisper.xcframework"; sourceTree = ""; }; - E1B1FDBD2D8C403100ADD08E /* whisper.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = whisper.xcframework; path = "../../whisper.cpp/build-apple/whisper.xcframework"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -125,7 +124,6 @@ isa = PBXGroup; children = ( E129E77A2D943393009322D9 /* whisper.xcframework */, - E1B1FDBD2D8C403100ADD08E /* whisper.xcframework */, ); name = Frameworks; sourceTree = ""; @@ -444,7 +442,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 115; + CURRENT_PROJECT_VERSION = 120; DEVELOPMENT_ASSET_PATHS = "\"VoiceInk/Preview Content\""; DEVELOPMENT_TEAM = V6J6A3VWY2; ENABLE_HARDENED_RUNTIME = YES; @@ -459,7 +457,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.16; + MARKETING_VERSION = 1.20; PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -477,7 +475,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 115; + CURRENT_PROJECT_VERSION = 120; DEVELOPMENT_ASSET_PATHS = "\"VoiceInk/Preview Content\""; DEVELOPMENT_TEAM = V6J6A3VWY2; ENABLE_HARDENED_RUNTIME = YES; @@ -492,7 +490,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.16; + MARKETING_VERSION = 1.20; PRODUCT_BUNDLE_IDENTIFIER = com.prakashjoshipax.VoiceInk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/VoiceInk/MediaController.swift b/VoiceInk/MediaController.swift index 82c0208..a27fb29 100644 --- a/VoiceInk/MediaController.swift +++ b/VoiceInk/MediaController.swift @@ -1,213 +1,154 @@ -import Foundation import AppKit -import SwiftUI -import os import Combine +import Foundation +import os +import SwiftUI +import CoreAudio +import AudioToolbox -/// Controls media playback detection and management during recording +/// Controls system audio management during recording class MediaController: ObservableObject { static let shared = MediaController() - private var mediaRemoteHandle: UnsafeMutableRawPointer? - private var mrNowPlayingIsPlaying: MRNowPlayingIsPlayingFunc? - private var didPauseMedia = false + private var previousVolume: Float = 1.0 + private var didMuteAudio = false private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "MediaController") - @Published var isMediaPauseEnabled: Bool = UserDefaults.standard.bool(forKey: "isMediaPauseEnabled") { + @Published var isSystemMuteEnabled: Bool = UserDefaults.standard.bool(forKey: "isSystemMuteEnabled") { didSet { - UserDefaults.standard.set(isMediaPauseEnabled, forKey: "isMediaPauseEnabled") + UserDefaults.standard.set(isSystemMuteEnabled, forKey: "isSystemMuteEnabled") } } - // Define function pointer types for MediaRemote functions - typealias MRNowPlayingIsPlayingFunc = @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void - typealias MRMediaRemoteCommandInfoFunc = @convention(c) () -> Void - - // Additional function pointers for direct control - private var mrSendCommand: (@convention(c) (Int, [String: Any]?) -> Bool)? - - // MediaRemote command constantst - private let kMRPlay = 0 - private let kMRPause = 1 - private let kMRTogglePlayPause = 2 - private init() { // Set default if not already set - if !UserDefaults.standard.contains(key: "isMediaPauseEnabled") { - UserDefaults.standard.set(true, forKey: "isMediaPauseEnabled") + if !UserDefaults.standard.contains(key: "isSystemMuteEnabled") { + UserDefaults.standard.set(true, forKey: "isSystemMuteEnabled") } - setupMediaRemote() } - private func setupMediaRemote() { - // Open the private framework - guard let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) else { - logger.error("Unable to open MediaRemote framework") - return + /// Mutes system audio during recording + func muteSystemAudio() async -> Bool { + guard isSystemMuteEnabled else { + logger.info("System mute feature is disabled") + return false } - mediaRemoteHandle = handle - // Get pointer for the "is playing" function - guard let playingPtr = dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") else { - logger.error("Unable to find MRMediaRemoteGetNowPlayingApplicationIsPlaying function") - dlclose(handle) - mediaRemoteHandle = nil + // Get current volume before muting + previousVolume = getSystemVolume() + logger.info("Muting system audio. Previous volume: \(self.previousVolume)") + + // Set system volume to 0 (mute) + setSystemVolume(0.0) + didMuteAudio = true + return true + } + + /// Restores system audio after recording + func unmuteSystemAudio() async { + guard isSystemMuteEnabled, didMuteAudio else { return } - mrNowPlayingIsPlaying = unsafeBitCast(playingPtr, to: MRNowPlayingIsPlayingFunc.self) + logger.info("Unmuting system audio to previous volume: \(self.previousVolume)") + setSystemVolume(previousVolume) + didMuteAudio = false + } + + /// Gets the current system output volume (0.0 to 1.0) + private func getSystemVolume() -> Float { + var defaultOutputDeviceID = AudioDeviceID(0) + var defaultOutputDeviceIDSize = UInt32(MemoryLayout.size(ofValue: defaultOutputDeviceID)) - // Get the send command function pointer - if let sendCommandPtr = dlsym(handle, "MRMediaRemoteSendCommand") { - mrSendCommand = unsafeBitCast(sendCommandPtr, to: (@convention(c) (Int, [String: Any]?) -> Bool).self) - logger.info("Successfully loaded MRMediaRemoteSendCommand function") + // Get the default output device + var getDefaultOutputDeviceProperty = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &getDefaultOutputDeviceProperty, + 0, + nil, + &defaultOutputDeviceIDSize, + &defaultOutputDeviceID) + + if status != kAudioHardwareNoError { + logger.error("Failed to get default output device: \(status)") + return 1.0 // Default to full volume on error + } + + // Get the volume + var volume: Float = 0.0 + var volumeSize = UInt32(MemoryLayout.size(ofValue: volume)) + + var volumeProperty = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain) + + let volumeStatus = AudioObjectGetPropertyData( + defaultOutputDeviceID, + &volumeProperty, + 0, + nil, + &volumeSize, + &volume) + + if volumeStatus != kAudioHardwareNoError { + logger.error("Failed to get system volume: \(volumeStatus)") + return 1.0 // Default to full volume on error + } + + return volume + } + + /// Sets the system output volume (0.0 to 1.0) + private func setSystemVolume(_ volume: Float) { + var defaultOutputDeviceID = AudioDeviceID(0) + var defaultOutputDeviceIDSize = UInt32(MemoryLayout.size(ofValue: defaultOutputDeviceID)) + + // Get the default output device + var getDefaultOutputDeviceProperty = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &getDefaultOutputDeviceProperty, + 0, + nil, + &defaultOutputDeviceIDSize, + &defaultOutputDeviceID) + + if status != kAudioHardwareNoError { + logger.error("Failed to get default output device: \(status)") + return + } + + // Clamp volume to valid range + var safeVolume = max(0.0, min(1.0, volume)) + + // Set the volume + var volumeProperty = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain) + + let volumeStatus = AudioObjectSetPropertyData( + defaultOutputDeviceID, + &volumeProperty, + 0, + nil, + UInt32(MemoryLayout.size(ofValue: safeVolume)), + &safeVolume) + + if volumeStatus != kAudioHardwareNoError { + logger.error("Failed to set system volume: \(volumeStatus)") } else { - logger.warning("Could not find MRMediaRemoteSendCommand function, fallback to key simulation") - } - - logger.info("MediaRemote framework initialized successfully") - } - - deinit { - if let handle = mediaRemoteHandle { - dlclose(handle) - } - } - - /// Checks if media is currently playing on the system - func isMediaPlaying() async -> Bool { - guard isMediaPauseEnabled, let mrNowPlayingIsPlaying = mrNowPlayingIsPlaying else { - return false - } - - return await withCheckedContinuation { continuation in - mrNowPlayingIsPlaying(DispatchQueue.main) { isPlaying in - continuation.resume(returning: isPlaying) - } - } - } - - /// Pauses media if it's currently playing - func pauseMediaIfPlaying() async -> Bool { - guard isMediaPauseEnabled else { - logger.info("Media pause feature is disabled") - return false - } - - if await isMediaPlaying() { - logger.info("Media is playing, pausing it for recording") - await MainActor.run { - // Try direct command first, then fall back to key simulation - if !sendMediaCommand(command: kMRPause) { - sendMediaKey() - } - } - didPauseMedia = true - return true - } - - logger.info("No media playing, no need to pause") - return false - } - - /// Resumes media if it was paused by this controller - func resumeMediaIfPaused() async { - guard isMediaPauseEnabled, didPauseMedia else { - return - } - - logger.info("Resuming previously paused media") - await MainActor.run { - // Try direct command first, then fall back to key simulation - if !sendMediaCommand(command: kMRPlay) { - sendMediaKey() - } - } - didPauseMedia = false - } - - /// Sends a media command using the MediaRemote framework - private func sendMediaCommand(command: Int) -> Bool { - guard let sendCommand = mrSendCommand else { - logger.warning("MRMediaRemoteSendCommand not available") - return false - } - - let result = sendCommand(command, nil) - logger.info("Sent media command \(command) with result: \(result)") - return result - } - - /// Simulates a media key press (Play/Pause) by posting a system-defined NSEvent - private func sendMediaKey() { - let NX_KEYTYPE_PLAY: UInt32 = 16 - let keys = [NX_KEYTYPE_PLAY] - - logger.info("Simulating media key press using NSEvent") - - for key in keys { - func postKeyEvent(down: Bool) { - let flags: NSEvent.ModifierFlags = down ? .init(rawValue: 0xA00) : .init(rawValue: 0xB00) - let data1 = Int((key << 16) | (down ? 0xA << 8 : 0xB << 8)) - - if let event = NSEvent.otherEvent( - with: .systemDefined, - location: .zero, - modifierFlags: flags, - timestamp: 0, - windowNumber: 0, - context: nil, - subtype: 8, - data1: data1, - data2: -1 - ) { - // Attempt to post directly to all applications - let didPost = event.cgEvent?.post(tap: .cghidEventTap) != nil - logger.info("Posted key event (down: \(down)) with result: \(didPost ? "success" : "failure")") - - // Add a small delay to ensure the event is processed - usleep(10000) // 10ms delay - } - } - - // Perform the key down/up sequence - postKeyEvent(down: true) - postKeyEvent(down: false) - - // Allow some time for the system to process the key event - usleep(50000) // 50ms delay - } - - // As a fallback, try to use CGEvent directly - createAndPostPlayPauseEvent() - } - - /// Creates and posts a CGEvent for media control as a fallback method - private func createAndPostPlayPauseEvent() { - logger.info("Attempting fallback CGEvent for media control") - - // Media keys as defined in IOKit - let NX_KEYTYPE_PLAY: Int64 = 16 - - // Create a CGEvent for the media key - guard let source = CGEventSource(stateID: .hidSystemState) else { - logger.error("Failed to create CGEventSource") - return - } - - if let keyDownEvent = CGEvent(keyboardEventSource: source, virtualKey: UInt16(NX_KEYTYPE_PLAY), keyDown: true) { - keyDownEvent.flags = .init(rawValue: 0xA00) - keyDownEvent.post(tap: .cghidEventTap) - logger.info("Posted play/pause key down event") - - // Small delay between down and up events - usleep(10000) // 10ms - - if let keyUpEvent = CGEvent(keyboardEventSource: source, virtualKey: UInt16(NX_KEYTYPE_PLAY), keyDown: false) { - keyUpEvent.flags = .init(rawValue: 0xB00) - keyUpEvent.post(tap: .cghidEventTap) - logger.info("Posted play/pause key up event") - } + logger.info("Set system volume to \(safeVolume)") } } } @@ -216,4 +157,9 @@ extension UserDefaults { func contains(key: String) -> Bool { return object(forKey: key) != nil } -} + + var isSystemMuteEnabled: Bool { + get { bool(forKey: "isSystemMuteEnabled") } + set { set(newValue, forKey: "isSystemMuteEnabled") } + } +} \ No newline at end of file diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index b4a3509..72949a4 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -103,10 +103,10 @@ class Recorder: ObservableObject { func startRecording(toOutputFile url: URL, delegate: AVAudioRecorderDelegate?) async throws { logger.info("Starting recording process") - // Check if media is playing and pause it if needed - let wasPaused = await mediaController.pauseMediaIfPlaying() - if wasPaused { - logger.info("Media playback paused for recording") + // Check if we need to mute system audio + let wasMuted = await mediaController.muteSystemAudio() + if wasMuted { + logger.info("System audio muted for recording") } // Get the current selected device @@ -156,8 +156,8 @@ class Recorder: ObservableObject { logger.error("Current device name: \(deviceName)") } - // Resume media if we paused it but failed to start recording - await mediaController.resumeMediaIfPaused() + // Restore system audio if we muted it but failed to start recording + await mediaController.unmuteSystemAudio() throw RecorderError.couldNotStartRecording } @@ -166,8 +166,8 @@ class Recorder: ObservableObject { logger.error("Recording settings used: \(recordSettings)") logger.error("Output URL: \(url.path)") - // Resume media if we paused it but failed to start recording - await mediaController.resumeMediaIfPaused() + // Restore system audio if we muted it but failed to start recording + await mediaController.unmuteSystemAudio() throw error } @@ -184,9 +184,9 @@ class Recorder: ObservableObject { logger.info("Triggering audio device change notification") NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil) - // Resume media if we paused it + // Restore system audio if we muted it Task { - await mediaController.resumeMediaIfPaused() + await mediaController.unmuteSystemAudio() } logger.info("Recording stopped successfully") @@ -244,4 +244,4 @@ class Recorder: ObservableObject { struct AudioMeter: Equatable { let averagePower: Double let peakPower: Double -} +} \ No newline at end of file diff --git a/VoiceInk/Views/MenuBarView.swift b/VoiceInk/Views/MenuBarView.swift index c23f24d..e1a7464 100644 --- a/VoiceInk/Views/MenuBarView.swift +++ b/VoiceInk/Views/MenuBarView.swift @@ -122,13 +122,13 @@ struct MenuBarView: View { } Button { - MediaController.shared.isMediaPauseEnabled.toggle() + MediaController.shared.isSystemMuteEnabled.toggle() menuRefreshTrigger.toggle() } label: { HStack { - Text("Pause Media During Recording") + Text("Mute System Audio During Recording") Spacer() - if MediaController.shared.isMediaPauseEnabled { + if MediaController.shared.isSystemMuteEnabled { Image(systemName: "checkmark") } } diff --git a/VoiceInk/Views/RecordView.swift b/VoiceInk/Views/RecordView.swift index 2ea121e..8f38f45 100644 --- a/VoiceInk/Views/RecordView.swift +++ b/VoiceInk/Views/RecordView.swift @@ -113,16 +113,16 @@ struct RecordView: View { } .toggleStyle(.switch) - Toggle(isOn: $mediaController.isMediaPauseEnabled) { + Toggle(isOn: $mediaController.isSystemMuteEnabled) { HStack { - Image(systemName: "play.slash") + Image(systemName: "speaker.slash") .foregroundColor(.secondary) - Text("Pause media during recording") + Text("Mute system audio during recording") .font(.subheadline.weight(.medium)) } } .toggleStyle(.switch) - .help("Automatically pause music playback when recording starts and resume when recording stops") + .help("Automatically mute system audio when recording starts and restore when recording stops") } } }