Fixed Pause/Play on 15.4 with mute/unmute functionality
This commit is contained in:
parent
8ce96b7181
commit
cb6da1641c
@ -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;
|
||||
|
||||
@ -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") }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user