diff --git a/MediaRemoteHelper/OSWMediaRemote.swift b/MediaRemoteHelper/OSWMediaRemote.swift new file mode 100644 index 00000000..30816e6d --- /dev/null +++ b/MediaRemoteHelper/OSWMediaRemote.swift @@ -0,0 +1,85 @@ +// OpenSuperWhisper media-remote helper. +// +// macOS 15.4+ only lets Apple *platform binaries* use the private MediaRemote +// framework; an unentitled process (our app) gets empty/false data back. So the +// media queries and commands must run inside `/usr/bin/perl`, which is such a +// platform binary. `osw-media-remote.pl` dlopens THIS dylib into perl's entitled +// process and calls one of the exported C symbols below. +// +// This dylib is bundled in the app but NEVER linked into it - perl loads it by +// absolute path. Each entry point is invoked by perl as an XSUB; the extra perl +// arguments are ignored. +// +// osw_media_get -> writes "true" or "false" (is anything playing) to stdout +// osw_media_pause -> sends kMRPause (discrete; a no-op if nothing is playing) +// osw_media_play -> sends kMRPlay +// +// Only two private MediaRemote symbols are used, both stable for years: +// MRMediaRemoteGetNowPlayingApplicationIsPlaying(queue, block(Bool)) +// MRMediaRemoteSendCommand(MRCommand, userInfo) -> Bool + +import Foundation + +private let mediaRemotePath = + "/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote" + +// MRCommand values (stable private constants). +private let kMRPlay: Int32 = 0 +private let kMRPause: Int32 = 1 + +private let mediaRemote = dlopen(mediaRemotePath, RTLD_NOW) + +private func mediaRemoteSymbol(_ name: String) -> UnsafeMutableRawPointer? { + guard let mediaRemote else { return nil } + return dlsym(mediaRemote, name) +} + +private func writeStdout(_ string: String) { + // FileHandle writes are unbuffered, so output survives the perl xsub return. + FileHandle.standardOutput.write(Data(string.utf8)) +} + +/// Blocks (up to `timeout`) on MediaRemote's async "is playing" callback. +/// Returns false on any failure so callers fail safe. +private func isPlaying(timeout: TimeInterval = 2) -> Bool { + typealias Fn = @convention(c) ( + DispatchQueue, @escaping @convention(block) (Bool) -> Void + ) -> Void + guard let symbol = mediaRemoteSymbol("MRMediaRemoteGetNowPlayingApplicationIsPlaying") + else { return false } + let getIsPlaying = unsafeBitCast(symbol, to: Fn.self) + + let semaphore = DispatchSemaphore(value: 0) + var playing = false + getIsPlaying(DispatchQueue.global()) { value in + playing = value + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + timeout) + return playing +} + +private func sendCommand(_ command: Int32) { + typealias Fn = @convention(c) (Int32, UnsafeRawPointer?) -> Bool + guard let symbol = mediaRemoteSymbol("MRMediaRemoteSendCommand") else { return } + let send = unsafeBitCast(symbol, to: Fn.self) + _ = send(command, nil) + // One more MediaRemote round-trip so the command reaches the media daemon + // before perl exits (mirrors the reference adapter's waitForCommandCompletion). + _ = isPlaying(timeout: 2) +} + +@_cdecl("osw_media_get") +public func osw_media_get() { + writeStdout(isPlaying() ? "true" : "false") +} + +@_cdecl("osw_media_pause") +public func osw_media_pause() { + sendCommand(kMRPause) +} + +@_cdecl("osw_media_play") +public func osw_media_play() { + sendCommand(kMRPlay) +} diff --git a/OpenSuperWhisper.xcodeproj/project.pbxproj b/OpenSuperWhisper.xcodeproj/project.pbxproj index 47d6baa5..a6376c3e 100644 --- a/OpenSuperWhisper.xcodeproj/project.pbxproj +++ b/OpenSuperWhisper.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ CEAC00032D82F00200000000 /* libautocorrect_swift.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = CEAC00012D82F00000000000 /* libautocorrect_swift.dylib */; }; CEAC00042D82F10000000000 /* libautocorrect_swift.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = CEAC00012D82F00000000000 /* libautocorrect_swift.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; CEAC00082D82F50000000000 /* libomp.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = CEAC00072D82F40000000000 /* libomp.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + CEAC01002DA0000000000002 /* libOSWMediaHelper.dylib in CopyFiles */ = {isa = PBXBuildFile; fileRef = CEAC01002DA0000000000001 /* libOSWMediaHelper.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -118,6 +119,7 @@ files = ( CEAC00042D82F10000000000 /* libautocorrect_swift.dylib in CopyFiles */, CEAC00082D82F50000000000 /* libomp.dylib in CopyFiles */, + CEAC01002DA0000000000002 /* libOSWMediaHelper.dylib in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -169,6 +171,7 @@ CE84DF792D5578BE00C54EA6 /* jfk.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = jfk.wav; sourceTree = ""; }; CEAC00012D82F00000000000 /* libautocorrect_swift.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libautocorrect_swift.dylib; path = build/libautocorrect_swift.dylib; sourceTree = ""; }; CEAC00072D82F40000000000 /* libomp.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libomp.dylib; path = build/libomp.dylib; sourceTree = ""; }; + CEAC01002DA0000000000001 /* libOSWMediaHelper.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libOSWMediaHelper.dylib; path = build/libOSWMediaHelper.dylib; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -401,6 +404,7 @@ children = ( CEAC00012D82F00000000000 /* libautocorrect_swift.dylib */, CEAC00072D82F40000000000 /* libomp.dylib */, + CEAC01002DA0000000000001 /* libOSWMediaHelper.dylib */, ); name = "Recovered References"; sourceTree = ""; diff --git a/OpenSuperWhisper/AudioRecorder.swift b/OpenSuperWhisper/AudioRecorder.swift index be676b34..3b6876cf 100644 --- a/OpenSuperWhisper/AudioRecorder.swift +++ b/OpenSuperWhisper/AudioRecorder.swift @@ -151,7 +151,11 @@ class AudioRecorder: NSObject, ObservableObject { print("Cannot start recording - no audio input available") return } - + + // Pause any playing music/video before our own start cue plays, so + // the notification sound is not mistaken for external playback. + MediaPlaybackController.shared.pauseIfPlaying() + if playSound { self.playNotificationSound() } @@ -215,6 +219,10 @@ class AudioRecorder: NSObject, ObservableObject { func stopRecording() async -> URL? { await withCheckedContinuation { continuation in workQueue.async { + // Resume music the moment the mic is released, not after the + // (possibly multi-second) transcription decode finishes. + MediaPlaybackController.shared.resumeIfPaused() + guard let recorder = self.audioRecorder, let url = self.currentRecordingURL else { continuation.resume(returning: self.performStop(discard: false)) return @@ -250,6 +258,7 @@ class AudioRecorder: NSObject, ObservableObject { func cancelRecording() { workQueue.sync { + MediaPlaybackController.shared.resumeIfPaused() _ = performStop(discard: true) } } diff --git a/OpenSuperWhisper/Settings.swift b/OpenSuperWhisper/Settings.swift index 518b9d96..83273eb1 100644 --- a/OpenSuperWhisper/Settings.swift +++ b/OpenSuperWhisper/Settings.swift @@ -178,6 +178,12 @@ class SettingsViewModel: ObservableObject { } } + @Published var pauseMediaWhileRecording: Bool { + didSet { + AppPreferences.shared.pauseMediaWhileRecording = pauseMediaWhileRecording + } + } + @Published var startHiddenInMenuBar: Bool { didSet { AppPreferences.shared.startHiddenInMenuBar = startHiddenInMenuBar @@ -222,6 +228,7 @@ class SettingsViewModel: ObservableObject { self.holdToRecord = prefs.holdToRecord self.doublePressToTrigger = prefs.doublePressToTrigger self.escCancelWithoutConfirmation = prefs.escCancelWithoutConfirmation + self.pauseMediaWhileRecording = prefs.pauseMediaWhileRecording self.startHiddenInMenuBar = prefs.startHiddenInMenuBar self.addSpaceAfterSentence = prefs.addSpaceAfterSentence self.autoCopyToClipboard = prefs.autoCopyToClipboard @@ -1375,7 +1382,21 @@ struct SettingsView: View { .labelsHidden() .help("Play a notification sound when recording begins") } - + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Pause media while recording") + .font(.subheadline) + Text("Pause music/video when recording starts and resume it when you finish") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Toggle("", isOn: $viewModel.pauseMediaWhileRecording) + .toggleStyle(SwitchToggleStyle(tint: Color.accentColor)) + .labelsHidden() + } + HStack { VStack(alignment: .leading, spacing: 2) { Text("Cancel without confirmation") diff --git a/OpenSuperWhisper/Utils/AppPreferences.swift b/OpenSuperWhisper/Utils/AppPreferences.swift index 9614e2c8..f3a8fd28 100644 --- a/OpenSuperWhisper/Utils/AppPreferences.swift +++ b/OpenSuperWhisper/Utils/AppPreferences.swift @@ -119,7 +119,10 @@ final class AppPreferences { @UserDefault(key: "doublePressToTrigger", defaultValue: false) var doublePressToTrigger: Bool - + + @UserDefault(key: "pauseMediaWhileRecording", defaultValue: false) + var pauseMediaWhileRecording: Bool + @UserDefault(key: "addSpaceAfterSentence", defaultValue: true) var addSpaceAfterSentence: Bool diff --git a/OpenSuperWhisper/Utils/MediaPlaybackController.swift b/OpenSuperWhisper/Utils/MediaPlaybackController.swift new file mode 100644 index 00000000..9bc84a4f --- /dev/null +++ b/OpenSuperWhisper/Utils/MediaPlaybackController.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Pauses whatever app is currently playing audio (music, video, browser) while +/// a recording is in progress, and resumes it afterwards. +/// +/// The mechanism is player-agnostic and, crucially, uses *discrete* pause/play +/// commands rather than a play/pause toggle. macOS 15.4+ only lets Apple +/// platform binaries use the private MediaRemote framework, so we cannot call it +/// from our own process (an unentitled app gets empty/false state back). Instead +/// we shell out to `/usr/bin/perl` (a platform binary), which loads our small +/// `libOSWMediaHelper.dylib` into its entitled process via `osw-media-remote.pl` +/// and calls MediaRemote there. Both files are bundled with the app; the dylib +/// is never linked into it. +/// +/// Why discrete matters: the previous implementation simulated the hardware +/// Play/Pause media *key*, which is a toggle, gated on a CoreAudio "output device +/// is running" check. Browsers keep that device running even while paused, so the +/// check false-positived and the toggle *started* paused audio on record-start. +/// A discrete `pause` is a no-op when nothing is playing, so it can never start +/// audio; a discrete `play` is only ever sent to resume media we ourselves paused. +/// +/// Threading: every entry point hops onto this controller's own serial +/// `mediaQueue`. `didPauseMedia` and the lazily-resolved bundle paths are only +/// ever touched there, so no additional locking is needed, and the (subprocess) +/// work never runs on AudioRecorder's workQueue where it could delay capture. +final class MediaPlaybackController { + static let shared = MediaPlaybackController() + private init() {} + + /// Serial queue that owns all helper interaction and `didPauseMedia`. + private let mediaQueue = DispatchQueue(label: "com.opensuperwhisper.mediaplayback") + + /// True only while a recording-triggered pause is in effect. + private var didPauseMedia = false + + /// Hard cap on any single helper invocation so a wedged helper can never + /// wedge `mediaQueue`. The helper's own MediaRemote waits are ~2 s; this is + /// the outer bound including perl startup. + private static let helperTimeout: TimeInterval = 5 + + private let perlURL = URL(fileURLWithPath: "/usr/bin/perl") + + /// Bundled perl launcher; resolved lazily and only from `mediaQueue`. + private lazy var scriptURL = Bundle.main.url( + forResource: "osw-media-remote", withExtension: "pl" + ) + + /// Bundled helper dylib (in Contents/Frameworks); perl loads it, our app + /// never links or loads it. + private lazy var helperURL = Bundle.main.privateFrameworksURL? + .appendingPathComponent("libOSWMediaHelper.dylib") + + // MARK: - Public API (called from AudioRecorder's workQueue) + + /// Pauses the current "Now Playing" app if the feature is enabled and audio + /// is actually playing. Idempotent: a second call while already paused is a + /// no-op, so the recorder's internal restart path never double-pauses. + func pauseIfPlaying() { + guard AppPreferences.shared.pauseMediaWhileRecording else { return } + mediaQueue.async { [self] in + guard !didPauseMedia else { return } + // Only claim ownership (and thus later resume) if something is truly + // playing. Sending pause itself is harmless when nothing plays, but + // we must not resume media that the user had already paused. + guard queryPlaying() else { return } + runHelper("pause") + didPauseMedia = true + print("MediaPlaybackController: paused media for recording") + } + } + + /// Resumes playback only if this controller was the one that paused it. + /// Safe to call unconditionally from every stop/cancel path. Intentionally + /// does not check the preference: if the user toggled the feature off mid + /// recording, we still owe them the resume of what we paused. + func resumeIfPaused() { + mediaQueue.async { [self] in + guard didPauseMedia else { return } + didPauseMedia = false + runHelper("play") + print("MediaPlaybackController: resumed media after recording") + } + } + + // MARK: - Helper + + /// Whether an app is currently playing, per the helper's `get`. Fails safe: + /// any missing binary, error, timeout, or unexpected output yields `false`, + /// so we never pause/resume on bad data. + private func queryPlaying() -> Bool { + guard let data = runHelper("get") else { return false } + let output = String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return output == "true" + } + + /// Runs `/usr/bin/perl