Fixed Pause/Play on 15.4 with mute/unmute functionality

This commit is contained in:
Beingpax 2025-04-05 21:46:52 +05:45
parent 8ce96b7181
commit cb6da1641c
5 changed files with 155 additions and 211 deletions

View File

@ -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 = "<group>"; };
E1B1FDBD2D8C403100ADD08E /* whisper.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = whisper.xcframework; path = "../../whisper.cpp/build-apple/whisper.xcframework"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@ -125,7 +124,6 @@
isa = PBXGroup;
children = (
E129E77A2D943393009322D9 /* whisper.xcframework */,
E1B1FDBD2D8C403100ADD08E /* whisper.xcframework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -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;

View File

@ -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") }
}
}

View File

@ -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
}
}

View File

@ -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")
}
}

View File

@ -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")
}
}
}