Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions MediaRemoteHelper/OSWMediaRemote.swift
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 4 additions & 0 deletions OpenSuperWhisper.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -118,6 +119,7 @@
files = (
CEAC00042D82F10000000000 /* libautocorrect_swift.dylib in CopyFiles */,
CEAC00082D82F50000000000 /* libomp.dylib in CopyFiles */,
CEAC01002DA0000000000002 /* libOSWMediaHelper.dylib in CopyFiles */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -169,6 +171,7 @@
CE84DF792D5578BE00C54EA6 /* jfk.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = jfk.wav; sourceTree = "<group>"; };
CEAC00012D82F00000000000 /* libautocorrect_swift.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libautocorrect_swift.dylib; path = build/libautocorrect_swift.dylib; sourceTree = "<group>"; };
CEAC00072D82F40000000000 /* libomp.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libomp.dylib; path = build/libomp.dylib; sourceTree = "<group>"; };
CEAC01002DA0000000000001 /* libOSWMediaHelper.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libOSWMediaHelper.dylib; path = build/libOSWMediaHelper.dylib; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
Expand Down Expand Up @@ -401,6 +404,7 @@
children = (
CEAC00012D82F00000000000 /* libautocorrect_swift.dylib */,
CEAC00072D82F40000000000 /* libomp.dylib */,
CEAC01002DA0000000000001 /* libOSWMediaHelper.dylib */,
);
name = "Recovered References";
sourceTree = "<group>";
Expand Down
11 changes: 10 additions & 1 deletion OpenSuperWhisper/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -250,6 +258,7 @@ class AudioRecorder: NSObject, ObservableObject {

func cancelRecording() {
workQueue.sync {
MediaPlaybackController.shared.resumeIfPaused()
_ = performStop(discard: true)
}
}
Expand Down
23 changes: 22 additions & 1 deletion OpenSuperWhisper/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion OpenSuperWhisper/Utils/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
140 changes: 140 additions & 0 deletions OpenSuperWhisper/Utils/MediaPlaybackController.swift
Original file line number Diff line number Diff line change
@@ -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 <script> <helper-dylib> <command>` and returns stdout,
/// or `nil` on any failure. Must be called on `mediaQueue`.
///
/// We wait on process exit (with a timeout) *before* draining stdout. The
/// helper's output is tiny (well under the pipe buffer), so the child always
/// exits on its own; the timeout only guards a pathological hang.
@discardableResult
private func runHelper(_ command: String) -> Data? {
guard let scriptURL, let helperURL else {
print("MediaPlaybackController: helper not bundled")
return nil
}

let process = Process()
process.executableURL = perlURL
process.arguments = [scriptURL.path, helperURL.path, command]
let stdout = Pipe()
process.standardOutput = stdout
process.standardError = FileHandle.nullDevice

let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }

do {
try process.run()
} catch {
print("MediaPlaybackController: failed to launch perl: \(error)")
return nil
}

if finished.wait(timeout: .now() + Self.helperTimeout) == .timedOut {
process.terminate()
print("MediaPlaybackController: helper timed out")
return nil
}

guard process.terminationStatus == 0 else {
print("MediaPlaybackController: helper exited \(process.terminationStatus)")
return nil
}

return stdout.fileHandleForReading.readDataToEndOfFile()
}
}
38 changes: 38 additions & 0 deletions OpenSuperWhisper/osw-media-remote.pl
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/perl
# OpenSuperWhisper media-remote launcher.
#
# macOS 15.4+ restricts the private MediaRemote framework to Apple platform
# binaries. /usr/bin/perl is one, so it loads our helper dylib into its own
# (entitled) process and calls one exported symbol. Our app cannot do this
# directly - it would get empty/false data back.
#
# Usage:
# /usr/bin/perl osw-media-remote.pl <helper-dylib-path> <get|pause|play>
#
# "get" prints "true"/"false" (is anything playing); "pause"/"play" send the
# discrete MediaRemote command.

use strict;
use warnings;
use DynaLoader;

my ($lib, $command) = @ARGV;
die "usage: $0 <helper-dylib> <get|pause|play>\n"
unless defined $lib && defined $command;

my %symbol_for = (
get => 'osw_media_get',
pause => 'osw_media_pause',
play => 'osw_media_play',
);
my $symbol_name = $symbol_for{$command}
or die "unknown command: $command\n";

my $handle = DynaLoader::dl_load_file($lib, 0)
or die "failed to load helper dylib: $lib\n";
my $symbol = DynaLoader::dl_find_symbol($handle, $symbol_name)
or die "symbol not found: $symbol_name\n";

# perl invokes the C symbol as an XSUB; our helper ignores the perl arguments.
DynaLoader::dl_install_xsub("main::entry", $symbol);
entry();
4 changes: 4 additions & 0 deletions notarize_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ cp /opt/homebrew/opt/libomp/lib/libomp.dylib ./build/libomp.dylib
install_name_tool -id "@rpath/libomp.dylib" ./build/libomp.dylib
codesign --force --sign "${CODE_SIGN_IDENTITY}" --timestamp ./build/libomp.dylib

echo "Building media-remote helper..."
swiftc -emit-library -O -target arm64-apple-macos14.0 -o ./build/libOSWMediaHelper.dylib MediaRemoteHelper/OSWMediaRemote.swift
codesign --force --sign "${CODE_SIGN_IDENTITY}" --timestamp ./build/libOSWMediaHelper.dylib

xcodebuild \
-scheme "OpenSuperWhisper" \
-configuration Release \
Expand Down
Loading
Loading