diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af66ba1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: Build and safety checks +on: + push: + branches: [main] + pull_request: +permissions: + contents: read +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: true +jobs: + check: + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-15-intel] + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: ./build.sh + - run: ./check.sh diff --git a/.gitignore b/.gitignore index a2d0223..ed38d15 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ xcuserdata/ # Editor / tool config .claude/ + +# Local test binaries +.checks/ +__pycache__/ diff --git a/App/AppIcon.icns b/App/AppIcon.icns new file mode 100644 index 0000000..cba2f4a Binary files /dev/null and b/App/AppIcon.icns differ diff --git a/App/AppState.swift b/App/AppState.swift index 4e66b00..3868ec9 100644 --- a/App/AppState.swift +++ b/App/AppState.swift @@ -1,406 +1,347 @@ -// AppState.swift -// -// Single source of truth for the menu bar UI. Owns the AudioRouter, the -// device list, the toggle state, and the dialogue boost. Saves a tiny bit of -// state to UserDefaults so the user's preferred output device sticks across -// launches. - -import Foundation -import CoreAudio -import Combine import AppKit +import AVFoundation +import Combine +import CoreAudio +import Foundation @MainActor final class AppState: ObservableObject { - - // MARK: - Published state - - /// Master toggle: ON = audio is being routed through MacStereoFix. - @Published var isOn: Bool = false - - /// Extra dB added on top of the -3 dB ITU center coefficient. - @Published var dialogueBoostDB: Float = 3.0 { + @Published private(set) var isOn = false + @Published private(set) var isBusy = false + @Published private(set) var driverInstalled = false + @Published private(set) var availableOutputs: [AudioOutputDevice] = [] + @Published private(set) var lastError: String? + @Published private(set) var statusMessage: String? + @Published var selectedOutputUID: String? { + didSet { defaults.set(selectedOutputUID, forKey: "selectedOutputUID") } + } + @Published var dialogueBoostDB: Float = 0 { didSet { router.setDialogueBoostDB(dialogueBoostDB) - UserDefaults.standard.set(dialogueBoostDB, forKey: "dialogueBoostDB") + defaults.set(dialogueBoostDB, forKey: "dialogueBoostDB") } } - - /// UID of the real output device stereo audio is sent to. - @Published var selectedOutputUID: String? { + /// Software attenuation; never changes hardware volume or channel balance. + @Published var outputVolume: Float = 1 { didSet { - if let uid = selectedOutputUID { - UserDefaults.standard.set(uid, forKey: "selectedOutputUID") - } else { - UserDefaults.standard.removeObject(forKey: "selectedOutputUID") + router.setOutputVolume(isMuted ? 0 : outputVolume) + defaults.set(outputVolume, forKey: "routingVolume") + if let control = virtualVolumeControlID, !updatingVolume { + SystemAudio.setControlScalarValue(control, outputVolume) } - // Picking a new device: sync the slider to that device's level. - refreshOutputVolumeFromDevice() } } - - /// Volume of the currently selected real output device, 0...1. - /// Setting this propagates to the device immediately. - @Published var outputVolume: Float = 1.0 { + @Published var isMuted = false { didSet { - guard !suppressVolumeWriteback else { return } - guard let uid = selectedOutputUID, - let dev = availableOutputs.first(where: { $0.uid == uid }) else { return } - SystemAudio.setDeviceVolume(dev.id, outputVolume) - // Mirror slider writes to the virtual device's volume control - // so the macOS volume HUD matches what the slider shows. When - // running, this also keeps the hardware-volume-key baseline in - // sync. The listener will fire but see no change and no-op. - if isOn, let ctl = virtualVolumeControlID { - SystemAudio.setControlScalarValue(ctl, outputVolume) + router.setOutputVolume(isMuted ? 0 : outputVolume) + if let control = virtualMuteControlID, !updatingVolume { + SystemAudio.setControlMuted(control, isMuted) } } } - /// True if the selected device exposes any volume control at all. - @Published private(set) var outputVolumeAvailable: Bool = false - - /// List of real output devices (excludes MacStereoFix itself). - @Published private(set) var availableOutputs: [AudioOutputDevice] = [] - - /// True when the MacStereoFix driver bundle is installed and visible. - @Published private(set) var driverInstalled: Bool = false - - /// Latest user-facing error message, or nil. - @Published private(set) var lastError: String? - - // MARK: - Private state - + private let defaults: UserDefaults private let router = AudioRouter() - - /// Device the user was on before we hijacked the default — restored on Off. - private var previousDefaultDevice: AudioDeviceID = 0 - - /// True while we're updating outputVolume from a device read, so the - /// didSet observer doesn't write the same value back to Core Audio. - private var suppressVolumeWriteback = false - - /// While running, the object ID of the MacStereoFix virtual device's - /// output volume control, or nil if the installed driver predates the - /// volume-control addition. Used to observe hardware volume-key presses. + private let recovery = AudioRecovery() + private var previousOutputUID: String? + private var routedDeviceID: AudioDeviceID? + private var generation = 0 + private var observations: [AudioObservation] = [] + private var notifications: [(NotificationCenter, NSObjectProtocol)] = [] + private var healthTimer: Timer? + private var lastProgress: (capture: UInt64, render: UInt64) = (0, 0) + private var stalledChecks = 0 private var virtualVolumeControlID: AudioObjectID? - - /// Listener block installed on `virtualVolumeControlID`. Held so it can - /// be removed cleanly on teardown — `AudioObjectRemovePropertyListenerBlock` - /// requires the same block reference used at registration. - private var virtualVolumeListenerBlock: AudioObjectPropertyListenerBlock? - - // MARK: - Init - - init() { - // Load persisted prefs - if let stored = UserDefaults.standard.object(forKey: "dialogueBoostDB") as? Double { - self.dialogueBoostDB = Float(stored) - } - router.setDialogueBoostDB(self.dialogueBoostDB) - - // Populate the device list first, then apply the stored selection. - // Doing this in the other order fires selectedOutputUID's didSet - // against an empty availableOutputs list. + private var volumeObservation: AudioObservation? + private var virtualMuteControlID: AudioObjectID? + private var muteObservation: AudioObservation? + private var updatingVolume = false + private var driverChangeInProgress = false + private let requestPermission: () async -> Bool + + init(defaults: UserDefaults = .standard, + requestPermission: @escaping () async -> Bool = AppState.requestAudioPermission) { + self.defaults = defaults + self.requestPermission = requestPermission + // Read the preference before device enumeration can select a fallback. + selectedOutputUID = defaults.string(forKey: "selectedOutputUID") + previousOutputUID = defaults.string(forKey: "recoveryOutputUID") + if let value = defaults.object(forKey: "dialogueBoostDB") as? NSNumber, + value.floatValue.isFinite { + dialogueBoostDB = min(max(value.floatValue, 0), 9) + } + if let value = defaults.object(forKey: "routingVolume") as? NSNumber, + value.floatValue.isFinite { + outputVolume = min(max(value.floatValue, 0), 1) + } + router.setDialogueBoostDB(dialogueBoostDB) + router.setOutputVolume(outputVolume) refreshDevices() - if let storedUID = UserDefaults.standard.string(forKey: "selectedOutputUID"), - availableOutputs.contains(where: { $0.uid == storedUID }) { - selectedOutputUID = storedUID + recoverOutput() + // Always start off. A crash, login, or permission prompt must not + // silently re-enable system-wide capture. + defaults.removeObject(forKey: "wasOn") + + if let listener = AudioObservation(selector: kAudioHardwarePropertyDevices, + handler: { [weak self] in self?.refreshDevices() }) { observations.append(listener) } + if let listener = AudioObservation(selector: kAudioHardwarePropertyDefaultOutputDevice, + handler: { [weak self] in self?.defaultOutputChanged() }) { observations.append(listener) } + observe(.default, NSApplication.willTerminateNotification) { [weak self] in self?.turnOff() } + observe(NSWorkspace.shared.notificationCenter, NSWorkspace.willSleepNotification) { [weak self] in + guard let self else { return } + self.turnOff() + self.statusMessage = "Paused for sleep. Turn Force Stereo on when you're ready." + } + observe(NSWorkspace.shared.notificationCenter, NSWorkspace.didWakeNotification) { [weak self] in + self?.refreshDevices() } - - installDeviceListListener() - installTerminationObserver() - recoverFromCrashedSession() - - // Auto-resume: if the user had it on last time, turn it back on. - if UserDefaults.standard.bool(forKey: "wasOn") { - // Small delay so the menu bar UI has time to appear and the driver - // device is fully registered after a fresh login / reboot. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self, !self.isOn, self.driverInstalled else { return } - self.turnOn() - } + healthTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.checkAudioHealth() } } } - /// If a previous run crashed while toggled On, the system default output - /// may still be pointing at MacStereoFix with nothing reading from it. - /// Detect that and bounce the default to the user's chosen real device. - private func recoverFromCrashedSession() { - let current = SystemAudio.defaultOutputDevice() - guard let mac = SystemAudio.macStereoFixDeviceID(), current == mac else { return } - if let uid = selectedOutputUID, - let target = availableOutputs.first(where: { $0.uid == uid }) { - SystemAudio.setDefaultOutputDevice(target.id) - } else if let first = availableOutputs.first { - SystemAudio.setDefaultOutputDevice(first.id) - } + deinit { + healthTimer?.invalidate() + for (center, token) in notifications { center.removeObserver(token) } } - private func installTerminationObserver() { - NotificationCenter.default.addObserver( - forName: NSApplication.willTerminateNotification, - object: nil, queue: .main - ) { [weak self] _ in - // willTerminate has a very brief window before the app dies, so - // we must run synchronously. We're on the main queue already - // (queue: .main above), so it's safe to assume the main actor. - MainActor.assumeIsolated { - guard let self = self else { return } - if self.isOn { self.turnOff() } - } + private func observe(_ center: NotificationCenter, _ name: Notification.Name, + handler: @escaping @MainActor () -> Void) { + let token = center.addObserver(forName: name, object: nil, queue: .main) { _ in + MainActor.assumeIsolated { handler() } } + notifications.append((center, token)) } - // MARK: - Device list - func refreshDevices() { - var devices = SystemAudio.allOutputDevices() - // Hide the MacStereoFix device itself from the picker. - devices.removeAll { $0.uid == SystemAudio.macStereoFixUID } - self.availableOutputs = devices - self.driverInstalled = DriverManager.isInstalled() - - let selectedStillPresent = devices.contains(where: { $0.uid == selectedOutputUID }) - - // If no valid selection, pick a new default. - if !selectedStillPresent { - let currentDefault = SystemAudio.defaultOutputDevice() - if let match = devices.first(where: { $0.id == currentDefault }) { - selectedOutputUID = match.uid - } else if let first = devices.first { - selectedOutputUID = first.uid - } + let devices = SystemAudio.allOutputDevices() + availableOutputs = devices + driverInstalled = DriverManager.isInstalled() + let selected = devices.first { $0.uid == selectedOutputUID } + if isOn && (selected == nil || selected?.id != routedDeviceID || !driverInstalled) { + stopWithError("Audio device disconnected or restarted. Select your output and turn Force Stereo on again.") } - - // Auto-failover: if we're running and the selected device just - // disconnected (e.g. AirPods taken off), switch to the new selection. - if isOn && !selectedStillPresent { - if let uid = selectedOutputUID { - switchOutputDeviceLive(to: uid) - } else { - // No devices left at all — turn off gracefully. - turnOff() - lastError = "Output device disconnected and no alternatives found." - } + if selected == nil { + selectedOutputUID = devices.first { $0.id == SystemAudio.defaultOutputDevice() }?.uid ?? devices.first?.uid } - - refreshOutputVolumeFromDevice() + if !isOn && !isBusy { recoverOutput() } } - /// Pull the current volume off the selected device into the slider value. - private func refreshOutputVolumeFromDevice() { - guard let uid = selectedOutputUID, - let dev = availableOutputs.first(where: { $0.uid == uid }) else { - outputVolumeAvailable = false - return - } - if let v = SystemAudio.deviceVolume(dev.id) { - suppressVolumeWriteback = true - outputVolume = v - suppressVolumeWriteback = false - outputVolumeAvailable = true - } else { - outputVolumeAvailable = false - } + private func defaultOutputChanged() { + guard isOn, SystemAudio.defaultOutputDevice() != SystemAudio.macStereoFixDeviceID() else { return } + turnOff() + statusMessage = "Force Stereo stopped because the output changed in macOS." } - private func installDeviceListListener() { - var addr = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyDevices, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in - DispatchQueue.main.async { - self?.refreshDevices() - } + private func recoverOutput() { + if SystemAudio.restoreOutput(preferredUID: previousOutputUID ?? selectedOutputUID) { + recovery.disarm() + defaults.removeObject(forKey: "recoveryOutputUID") + } else { + lastError = "Could not restore sound. Open System Settings → Sound → Output and choose your speakers or headphones." } - AudioObjectAddPropertyListenerBlock( - AudioObjectID(kAudioObjectSystemObject), - &addr, - DispatchQueue.main, - block) } - // MARK: - Toggle / routing - func toggle() { - if isOn { - turnOff() - } else { - turnOn() - } + guard !isBusy else { return } + if isOn { turnOff() } else { turnOn() } } func turnOn() { - guard !isOn else { return } + guard !isOn, !isBusy else { return } lastError = nil - guard let outputUID = selectedOutputUID, - let target = availableOutputs.first(where: { $0.uid == outputUID }) else { - lastError = "No output device selected." - return - } - guard DriverManager.isInstalled() else { - lastError = "MacStereoFix driver not installed." - return + statusMessage = nil + isBusy = true + generation += 1 + let request = generation + let requestedOutput = selectedOutputUID + Task { [weak self] in + guard let self else { return } + let allowed = await self.requestPermission() + guard self.generation == request else { return } + guard allowed else { + self.isBusy = false + self.lastError = "Allow MacStereoFix in System Settings → Privacy & Security → Microphone, then try again. It reads the virtual audio device, not your physical microphone." + return + } + guard self.selectedOutputUID == requestedOutput else { + self.isBusy = false + self.lastError = "The selected output changed while waiting for permission. Select your output and try again." + return + } + await self.startRouting(request: request) } - guard let macStereoFix = SystemAudio.macStereoFixDeviceID() else { - lastError = "MacStereoFix device missing — try Reinstall Driver." + } + + private func startRouting(request: Int) async { + defer { if generation == request { isBusy = false } } + guard let uid = selectedOutputUID, + let target = availableOutputs.first(where: { $0.uid == uid }), + DriverManager.isInstalled(), let virtual = SystemAudio.macStereoFixDeviceID() else { + lastError = "Install the current driver and select an available stereo output first." return } + let current = SystemAudio.defaultOutputDevice() + previousOutputUID = current == virtual ? target.uid : SystemAudio.deviceUID(current) ?? target.uid + defaults.set(previousOutputUID, forKey: "recoveryOutputUID") do { + try recovery.arm(preferredUID: previousOutputUID) try router.start(outputDevice: target.id) + // Confirm both callbacks run before moving any app's sound to the driver. + for _ in 0..<100 { + guard generation == request else { return } + let progress = router.progress + if progress.capture > 0 && progress.render > 0 { break } + if router.audioFailed { break } + try await Task.sleep(nanoseconds: 20_000_000) + } + guard generation == request else { return } + let progress = router.progress + guard !router.audioFailed, progress.capture > 0, progress.render > 0, recovery.isRunning else { + throw RoutingError.message("Audio could not start. Your normal output was kept. Check audio permission and reconnect your output device.") + } + guard selectedOutputUID == uid, SystemAudio.allOutputDevices().contains(target), + DriverManager.isInstalled(), SystemAudio.sampleRate(target.id) == router.outputSampleRate else { + throw RoutingError.message("The selected audio device changed while starting. Select your output and try again.") + } + guard SystemAudio.defaultOutputDevice() == current else { + throw RoutingError.message("The macOS output changed while starting. Try again with the output you want.") + } + setupVirtualVolume(virtual) + guard SystemAudio.setDefaultOutputDevice(virtual), SystemAudio.defaultOutputDevice() == virtual else { + throw RoutingError.message("Could not select MacStereoFix as the output.") + } + routedDeviceID = target.id + lastProgress = router.progress + stalledChecks = 0 + isOn = true } catch { - lastError = error.localizedDescription - return + stopWithError(error.localizedDescription) } - // Wire the virtual device's volume control to the real output so - // the hardware volume keys adjust the real device while running. - // Must happen BEFORE hijacking the default so the first key press - // (which usually happens after the default change) finds us ready. - setupVirtualVolumeBridge(macStereoFix: macStereoFix, realDeviceID: target.id) + } - // Hijack the system default output to MacStereoFix so games go through us. - previousDefaultDevice = SystemAudio.defaultOutputDevice() - if previousDefaultDevice == macStereoFix { - // Avoid restoring back to ourselves on Off; pick the user's chosen - // real device as the "previous" so toggling Off restores cleanly. - previousDefaultDevice = target.id - } - if !SystemAudio.setDefaultOutputDevice(macStereoFix) { - tearDownVirtualVolumeBridge() - router.stop() - lastError = "Could not set MacStereoFix as default output device." - return - } - isOn = true - UserDefaults.standard.set(true, forKey: "wasOn") + private func stopWithError(_ message: String) { + let restored = turnOff() + lastError = restored ? message : message + " Choose your speakers or headphones in System Settings → Sound → Output to restore sound." } - func turnOff() { - if previousDefaultDevice != 0 { - SystemAudio.setDefaultOutputDevice(previousDefaultDevice) - } - tearDownVirtualVolumeBridge() + @discardableResult + func turnOff() -> Bool { + generation += 1 + // Restore before stopping capture/render; preserve a user's manual output change. + let restored = SystemAudio.restoreOutput(preferredUID: previousOutputUID ?? selectedOutputUID) + volumeObservation = nil + muteObservation = nil + virtualVolumeControlID = nil + virtualMuteControlID = nil router.stop() + routedDeviceID = nil isOn = false - UserDefaults.standard.set(false, forKey: "wasOn") - } - - /// Switch the real output device while the pipeline stays active. - /// The system default (MacStereoFix) is untouched — only the render side - /// is rebuilt, so there's no audible gap or device-switch glitch. - func switchOutputDeviceLive(to uid: String) { - guard isOn else { return } - guard let target = availableOutputs.first(where: { $0.uid == uid }) else { - lastError = "Device not found." - return - } - do { - try router.switchOutputDevice(target.id) - // Update previousDefaultDevice so Off restores to the new choice. - previousDefaultDevice = target.id - // Re-seed the virtual control from the new real device so the - // next volume key press steps from the right baseline. - reseedVirtualVolumeBridge(realDeviceID: target.id) - } catch { - lastError = error.localizedDescription + isBusy = driverChangeInProgress + if restored { + recovery.disarm() + defaults.removeObject(forKey: "recoveryOutputUID") + } else { + lastError = "Could not restore sound. Choose your speakers or headphones in System Settings → Sound → Output." } + return restored } - // MARK: - Driver install / uninstall - - func installDriver() { - lastError = nil - if let err = DriverManager.installDriver() { - lastError = err - } - // Give coreaudiod a moment to come back, then re-scan. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.refreshDevices() - } + func selectOutput(_ uid: String) { + guard !isBusy else { return } + let resume = isOn + if resume && !turnOff() { return } + selectedOutputUID = uid.isEmpty ? nil : uid + if resume { turnOn() } } - func uninstallDriver() { - if isOn { turnOff() } - lastError = nil - if let err = DriverManager.uninstallDriver() { - lastError = err + private func setupVirtualVolume(_ virtual: AudioDeviceID) { + if let muteControl = SystemAudio.outputControlID(for: virtual, class: kAudioMuteControlClassID) { + virtualMuteControlID = muteControl + SystemAudio.setControlMuted(muteControl, isMuted) + muteObservation = AudioObservation(object: muteControl, selector: kAudioBooleanControlPropertyValue) { [weak self] in + guard let self, let value = SystemAudio.controlMuted(muteControl) else { return } + self.updatingVolume = true + self.isMuted = value + self.updatingVolume = false + } } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.refreshDevices() + guard let control = SystemAudio.outputControlID(for: virtual, class: kAudioVolumeControlClassID) else { return } + virtualVolumeControlID = control + SystemAudio.setControlScalarValue(control, outputVolume) + volumeObservation = AudioObservation(object: control, selector: kAudioLevelControlPropertyScalarValue) { [weak self] in + guard let self, let value = SystemAudio.controlScalarValue(control) else { return } + self.updatingVolume = true + self.outputVolume = value + self.updatingVolume = false } } - // MARK: - Virtual volume bridge - - /// Discover MacStereoFix's output volume control, seed it with the - /// real device's current volume, and install a listener so hardware - /// volume-key presses mirror onto the real device. Called by turnOn. - private func setupVirtualVolumeBridge(macStereoFix: AudioDeviceID, realDeviceID: AudioDeviceID) { - guard let ctl = SystemAudio.outputVolumeControlID(for: macStereoFix) else { - // Driver predates the volume control — slider still works, - // hardware volume keys just won't. Not an error. - virtualVolumeControlID = nil - virtualVolumeListenerBlock = nil - return + private func checkAudioHealth() { + guard isOn else { return } + router.updateClockDrift() + let progress = router.progress + let stalled = progress.capture == lastProgress.capture || progress.render == lastProgress.render + stalledChecks = stalled ? stalledChecks + 1 : 0 + lastProgress = progress + let rateChanged = routedDeviceID.map { SystemAudio.sampleRate($0) != router.outputSampleRate } ?? true + if router.audioFailed || stalledChecks >= 3 || rateChanged || !recovery.isRunning { + stopWithError("Audio routing stopped working, so Force Stereo was turned off. Check your output device and try again.") } - virtualVolumeControlID = ctl + } - // Seed the control with the real device's current volume so the - // first key press produces a normal-sized step rather than a jump - // from whatever stale value the driver last remembered. - if let realVol = SystemAudio.deviceVolume(realDeviceID) { - SystemAudio.setControlScalarValue(ctl, realVol) - } + func installDriver() { changeDriver(install: true) } + func uninstallDriver() { changeDriver(install: false) } - // Install the listener. AudioObjectAddPropertyListenerBlock will - // deliver the callback on the queue we pass, so we're already on - // main when the handler runs. - let block = SystemAudio.installControlScalarListener( - on: ctl, - queue: .main - ) { [weak self] in - MainActor.assumeIsolated { - self?.handleVirtualVolumeChange() + private func changeDriver(install: Bool) { + guard !isBusy else { return } + // Removal must remain possible on a headless Mac with no physical + // output. Always stop IO, even when there is nowhere to restore sound. + let restored = turnOff() + lastError = nil + statusMessage = install ? "Installing driver…" : "Removing driver…" + isBusy = true + driverChangeInProgress = true + Task { [weak self] in + let error = await Task.detached { + install ? DriverManager.installDriver() : DriverManager.uninstallDriver() + }.value + guard let self else { return } + if let error { + self.statusMessage = nil + self.lastError = error + } else { + self.statusMessage = install ? "Driver copied. Waiting for macOS audio…" : "Driver removed." } + // Registration can take longer than a fixed 1.5-second delay. + self.refreshDevices() + if error == nil { + for _ in 0..<20 { + if install == self.driverInstalled { break } + try? await Task.sleep(nanoseconds: 250_000_000) + self.refreshDevices() + } + } + if error == nil && install { + self.statusMessage = self.driverInstalled ? "Driver ready." : nil + if !self.driverInstalled { self.lastError = "Driver copied, but macOS hasn't loaded it. Restart your Mac, then try again." } + } + self.driverChangeInProgress = false + self.isBusy = false + if !restored && error == nil { self.recoverOutput() } } - virtualVolumeListenerBlock = block - } - - /// Remove the listener and clear cached state. Called by turnOff. - private func tearDownVirtualVolumeBridge() { - if let ctl = virtualVolumeControlID, let block = virtualVolumeListenerBlock { - SystemAudio.removeControlScalarListener(on: ctl, queue: .main, block: block) - } - virtualVolumeControlID = nil - virtualVolumeListenerBlock = nil } - /// When the real output device changes mid-session, re-read its - /// current volume and push it into the virtual control so the next - /// key press steps from the right baseline. - private func reseedVirtualVolumeBridge(realDeviceID: AudioDeviceID) { - guard let ctl = virtualVolumeControlID else { return } - guard let realVol = SystemAudio.deviceVolume(realDeviceID) else { return } - SystemAudio.setControlScalarValue(ctl, realVol) + private enum RoutingError: LocalizedError { + case message(String) + var errorDescription: String? { switch self { case .message(let text): return text } } } - /// Fired when macOS writes a new value to the virtual device's output - /// volume control — usually because the user pressed a hardware volume - /// key. Mirror the new value to the real device and update the slider. - private func handleVirtualVolumeChange() { - guard let ctl = virtualVolumeControlID else { return } - guard let newValue = SystemAudio.controlScalarValue(ctl) else { return } - if let uid = selectedOutputUID, - let dev = availableOutputs.first(where: { $0.uid == uid }) { - SystemAudio.setDeviceVolume(dev.id, newValue) + nonisolated static func requestAudioPermission() async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: return true + case .notDetermined: return await AVCaptureDevice.requestAccess(for: .audio) + default: return false } - // Update the slider, suppressing the didSet so it doesn't write - // back to the real device or the virtual control again. - suppressVolumeWriteback = true - outputVolume = newValue - suppressVolumeWriteback = false } } diff --git a/App/AudioObservation.swift b/App/AudioObservation.swift new file mode 100644 index 0000000..cb99e64 --- /dev/null +++ b/App/AudioObservation.swift @@ -0,0 +1,20 @@ +import CoreAudio +import Foundation + +/// Owns the exact block and queue required to remove a CoreAudio listener. +final class AudioObservation { + private let object: AudioObjectID + private var address: AudioObjectPropertyAddress + private let block: AudioObjectPropertyListenerBlock + + init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject), + selector: AudioObjectPropertySelector, handler: @escaping @MainActor () -> Void) { + self.object = object + address = AudioObjectPropertyAddress(mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + block = { _, _ in MainActor.assumeIsolated { handler() } } + guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr else { return nil } + } + + deinit { AudioObjectRemovePropertyListenerBlock(object, &address, .main, block) } +} diff --git a/App/AudioRecovery.swift b/App/AudioRecovery.swift new file mode 100644 index 0000000..61adbb2 --- /dev/null +++ b/App/AudioRecovery.swift @@ -0,0 +1,44 @@ +import Foundation + +/// A child owns the read end of a pipe. App termination (including SIGKILL) +/// closes the write end, waking it to restore the output without a login item. +final class AudioRecovery { + private var process: Process? + private var input: FileHandle? + + var isRunning: Bool { process?.isRunning == true } + + func arm(preferredUID: String?) throws { + disarm() + guard let executable = Bundle.main.url(forAuxiliaryExecutable: "MacStereoFixRecovery") else { + throw CocoaError(.fileNoSuchFile) + } + let child = Process() + let pipe = Pipe() + child.executableURL = executable + child.arguments = [preferredUID ?? ""] + child.standardInput = pipe + child.standardOutput = FileHandle.nullDevice + child.standardError = FileHandle.nullDevice + try child.run() + _ = fcntl(pipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) + process = child + input = pipe.fileHandleForWriting + // Process.run returns only after a successful exec; EOF is retained if + // the parent exits before the child starts reading. + } + + func disarm() { + if let input { + try? input.write(contentsOf: Data("disarm\n".utf8)) + try? input.close() + } + input = nil + process = nil + } + + deinit { + // No disarm here: unexpected owner destruction must trigger recovery. + try? input?.close() + } +} diff --git a/App/AudioRouter.swift b/App/AudioRouter.swift index ec37d4f..4247e2f 100644 --- a/App/AudioRouter.swift +++ b/App/AudioRouter.swift @@ -21,19 +21,11 @@ import AudioToolbox let kMSFChannelCount: Int = 8 /// Sample rate of the virtual device. Must match the driver's kSampleRate. let kMSFSampleRate: Float64 = 48000 -/// Frames of ring-buffer headroom between capture and render threads. -/// Matches the driver's kRingBufferFrameCount — enough slack for any IO -/// cycle size we'd ever pick, small enough to cap worst-case latency. -let kMSFRingFrames: Int = 4096 -/// Target IO buffer size (frames) requested on both capture and render -/// devices. At 48 kHz this is ~2.7 ms per cycle. Clamped to each device's -/// BufferFrameSizeRange at start; devices that can't go this small (e.g. -/// Bluetooth) will simply use their smallest supported size. -let kMSFTargetBufferFrames: UInt32 = 128 -/// Render-side ring-buffer fill cap. When fill exceeds this, the render -/// callback drops the oldest frames before reading. Keeps app-side latency -/// bounded at roughly one target IO cycle. -let kMSFMaxFillFrames: Int = 256 +/// Bounded storage for large hardware IO cycles and sample-rate conversion. +let kMSFRingFrames = 32768 +let kMSFMaximumFrames: UInt32 = 8192 +let kMSFMaximumSourceFrames = 16384 +let kMSFMaxFillFrames = 4096 // MARK: - AudioRouter @@ -62,6 +54,7 @@ final class AudioRouter: @unchecked Sendable { private var captureUnit: AudioUnit? private var renderUnit: AudioUnit? + private var converterUnit: AudioUnit? // MARK: Buffers @@ -77,25 +70,42 @@ final class AudioRouter: @unchecked Sendable { /// Scratch used inside the render callback to pull 8-ch frames from the /// ring buffer before downmixing. Lives as long as the render unit. private var pullScratch: UnsafeMutablePointer? + private var stereoScratch: UnsafeMutablePointer? private var pullScratchCapacity: Int = 0 + private var renderPrimed = false // consumer only; reset with IO stopped // MARK: Downmix gains /// Linear center-channel gain. Read on the real-time render thread, written /// from the UI thread — stored as an atomic float so there's no tearing. private let centerGain = UnsafeMutablePointer.allocate(capacity: 1) - /// Surround / rear-surround coefficients are fixed ITU constants. - private static let surroundGain: Float = 0.707 // -3 dB, ITU Ls/Rs - private static let rearGain: Float = 0.5 // -6 dB, Lsr/Rsr + /// Software attenuation applied after the bounded stereo downmix. + private let outputGain = UnsafeMutablePointer.allocate(capacity: 1) + private let callbackFailure = UnsafeMutablePointer.allocate(capacity: 1) + private let captureCycles = UnsafeMutablePointer.allocate(capacity: 1) + private let renderCycles = UnsafeMutablePointer.allocate(capacity: 1) + private let bufferedFrames = UnsafeMutablePointer.allocate(capacity: 1) + private(set) var outputSampleRate: Float64 = 0 // MARK: - Init / deinit init() { msf_atomic_float_init(centerGain, 0.707) + msf_atomic_float_init(outputGain, 1) + msf_atomic_init(callbackFailure, 0) + msf_atomic_init(captureCycles, 0) + msf_atomic_init(renderCycles, 0) + msf_atomic_float_init(bufferedFrames, Float(StereoMix.reserveFrames)) } deinit { + stop() centerGain.deallocate() + outputGain.deallocate() + callbackFailure.deallocate() + captureCycles.deallocate() + renderCycles.deallocate() + bufferedFrames.deallocate() } // MARK: - Public API @@ -105,7 +115,7 @@ final class AudioRouter: @unchecked Sendable { /// Set the dialogue boost in dB (added on top of the -3 dB ITU baseline). func setDialogueBoostDB(_ dB: Float) { - let linear: Float = 0.707 * pow(10.0, dB / 20.0) + let linear = StereoMix.centerGain(boostDB: dB) msf_atomic_float_store(centerGain, linear) } @@ -119,12 +129,16 @@ final class AudioRouter: @unchecked Sendable { } ringBuffer.reset() - - try buildCaptureUnit(deviceID: macStereoFix) + renderPrimed = false + msf_atomic_float_store(bufferedFrames, Float(StereoMix.reserveFrames)) + msf_atomic_store(callbackFailure, 0) + msf_atomic_store(captureCycles, 0) + msf_atomic_store(renderCycles, 0) do { + try buildCaptureUnit(deviceID: macStereoFix) try buildRenderUnit(deviceID: outputDevice) } catch { - tearDownCaptureUnit() + tearDown() throw error } @@ -146,27 +160,31 @@ final class AudioRouter: @unchecked Sendable { } func stop() { - if let c = captureUnit { AudioOutputUnitStop(c) } - if let r = renderUnit { AudioOutputUnitStop(r) } tearDown() } - /// Swap only the render (output) device while capture stays running. - /// This avoids toggling the system default device and eliminates the - /// audible gap when the user switches output devices mid-session. + /// Rebuild with both callbacks stopped before touching shared storage. func switchOutputDevice(_ newDeviceID: AudioDeviceID) throws { - guard isRunning else { return } - if let r = renderUnit { AudioOutputUnitStop(r) } - tearDownRenderUnit() - try buildRenderUnit(deviceID: newDeviceID) - if let r = renderUnit { - let status = AudioOutputUnitStart(r) - if status != noErr { - tearDownRenderUnit() - throw RouterError.audioUnitError("AudioOutputUnitStart(render switch)", status) - } + stop() + try start(outputDevice: newDeviceID) + } + + func setOutputVolume(_ volume: Float) { + msf_atomic_float_store(outputGain, volume.isFinite ? min(max(volume, 0), 1) : 0) + } + + var audioFailed: Bool { msf_atomic_load(callbackFailure) != 0 } + func updateClockDrift() { + guard let converterUnit else { return } + let rate = StereoMix.clockRate(bufferedFrames: msf_atomic_float_load(bufferedFrames)) + if AudioUnitSetParameter(converterUnit, kVarispeedParam_PlaybackRate, + kAudioUnitScope_Global, 0, rate, 0) != noErr { + msf_atomic_store(callbackFailure, 1) } } + var progress: (capture: UInt64, render: UInt64) { + (msf_atomic_load(captureCycles), msf_atomic_load(renderCycles)) + } // MARK: - Teardown @@ -177,6 +195,7 @@ final class AudioRouter: @unchecked Sendable { private func tearDownCaptureUnit() { if let c = captureUnit { + AudioOutputUnitStop(c) AudioUnitUninitialize(c) AudioComponentInstanceDispose(c) captureUnit = nil @@ -194,45 +213,23 @@ final class AudioRouter: @unchecked Sendable { private func tearDownRenderUnit() { if let r = renderUnit { + AudioOutputUnitStop(r) AudioUnitUninitialize(r) AudioComponentInstanceDispose(r) renderUnit = nil } + if let c = converterUnit { + AudioUnitUninitialize(c) + AudioComponentInstanceDispose(c) + converterUnit = nil + } if let p = pullScratch { p.deallocate() pullScratch = nil pullScratchCapacity = 0 } - } - - // MARK: - Device buffer size helper - - /// Ask a device to run with the smallest IO cycle that its range permits, - /// capped at `target`. Non-fatal on failure: the device keeps its current - /// size and we'll just get whatever latency it gives us. Must be called - /// before AudioUnitInitialize. - private func requestSmallBufferSize(deviceID: AudioDeviceID, target: UInt32) { - var rangeAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyBufferFrameSizeRange, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - var range = AudioValueRange(mMinimum: 0, mMaximum: 0) - var rangeSize = UInt32(MemoryLayout.size) - var desired = target - if AudioObjectGetPropertyData(deviceID, &rangeAddr, 0, nil, &rangeSize, &range) == noErr, - range.mMaximum > 0 { - let lo = UInt32(max(1.0, range.mMinimum)) - let hi = UInt32(range.mMaximum) - if desired < lo { desired = lo } - if desired > hi { desired = hi } - } - var value = desired - var sizeAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyBufferFrameSize, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - _ = AudioObjectSetPropertyData(deviceID, &sizeAddr, 0, nil, - UInt32(MemoryLayout.size), &value) + stereoScratch?.deallocate() + stereoScratch = nil } // MARK: - Capture unit @@ -318,19 +315,14 @@ final class AudioRouter: @unchecked Sendable { throw RouterError.audioUnitError("SetInputCallback", status) } - // Query the device's maximum IO buffer size so we never under-allocate. - // Fall back to a safe default if the query fails. - var maxFrameSize: UInt32 = 0 - var propSize = UInt32(MemoryLayout.size) - var bufSizeAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyBufferFrameSize, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - if AudioObjectGetPropertyData(deviceID, &bufSizeAddr, 0, nil, &propSize, &maxFrameSize) != noErr || maxFrameSize == 0 { - maxFrameSize = 4096 + var maximum = kMSFMaximumFrames + status = AudioUnitSetProperty(u, kAudioUnitProperty_MaximumFramesPerSlice, + kAudioUnitScope_Global, 0, &maximum, UInt32(MemoryLayout.size)) + if status != noErr { + AudioComponentInstanceDispose(u) + throw RouterError.audioUnitError("MaximumFrames(capture)", status) } - // Add headroom: some devices may deliver slightly more than reported. - let maxFrames = Int(maxFrameSize) * 2 + let maxFrames = Int(maximum) let totalSamples = maxFrames * kMSFChannelCount let storage = UnsafeMutablePointer.allocate(capacity: totalSamples) let abl = UnsafeMutablePointer.allocate(capacity: 1) @@ -342,11 +334,6 @@ final class AudioRouter: @unchecked Sendable { captureBufferStorage = storage captureBufferCapacity = maxFrames - // Ask the virtual device for the smallest IO cycle we can get. - // Must happen before AudioUnitInitialize so the unit picks up the - // new cycle size. - requestSmallBufferSize(deviceID: deviceID, target: kMSFTargetBufferFrames) - status = AudioUnitInitialize(u) if status != noErr { AudioComponentInstanceDispose(u) @@ -385,15 +372,26 @@ final class AudioRouter: @unchecked Sendable { throw RouterError.audioUnitError("CurrentDevice(render)", status) } - // We provide stereo Float32 at 48k. The HALOutput unit will sample-rate - // convert to whatever the device wants. + // AUHAL requires the hardware's sample rate. A separate Apple converter + // supplies stereo at that rate without changing the user's device format. + var hardwareFormat = AudioStreamBasicDescription() + var formatSize = UInt32(MemoryLayout.size) + status = AudioUnitGetProperty(u, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, 0, &hardwareFormat, &formatSize) + guard status == noErr, hardwareFormat.mSampleRate.isFinite, + (32000...192000).contains(hardwareFormat.mSampleRate), + hardwareFormat.mChannelsPerFrame >= 2 else { + AudioComponentInstanceDispose(u) + throw RouterError.audioUnitError("Select a stereo output at 32–192 kHz", status == noErr ? kAudioDeviceUnsupportedFormatError : status) + } + outputSampleRate = hardwareFormat.mSampleRate var fmt = AudioStreamBasicDescription( - mSampleRate: kMSFSampleRate, + mSampleRate: outputSampleRate, mFormatID: kAudioFormatLinearPCM, - mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagsNativeEndian | kAudioFormatFlagIsPacked, - mBytesPerPacket: UInt32(2 * MemoryLayout.size), + mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagsNativeEndian | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved, + mBytesPerPacket: UInt32(MemoryLayout.size), mFramesPerPacket: 1, - mBytesPerFrame: UInt32(2 * MemoryLayout.size), + mBytesPerFrame: UInt32(MemoryLayout.size), mChannelsPerFrame: 2, mBitsPerChannel: 32, mReserved: 0) @@ -406,45 +404,72 @@ final class AudioRouter: @unchecked Sendable { throw RouterError.audioUnitError("StreamFormat(render in)", status) } - var cb = AURenderCallbackStruct( - inputProc: AudioRouter.renderCallback, - inputProcRefCon: Unmanaged.passUnretained(self).toOpaque()) - status = AudioUnitSetProperty(u, - kAudioUnitProperty_SetRenderCallback, - kAudioUnitScope_Input, 0, - &cb, UInt32(MemoryLayout.size)) - if status != noErr { + do { + let converter = try buildConverter(outputFormat: fmt) + var connection = AudioUnitConnection(sourceAudioUnit: converter, sourceOutputNumber: 0, destInputNumber: 0) + var maximum = kMSFMaximumFrames + try check("Output frame limit", AudioUnitSetProperty(u, kAudioUnitProperty_MaximumFramesPerSlice, + kAudioUnitScope_Global, 0, &maximum, UInt32(MemoryLayout.size))) + try check("Connect converter", AudioUnitSetProperty(u, kAudioUnitProperty_MakeConnection, + kAudioUnitScope_Input, 0, &connection, UInt32(MemoryLayout.size))) + try check("Initialize render", AudioUnitInitialize(u)) + } catch { AudioComponentInstanceDispose(u) - throw RouterError.audioUnitError("SetRenderCallback", status) + throw error } + renderUnit = u + } - // Query the output device's buffer size and add headroom for the - // render-side 8ch pull scratch. - var outBufSize: UInt32 = 0 - var obsPropSize = UInt32(MemoryLayout.size) - var obsSizeAddr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyBufferFrameSize, - mScope: kAudioObjectPropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain) - if AudioObjectGetPropertyData(deviceID, &obsSizeAddr, 0, nil, &obsPropSize, &outBufSize) != noErr || outBufSize == 0 { - outBufSize = 4096 - } - let maxFrames = Int(outBufSize) * 2 - let total = maxFrames * kMSFChannelCount - pullScratch = UnsafeMutablePointer.allocate(capacity: total) - pullScratchCapacity = maxFrames + private func check(_ operation: String, _ status: OSStatus) throws { + if status != noErr { throw RouterError.audioUnitError(operation, status) } + } - // Request the smallest IO cycle the real device supports so render - // latency stays low. Must happen before AudioUnitInitialize. - requestSmallBufferSize(deviceID: deviceID, target: kMSFTargetBufferFrames) + private func buildConverter(outputFormat: AudioStreamBasicDescription) throws -> AudioUnit { + var description = AudioComponentDescription(componentType: kAudioUnitType_FormatConverter, + componentSubType: kAudioUnitSubType_Varispeed, + componentManufacturer: kAudioUnitManufacturer_Apple, componentFlags: 0, componentFlagsMask: 0) + guard let component = AudioComponentFindNext(nil, &description) else { throw RouterError.componentNotFound } + var unit: AudioUnit? + try check("Create converter", AudioComponentInstanceNew(component, &unit)) + guard let unit else { throw RouterError.componentNotFound } + converterUnit = unit + var format = outputFormat + var source = format + source.mSampleRate = kMSFSampleRate + let formatSize = UInt32(MemoryLayout.size) + var callback = AURenderCallbackStruct(inputProc: AudioRouter.renderCallback, + inputProcRefCon: Unmanaged.passUnretained(self).toOpaque()) + var maximum = UInt32(kMSFMaximumSourceFrames) + try check("Converter input", AudioUnitSetProperty(unit, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Input, 0, &source, formatSize)) + try check("Converter output", AudioUnitSetProperty(unit, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, 0, &format, formatSize)) + try check("Converter callback", AudioUnitSetProperty(unit, kAudioUnitProperty_SetRenderCallback, + kAudioUnitScope_Input, 0, &callback, UInt32(MemoryLayout.size))) + try check("Converter frame limit", AudioUnitSetProperty(unit, kAudioUnitProperty_MaximumFramesPerSlice, + kAudioUnitScope_Global, 0, &maximum, UInt32(MemoryLayout.size))) + pullScratch = UnsafeMutablePointer.allocate(capacity: kMSFMaximumSourceFrames * kMSFChannelCount) + stereoScratch = UnsafeMutablePointer.allocate(capacity: kMSFMaximumSourceFrames * 2) + pullScratchCapacity = kMSFMaximumSourceFrames + try check("Initialize converter", AudioUnitInitialize(unit)) + return unit + } - status = AudioUnitInitialize(u) - if status != noErr { - AudioComponentInstanceDispose(u) - throw RouterError.audioUnitError("AudioUnitInitialize(render)", status) - } - renderUnit = u + #if MSF_TESTING + // Exercise the production converter and callback without installing a driver + // or sending any audio to hardware. This code is absent from app builds. + func prepareOfflineOutput(sampleRate: Float64) throws -> AudioUnit { + stop() + ringBuffer.reset() + renderPrimed = false + return try buildConverter(outputFormat: AudioStreamBasicDescription(mSampleRate: sampleRate, + mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagsNativeEndian | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved, + mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4, + mChannelsPerFrame: 2, mBitsPerChannel: 32, mReserved: 0)) } + func enqueueOfflineAudio(_ source: UnsafePointer, frames: Int) { ringBuffer.write(source, frameCount: frames) } + #endif // MARK: - Real-time callbacks (C-style) @@ -455,13 +480,24 @@ final class AudioRouter: @unchecked Sendable { let abl = router.captureBufferList else { return noErr } - // Clamp to our buffer capacity — drop excess frames rather than overrun. - let framesToCapture = min(Int(inNumberFrames), router.captureBufferCapacity) + let framesToCapture = Int(inNumberFrames) + guard framesToCapture <= router.captureBufferCapacity else { + msf_atomic_store(router.callbackFailure, 1) + return kAudioUnitErr_TooManyFramesToProcess + } abl.pointee.mBuffers.mDataByteSize = UInt32(framesToCapture * kMSFChannelCount * MemoryLayout.size) let status = AudioUnitRender(unit, ioActionFlags, inTimeStamp, inBusNumber, UInt32(framesToCapture), abl) if status != noErr { + msf_atomic_store(router.callbackFailure, 1) return status } + guard abl.pointee.mNumberBuffers == 1, abl.pointee.mBuffers.mNumberChannels == UInt32(kMSFChannelCount), + abl.pointee.mBuffers.mData != nil, + Int(abl.pointee.mBuffers.mDataByteSize) >= framesToCapture * kMSFChannelCount * MemoryLayout.size else { + msf_atomic_store(router.callbackFailure, 1) + return kAudioUnitErr_FormatNotSupported + } + msf_atomic_store(router.captureCycles, msf_atomic_load(router.captureCycles) &+ 1) // Push captured 8ch frames into the ring buffer (drop on overflow). if let raw = abl.pointee.mBuffers.mData { let src = raw.assumingMemoryBound(to: Float.self) @@ -475,19 +511,30 @@ final class AudioRouter: @unchecked Sendable { let router = Unmanaged.fromOpaque(inRefCon).takeUnretainedValue() guard let ioData = ioData else { return noErr } let abl = UnsafeMutableAudioBufferListPointer(ioData) - // Render unit's input format is interleaved stereo Float32 -> single buffer. - guard abl.count >= 1 else { return noErr } - let stereoBuf = abl[0] - guard let stereoRaw = stereoBuf.mData else { return noErr } - let dst = stereoRaw.assumingMemoryBound(to: Float.self) let frames = Int(inNumberFrames) - - // Pull 8ch frames from ring buffer into scratch. - guard let scratch = router.pullScratch, router.pullScratchCapacity >= frames else { - // No scratch -> output silence. - memset(dst, 0, frames * 2 * MemoryLayout.size) - return noErr + let bytes = frames * MemoryLayout.size + // AUVarispeed uses planar Float32. Provide preallocated storage whenever + // the converter leaves a channel pointer nil, and validate every plane. + guard abl.count == 2, frames <= router.pullScratchCapacity, + let scratch = router.pullScratch, let stereo = router.stereoScratch else { + for buffer in abl { if let data = buffer.mData { memset(data, 0, Int(buffer.mDataByteSize)) } } + msf_atomic_store(router.callbackFailure, 1) + return kAudioUnitErr_TooManyFramesToProcess + } + for channel in 0..<2 { + if abl[channel].mData == nil { + abl[channel].mData = UnsafeMutableRawPointer(stereo.advanced(by: channel * router.pullScratchCapacity)) + abl[channel].mDataByteSize = UInt32(bytes) + } + guard abl[channel].mNumberChannels == 1, Int(abl[channel].mDataByteSize) >= bytes else { + for buffer in abl { if let data = buffer.mData { memset(data, 0, Int(buffer.mDataByteSize)) } } + msf_atomic_store(router.callbackFailure, 1) + return kAudioUnitErr_FormatNotSupported + } } + let left = abl[0].mData!.assumingMemoryBound(to: Float.self) + let right = abl[1].mData!.assumingMemoryBound(to: Float.self) + // Cap app-side latency: if the capture side has gotten ahead of us // by more than kMSFMaxFillFrames + this cycle's frames, discard the // excess oldest frames before reading. Without this the steady-state @@ -495,41 +542,33 @@ final class AudioRouter: @unchecked Sendable { // produced, which just sits in the pipe forever as pure delay. let targetFill = kMSFMaxFillFrames + frames let currentFill = router.ringBuffer.fillFrames() + if !router.renderPrimed { + guard currentFill >= frames + StereoMix.reserveFrames else { + memset(left, 0, bytes) + memset(right, 0, bytes) + return noErr + } + router.renderPrimed = true + } if currentFill > targetFill { router.ringBuffer.skip(frameCount: currentFill - targetFill) } let read = router.ringBuffer.read(scratch, frameCount: frames) + let fill = Float(router.ringBuffer.fillFrames()) + let previous = msf_atomic_float_load(router.bufferedFrames) + msf_atomic_float_store(router.bufferedFrames, previous + 0.01 * (fill - previous)) if read < frames { + router.renderPrimed = false // Zero-fill the tail of scratch where we have no data. let tailStart = read * kMSFChannelCount let tailCount = (frames - read) * kMSFChannelCount memset(scratch.advanced(by: tailStart), 0, tailCount * MemoryLayout.size) } - // Downmix 8ch interleaved -> stereo interleaved. - // Channel order (matches driver's preferred layout): - // 0: L 1: R 2: C 3: LFE 4: Ls 5: Rs 6: Lsr 7: Rsr - let cgain = msf_atomic_float_load(router.centerGain) - let sgain = AudioRouter.surroundGain - let rgain = AudioRouter.rearGain - var s = 0 - var d = 0 - for _ in 0.. Bool { - return FileManager.default.fileExists(atPath: driverInstallPath) - && SystemAudio.isInstalled() + guard let bundled = bundledDriverPath(), + let expected = version(at: bundled), expected == version(at: driverInstallPath) else { return false } + return SystemAudio.macStereoFixDriverVersion() == expected } - /// Returns the path to the bundled driver inside our app's Resources, or - /// nil if it's missing (which should never happen in a properly built app). - private static func bundledDriverPath() -> String? { - guard let resPath = Bundle.main.resourcePath else { return nil } - let candidate = (resPath as NSString).appendingPathComponent("MacStereoFix.driver") - return FileManager.default.fileExists(atPath: candidate) ? candidate : nil + private static func version(at path: String) -> String? { + // Read the plist directly: Bundle caches metadata across reinstalls. + let url = URL(fileURLWithPath: path).appendingPathComponent("Contents/Info.plist") + guard let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil } + return plist["CFBundleVersion"] as? String } - // MARK: - Install / uninstall + private static func bundledDriverPath() -> String? { + Bundle.main.resourceURL?.appendingPathComponent("MacStereoFix.driver").path + } - /// Install (or reinstall) the driver. Prompts the user for an admin - /// password via the standard macOS authentication dialog. Returns nil on - /// success or an error message on failure. static func installDriver() -> String? { - guard let src = bundledDriverPath() else { - return "Bundled driver not found inside MacStereoFix.app/Contents/Resources." - } - let escapedSrc = shellEscape(src) - let escapedDst = shellEscape(driverInstallPath) - let shell = """ - mkdir -p '/Library/Audio/Plug-Ins/HAL' && \ - rm -rf '\(escapedDst)' && \ - cp -R '\(escapedSrc)' '\(escapedDst)' && \ - xattr -dr com.apple.quarantine '\(escapedDst)' 2>/dev/null; \ - chown -R root:wheel '\(escapedDst)' && \ - killall coreaudiod 2>/dev/null || true - """ - return runPrivileged(shell: shell, failureLabel: "Install failed.") + guard let source = bundledDriverPath() else { return "Bundled driver not found." } + // Generated Swift constants are sealed in the app's signed executable. + let shell = "set -- \(shellQuote(source)) \(shellQuote(signingRequirement))\n" + InstallerScripts.install + return runPrivileged(shell: shell, failureLabel: "Installation failed.") } - /// Uninstall the driver. Same admin prompt flow. static func uninstallDriver() -> String? { - let escapedDst = shellEscape(driverInstallPath) - let shell = """ - rm -rf '\(escapedDst)' && (killall coreaudiod 2>/dev/null || true) - """ - return runPrivileged(shell: shell, failureLabel: "Uninstall failed.") + runPrivileged(shell: InstallerScripts.uninstall, failureLabel: "Removal failed.") } - // MARK: - Privileged shell helper + static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } - /// Wrap `shell` in a `do shell script ... with administrator privileges` - /// AppleScript and run it. Returns nil on success or an error message. - private static func runPrivileged(shell: String, failureLabel: String) -> String? { - // Escape the shell string for embedding inside a double-quoted - // AppleScript literal: backslashes first, then double quotes. - let escaped = shell - .replacingOccurrences(of: "\\", with: "\\\\") + static func appleScript(shell: String) -> String { + // A fixed shell and a single quoted script argument prevent any path + // (including spaces, quotes and newlines) from becoming executable code. + let command = "/bin/bash -c " + shellQuote(shell) + let escaped = command.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") - let appleScript = """ - do shell script "\(escaped)" with administrator privileges - """ - guard let scriptObj = NSAppleScript(source: appleScript) else { - return "Could not create AppleScript." - } - var errorDict: NSDictionary? - _ = scriptObj.executeAndReturnError(&errorDict) - if let err = errorDict { - return (err["NSAppleScriptErrorMessage"] as? String) ?? failureLabel - } - return nil + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + return "do shell script \"\(escaped)\" with administrator privileges" } - /// Single-quote-escape a path so it's safe to embed inside a `'...'` shell - /// literal (closes the quote, inserts an escaped quote, reopens). - private static func shellEscape(_ s: String) -> String { - return s.replacingOccurrences(of: "'", with: "'\\''") + private static func runPrivileged(shell: String, failureLabel: String) -> String? { + let process = Process() + let errorPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", appleScript(shell: shell)] + process.standardOutput = FileHandle.nullDevice + process.standardError = errorPipe + do { + try process.run() + // Drain before waiting so a large error cannot fill the pipe and deadlock. + let data = errorPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus != 0 else { return nil } + let message = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return message.flatMap { $0.isEmpty ? nil : $0 } ?? failureLabel + } catch { return "\(failureLabel) \(error.localizedDescription)" } } } diff --git a/App/Info.plist b/App/Info.plist index 36b3241..1d2dad0 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -6,6 +6,8 @@ en CFBundleExecutable MacStereoFix + CFBundleIconFile + AppIcon CFBundleIdentifier com.macstereofix.app CFBundleInfoDictionaryVersion @@ -17,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2 + 1.3.0 CFBundleVersion - 3 + 4 LSMinimumSystemVersion 13.0 LSUIElement @@ -29,6 +31,6 @@ NSHumanReadableCopyright MacStereoFix NSMicrophoneUsageDescription - MacStereoFix will not use or access your microphone. macOS requires this prompt because MacStereoFix reads from its own virtual audio device. Nothing is accessed or recorded. + MacStereoFix reads audio routed to its virtual device to play it in stereo. It does not select your physical microphone, save recordings, or send audio over the network. macOS classifies virtual-device capture as microphone access. diff --git a/App/MSFAtomic.h b/App/MSFAtomic.h index 563404d..703ecce 100644 --- a/App/MSFAtomic.h +++ b/App/MSFAtomic.h @@ -10,6 +10,9 @@ #include #include +_Static_assert(ATOMIC_LLONG_LOCK_FREE == 2 && ATOMIC_INT_LOCK_FREE == 2, + "Audio callbacks require lock-free atomics"); + // MARK: - MSFAtomicU64 (ring-buffer read/write indices) typedef struct { @@ -17,7 +20,7 @@ typedef struct { } MSFAtomicU64; static inline void msf_atomic_init(MSFAtomicU64 *a, uint64_t v) { - atomic_store_explicit(&a->value, v, memory_order_relaxed); + atomic_init(&a->value, v); } static inline void msf_atomic_store(MSFAtomicU64 *a, uint64_t v) { @@ -40,7 +43,7 @@ typedef struct { static inline void msf_atomic_float_init(MSFAtomicFloat *a, float v) { uint32_t b; memcpy(&b, &v, sizeof(b)); - atomic_store_explicit(&a->bits, b, memory_order_relaxed); + atomic_init(&a->bits, b); } static inline void msf_atomic_float_store(MSFAtomicFloat *a, float v) { diff --git a/App/MacStereoFixApp.swift b/App/MacStereoFixApp.swift index 0834679..c23adb5 100644 --- a/App/MacStereoFixApp.swift +++ b/App/MacStereoFixApp.swift @@ -9,12 +9,28 @@ import SwiftUI struct MacStereoFixApp: App { @StateObject private var state = AppState() + init() { + // Hold the lock until process exit. O_CLOEXEC keeps the recovery helper + // from inheriting it; O_NOFOLLOW prevents following an unexpected link. + let path = FileManager.default.temporaryDirectory.appendingPathComponent("com.macstereofix.app.lock").path + let descriptor = open(path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600) + guard descriptor >= 0, flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + if descriptor >= 0 { close(descriptor) } + let alert = NSAlert() + alert.messageText = "MacStereoFix is already open or couldn't acquire its session lock." + alert.informativeText = "Use the speaker icon in your menu bar. If no copy is running, restart your Mac and try again." + alert.runModal() + exit(0) + } + } + var body: some Scene { MenuBarExtra { MenuBarView() .environmentObject(state) } label: { Image(systemName: state.isOn ? "hifispeaker.2.fill" : "hifispeaker.2") + .accessibilityLabel(state.isOn ? "MacStereoFix, Force Stereo on" : "MacStereoFix, Force Stereo off") } .menuBarExtraStyle(.window) } diff --git a/App/MenuBarView.swift b/App/MenuBarView.swift index 986ecbe..cb2ac82 100644 --- a/App/MenuBarView.swift +++ b/App/MenuBarView.swift @@ -1,164 +1,281 @@ -// MenuBarView.swift -// -// The dropdown shown when the user clicks the menu bar icon. Big toggle, an -// output device picker, a dialogue boost slider, and driver install controls. - import SwiftUI struct MenuBarView: View { - @EnvironmentObject var state: AppState + @Environment(\.colorScheme) private var colorScheme - /// Shown in the footer. Pulled from Info.plist so bumping the version - /// there is enough — no source change needed. - private static let appVersion: String = + private var accent: Color { + colorScheme == .dark ? .cyan : Color(red: 0, green: 0.38, blue: 0.43) + } + private var mutedColor: Color { + colorScheme == .dark ? .orange : Color(red: 0.6, green: 0.29, blue: 0) + } + private var errorColor: Color { + colorScheme == .dark ? .red : Color(red: 0.7, green: 0.12, blue: 0.12) + } + + private static let appVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?" - // MARK: - Body + private var isSilent: Bool { state.isMuted || state.outputVolume == 0 } + private var statusColor: Color { + state.isOn ? (isSilent ? mutedColor : accent) : .secondary + } + private var statusLabel: String { + if state.isBusy { return "Working…" } + return state.isOn ? (isSilent ? "Muted" : "On") : "Off" + } var body: some View { - VStack(alignment: .leading, spacing: 12) { - header - Divider() - if !state.driverInstalled { - driverNotInstalledSection - } else { - toggleSection - Divider() - outputPickerSection - if state.outputVolumeAvailable { - volumeSection + ScrollView { + VStack(alignment: .leading, spacing: 14) { + header + if state.driverInstalled { + routingSection + VStack(alignment: .leading, spacing: 14) { + outputSection + Divider() + volumeSection + } + .padding(14) + .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12)) + } else { + setupSection } - Divider() + messages advancedSection - } - if let error = state.lastError { Divider() - Text(error) - .font(.caption) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) + footer } - Divider() - footer + .padding(18) } - .padding(14) - .frame(width: 320) + .frame(width: 360) + .frame(maxHeight: min(680, (NSScreen.main?.visibleFrame.height ?? 768) - 40)) + .tint(accent) } - // MARK: - Sections - private var header: some View { - HStack(spacing: 8) { - Image(systemName: state.isOn ? "hifispeaker.2.fill" : "hifispeaker.2") - .font(.title2) - .foregroundStyle(state.isOn ? .green : .secondary) - Text("MacStereoFix") - .font(.headline) - Spacer() - } - } - - private var driverNotInstalledSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Driver not installed") - .font(.subheadline).bold() - Text("MacStereoFix needs to install a small audio driver. You'll be asked for your password once.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Button("Install Driver") { - state.installDriver() + HStack(spacing: 11) { + Image(systemName: "hifispeaker.2.fill") + .font(.system(size: 21, weight: .medium)) + .foregroundStyle(accent) + .frame(width: 42, height: 42) + .background(accent.opacity(0.1), in: RoundedRectangle(cornerRadius: 11)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text("MacStereoFix").font(.system(size: 17, weight: .semibold)) + Text("Surround sound, in stereo.") + .font(.caption).foregroundStyle(.secondary) } - .buttonStyle(.borderedProminent) + Spacer(minLength: 0) } } - private var toggleSection: some View { - HStack { - Toggle(isOn: Binding( - get: { state.isOn }, - set: { _ in state.toggle() } - )) { - Text("Force Stereo") - .font(.subheadline).bold() + private var routingSection: some View { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Force Stereo").font(.headline) + Spacer() + Text(statusLabel) + .font(.caption.weight(.semibold)) + .foregroundStyle(statusColor) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(statusColor.opacity(0.1), in: Capsule()) + Toggle("Force Stereo", isOn: Binding( + get: { state.isOn }, set: { _ in state.toggle() } + )) + .labelsHidden() + .toggleStyle(.switch) + .disabled(state.isBusy || state.availableOutputs.isEmpty) + .help("Mix surround audio into your selected stereo output") } - .toggleStyle(.switch) + Text(state.isBusy ? (state.statusMessage ?? "Preparing audio. Check for a macOS permission prompt.") + : state.isOn + ? (state.isMuted ? "Audio is muted. Tap the speaker button to listen again." + : isSilent ? "Volume is at 0%. Raise it to hear your stereo mix." + : "Your stereo mix is playing through the output below.") + : "Turn on to hear surround channels through two speakers or headphones.") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(14) + .background(statusColor.opacity(state.isOn ? 0.08 : 0.04), in: RoundedRectangle(cornerRadius: 12)) } - private var outputPickerSection: some View { - VStack(alignment: .leading, spacing: 4) { - Text("Send stereo to") - .font(.caption) - .foregroundStyle(.secondary) - Picker("", selection: Binding( - get: { state.selectedOutputUID ?? "" }, - set: { newUID in - let uid = newUID.isEmpty ? nil : newUID - state.selectedOutputUID = uid - if state.isOn, let uid { - state.switchOutputDeviceLive(to: uid) - } - } + private var outputSection: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Stereo output", systemImage: "headphones") + .font(.subheadline.weight(.medium)) + Picker("Stereo output", selection: Binding( + get: { state.selectedOutputUID ?? "" }, set: { state.selectOutput($0) } )) { - ForEach(state.availableOutputs) { dev in - Text(dev.name).tag(dev.uid) + if state.selectedOutputUID == nil { Text("No output connected").tag("") } + ForEach(state.availableOutputs) { device in + Text(device.name).tag(device.uid) } } .labelsHidden() .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + .help(state.availableOutputs.first { $0.uid == state.selectedOutputUID }?.name ?? "Connect stereo speakers or headphones") + .disabled(state.isBusy || state.availableOutputs.isEmpty) + if state.availableOutputs.isEmpty { + Text("Connect stereo speakers or headphones, then refresh. Virtual and aggregate outputs aren't supported.") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button("Refresh outputs") { state.refreshDevices() } + .controlSize(.small).disabled(state.isBusy) + } } } private var volumeSection: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 7) { HStack { - Text("Volume") - .font(.caption) - .foregroundStyle(.secondary) + Text("Volume").font(.subheadline.weight(.medium)) Spacer() Text("\(Int((state.outputVolume * 100).rounded()))%") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) } - HStack(spacing: 6) { - Image(systemName: "speaker.fill") - .font(.caption2) - .foregroundStyle(.secondary) - Slider(value: $state.outputVolume, in: 0...1) - Image(systemName: "speaker.wave.3.fill") - .font(.caption2) - .foregroundStyle(.secondary) + HStack(spacing: 10) { + Toggle(isOn: $state.isMuted) { + Label("Mute routed audio", systemImage: state.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + } + .labelStyle(.iconOnly) + .toggleStyle(.button) + .controlSize(.small) + .help(state.isMuted ? "Unmute routed audio" : "Mute routed audio") + Slider(value: $state.outputVolume, in: 0...1) { Text("Routing volume") } + .labelsHidden() + .accessibilityValue("\(Int((state.outputVolume * 100).rounded())) percent") + } + .disabled(state.isBusy) + Text("Applies while Force Stereo is on. Off returns to your device’s normal volume.") + .font(.caption2).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var dialogueSection: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text("Dialogue boost").font(.subheadline.weight(.medium)) + Spacer() + Text(state.dialogueBoostDB == 0 ? "Off" : "+\(Int(state.dialogueBoostDB)) dB") + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + Slider(value: $state.dialogueBoostDB, in: 0...9, step: 1) { Text("Dialogue boost") } + .labelsHidden() + .accessibilityValue(state.dialogueBoostDB == 0 ? "Off" : "\(Int(state.dialogueBoostDB)) decibels") + .disabled(state.isBusy) + Text("Optional center-channel boost. Off keeps the standard stereo mix. High boost can distort loud scenes.") + .font(.caption2).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var setupSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Set up stereo audio").font(.title3.weight(.semibold)) + Text("Install the audio driver to bring dialogue and surround effects into your stereo mix.") + .font(.subheadline).foregroundStyle(.secondary) + Label { + Text("All Mac audio will briefly stop during installation. Finish calls and recordings first.") + } icon: { + Image(systemName: "speaker.wave.2.bubble") + } + .font(.caption) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + Button(action: state.installDriver) { + Text("Install Audio Driver…").frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(state.isBusy) + Text("macOS asks for an administrator password. Microphone access is requested when you first turn on Force Stereo to read the virtual audio device.") + .font(.caption2).foregroundStyle(.secondary) + } + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder private var messages: some View { + if let error = state.lastError { + VStack(alignment: .leading, spacing: 8) { + Label("Needs attention", systemImage: "exclamationmark.circle.fill") + .font(.caption.weight(.semibold)).foregroundStyle(errorColor) + ScrollView { + Text(error) + .font(.caption) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 100) + Button("Open System Settings…") { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app")) + } + .controlSize(.small) + } + .padding(12) + .background(.red.opacity(0.06), in: RoundedRectangle(cornerRadius: 10)) + } + if state.isBusy || state.statusMessage != nil { + HStack(alignment: .top, spacing: 8) { + if state.isBusy { + ProgressView().controlSize(.small).accessibilityLabel("Working") + } else { + Image(systemName: "info.circle").foregroundStyle(.secondary) + } + Text(state.statusMessage ?? "Preparing audio…") + .font(.caption).fixedSize(horizontal: false, vertical: true) } } - .padding(.top, 8) } private var advancedSection: some View { DisclosureGroup("Advanced") { - VStack(alignment: .leading, spacing: 6) { - Button("Reinstall Driver") { state.installDriver() } - Button("Uninstall Driver", role: .destructive) { state.uninstallDriver() } - Button("Refresh Devices") { state.refreshDevices() } + VStack(alignment: .leading, spacing: 10) { + if state.driverInstalled { + dialogueSection + Divider() + } + Label(state.driverInstalled ? "Audio driver ready" : "Current audio driver needed", + systemImage: state.driverInstalled ? "checkmark.circle" : "info.circle") + HStack { + Button("Reinstall…", action: state.installDriver) + Button("Uninstall…", role: .destructive, action: state.uninstallDriver) + } + .controlSize(.small).disabled(state.isBusy) + Text("Installing or removing the driver briefly interrupts all Mac audio.") + .foregroundStyle(.secondary) + Divider() + Text("Audio is processed on this Mac. No recordings or uploads. Microphone permission lets macOS capture the virtual device; your physical microphone is not selected.") + .foregroundStyle(.secondary) } - .padding(.top, 4) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 10) } .font(.caption) } private var footer: some View { HStack { - Text("v\(Self.appVersion)") - .font(.caption2) - .foregroundStyle(.secondary) + Text("v\(Self.appVersion)").foregroundStyle(.secondary) Spacer() + Button("Refresh", action: state.refreshDevices) + .help("Refresh audio outputs and driver status") + .keyboardShortcut("r", modifiers: .command) + .disabled(state.isBusy) Button("Quit") { if state.isOn { state.turnOff() } NSApplication.shared.terminate(nil) } - .buttonStyle(.plain) - .font(.caption) + .keyboardShortcut("q", modifiers: .command) + .disabled(state.isBusy) } + .buttonStyle(.borderless) + .font(.caption) } } diff --git a/App/PrivacyInfo.xcprivacy b/App/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5fd8303 --- /dev/null +++ b/App/PrivacyInfo.xcprivacy @@ -0,0 +1,11 @@ + + + + NSPrivacyTracking + NSPrivacyTrackingDomains + NSPrivacyCollectedDataTypes + NSPrivacyAccessedAPITypes + NSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasonsCA92.1 + + diff --git a/App/RingBuffer.swift b/App/RingBuffer.swift index de1c11b..b30d2ed 100644 --- a/App/RingBuffer.swift +++ b/App/RingBuffer.swift @@ -26,6 +26,7 @@ final class RingBuffer { // MARK: - Init / deinit init(frames: Int, channels: Int) { + precondition(frames >= 2 && frames <= 1 << 20 && channels > 0 && channels <= 64) // Round up to power of two for cheap modulo via bitmask. Not strictly // required, but helps consistency with the driver's ring size. var p = 1 @@ -83,6 +84,7 @@ final class RingBuffer { /// Caller is the single producer. @discardableResult func write(_ src: UnsafePointer, frameCount: Int) -> Int { + guard frameCount > 0 else { return 0 } let w = msf_atomic_load(writePtr) let r = msf_atomic_load(readPtr) let free = capacityFrames - 1 - Int(w &- r) @@ -110,6 +112,7 @@ final class RingBuffer { /// Caller is the single consumer. @discardableResult func read(_ dst: UnsafeMutablePointer, frameCount: Int) -> Int { + guard frameCount > 0 else { return 0 } let w = msf_atomic_load(writePtr) let r = msf_atomic_load(readPtr) let avail = Int(w &- r) diff --git a/App/StereoMix.swift b/App/StereoMix.swift new file mode 100644 index 0000000..645fde7 --- /dev/null +++ b/App/StereoMix.swift @@ -0,0 +1,40 @@ +import Foundation + +enum StereoMix { + static let reserveFrames = 512 + + /// Independent audio clocks drift. Keep a small reserve using Apple's + /// varispeed converter, with at most 0.1% correction (under two cents). + static func clockRate(bufferedFrames: Float) -> Float { + guard bufferedFrames.isFinite else { return 1 } + return 1 + min(max((bufferedFrames - Float(reserveFrames)) * 0.000002, -0.001), 0.001) + } + + static func centerGain(boostDB: Float) -> Float { + let boost = boostDB.isFinite ? min(max(boostDB, 0), 9) : 0 + return 0.707 * pow(10, boost / 20) + } + + /// L, R, C, LFE, Ls, Rs, Lsr, Rsr -> L, R. No allocation on the audio thread. + static func process(_ source: UnsafePointer, into output: UnsafeMutablePointer, + right: UnsafeMutablePointer? = nil, frames: Int, centerGain: Float, volume: Float) { + guard frames > 0 else { return } + let stride = right == nil ? 2 : 1 + let rightOutput = right ?? output.advanced(by: 1) + let gain = volume.isFinite ? min(max(volume, 0), 1) : 0 + let center = centerGain.isFinite ? min(max(centerGain, 0), 2) : 0 + for frame in 0.. Float { + sample.isFinite ? min(max(sample, -1), 1) : 0 + } +} diff --git a/App/SystemAudio.swift b/App/SystemAudio.swift index 87d42b8..294a619 100644 --- a/App/SystemAudio.swift +++ b/App/SystemAudio.swift @@ -43,14 +43,20 @@ enum SystemAudio { AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &dataSize, buf.baseAddress!) } guard status == noErr else { return [] } - return ids + return Array(ids.prefix(Int(dataSize) / MemoryLayout.size)) } static func allOutputDevices() -> [AudioOutputDevice] { var result: [AudioOutputDevice] = [] for id in allDeviceIDs() { - guard hasOutputChannels(deviceID: id) else { continue } - let uid = stringProperty(deviceID: id, selector: kAudioDevicePropertyDeviceUID, scope: kAudioObjectPropertyScopeGlobal) ?? "" + guard hasOutputChannels(deviceID: id), + uintProperty(id, selector: kAudioDevicePropertyDeviceIsAlive) == 1 else { continue } + let transport = uintProperty(id, selector: kAudioDevicePropertyTransportType) + // An aggregate or virtual output can contain our input and create a loop. + guard transport != nil, transport != kAudioDeviceTransportTypeVirtual, + transport != kAudioDeviceTransportTypeAggregate, + transport != kAudioDeviceTransportTypeAutoAggregate else { continue } + guard let uid = deviceUID(id), !uid.isEmpty else { continue } let name = stringProperty(deviceID: id, selector: kAudioObjectPropertyName, scope: kAudioObjectPropertyScopeGlobal) ?? "(unknown)" result.append(AudioOutputDevice(id: id, uid: uid, name: name)) } @@ -69,13 +75,16 @@ enum SystemAudio { let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(dataSize), alignment: 16) defer { raw.deallocate() } guard AudioObjectGetPropertyData(deviceID, &addr, 0, nil, &dataSize, raw) == noErr else { return false } + let header = MemoryLayout.offset(of: \.mBuffers)! + guard Int(dataSize) >= header else { return false } let bufferList = raw.assumingMemoryBound(to: AudioBufferList.self) + guard Int(bufferList.pointee.mNumberBuffers) <= (Int(dataSize) - header) / MemoryLayout.stride else { return false } let abl = UnsafeMutableAudioBufferListPointer(bufferList) var totalChannels = 0 for i in 0.. 0 + return totalChannels >= 2 } private static func stringProperty(deviceID: AudioDeviceID, selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope) -> String? { @@ -111,6 +120,12 @@ enum SystemAudio { return macStereoFixDeviceID() != nil } + static func macStereoFixDriverVersion() -> String? { + guard let device = macStereoFixDeviceID() else { return nil } + return stringProperty(deviceID: device, selector: kAudioObjectPropertyFirmwareVersion, + scope: kAudioObjectPropertyScopeGlobal) + } + // MARK: - Default output device static func defaultOutputDevice() -> AudioDeviceID { @@ -139,78 +154,44 @@ enum SystemAudio { return status == noErr } - // MARK: - Per-device output volume + static func deviceUID(_ id: AudioDeviceID) -> String? { + stringProperty(deviceID: id, selector: kAudioDevicePropertyDeviceUID, scope: kAudioObjectPropertyScopeGlobal) + } - /// Read the current output volume (0...1) for a device. Tries the main - /// element first; falls back to averaging channels 1 and 2 for devices - /// that only support per-channel volume. Matches `setDeviceVolume`, which - /// writes both channels in the same fallback path — reading only ch1 - /// while writing both would make the slider disagree with reality on - /// devices where L/R volumes have drifted apart. - /// Returns nil if the device exposes no volume control. - static func deviceVolume(_ deviceID: AudioDeviceID) -> Float? { - var addr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioObjectPropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain - ) - if AudioObjectHasProperty(deviceID, &addr) { - var vol: Float32 = 0 - var size = UInt32(MemoryLayout.size) - if AudioObjectGetPropertyData(deviceID, &addr, 0, nil, &size, &vol) == noErr { - return vol - } - } - var sum: Float = 0 - var count: Int = 0 - for ch: AudioObjectPropertyElement in [1, 2] { - addr.mElement = ch - if AudioObjectHasProperty(deviceID, &addr) { - var vol: Float32 = 0 - var size = UInt32(MemoryLayout.size) - if AudioObjectGetPropertyData(deviceID, &addr, 0, nil, &size, &vol) == noErr { - sum += vol - count += 1 - } - } - } - return count > 0 ? sum / Float(count) : nil + static func sampleRate(_ id: AudioDeviceID) -> Float64? { + var value: Float64 = 0 + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &value) == noErr, + value.isFinite else { return nil } + return value + } + + private static func uintProperty(_ id: AudioObjectID, selector: AudioObjectPropertySelector) -> UInt32? { + var value: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress(mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + return AudioObjectGetPropertyData(id, &address, 0, nil, &size, &value) == noErr ? value : nil } - /// Set the output volume (0...1) on a device. Tries the main element - /// first; falls back to all output channels for devices that only support - /// per-channel volume. Returns true if at least one element was updated. + /// Restore only the default owned by this app; preserve changes made in Sound settings. @discardableResult - static func setDeviceVolume(_ deviceID: AudioDeviceID, _ value: Float) -> Bool { - var v: Float32 = max(0, min(1, value)) - var addr = AudioObjectPropertyAddress( - mSelector: kAudioDevicePropertyVolumeScalar, - mScope: kAudioObjectPropertyScopeOutput, - mElement: kAudioObjectPropertyElementMain - ) - if AudioObjectHasProperty(deviceID, &addr) { - var settable: DarwinBoolean = false - if AudioObjectIsPropertySettable(deviceID, &addr, &settable) == noErr, settable.boolValue { - if AudioObjectSetPropertyData(deviceID, &addr, 0, nil, - UInt32(MemoryLayout.size), &v) == noErr { - return true - } - } - } - var anySuccess = false - for ch: AudioObjectPropertyElement in [1, 2] { - addr.mElement = ch - if AudioObjectHasProperty(deviceID, &addr) { - var settable: DarwinBoolean = false - if AudioObjectIsPropertySettable(deviceID, &addr, &settable) == noErr, settable.boolValue { - if AudioObjectSetPropertyData(deviceID, &addr, 0, nil, - UInt32(MemoryLayout.size), &v) == noErr { - anySuccess = true - } - } - } + static func restoreOutput(preferredUID: String?) -> Bool { + let current = defaultOutputDevice() + guard current != 0, let currentUID = deviceUID(current) else { return false } + guard currentUID == macStereoFixUID else { return true } + let outputs = allOutputDevices() + let preferred = outputs.first { $0.uid == preferredUID } + let systemID = uintProperty(AudioObjectID(kAudioObjectSystemObject), selector: kAudioHardwarePropertyDefaultSystemOutputDevice) + let system = outputs.first { $0.id == systemID } + var candidates = [preferred, system].compactMap { $0 } + candidates += outputs.filter { !candidates.contains($0) } + for device in candidates { + if setDefaultOutputDevice(device.id), defaultOutputDevice() == device.id { return true } } - return anySuccess + return false } // MARK: - Volume control bridge @@ -218,7 +199,7 @@ enum SystemAudio { /// Find the output-scope volume control owned by a device, if any. /// MacStereoFix exposes one so the helper app can observe hardware /// volume-key presses and mirror them to the real output device. - static func outputVolumeControlID(for deviceID: AudioDeviceID) -> AudioObjectID? { + static func outputControlID(for deviceID: AudioDeviceID, class expectedClass: AudioClassID) -> AudioObjectID? { var listAddr = AudioObjectPropertyAddress( mSelector: kAudioObjectPropertyControlList, mScope: kAudioObjectPropertyScopeGlobal, @@ -234,6 +215,7 @@ enum SystemAudio { AudioObjectGetPropertyData(deviceID, &listAddr, 0, nil, &size, buf.baseAddress!) } guard status == noErr else { return nil } + controls = Array(controls.prefix(Int(size) / MemoryLayout.size)) var classAddr = AudioObjectPropertyAddress( mSelector: kAudioObjectPropertyClass, @@ -249,7 +231,7 @@ enum SystemAudio { var classID: AudioClassID = 0 var classSize = UInt32(MemoryLayout.size) guard AudioObjectGetPropertyData(controlID, &classAddr, 0, nil, &classSize, &classID) == noErr else { continue } - if classID != kAudioVolumeControlClassID { continue } + if classID != expectedClass { continue } var scope: AudioObjectPropertyScope = 0 var scopeSize = UInt32(MemoryLayout.size) guard AudioObjectGetPropertyData(controlID, &scopeAddr, 0, nil, &scopeSize, &scope) == noErr else { continue } @@ -270,7 +252,7 @@ enum SystemAudio { var v: Float32 = 0 var size = UInt32(MemoryLayout.size) if AudioObjectGetPropertyData(controlID, &addr, 0, nil, &size, &v) == noErr { - return v + return v.isFinite ? min(max(v, 0), 1) : nil } return nil } @@ -278,6 +260,7 @@ enum SystemAudio { /// Write the 0...1 scalar value of a level/volume control object. @discardableResult static func setControlScalarValue(_ controlID: AudioObjectID, _ value: Float) -> Bool { + guard value.isFinite else { return false } var v: Float32 = max(0, min(1, value)) var addr = AudioObjectPropertyAddress( mSelector: kAudioLevelControlPropertyScalarValue, @@ -288,38 +271,16 @@ enum SystemAudio { UInt32(MemoryLayout.size), &v) == noErr } - /// Install a listener on a volume control's scalar-value property. - /// Returns the block that must be retained by the caller and passed - /// back to `removeControlScalarListener` for clean removal. - static func installControlScalarListener( - on controlID: AudioObjectID, - queue: DispatchQueue, - handler: @escaping () -> Void - ) -> AudioObjectPropertyListenerBlock? { - var addr = AudioObjectPropertyAddress( - mSelector: kAudioLevelControlPropertyScalarValue, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - let block: AudioObjectPropertyListenerBlock = { _, _ in - handler() - } - let status = AudioObjectAddPropertyListenerBlock(controlID, &addr, queue, block) - return status == noErr ? block : nil + static func controlMuted(_ controlID: AudioObjectID) -> Bool? { + uintProperty(controlID, selector: kAudioBooleanControlPropertyValue).map { $0 != 0 } } - /// Remove a previously-installed scalar-value listener. Must be called - /// with the exact same control ID, queue, and block used to install it. - static func removeControlScalarListener( - on controlID: AudioObjectID, - queue: DispatchQueue, - block: @escaping AudioObjectPropertyListenerBlock - ) { - var addr = AudioObjectPropertyAddress( - mSelector: kAudioLevelControlPropertyScalarValue, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain - ) - _ = AudioObjectRemovePropertyListenerBlock(controlID, &addr, queue, block) + @discardableResult + static func setControlMuted(_ controlID: AudioObjectID, _ muted: Bool) -> Bool { + var value: UInt32 = muted ? 1 : 0 + var address = AudioObjectPropertyAddress(mSelector: kAudioBooleanControlPropertyValue, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + return AudioObjectSetPropertyData(controlID, &address, 0, nil, + UInt32(MemoryLayout.size), &value) == noErr } } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d40e12f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## 1.3.0 — Unreleased + +- Fixed driver buffer handling and cases that could replay old audio. +- Added output recovery for crashes and failed routing starts. +- Improved device switching, sample-rate conversion, and sleep handling. +- Added volume and mute controls without changing hardware volume or balance. +- Added driver signature checks and rollback if installation fails. +- Updated the menu layout, setup instructions, error messages, and app icon. +- Dialogue boost is now optional under Advanced and off by default. + +## 1.2 — April 14, 2026 + +- Reduced audio buffering to lower playback latency. +- Limited queued audio to prevent startup delays from persisting during playback. + +Reinstall the audio driver after upgrading to 1.2; replacing the app alone does +not update it. + +## 1.0 — April 11, 2026 + +Initial release with surround-to-stereo mixing and a menu bar output selector. diff --git a/Driver/Info.plist b/Driver/Info.plist index 9395c97..f1c0c10 100644 --- a/Driver/Info.plist +++ b/Driver/Info.plist @@ -7,7 +7,7 @@ CFBundleExecutable MacStereoFix CFBundleGetInfoString - MacStereoFix Driver 1.2 + MacStereoFix Driver 1.3.0 CFBundleIdentifier com.macstereofix.driver CFBundleInfoDictionaryVersion @@ -17,11 +17,11 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.2 + 1.3.0 CFBundleSignature ???? CFBundleVersion - 3 + 4 CFPlugInDynamicRegistration NO CFPlugInFactories diff --git a/Driver/MacStereoFixDriver.c b/Driver/MacStereoFixDriver.c index 70ecf74..4758b7c 100644 --- a/Driver/MacStereoFixDriver.c +++ b/Driver/MacStereoFixDriver.c @@ -10,7 +10,7 @@ // buffer indexed by the host's sample clock. All downmixing happens in the // MacStereoFix.app helper. // -// Modeled on Apple's NullAudio sample (Apple Sample Code License). +// Modeled on Apple's NullAudio sample. See ThirdParty/Apple-NullAudio-LICENSE.txt. #include #include @@ -18,6 +18,8 @@ #include #include #include +#include +#include #pragma mark - Configuration @@ -29,6 +31,7 @@ #define kDevice_ModelUID "MacStereoFixDevice_ModelUID" #define kDevice_Name "MacStereoFix" #define kDevice_Manufacturer "MacStereoFix" +#define kDriverVersion "4" #define kChannelCount 8 #define kSampleRate 48000.0 @@ -36,12 +39,11 @@ #define kRingBufferFrameCount 4096u #define kRingBufferSampleCount (kRingBufferFrameCount * kChannelCount) -// Client-requested IO cycle size. Clamped to [kBufferFrameSize_Min, Max] and -// must stay below the ring so writer/reader sample-time offsets never wrap -// past each other within a single IO cycle. -#define kBufferFrameSize_Min 32u -#define kBufferFrameSize_Max 2048u -#define kBufferFrameSize_Default 512u +// Fixed device configuration. IO-affecting changes require a host-coordinated +// configuration change; never mutate the buffer size from a property setter. +#define kBufferFrameSize_Min 128u +#define kBufferFrameSize_Max 128u +#define kBufferFrameSize_Default 128u enum { kObjectID_PlugIn = kAudioObjectPlugInObject, @@ -49,7 +51,8 @@ enum { kObjectID_Device = 3, kObjectID_Stream_Input = 4, kObjectID_Stream_Output = 5, - kObjectID_Volume_Output_Master = 6 + kObjectID_Volume_Output_Master = 6, + kObjectID_Mute_Output_Master = 7 }; #pragma mark - State @@ -63,22 +66,41 @@ static Boolean gBox_Acquired = true; static UInt64 gDevice_IOIsRunning = 0; static Float64 gDevice_HostTicksPerFrame = 0.0; -static UInt64 gDevice_NumberTimeStamps = 0; -static Float64 gDevice_AnchorSampleTime = 0.0; -static UInt64 gDevice_AnchorHostTime = 0; +static _Atomic UInt64 gDevice_AnchorHostTime = 0; +static _Atomic UInt64 gDevice_TimeStampSeed = 0; static UInt32 gDevice_BufferFrameSize = kBufferFrameSize_Default; -static bool gStream_Input_IsActive = true; -static bool gStream_Output_IsActive = true; - -// Interleaved Float32 ring buffer shared between WriteMix and ReadInput. -static Float32 gRingBuffer[kRingBufferSampleCount]; +static _Atomic bool gStream_Input_IsActive = true; +static _Atomic bool gStream_Output_IsActive = true; + +// Each slot carries its absolute sample time, so a missing write produces +// silence rather than replaying audio from a previous lap. Atomic samples and +// a sequence counter make overlapping read/write safe without a realtime lock. +// Sequential consistency keeps the two sequence checks ordered around samples. +_Static_assert(ATOMIC_LLONG_LOCK_FREE == 2 && ATOMIC_INT_LOCK_FREE == 2, + "The audio path requires lock-free 32/64-bit atomics"); +typedef struct { + _Atomic unsigned long long sequence; + _Atomic unsigned long long sampleTime; + _Atomic unsigned int samples[kChannelCount]; +} MSFFrame; +static MSFFrame gRingBuffer[kRingBufferFrameCount]; + +static void MSF_ClearRing(void) { + // Called before the host starts IO, never concurrently with audio callbacks. + for (UInt32 i = 0; i < kRingBufferFrameCount; ++i) { + atomic_store(&gRingBuffer[i].sequence, 0); + atomic_store(&gRingBuffer[i].sampleTime, ULLONG_MAX); + for (UInt32 c = 0; c < kChannelCount; ++c) atomic_store(&gRingBuffer[i].samples[c], 0); + } +} // Master output volume (0..1 linear scalar) exposed as a volume control // on the device. Written by coreaudiod on behalf of hardware volume keys // and by the helper app's slider; read by the helper app to mirror onto // the real output device. The driver itself does not attenuate audio. static Float32 gVolume_OutputMaster = 1.0f; +static _Atomic bool gMute_OutputMaster = false; // dB range reported by the output volume control. static const Float32 kVolume_MinDB = -96.0f; @@ -170,7 +192,7 @@ __attribute__((visibility("default"))) void* MacStereoFix_Create(CFAllocatorRef inAllocator, CFUUIDRef inRequestedTypeUUID) { (void)inAllocator; - if (!CFEqual(inRequestedTypeUUID, kAudioServerPlugInTypeUUID)) { + if (inRequestedTypeUUID == NULL || !CFEqual(inRequestedTypeUUID, kAudioServerPlugInTypeUUID)) { return NULL; } return gAudioServerPlugInDriverRef; @@ -182,6 +204,7 @@ static HRESULT MacStereoFix_QueryInterface(void* inDriver, REFIID inUUID, LPVOID { if (inDriver != gAudioServerPlugInDriverRef) return kAudioHardwareBadObjectError; if (outInterface == NULL) return kAudioHardwareIllegalOperationError; + *outInterface = NULL; CFUUIDRef theRequestedUUID = CFUUIDCreateFromUUIDBytes(NULL, inUUID); if (theRequestedUUID == NULL) return kAudioHardwareIllegalOperationError; @@ -189,7 +212,7 @@ static HRESULT MacStereoFix_QueryInterface(void* inDriver, REFIID inUUID, LPVOID HRESULT theAnswer = 0; if (CFEqual(theRequestedUUID, IUnknownUUID) || CFEqual(theRequestedUUID, kAudioServerPlugInDriverInterfaceUUID)) { pthread_mutex_lock(&gPlugIn_StateMutex); - ++gPlugIn_RefCount; + if (gPlugIn_RefCount < UINT32_MAX) ++gPlugIn_RefCount; pthread_mutex_unlock(&gPlugIn_StateMutex); *outInterface = gAudioServerPlugInDriverRef; } else { @@ -234,7 +257,7 @@ static OSStatus MacStereoFix_Initialize(AudioServerPlugInDriverRef inDriver, Aud gDevice_HostTicksPerFrame = theHostClockFrequency / kSampleRate; // Zero the ring buffer - memset(gRingBuffer, 0, sizeof(gRingBuffer)); + MSF_ClearRing(); // Default box name if (gBox_Name == NULL) { @@ -333,6 +356,7 @@ static Boolean MacStereoFix_HasProperty(AudioServerPlugInDriverRef inDriver, Aud case kAudioObjectPropertyBaseClass: case kAudioObjectPropertyClass: case kAudioObjectPropertyOwner: + case kAudioObjectPropertyFirmwareVersion: case kAudioObjectPropertyName: case kAudioObjectPropertyManufacturer: case kAudioObjectPropertyOwnedObjects: @@ -383,6 +407,7 @@ static Boolean MacStereoFix_HasProperty(AudioServerPlugInDriverRef inDriver, Aud break; case kObjectID_Volume_Output_Master: + case kObjectID_Mute_Output_Master: switch (inAddress->mSelector) { case kAudioObjectPropertyBaseClass: case kAudioObjectPropertyClass: @@ -390,12 +415,15 @@ static Boolean MacStereoFix_HasProperty(AudioServerPlugInDriverRef inDriver, Aud case kAudioObjectPropertyOwnedObjects: case kAudioControlPropertyScope: case kAudioControlPropertyElement: + return true; + case kAudioBooleanControlPropertyValue: + return inObjectID == kObjectID_Mute_Output_Master; case kAudioLevelControlPropertyScalarValue: case kAudioLevelControlPropertyDecibelValue: case kAudioLevelControlPropertyDecibelRange: case kAudioLevelControlPropertyConvertScalarToDecibels: case kAudioLevelControlPropertyConvertDecibelsToScalar: - return true; + return inObjectID == kObjectID_Volume_Output_Master; } break; } @@ -407,18 +435,13 @@ static OSStatus MacStereoFix_IsPropertySettable(AudioServerPlugInDriverRef inDri (void)inClientProcessID; if (inDriver != gAudioServerPlugInDriverRef) return kAudioHardwareBadObjectError; if (inAddress == NULL || outIsSettable == NULL) return kAudioHardwareIllegalOperationError; + if (!MacStereoFix_HasProperty(inDriver, inObjectID, inClientProcessID, inAddress)) + return kAudioHardwareUnknownPropertyError; *outIsSettable = false; switch (inObjectID) { case kObjectID_Box: - if (inAddress->mSelector == kAudioObjectPropertyName || - inAddress->mSelector == kAudioObjectPropertyIdentify || - inAddress->mSelector == kAudioBoxPropertyAcquired) { - *outIsSettable = true; - } - break; - case kObjectID_Device: - if (inAddress->mSelector == kAudioDevicePropertyBufferFrameSize) { + if (inAddress->mSelector == kAudioObjectPropertyIdentify) { *outIsSettable = true; } break; @@ -430,6 +453,9 @@ static OSStatus MacStereoFix_IsPropertySettable(AudioServerPlugInDriverRef inDri *outIsSettable = true; } break; + case kObjectID_Mute_Output_Master: + *outIsSettable = inAddress->mSelector == kAudioBooleanControlPropertyValue; + break; case kObjectID_Volume_Output_Master: if (inAddress->mSelector == kAudioLevelControlPropertyScalarValue || inAddress->mSelector == kAudioLevelControlPropertyDecibelValue) { @@ -447,6 +473,10 @@ static OSStatus MacStereoFix_GetPropertyDataSize(AudioServerPlugInDriverRef inDr if (inAddress == NULL || outDataSize == NULL) return kAudioHardwareIllegalOperationError; *outDataSize = 0; + if (inObjectID < kObjectID_PlugIn || inObjectID > kObjectID_Mute_Output_Master) + return kAudioHardwareBadObjectError; + if (!MacStereoFix_HasProperty(inDriver, inObjectID, inClientProcessID, inAddress)) + return kAudioHardwareUnknownPropertyError; switch (inObjectID) { case kObjectID_PlugIn: @@ -455,7 +485,7 @@ static OSStatus MacStereoFix_GetPropertyDataSize(AudioServerPlugInDriverRef inDr case kAudioObjectPropertyClass: *outDataSize = sizeof(AudioClassID); break; case kAudioObjectPropertyOwner: *outDataSize = sizeof(AudioObjectID); break; case kAudioObjectPropertyManufacturer: *outDataSize = sizeof(CFStringRef); break; - case kAudioObjectPropertyOwnedObjects: *outDataSize = sizeof(AudioObjectID); break; + case kAudioObjectPropertyOwnedObjects: *outDataSize = 2 * sizeof(AudioObjectID); break; case kAudioPlugInPropertyBoxList: *outDataSize = sizeof(AudioObjectID); break; case kAudioPlugInPropertyTranslateUIDToBox: *outDataSize = sizeof(AudioObjectID); break; case kAudioPlugInPropertyDeviceList: *outDataSize = sizeof(AudioObjectID); break; @@ -500,9 +530,10 @@ static OSStatus MacStereoFix_GetPropertyDataSize(AudioServerPlugInDriverRef inDr case kAudioObjectPropertyBaseClass: *outDataSize = sizeof(AudioClassID); break; case kAudioObjectPropertyClass: *outDataSize = sizeof(AudioClassID); break; case kAudioObjectPropertyOwner: *outDataSize = sizeof(AudioObjectID); break; + case kAudioObjectPropertyFirmwareVersion: *outDataSize = sizeof(CFStringRef); break; case kAudioObjectPropertyName: *outDataSize = sizeof(CFStringRef); break; case kAudioObjectPropertyManufacturer: *outDataSize = sizeof(CFStringRef); break; - case kAudioObjectPropertyOwnedObjects: *outDataSize = 3 * sizeof(AudioObjectID); break; + case kAudioObjectPropertyOwnedObjects: *outDataSize = 4 * sizeof(AudioObjectID); break; case kAudioDevicePropertyDeviceUID: *outDataSize = sizeof(CFStringRef); break; case kAudioDevicePropertyModelUID: *outDataSize = sizeof(CFStringRef); break; case kAudioDevicePropertyTransportType: *outDataSize = sizeof(UInt32); break; @@ -524,7 +555,7 @@ static OSStatus MacStereoFix_GetPropertyDataSize(AudioServerPlugInDriverRef inDr case kAudioObjectPropertyControlList: if (inAddress->mScope == kAudioObjectPropertyScopeGlobal || inAddress->mScope == kAudioObjectPropertyScopeOutput) { - *outDataSize = sizeof(AudioObjectID); + *outDataSize = 2 * sizeof(AudioObjectID); } else { *outDataSize = 0; } @@ -564,6 +595,7 @@ static OSStatus MacStereoFix_GetPropertyDataSize(AudioServerPlugInDriverRef inDr break; case kObjectID_Volume_Output_Master: + case kObjectID_Mute_Output_Master: switch (inAddress->mSelector) { case kAudioObjectPropertyBaseClass: *outDataSize = sizeof(AudioClassID); break; case kAudioObjectPropertyClass: *outDataSize = sizeof(AudioClassID); break; @@ -571,6 +603,7 @@ static OSStatus MacStereoFix_GetPropertyDataSize(AudioServerPlugInDriverRef inDr case kAudioObjectPropertyOwnedObjects: *outDataSize = 0; break; case kAudioControlPropertyScope: *outDataSize = sizeof(AudioObjectPropertyScope); break; case kAudioControlPropertyElement: *outDataSize = sizeof(AudioObjectPropertyElement); break; + case kAudioBooleanControlPropertyValue: *outDataSize = sizeof(UInt32); break; case kAudioLevelControlPropertyScalarValue: *outDataSize = sizeof(Float32); break; case kAudioLevelControlPropertyDecibelValue: *outDataSize = sizeof(Float32); break; case kAudioLevelControlPropertyDecibelRange: *outDataSize = sizeof(AudioValueRange); break; @@ -591,6 +624,23 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver (void)inClientProcessID; if (inDriver != gAudioServerPlugInDriverRef) return kAudioHardwareBadObjectError; if (inAddress == NULL || outDataSize == NULL || outData == NULL) return kAudioHardwareIllegalOperationError; + *outDataSize = 0; + + // One shared check covers every scalar, string and structure response. + // Lists may return a whole-element prefix that fits the caller's capacity. + UInt32 required = 0; + OSStatus sizeStatus = MacStereoFix_GetPropertyDataSize(inDriver, inObjectID, + inClientProcessID, inAddress, inQualifierDataSize, inQualifierData, &required); + if (sizeStatus != noErr) return sizeStatus; + bool isList = inAddress->mSelector == kAudioObjectPropertyOwnedObjects || + inAddress->mSelector == kAudioPlugInPropertyBoxList || + inAddress->mSelector == kAudioPlugInPropertyDeviceList || + inAddress->mSelector == kAudioBoxPropertyDeviceList || + inAddress->mSelector == kAudioDevicePropertyRelatedDevices || + inAddress->mSelector == kAudioDevicePropertyStreams || + inAddress->mSelector == kAudioObjectPropertyControlList || + inAddress->mSelector == kAudioDevicePropertyAvailableNominalSampleRates; + if (!isList && inDataSize < required) return kAudioHardwareBadPropertySizeError; UInt32 written = 0; OSStatus status = kAudioHardwareNoError; @@ -622,7 +672,8 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver case kAudioObjectPropertyOwnedObjects: { UInt32 count = inDataSize / sizeof(AudioObjectID); if (count >= 1) ((AudioObjectID*)outData)[0] = kObjectID_Box; - written = (count >= 1 ? 1 : 0) * sizeof(AudioObjectID); + if (count >= 2) ((AudioObjectID*)outData)[1] = kObjectID_Device; + written = (count >= 2 ? 2 : count) * sizeof(AudioObjectID); break; } case kAudioPlugInPropertyBoxList: { @@ -635,6 +686,7 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver if (inQualifierDataSize != sizeof(CFStringRef) || inQualifierData == NULL) return kAudioHardwareBadPropertySizeError; if (inDataSize < sizeof(AudioObjectID)) return kAudioHardwareBadPropertySizeError; CFStringRef uid = *((CFStringRef*)inQualifierData); + if (uid != NULL && CFGetTypeID(uid) != CFStringGetTypeID()) return kAudioHardwareIllegalOperationError; if (uid != NULL && CFStringCompare(uid, CFSTR(kBox_UID), 0) == kCFCompareEqualTo) { *((AudioObjectID*)outData) = kObjectID_Box; } else { @@ -658,6 +710,7 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver if (inQualifierDataSize != sizeof(CFStringRef) || inQualifierData == NULL) return kAudioHardwareBadPropertySizeError; if (inDataSize < sizeof(AudioObjectID)) return kAudioHardwareBadPropertySizeError; CFStringRef uid = *((CFStringRef*)inQualifierData); + if (uid != NULL && CFGetTypeID(uid) != CFStringGetTypeID()) return kAudioHardwareIllegalOperationError; if (uid != NULL && CFStringCompare(uid, CFSTR(kDevice_UID), 0) == kCFCompareEqualTo) { *((AudioObjectID*)outData) = kObjectID_Device; } else { @@ -781,6 +834,10 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver *((AudioObjectID*)outData) = kObjectID_PlugIn; written = sizeof(AudioObjectID); break; + case kAudioObjectPropertyFirmwareVersion: + *((CFStringRef*)outData) = CFSTR(kDriverVersion); + written = sizeof(CFStringRef); + break; case kAudioObjectPropertyName: *((CFStringRef*)outData) = CFSTR(kDevice_Name); written = sizeof(CFStringRef); @@ -791,10 +848,11 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver break; case kAudioObjectPropertyOwnedObjects: { UInt32 maxCount = inDataSize / sizeof(AudioObjectID); - if (maxCount > 3) maxCount = 3; + if (maxCount > 4) maxCount = 4; if (maxCount >= 1) ((AudioObjectID*)outData)[0] = kObjectID_Stream_Input; if (maxCount >= 2) ((AudioObjectID*)outData)[1] = kObjectID_Stream_Output; if (maxCount >= 3) ((AudioObjectID*)outData)[2] = kObjectID_Volume_Output_Master; + if (maxCount >= 4) ((AudioObjectID*)outData)[3] = kObjectID_Mute_Output_Master; written = maxCount * sizeof(AudioObjectID); break; } @@ -833,11 +891,13 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver break; } case kAudioDevicePropertyDeviceCanBeDefaultDevice: - *((UInt32*)outData) = 1; + *((UInt32*)outData) = inAddress->mScope == kAudioObjectPropertyScopeOutput ? 1 : 0; written = sizeof(UInt32); break; case kAudioDevicePropertyDeviceCanBeDefaultSystemDevice: - *((UInt32*)outData) = 1; + // Keep alerts on the user's physical system output. The app + // only owns the normal default output, never the input device. + *((UInt32*)outData) = 0; written = sizeof(UInt32); break; case kAudioDevicePropertyLatency: @@ -862,7 +922,8 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver inAddress->mScope == kAudioObjectPropertyScopeOutput) && maxCount >= 1) { ((AudioObjectID*)outData)[0] = kObjectID_Volume_Output_Master; - written = sizeof(AudioObjectID); + if (maxCount >= 2) ((AudioObjectID*)outData)[1] = kObjectID_Mute_Output_Master; + written = (maxCount >= 2 ? 2 : 1) * sizeof(AudioObjectID); } else { written = 0; } @@ -1036,15 +1097,16 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver } case kObjectID_Volume_Output_Master: + case kObjectID_Mute_Output_Master: switch (inAddress->mSelector) { case kAudioObjectPropertyBaseClass: if (inDataSize < sizeof(AudioClassID)) return kAudioHardwareBadPropertySizeError; - *((AudioClassID*)outData) = kAudioLevelControlClassID; + *((AudioClassID*)outData) = inObjectID == kObjectID_Mute_Output_Master ? kAudioBooleanControlClassID : kAudioLevelControlClassID; written = sizeof(AudioClassID); break; case kAudioObjectPropertyClass: if (inDataSize < sizeof(AudioClassID)) return kAudioHardwareBadPropertySizeError; - *((AudioClassID*)outData) = kAudioVolumeControlClassID; + *((AudioClassID*)outData) = inObjectID == kObjectID_Mute_Output_Master ? kAudioMuteControlClassID : kAudioVolumeControlClassID; written = sizeof(AudioClassID); break; case kAudioObjectPropertyOwner: @@ -1065,6 +1127,10 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver *((AudioObjectPropertyElement*)outData) = kAudioObjectPropertyElementMain; written = sizeof(AudioObjectPropertyElement); break; + case kAudioBooleanControlPropertyValue: + *((UInt32*)outData) = atomic_load(&gMute_OutputMaster) ? 1 : 0; + written = sizeof(UInt32); + break; case kAudioLevelControlPropertyScalarValue: { if (inDataSize < sizeof(Float32)) return kAudioHardwareBadPropertySizeError; pthread_mutex_lock(&gPlugIn_StateMutex); @@ -1095,6 +1161,7 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver // it with the equivalent dB value. if (inDataSize < sizeof(Float32)) return kAudioHardwareBadPropertySizeError; Float32 scalar = *((Float32*)outData); + if (!isfinite(scalar)) return kAudioHardwareIllegalOperationError; if (scalar < 0.0f) scalar = 0.0f; if (scalar > 1.0f) scalar = 1.0f; *((Float32*)outData) = MSF_VolumeScalarToDB(scalar); @@ -1106,6 +1173,7 @@ static OSStatus MacStereoFix_GetPropertyData(AudioServerPlugInDriverRef inDriver // with the equivalent scalar value. if (inDataSize < sizeof(Float32)) return kAudioHardwareBadPropertySizeError; Float32 dB = *((Float32*)outData); + if (!isfinite(dB)) return kAudioHardwareIllegalOperationError; *((Float32*)outData) = MSF_VolumeDBToScalar(dB); written = sizeof(Float32); break; @@ -1130,56 +1198,18 @@ static OSStatus MacStereoFix_SetPropertyData(AudioServerPlugInDriverRef inDriver (void)inClientProcessID; (void)inQualifierDataSize; (void)inQualifierData; if (inDriver != gAudioServerPlugInDriverRef) return kAudioHardwareBadObjectError; if (inAddress == NULL || inData == NULL) return kAudioHardwareIllegalOperationError; + Boolean settable = false; + OSStatus settableStatus = MacStereoFix_IsPropertySettable(inDriver, inObjectID, + inClientProcessID, inAddress, &settable); + if (settableStatus != noErr) return settableStatus; + if (!settable) return kAudioHardwareIllegalOperationError; switch (inObjectID) { case kObjectID_Box: switch (inAddress->mSelector) { - case kAudioObjectPropertyName: { - if (inDataSize != sizeof(CFStringRef)) return kAudioHardwareBadPropertySizeError; - CFStringRef newName = *((CFStringRef*)inData); - pthread_mutex_lock(&gPlugIn_StateMutex); - if (gBox_Name != NULL) CFRelease(gBox_Name); - if (newName != NULL) CFRetain(newName); - gBox_Name = newName; - pthread_mutex_unlock(&gPlugIn_StateMutex); - return kAudioHardwareNoError; - } case kAudioObjectPropertyIdentify: - return kAudioHardwareNoError; - case kAudioBoxPropertyAcquired: { if (inDataSize != sizeof(UInt32)) return kAudioHardwareBadPropertySizeError; - pthread_mutex_lock(&gPlugIn_StateMutex); - gBox_Acquired = (*(UInt32*)inData != 0); - pthread_mutex_unlock(&gPlugIn_StateMutex); return kAudioHardwareNoError; - } - } - break; - - case kObjectID_Device: - switch (inAddress->mSelector) { - case kAudioDevicePropertyBufferFrameSize: { - if (inDataSize != sizeof(UInt32)) return kAudioHardwareBadPropertySizeError; - UInt32 requested = *((const UInt32*)inData); - if (requested < kBufferFrameSize_Min) requested = kBufferFrameSize_Min; - if (requested > kBufferFrameSize_Max) requested = kBufferFrameSize_Max; - bool changed = false; - pthread_mutex_lock(&gPlugIn_StateMutex); - if (gDevice_BufferFrameSize != requested) { - gDevice_BufferFrameSize = requested; - changed = true; - } - pthread_mutex_unlock(&gPlugIn_StateMutex); - if (changed && gPlugIn_Host != NULL) { - AudioObjectPropertyAddress changedAddr = { - kAudioDevicePropertyBufferFrameSize, - kAudioObjectPropertyScopeGlobal, - kAudioObjectPropertyElementMain - }; - gPlugIn_Host->PropertiesChanged(gPlugIn_Host, kObjectID_Device, 1, &changedAddr); - } - return kAudioHardwareNoError; - } } break; @@ -1193,6 +1223,10 @@ static OSStatus MacStereoFix_SetPropertyData(AudioServerPlugInDriverRef inDriver if (inObjectID == kObjectID_Stream_Input) gStream_Input_IsActive = active; else gStream_Output_IsActive = active; pthread_mutex_unlock(&gPlugIn_StateMutex); + if (gPlugIn_Host != NULL) { + AudioObjectPropertyAddress changed = *inAddress; + gPlugIn_Host->PropertiesChanged(gPlugIn_Host, inObjectID, 1, &changed); + } return kAudioHardwareNoError; } case kAudioStreamPropertyVirtualFormat: @@ -1202,16 +1236,31 @@ static OSStatus MacStereoFix_SetPropertyData(AudioServerPlugInDriverRef inDriver if (fmt->mFormatID != kAudioFormatLinearPCM) return kAudioDeviceUnsupportedFormatError; if (fmt->mChannelsPerFrame != kChannelCount) return kAudioDeviceUnsupportedFormatError; if (fmt->mSampleRate != kSampleRate) return kAudioHardwareIllegalOperationError; + if (fmt->mFormatFlags != (kAudioFormatFlagIsFloat | kAudioFormatFlagsNativeEndian | kAudioFormatFlagIsPacked) || + fmt->mBytesPerPacket != kBytesPerFrame || fmt->mFramesPerPacket != 1 || + fmt->mBytesPerFrame != kBytesPerFrame || fmt->mBitsPerChannel != 32) + return kAudioDeviceUnsupportedFormatError; return kAudioHardwareNoError; } } break; case kObjectID_Volume_Output_Master: + case kObjectID_Mute_Output_Master: switch (inAddress->mSelector) { + case kAudioBooleanControlPropertyValue: { + if (inDataSize != sizeof(UInt32)) return kAudioHardwareBadPropertySizeError; + bool muted = *((const UInt32*)inData) != 0; + if (atomic_exchange(&gMute_OutputMaster, muted) != muted && gPlugIn_Host != NULL) { + AudioObjectPropertyAddress changed = *inAddress; + gPlugIn_Host->PropertiesChanged(gPlugIn_Host, inObjectID, 1, &changed); + } + return kAudioHardwareNoError; + } case kAudioLevelControlPropertyScalarValue: { if (inDataSize != sizeof(Float32)) return kAudioHardwareBadPropertySizeError; Float32 newScalar = *((const Float32*)inData); + if (!isfinite(newScalar)) return kAudioHardwareIllegalOperationError; if (newScalar < 0.0f) newScalar = 0.0f; if (newScalar > 1.0f) newScalar = 1.0f; bool changed = false; @@ -1239,6 +1288,7 @@ static OSStatus MacStereoFix_SetPropertyData(AudioServerPlugInDriverRef inDriver case kAudioLevelControlPropertyDecibelValue: { if (inDataSize != sizeof(Float32)) return kAudioHardwareBadPropertySizeError; Float32 newDB = *((const Float32*)inData); + if (!isfinite(newDB)) return kAudioHardwareIllegalOperationError; if (newDB < kVolume_MinDB) newDB = kVolume_MinDB; if (newDB > kVolume_MaxDB) newDB = kVolume_MaxDB; Float32 newScalar = MSF_VolumeDBToScalar(newDB); @@ -1276,10 +1326,9 @@ static OSStatus MacStereoFix_StartIO(AudioServerPlugInDriverRef inDriver, AudioO pthread_mutex_lock(&gPlugIn_StateMutex); if (gDevice_IOIsRunning == 0) { - gDevice_NumberTimeStamps = 0; - gDevice_AnchorSampleTime = 0.0; - gDevice_AnchorHostTime = mach_absolute_time(); - memset(gRingBuffer, 0, sizeof(gRingBuffer)); + atomic_store(&gDevice_AnchorHostTime, mach_absolute_time()); + atomic_fetch_add(&gDevice_TimeStampSeed, 1); + MSF_ClearRing(); } ++gDevice_IOIsRunning; pthread_mutex_unlock(&gPlugIn_StateMutex); @@ -1305,20 +1354,16 @@ static OSStatus MacStereoFix_GetZeroTimeStamp(AudioServerPlugInDriverRef inDrive if (inDeviceObjectID != kObjectID_Device) return kAudioHardwareBadObjectError; if (outSampleTime == NULL || outHostTime == NULL || outSeed == NULL) return kAudioHardwareIllegalOperationError; - // No lock here: GetZeroTimeStamp is on the audio realtime path. The host - // serialises calls per device, and gDevice_NumberTimeStamps / - // gDevice_AnchorHostTime are only ever written from this function (or - // reset under lock in StartIO before IO begins). + // Derive the timestamp from elapsed time so sleep or a delayed callback + // catches up in one call instead of advancing one period at a time. UInt64 currentHostTime = mach_absolute_time(); + UInt64 anchor = atomic_load(&gDevice_AnchorHostTime); Float64 hostTicksPerRingBuffer = gDevice_HostTicksPerFrame * (Float64)kRingBufferFrameCount; - Float64 hostTickOffset = ((Float64)gDevice_NumberTimeStamps + 1.0) * hostTicksPerRingBuffer; - UInt64 nextHostTime = gDevice_AnchorHostTime + (UInt64)hostTickOffset; - if (nextHostTime <= currentHostTime) { - ++gDevice_NumberTimeStamps; - } - *outSampleTime = gDevice_NumberTimeStamps * (Float64)kRingBufferFrameCount; - *outHostTime = gDevice_AnchorHostTime + (UInt64)((Float64)gDevice_NumberTimeStamps * hostTicksPerRingBuffer); - *outSeed = 1; + if (hostTicksPerRingBuffer <= 0 || currentHostTime < anchor) return kAudioHardwareIllegalOperationError; + UInt64 periods = (UInt64)((currentHostTime - anchor) / hostTicksPerRingBuffer); + *outSampleTime = periods * (Float64)kRingBufferFrameCount; + *outHostTime = anchor + (UInt64)(periods * hostTicksPerRingBuffer); + *outSeed = atomic_load(&gDevice_TimeStampSeed); return kAudioHardwareNoError; } @@ -1357,44 +1402,51 @@ static OSStatus MacStereoFix_DoIOOperation(AudioServerPlugInDriverRef inDriver, if (inDeviceObjectID != kObjectID_Device) return kAudioHardwareBadObjectError; if (ioMainBuffer == NULL || inIOCycleInfo == NULL) return kAudioHardwareIllegalOperationError; - if (inOperationID == kAudioServerPlugInIOOperationWriteMix && inStreamObjectID == kObjectID_Stream_Output) { - // Game writing into the device output goes into the ring buffer. - Float64 sampleTime = inIOCycleInfo->mOutputTime.mSampleTime; - UInt64 startFrame = ((UInt64)sampleTime) % kRingBufferFrameCount; - UInt32 framesRemaining = inIOBufferFrameSize; - const Float32* src = (const Float32*)ioMainBuffer; - UInt64 cursor = startFrame; - while (framesRemaining > 0) { - UInt32 framesUntilWrap = (UInt32)(kRingBufferFrameCount - cursor); - UInt32 chunk = framesRemaining < framesUntilWrap ? framesRemaining : framesUntilWrap; - memcpy(&gRingBuffer[cursor * kChannelCount], src, chunk * kBytesPerFrame); - src += chunk * kChannelCount; - framesRemaining -= chunk; - cursor += chunk; - if (cursor >= kRingBufferFrameCount) cursor = 0; - } + bool writing = inOperationID == kAudioServerPlugInIOOperationWriteMix; + bool reading = inOperationID == kAudioServerPlugInIOOperationReadInput; + if (!writing && !reading) return kAudioHardwareUnsupportedOperationError; + if (inStreamObjectID != (writing ? kObjectID_Stream_Output : kObjectID_Stream_Input)) + return kAudioHardwareBadObjectError; + if (inIOBufferFrameSize > kRingBufferFrameCount) return kAudioHardwareIllegalOperationError; + Float64 sampleTime = writing ? inIOCycleInfo->mOutputTime.mSampleTime : inIOCycleInfo->mInputTime.mSampleTime; + // Limit to exactly representable integral times and leave room for this cycle. + if (!isfinite(sampleTime) || sampleTime > 0x1p53 - kRingBufferFrameCount || + floor(sampleTime) != sampleTime) return kAudioHardwareIllegalOperationError; + // The host may preroll with a negative sample time at startup. There is + // no audio before our anchor; return silence without an invalid conversion. + if (sampleTime < 0) { + if (reading) memset(ioMainBuffer, 0, inIOBufferFrameSize * kBytesPerFrame); return kAudioHardwareNoError; } - - if (inOperationID == kAudioServerPlugInIOOperationReadInput && inStreamObjectID == kObjectID_Stream_Input) { - // Helper app reading from the device input pulls from the ring buffer. - Float64 sampleTime = inIOCycleInfo->mInputTime.mSampleTime; - UInt64 startFrame = ((UInt64)sampleTime) % kRingBufferFrameCount; - UInt32 framesRemaining = inIOBufferFrameSize; - Float32* dst = (Float32*)ioMainBuffer; - UInt64 cursor = startFrame; - while (framesRemaining > 0) { - UInt32 framesUntilWrap = (UInt32)(kRingBufferFrameCount - cursor); - UInt32 chunk = framesRemaining < framesUntilWrap ? framesRemaining : framesUntilWrap; - memcpy(dst, &gRingBuffer[cursor * kChannelCount], chunk * kBytesPerFrame); - dst += chunk * kChannelCount; - framesRemaining -= chunk; - cursor += chunk; - if (cursor >= kRingBufferFrameCount) cursor = 0; + UInt64 first = (UInt64)sampleTime; + Float32* samples = (Float32*)ioMainBuffer; + bool active = atomic_load(writing ? &gStream_Output_IsActive : &gStream_Input_IsActive); + for (UInt32 i = 0; i < inIOBufferFrameSize; ++i) { + UInt64 time = first + i; + MSFFrame* frame = &gRingBuffer[time % kRingBufferFrameCount]; + if (writing) { + // The host supplies one fully mixed output writer for this device. + atomic_fetch_add(&frame->sequence, 1); // odd: write in progress + for (UInt32 c = 0; c < kChannelCount; ++c) { + Float32 sample = active && isfinite(samples[i * kChannelCount + c]) + ? samples[i * kChannelCount + c] : 0; + unsigned int bits; + memcpy(&bits, &sample, sizeof(bits)); + atomic_store(&frame->samples[c], bits); + } + atomic_store(&frame->sampleTime, time); + atomic_fetch_add(&frame->sequence, 1); // even: complete + } else { + unsigned long long sequence = atomic_load(&frame->sequence); + bool valid = active && !(sequence & 1) && atomic_load(&frame->sampleTime) == time; + for (UInt32 c = 0; c < kChannelCount; ++c) { + unsigned int bits = atomic_load(&frame->samples[c]); + memcpy(&samples[i * kChannelCount + c], &bits, sizeof(bits)); + } + if (!valid || atomic_load(&frame->sequence) != sequence) + memset(&samples[i * kChannelCount], 0, kBytesPerFrame); } - return kAudioHardwareNoError; } - return kAudioHardwareNoError; } diff --git a/FRIENDS_README.txt b/FRIENDS_README.txt deleted file mode 100644 index bd35fb4..0000000 --- a/FRIENDS_README.txt +++ /dev/null @@ -1,104 +0,0 @@ -MacStereoFix — quick install -============================ - -What it does ------------- -Forces every app's audio on your Mac through a stereo downmix with a -"dialogue boost" so center-channel voices stop getting lost. Built mainly -to fix the "voices are super faint in this game" problem in CrossOver -games, but it works for any audio source. - -You'll do a one-time install dance the very first time you open it. -After that it's just "click menu bar icon → flip toggle → play game". - - -Step 1 — install the app ------------------------- -1. Drag MacStereoFix.app into your Applications folder. -2. Double-click MacStereoFix in Applications. -3. macOS will pop up a warning that says something like - "MacStereoFix can't be opened because Apple cannot check it - for malicious software." Click OK. -4. Open System Settings (Apple menu → System Settings). -5. Go to Privacy & Security in the sidebar. -6. Scroll down. You'll see a line that says - "MacStereoFix was blocked from use because it is not from - an identified developer." - Click the OPEN ANYWAY button next to it. -7. Confirmation dialog → click Open Anyway → enter your Mac password. -8. The app launches. A small speaker icon appears at the top right - of your screen, in the menu bar. There is no app window — that's - on purpose. The app lives entirely in the menu bar. - -You only ever do steps 3-7 ONCE. Forever after, MacStereoFix opens like -any normal app. - - -Step 2 — install the driver (one click) ----------------------------------------- -1. Click the small speaker icon in your menu bar. -2. A panel drops down showing "Driver not installed" and a big - "Install Driver" button. Click it. -3. macOS will ask for your password. Type it and press Enter. -4. Wait about 2 seconds. The panel refreshes and now shows the real UI: - a Force Stereo toggle, a "Send stereo to" picker, a Volume slider, - and a Dialogue boost slider. - -You only ever do this ONCE. The driver stays installed forever. - - -Step 3 — use it ---------------- -1. Click the menu bar icon any time you want to use it. -2. Under "Send stereo to", pick whatever you actually listen with — - MacBook speakers, AirPods, headphones, monitor speakers, etc. -3. Flip "Force Stereo" to ON. -4. The very first time you toggle ON, macOS will ask for microphone - permission. Click Allow. - (No actual microphone is involved. Our virtual audio device is - technically classified as an audio input by macOS, so it asks. - If you click "Don't Allow" by accident, fix it in - System Settings → Privacy & Security → Microphone → turn on - MacStereoFix, then quit and relaunch the app.) -5. If voices in your game are still too quiet, drag the - "Dialogue boost" slider to the right. Start at +3 dB. Go higher - only if you need to. -6. Launch your game. Audio routes through MacStereoFix automatically. -7. When you're done, click the menu bar icon and flip Force Stereo - to OFF. Your normal audio comes back exactly as it was. - - -Important rules ---------------- -- NEVER pick "MacStereoFix" manually in System Settings → Sound. The - app does that for you when you flip the toggle on. If you set it - manually, you'll get silence. -- Don't quit the app while the toggle is ON. Flip it OFF first, then - quit. (If you forget, the next launch auto-recovers.) -- The macOS volume keys won't work while Force Stereo is on, because - the active output is a virtual device. Use the Volume slider in - the menu instead. - - -If something goes wrong ------------------------ -- "I can't see the menu bar icon." - It's at the top-right of your screen, near the clock. It's a small - speaker icon. If your menu bar is full, hold Cmd and drag other - icons left to make room. - -- "I clicked Install Driver and nothing happened / it failed." - Quit the app, reopen it, try again. The most common cause is the - password dialog being dismissed too fast. - -- "I have no audio at all." - Open System Settings → Sound → Output and click your normal output - device (MacBook Pro Speakers, AirPods, etc). Audio comes back - immediately. - -- "Voices are still faint." - Push the Dialogue boost slider higher. The default is conservative. - -- "I want to remove it." - Click the menu bar icon → Advanced → Uninstall Driver. Then drag - MacStereoFix.app from /Applications to the Trash. diff --git a/INSTALL.txt b/INSTALL.txt new file mode 100644 index 0000000..44b4e77 --- /dev/null +++ b/INSTALL.txt @@ -0,0 +1,35 @@ +MacStereoFix + +1. Unzip and move MacStereoFix.app to Applications. +2. Open it and click the speaker icon in the menu bar. +3. Finish calls and recordings, then click Install Audio Driver… and approve + the administrator prompt. All Mac audio briefly stops while the driver loads. +4. Choose your speakers or headphones and turn Force Stereo on. +5. Allow microphone access when macOS asks. This reads the virtual audio device, + not your physical microphone. The app does not record or upload audio. + +Start at a low listening volume. Volume and Mute affect routed audio. Turning +Force Stereo off returns to your device's normal volume. Dialogue is part of +normal stereo mixing; optional Dialogue boost is under Advanced and defaults +to Off. + +The app starts off each time you open it. Sleep, a disconnected output, or a +manual output change stops routing. Choose your output and turn it on again. + +If sound stops: +Choose your speakers or headphones in System Settings > Sound > Output. Do not +select MacStereoFix manually while the app is off. + +If microphone permission was denied: +Allow MacStereoFix in System Settings > Privacy & Security > Microphone. + +If an updated driver has not loaded: +Restart the Mac. + +To uninstall: +Choose Advanced > Uninstall…, then move the app to the Trash. Removing the driver +requires administrator approval and briefly interrupts all Mac audio. + +Download releases from https://github.com/macprotips/MacStereoFix/releases. +If macOS cannot verify the app, download a signed, notarized release. Keep +Gatekeeper and other macOS security settings enabled. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6bcaa04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,4 @@ +For personal use among friends. + +The audio driver is based on Apple's NullAudio sample. The Apple sample notice +is included in ThirdParty/Apple-NullAudio-LICENSE.txt and applies to that code. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..23cc09f --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,48 @@ +# Privacy + +## Audio + +While Force Stereo is on, MacStereoFix reads audio sent to its virtual device +and mixes it in memory. That audio may include conversations or other private +content from apps using the default output. + +The app does not save recordings or send audio over the network. It has no +analytics, advertising, or automatic updater. + +## Microphone permission + +macOS requires microphone permission to read the virtual audio input. +MacStereoFix selects its own virtual device for capture, not your physical +microphone. If permission is denied, routing stays off. + +You can revoke access in **System Settings → Privacy & Security → Microphone**. + +The virtual input is a system audio device. Other local apps with the necessary +audio permission may also select it. Turn Force Stereo off when you no longer +need it. + +## Saved settings + +The app saves the selected output device, volume, dialogue boost, and the output +to restore after a failure. These settings stay in the app's local preferences. +It does not keep an audio history. + +A recovery helper receives the fallback output identifier and watches for the +app to close. It runs as your user account and exits after recovery or a normal +shutdown. It has no administrator privileges or login item. + +## Administrator access + +Installing or removing the driver requires administrator approval. These actions +modify MacStereoFix's driver in `/Library/Audio/Plug-Ins/HAL` and restart CoreAudio. +They do not change hardware volume, Gatekeeper, SIP, or login items. + +## Removing saved settings + +After uninstalling, preferences can be removed in Terminal with: + +```sh +defaults delete com.macstereofix.app +``` + +Deleting the app alone may leave these preferences on your Mac. diff --git a/README.md b/README.md index e479bf1..5ab5597 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,80 @@ # MacStereoFix -A small Mac utility that **forces every app's audio through a stereo downmix**, with a **dialogue boost** so center-channel voices stop getting lost. Built specifically to fix the "voices are super faint in this game" problem in CrossOver bottles, but it works for *any* macOS audio source — games, players, browsers — because it sits at the system audio layer. +A Mac menu bar app that mixes surround audio into stereo. It can help when +voices or sound effects are missing in games played through stereo speakers +or headphones. -It works by installing a small CoreAudio virtual device called **MacStereoFix**, then routing all audio through that device, downmixing 7.1 / 5.1 to 2 channels in real time, and sending the result to the speaker / headphones / AirPods you pick. +[Downloads](https://github.com/macprotips/MacStereoFix/releases) · +[Changelog](CHANGELOG.md) · [Report a problem](https://github.com/macprotips/MacStereoFix/issues) -This is a personal-use project for a small group of friends. It is not a polished commercial app. +**Version 1.3 is in development.** The current v1.2 download does not include +the driver and recovery fixes described below. Live audio testing and Apple +notarization for 1.3 are still pending. ---- + + + +
MacStereoFix in light modeMacStereoFix in dark mode
-## Download +Interface previews for 1.3, shown with a sample output. -Prebuilt signed + notarized app: -**[MacStereoFix v1.2](https://github.com/macprotips/MacStereoFix/releases/latest)** +## Using MacStereoFix -Unzip, drag `MacStereoFix.app` into `/Applications`, launch it, and click **Install Driver** in the menu bar popover. That's it. +1. Unzip a release and move `MacStereoFix.app` to Applications. +2. Open the app and click its speaker icon in the menu bar. +3. Click **Install Audio Driver…** and approve the macOS administrator prompt. + Installation briefly interrupts all Mac audio, so finish calls and recordings first. +4. Choose your speakers or headphones, then turn **Force Stereo** on. +5. Allow microphone access when macOS asks. This lets the app read its virtual + audio device; it does not select your physical microphone. ---- +Start with your device at a low listening volume. **Volume** and **Mute** affect +routed audio. Turning Force Stereo off returns to your device's normal volume. -## What's in here +Dialogue is included in the standard stereo mix. An optional **Dialogue boost** +is available under **Advanced** and is off by default. -``` -MacStereoFix/ -├── Driver/ # Audio server plug-in (C) -│ ├── MacStereoFixDriver.c # The HAL plug-in implementation -│ └── Info.plist # Driver bundle metadata -├── App/ # Menu bar app (Swift) -│ ├── MacStereoFixApp.swift # @main entry / MenuBarExtra -│ ├── AppState.swift # Toggle state, persisted prefs, observers -│ ├── MenuBarView.swift # SwiftUI menu UI -│ ├── AudioRouter.swift # Two HALOutput AUs + downmix DSP -│ ├── RingBuffer.swift # SPSC lock-free Float ring buffer -│ ├── SystemAudio.swift # Device enumeration & default-output control -│ ├── DriverManager.swift # Install/uninstall via authenticated AppleScript -│ ├── MSFAtomic.h # C atomic helpers (imported via bridging header) -│ └── Info.plist # App bundle metadata -├── build.sh # Builds driver + app into ./build/ -├── install.sh # sudo install of the driver -├── uninstall.sh # sudo remove of the driver -└── README.md # this file -``` +The app starts off each time you open it. Sleep, device disconnection, a routing +error, or a manual output change stops routing. Select your output and turn it +on again when you're ready. ---- +## How it works -## How it works (the short version) +MacStereoFix installs a virtual CoreAudio device and mixes its surround channels +into two output channels. Apps that use the macOS default output follow this +route; apps that choose their own output may bypass it. System alerts and +protected media may behave differently. -1. The **driver** is a CoreAudio audio server plug-in that exposes one virtual device, `MacStereoFix`, with one 8-channel input stream and one 8-channel output stream. Internally it's just a circular buffer: whatever an app writes to the output stream becomes available on the input stream a moment later. The driver does **no DSP**. +The destination must have at least two output channels. Virtual and aggregate +outputs and Bluetooth mono call modes are not supported. The app handles PCM +audio; it does not decode Dolby or DTS bitstreams. -2. The **menu bar app** owns two `kAudioUnitSubType_HALOutput` audio units: - - The **capture unit** is bound to MacStereoFix and pulls 8-channel Float32 audio out of it via an input callback. - - The **render unit** is bound to your chosen real output device (MacBook speakers, AirPods, monitor, etc.). Its render callback reads 8-channel frames from the ring buffer, **downmixes them to stereo with the current dialogue boost**, and sends them to the device. +Audio is processed in memory on your Mac. There are no recordings, uploads, +analytics, or ads. See [Privacy](PRIVACY.md) for permission and storage details. -3. When you toggle **Force Stereo: ON**, the app: - - starts both audio units - - sets the system default output device to MacStereoFix - so that every app on your Mac (including CrossOver bottles, since Wine respects the macOS default output) sends its audio into our pipeline. +## If sound stops -4. When you toggle **OFF**, the app stops the audio units and restores the previous default output device. +Open **System Settings → Sound → Output** and choose your normal speakers or +headphones. Do not select MacStereoFix manually while its app is off. -The downmix uses the standard ITU coefficients with an adjustable boost on the center channel: +- **Permission denied:** allow MacStereoFix in **Privacy & Security → Microphone**. +- **Driver update has not loaded:** restart the Mac. +- **Bluetooth is in call mode:** end the call or choose another stereo output. +- **macOS cannot verify the app:** download a signed, notarized release. Keep + Gatekeeper and other macOS security settings enabled. -``` -Lo = L + Cgain·C + 0.707·Ls + 0.5·Lsr -Ro = R + Cgain·C + 0.707·Rs + 0.5·Rsr -``` +## Uninstall -`Cgain` defaults to 0.707 (-3 dB) and goes up to roughly 2.0 (+6 dB) at the top of the **Dialogue boost** slider. LFE is dropped. +Choose **Advanced → Uninstall…**, then move the app to the Trash. Removing the +driver requires administrator approval and briefly interrupts Mac audio. ---- +## Development -## Building +Builds target macOS 13 and include Apple Silicon and Intel binaries. OS and device +coverage is tracked in the [release checklist](docs/RELEASE_CHECKLIST.md). -You need: +Build instructions, tests, and release commands are in +[Development](docs/DEVELOPMENT.md). Security issues can be +[reported privately](SECURITY.md). -- macOS 13+ (the app uses SwiftUI's `MenuBarExtra`) -- Xcode Command Line Tools (`xcode-select --install`) — gives you `clang`, `swiftc`, `codesign`, `lipo` -- A few seconds - -```sh -./build.sh -``` - -This produces: - -``` -build/MacStereoFix.driver # the audio server plug-in bundle -build/MacStereoFix.app # the menu bar app, with the driver bundled inside -``` - -Both bundles are universal (arm64 + x86_64) and ad-hoc signed. - ---- - -## Installing - -You have two options: - -### Option A — let the app install the driver for you (recommended) - -1. Drag `build/MacStereoFix.app` into `/Applications`. -2. Launch it. It'll appear in your menu bar as a small speaker icon. -3. Click the icon. Because the driver isn't installed yet, you'll see an **Install Driver** button. Click it. -4. macOS will prompt for your administrator password (this is the standard "do shell script with administrator privileges" dialog). After you authorize it, the app: - - copies `MacStereoFix.driver` from `Contents/Resources/` into `/Library/Audio/Plug-Ins/HAL/` - - chowns it to `root:wheel` - - kicks `coreaudiod` so the device shows up immediately -5. Click the icon again — the toggle and device picker now appear. - -### Option B — install from the command line - -```sh -./build.sh -sudo ./install.sh -cp -R build/MacStereoFix.app /Applications/ -``` - -Then launch the app from `/Applications`. - ---- - -## Using it - -1. Click the menu bar icon. -2. Pick your real output device under **Send stereo to** (your speakers, AirPods, etc.). -3. Flip **Force Stereo** to **ON**. -4. Play your game / movie / whatever. All audio now flows: app → MacStereoFix → MacStereoFix.app → real output, downmixed to stereo. -5. Flip **OFF** when you're done. The app restores your previous default output device. - -The first time you toggle ON, macOS will show a **microphone access** prompt. This is because the helper app is technically reading from MacStereoFix's input stream, which macOS classifies as audio input. Allow it. (No actual microphone is involved.) - -### CrossOver-specific notes - -No need to edit the registry of a bottle in CrossOver or install complicated audio applications. With MacStereoFix ON, anything playing inside a bottle automatically goes through the downmix. - ---- - -## Uninstalling - -From the app: **Advanced → Uninstall Driver**. You'll get the same admin prompt. - -Or from the command line: - -```sh -sudo ./uninstall.sh -rm -rf /Applications/MacStereoFix.app -``` - ---- - -## Troubleshooting - -**The MacStereoFix device doesn't appear in System Settings → Sound after install.** -1. Check the driver bundle exists: `ls /Library/Audio/Plug-Ins/HAL/MacStereoFix.driver` -2. Check ownership: `ls -ld /Library/Audio/Plug-Ins/HAL/MacStereoFix.driver` should show `root wheel`. -3. Restart coreaudiod manually: `sudo launchctl kickstart -k system/com.apple.audio.coreaudiod` -4. Look at the system log for plug-in load errors: - `log show --predicate 'subsystem == "com.apple.coreaudio"' --last 5m | grep -i macstereofix` -5. If the driver is **unsigned** or **signed by an unknown identity**, modern macOS may silently refuse to load it. Rebuild with a real `SIGN_IDENTITY` and re-install. - -**The toggle turns on but I hear no sound.** -- Check that your **Send stereo to** picker isn't pointing at MacStereoFix itself (it's filtered out, but if your selection is stale it could happen — use **Refresh Devices** in Advanced). -- Open System Settings → Sound and confirm the system output is `MacStereoFix` while the toggle is on. -- If macOS is asking for microphone permission, grant it — without that, the capture side is silent. - -**I want to remove everything.** -```sh -sudo ./uninstall.sh -rm -rf /Applications/MacStereoFix.app -defaults delete com.macstereofix.app 2>/dev/null || true -``` - ---- - -## Things this v1 deliberately does NOT do - -- **No bitstream / passthrough.** PCM only. Dolby Digital and DTS streams are not handled. Almost every game and movie uses PCM through CoreAudio anyway. -- **No per-app routing.** It's a system-wide toggle. If you want one app on stereo and another on surround, you'd need a much fancier app. -- **No multiple sample rates.** The virtual device is locked to 48 kHz. macOS will sample-rate-convert for you when an app outputs 44.1 kHz. -- **No surround panning, EQ, virtualization, or HRTF.** Just an honest downmix. -- **No automatic launch at login.** Add it to System Settings → General → Login Items yourself if you want that. - ---- - -## License - -For personal use among friends. Driver structure is modeled on Apple's `NullAudio` sample (Apple Sample Code License). The rest is freshly written. +[Usage terms](LICENSE) · [Apple sample attribution](ThirdParty/README.md) diff --git a/Recovery/main.swift b/Recovery/main.swift new file mode 100644 index 0000000..45f3cef --- /dev/null +++ b/Recovery/main.swift @@ -0,0 +1,16 @@ +import Foundation + +// This helper runs as the logged-in user. It changes only an output still set +// to MacStereoFix, and exits as soon as the owning app closes its pipe. +let preferredUID = CommandLine.arguments.dropFirst().first +var disarmed = false +while let line = readLine() { + if line == "disarm" { disarmed = true } +} +if !disarmed { + // CoreAudio may be restarting at the same time the app exits. + for _ in 0..<25 { + if SystemAudio.restoreOutput(preferredUID: preferredUID) { break } + Thread.sleep(forTimeInterval: 0.2) + } +} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..57c0c48 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security + +Please report security issues through +[GitHub's private reporting form](https://github.com/macprotips/MacStereoFix/security/advisories/new). +Include the app version, macOS version, and steps to reproduce the problem. +Do not include private recordings, passwords, or signing credentials. + +Memory errors in the audio driver, unsafe installation behavior, and unexpected +audio capture are security issues. For other audio problems, use +[GitHub Issues](https://github.com/macprotips/MacStereoFix/issues). + +If sound stops, choose your speakers or headphones in +**System Settings → Sound → Output**. + +## Versions + +Version 1.3 is in development and includes driver, installation, and recovery +fixes that are not in the published v1.2 app. There is no automatic updater; +users must download a new release to receive fixes. diff --git a/Scripts/install-driver.sh b/Scripts/install-driver.sh new file mode 100755 index 0000000..66f7679 --- /dev/null +++ b/Scripts/install-driver.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Compiled into the app by build.sh. Do not execute a mutable resource script as root. +set -euo pipefail +export PATH=/usr/bin:/bin:/usr/sbin:/sbin +umask 077 +[[ $EUID -eq 0 ]] || { echo 'Administrator authorization is required.' >&2; exit 1; } +[[ $# -eq 2 ]] || { echo 'Expected driver path and signing requirement.' >&2; exit 1; } +source_driver=$1 +requirement=$2 +hal=/Library/Audio/Plug-Ins/HAL +destination=$hal/MacStereoFix.driver + +# All ancestors must be real, root-owned directories that other users cannot write. +for directory in /Library /Library/Audio /Library/Audio/Plug-Ins "$hal"; do + [[ ! -L "$directory" ]] || { echo "Refusing linked directory: $directory" >&2; exit 1; } + if [[ ! -e "$directory" ]]; then /usr/bin/install -d -o root -g wheel -m 755 "$directory"; fi + [[ -d "$directory" && $(/usr/bin/stat -f %u "$directory") == 0 ]] || exit 1 + acl=$(/bin/ls -lde "$directory") + [[ "$acl" != *" allow "* ]] || { echo "Unexpected directory access rules: $directory" >&2; exit 1; } + mode=$(/usr/bin/stat -f %Lp "$directory") + (( (8#$mode & 0022) == 0 )) || { echo "Unsafe directory permissions: $directory" >&2; exit 1; } +done +[[ -d "$source_driver" && ! -L "$source_driver" ]] || { echo 'Bundled driver is missing or linked.' >&2; exit 1; } +[[ ! -L "$destination" ]] || { echo 'Installed driver is a symbolic link; refusing to replace it.' >&2; exit 1; } + +# Copy into a private, root-owned staging directory before verification. A change +# to the app's user-writable source after this point cannot alter the verified copy. +stage=$(/usr/bin/mktemp -d /Library/Audio/.MacStereoFix.XXXXXX) +backup=0 +committed=0 +cleanup() { + if [[ $backup == 1 && $committed == 0 && ! -e "$destination" ]]; then + /bin/mv "$stage/previous" "$destination" || { + echo "Could not restore the previous driver. Backup retained at $stage/previous" >&2 + return + } + fi + /bin/rm -rf "$stage" +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM +/usr/bin/ditto --noqtn --noextattr --noacl "$source_driver" "$stage/driver" +unsafe=$(/usr/bin/find -P "$stage/driver" \( -type l -o \( ! -type f -a ! -type d \) -o \( -type f -a -links +1 \) \) -print) +[[ -z "$unsafe" ]] || { echo 'Driver contains links or special files.' >&2; exit 1; } +[[ $(/usr/bin/plutil -extract CFBundleExecutable raw -o - "$stage/driver/Contents/Info.plist") == MacStereoFix ]] || exit 1 +[[ $(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$stage/driver/Contents/Info.plist") == com.macstereofix.driver ]] || exit 1 +if [[ "$requirement" == --allow-adhoc ]]; then + /usr/bin/codesign --verify --strict "$stage/driver" +else + /usr/bin/codesign --verify --strict -R "$requirement" "$stage/driver" +fi +/bin/chmod -RN "$stage/driver" +/usr/sbin/chown -R root:wheel "$stage/driver" +/bin/chmod -R u=rwX,go=rX "$stage/driver" +if [[ -e "$destination" ]]; then + [[ -d "$destination" ]] || { echo 'Existing driver path is not a directory.' >&2; exit 1; } + /bin/mv "$destination" "$stage/previous" + backup=1 +fi +/bin/mv "$stage/driver" "$destination" +committed=1 +# Failure to restart must be reported, even though the copy succeeded. +if /usr/bin/pgrep -x coreaudiod >/dev/null; then + /usr/bin/killall coreaudiod || { echo 'Driver installed. Restart your Mac to load it.' >&2; exit 1; } +fi diff --git a/Scripts/make-icon.swift b/Scripts/make-icon.swift new file mode 100644 index 0000000..6e0068e --- /dev/null +++ b/Scripts/make-icon.swift @@ -0,0 +1,60 @@ +// Original vector artwork, rendered with AppKit; no fonts or external assets. +// Regenerate: swift Scripts/make-icon.swift +// Then: iconutil -c icns -o App/AppIcon.icns +import AppKit + +let destination = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) +try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) +let artwork = NSImage(size: NSSize(width: 1024, height: 1024), flipped: false) { _ in + let background = NSBezierPath(roundedRect: NSRect(x: 92, y: 92, width: 840, height: 840), xRadius: 184, yRadius: 184) + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.22) + shadow.shadowBlurRadius = 20 + shadow.shadowOffset = NSSize(width: 0, height: -8) + shadow.set() + NSColor(calibratedRed: 0.04, green: 0.2, blue: 0.23, alpha: 1).setFill() + background.fill() + NSGraphicsContext.restoreGraphicsState() + NSGradient(starting: NSColor(calibratedRed: 0.04, green: 0.18, blue: 0.21, alpha: 1), + ending: NSColor(calibratedRed: 0.12, green: 0.46, blue: 0.48, alpha: 1))! + .draw(in: background, angle: 90) + + for x: CGFloat in [252, 552] { + let cabinet = NSBezierPath(roundedRect: NSRect(x: x, y: 282, width: 220, height: 460), xRadius: 34, yRadius: 34) + NSGraphicsContext.saveGraphicsState() + shadow.shadowOffset = NSSize(width: 0, height: -12) + shadow.set() + NSColor.white.setFill() + cabinet.fill() + NSGraphicsContext.restoreGraphicsState() + NSGradient(starting: NSColor(calibratedWhite: 0.81, alpha: 1), ending: .white)! + .draw(in: cabinet, angle: 90) + for (y, radius): (CGFloat, CGFloat) in [(626, 30), (434, 73)] { + let cone = NSBezierPath(ovalIn: NSRect(x: x + 110 - radius, y: y - radius, width: radius * 2, height: radius * 2)) + NSColor(calibratedRed: 0.08, green: 0.24, blue: 0.27, alpha: 1).setFill() + cone.fill() + NSColor(calibratedRed: 0.30, green: 0.70, blue: 0.71, alpha: 1).setStroke() + cone.lineWidth = 6 + cone.stroke() + NSColor(calibratedRed: 0.20, green: 0.46, blue: 0.49, alpha: 1).setFill() + NSBezierPath(ovalIn: NSRect(x: x + 110 - radius * 0.35, y: y - radius * 0.35, width: radius * 0.7, height: radius * 0.7)).fill() + } + } + return true +} +for size in [16, 32, 128, 256, 512] { + for scale in [1, 2] { + let pixels = size * scale + let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + artwork.draw(in: NSRect(x: 0, y: 0, width: pixels, height: pixels)) + NSGraphicsContext.restoreGraphicsState() + let suffix = scale == 2 ? "@2x" : "" + try bitmap.representation(using: .png, properties: [:])! + .write(to: destination.appendingPathComponent("icon_\(size)x\(size)\(suffix).png")) + } +} diff --git a/Scripts/uninstall-driver.sh b/Scripts/uninstall-driver.sh new file mode 100755 index 0000000..ff7a186 --- /dev/null +++ b/Scripts/uninstall-driver.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +export PATH=/usr/bin:/bin:/usr/sbin:/sbin +[[ $EUID -eq 0 ]] || { echo 'Administrator authorization is required.' >&2; exit 1; } +for directory in /Library /Library/Audio /Library/Audio/Plug-Ins /Library/Audio/Plug-Ins/HAL; do + [[ ! -L "$directory" ]] || { echo "Refusing linked directory: $directory" >&2; exit 1; } + [[ -e "$directory" ]] || exit 0 + [[ -d "$directory" && $(/usr/bin/stat -f %u "$directory") == 0 ]] || exit 1 + acl=$(/bin/ls -lde "$directory") + [[ "$acl" != *" allow "* ]] || { echo "Unexpected directory access rules: $directory" >&2; exit 1; } + mode=$(/usr/bin/stat -f %Lp "$directory") + (( (8#$mode & 0022) == 0 )) || { echo "Unsafe directory permissions: $directory" >&2; exit 1; } +done +destination=/Library/Audio/Plug-Ins/HAL/MacStereoFix.driver +if [[ -e "$destination" || -L "$destination" ]]; then + # rm does not traverse a symlink passed as its final path component. + /bin/rm -rf "$destination" + if /usr/bin/pgrep -x coreaudiod >/dev/null; then + /usr/bin/killall coreaudiod || { echo 'Driver removed. Restart your Mac to finish.' >&2; exit 1; } + fi +fi diff --git a/Tests/audio_tests.swift b/Tests/audio_tests.swift new file mode 100644 index 0000000..4a09e68 --- /dev/null +++ b/Tests/audio_tests.swift @@ -0,0 +1,148 @@ +import AudioToolbox +import CoreAudio +import Foundation + +@main +struct AudioTests { + static func main() throws { + testDownmix() + testRing() + testConcurrentRing() + try testConverter() + testClockDrift() + } + + static func testClockDrift() { + // Model an output clock +/-500 ppm from the virtual clock for 30 minutes, + // with the same once-per-second correction used by the app. + for ppm: Float in [-500, -100, 0, 100, 500] { + var fill = Float(StereoMix.reserveFrames) + for _ in 0..<1800 { + let ratio = StereoMix.clockRate(bufferedFrames: fill) + fill += 48000 - 48000 * (1 + ppm / 1_000_000) * ratio + assert(fill > 128 && fill < 1024) + } + } + assert(StereoMix.clockRate(bufferedFrames: .nan) == 1) + print("Clock correction simulation stayed bounded for 30 minutes at +/-500 ppm") + } + + static func testDownmix() { + let expected: [(Float, Float)] = [(0.25, 0), (0, 0.25), (0.17675, 0.17675), (0, 0), + (0.17675, 0), (0, 0.17675), (0.125, 0), (0, 0.125)] + for channel in 0..<8 { + var source = [Float](repeating: 0, count: 8) + source[channel] = 0.25 + var output = [Float](repeating: 0, count: 2) + StereoMix.process(source, into: &output, frames: 1, centerGain: 0.707, volume: 1) + assert(abs(output[0] - expected[channel].0) < 0.00001) + assert(abs(output[1] - expected[channel].1) < 0.00001) + } + for bad: Float in [.nan, .infinity, -.infinity, .greatestFiniteMagnitude, -.greatestFiniteMagnitude] { + let source = [Float](repeating: bad, count: 8) + for volume: Float in [0, 0.5, 1, .nan, .infinity] { + var output = [Float](repeating: 99, count: 2) + StereoMix.process(source, into: &output, frames: 1, centerGain: .infinity, volume: volume) + assert(output.allSatisfy { $0.isFinite && abs($0) <= 1 }) + if volume == 0 { assert(output == [0, 0]) } + } + } + assert(StereoMix.centerGain(boostDB: .nan).isFinite) + assert(StereoMix.centerGain(boostDB: 100) == StereoMix.centerGain(boostDB: 9)) + var muted = [Float](repeating: 1, count: 2) + StereoMix.process([Float](repeating: 1, count: 8), into: &muted, frames: 1, centerGain: 2, volume: 0) + assert(muted == [0, 0]) + print("Eight channel impulses, LFE exclusion, gain limits, mute and non-finite samples passed") + } + + static func testRing() { + let ring = RingBuffer(frames: 8, channels: 2) + let source = (0..<32).map(Float.init) + var output = [Float](repeating: -1, count: 32) + assert(ring.write(source, frameCount: -1) == 0) + assert(ring.read(&output, frameCount: -1) == 0) + assert(ring.write(source, frameCount: 16) == 7) + assert(ring.fillFrames() == 7) + assert(ring.read(&output, frameCount: 5) == 5) + assert(Array(output.prefix(10)) == Array(source.prefix(10))) + assert(ring.write(source, frameCount: 5) == 5) + assert(ring.read(&output, frameCount: 7) == 7) + assert(Array(output.prefix(14)) == Array(source[10..<14]) + Array(source.prefix(10))) + assert(ring.read(&output, frameCount: 1) == 0) + assert(ring.skip(frameCount: 20) == 0) + ring.write(source, frameCount: 4) + assert(ring.skip(frameCount: 10) == 4) + ring.reset() + assert(ring.fillFrames() == 0) + print("Ring overflow, underflow, wrap, skip, reset and negative lengths passed") + } + + static func testConcurrentRing() { + let ring = RingBuffer(frames: 1024, channels: 2) + let group = DispatchGroup() + let frames = 200_000 + group.enter() + DispatchQueue.global().async { + var position = 0 + while position < frames { + let count = min(37, frames - position) + var source = [Float](repeating: 0, count: count * 2) + for i in 0.. OSStatus in + let list = AudioBufferList.allocate(maximumBuffers: 2) + defer { free(list.unsafeMutablePointer) } + list[0] = AudioBuffer(mNumberChannels: 1, mDataByteSize: UInt32(bytes.count / 2), mData: bytes.baseAddress) + list[1] = AudioBuffer(mNumberChannels: 1, mDataByteSize: UInt32(bytes.count / 2), mData: bytes.baseAddress!.advanced(by: bytes.count / 2)) + return AudioUnitRender(unit, &flags, ×tamp, 0, 512, list.unsafeMutablePointer) + } + assert(status == noErr, "Converter failed at \(rate): \(status)") + } + assert(output.allSatisfy { $0.isFinite && abs($0) <= 0.11 }) + assert(output.filter { abs($0 - 0.088375) < 0.0001 }.count > 700, + "Center channel missing after conversion at \(rate)") + assert(!router.audioFailed) + router.stop() + } + print("Production audio callback and Apple converter passed at 32, 44.1, 48, 96 and 192 kHz (offline)") + } +} diff --git a/Tests/driver_tests.c b/Tests/driver_tests.c new file mode 100644 index 0000000..3c1d625 --- /dev/null +++ b/Tests/driver_tests.c @@ -0,0 +1,222 @@ +#include +#include +#include +#include +#include "../Driver/MacStereoFixDriver.c" + +static AudioObjectPropertyAddress address(AudioObjectPropertySelector selector) { + return (AudioObjectPropertyAddress){selector, kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain}; +} + +static OSStatus read_property(AudioObjectID object, AudioObjectPropertySelector selector, + UInt32 size, UInt32 *written, void *data) { + AudioObjectPropertyAddress a = address(selector); + return MacStereoFix_GetPropertyData(gAudioServerPlugInDriverRef, object, 0, + &a, 0, NULL, size, written, data); +} + +static OSStatus set_property(AudioObjectID object, AudioObjectPropertySelector selector, + UInt32 size, const void *data) { + AudioObjectPropertyAddress a = address(selector); + return MacStereoFix_SetPropertyData(gAudioServerPlugInDriverRef, object, 0, + &a, 0, NULL, size, data); +} + +static void test_property_bounds(void) { + const AudioObjectPropertySelector selectors[] = { + kAudioObjectPropertyBaseClass, kAudioObjectPropertyClass, kAudioObjectPropertyOwner, + kAudioObjectPropertyName, kAudioObjectPropertyModelName, kAudioObjectPropertyManufacturer, + kAudioObjectPropertyOwnedObjects, kAudioObjectPropertyIdentify, + kAudioObjectPropertySerialNumber, kAudioObjectPropertyFirmwareVersion, + kAudioPlugInPropertyBoxList, kAudioPlugInPropertyDeviceList, kAudioPlugInPropertyResourceBundle, + kAudioBoxPropertyBoxUID, kAudioBoxPropertyTransportType, kAudioBoxPropertyHasAudio, + kAudioBoxPropertyHasVideo, kAudioBoxPropertyHasMIDI, kAudioBoxPropertyIsProtected, + kAudioBoxPropertyAcquired, kAudioBoxPropertyAcquisitionFailed, kAudioBoxPropertyDeviceList, + kAudioDevicePropertyDeviceUID, kAudioDevicePropertyModelUID, kAudioDevicePropertyTransportType, + kAudioDevicePropertyRelatedDevices, kAudioDevicePropertyClockDomain, + kAudioDevicePropertyDeviceIsAlive, kAudioDevicePropertyDeviceIsRunning, + kAudioDevicePropertyDeviceCanBeDefaultDevice, kAudioDevicePropertyDeviceCanBeDefaultSystemDevice, + kAudioDevicePropertyLatency, kAudioDevicePropertyStreams, kAudioObjectPropertyControlList, + kAudioDevicePropertySafetyOffset, kAudioDevicePropertyBufferFrameSize, + kAudioDevicePropertyBufferFrameSizeRange, kAudioDevicePropertyNominalSampleRate, + kAudioDevicePropertyAvailableNominalSampleRates, kAudioDevicePropertyIsHidden, + kAudioDevicePropertyZeroTimeStampPeriod, kAudioDevicePropertyIcon, + kAudioDevicePropertyPreferredChannelsForStereo, kAudioDevicePropertyPreferredChannelLayout, + kAudioStreamPropertyIsActive, kAudioStreamPropertyDirection, kAudioStreamPropertyTerminalType, + kAudioStreamPropertyStartingChannel, kAudioStreamPropertyLatency, + kAudioStreamPropertyVirtualFormat, kAudioStreamPropertyPhysicalFormat, + kAudioStreamPropertyAvailableVirtualFormats, kAudioStreamPropertyAvailablePhysicalFormats, + kAudioControlPropertyScope, kAudioControlPropertyElement, kAudioBooleanControlPropertyValue, + kAudioLevelControlPropertyScalarValue, kAudioLevelControlPropertyDecibelValue, + kAudioLevelControlPropertyDecibelRange, kAudioLevelControlPropertyConvertScalarToDecibels, + kAudioLevelControlPropertyConvertDecibelsToScalar + }; + const AudioObjectPropertyScope scopes[] = {kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyScopeInput, kAudioObjectPropertyScopeOutput}; + unsigned checks = 0; + for (AudioObjectID object = kObjectID_PlugIn; object <= kObjectID_Mute_Output_Master; ++object) { + for (size_t s = 0; s < sizeof(selectors) / sizeof(selectors[0]); ++s) { + for (size_t scope = 0; scope < sizeof(scopes) / sizeof(scopes[0]); ++scope) { + AudioObjectPropertyAddress a = address(selectors[s]); + a.mScope = scopes[scope]; + if (!MacStereoFix_HasProperty(gAudioServerPlugInDriverRef, object, 0, &a)) continue; + UInt32 size = 0; + assert(MacStereoFix_GetPropertyDataSize(gAudioServerPlugInDriverRef, object, 0, + &a, 0, NULL, &size) == noErr); + for (UInt32 capacity = 0; capacity <= size; ++capacity) { + void *data = calloc(1, capacity ? capacity : 1); + UInt32 written = UINT32_MAX; + OSStatus status = MacStereoFix_GetPropertyData(gAudioServerPlugInDriverRef, + object, 0, &a, 0, NULL, capacity, &written, data); + assert(written <= capacity); + if (capacity == size) assert(status == noErr && written == size); + if (status == noErr && written == sizeof(CFStringRef)) { + switch (selectors[s]) { + case kAudioObjectPropertyName: case kAudioObjectPropertyModelName: + case kAudioObjectPropertyManufacturer: case kAudioObjectPropertySerialNumber: + case kAudioObjectPropertyFirmwareVersion: case kAudioPlugInPropertyResourceBundle: + case kAudioBoxPropertyBoxUID: case kAudioDevicePropertyDeviceUID: + case kAudioDevicePropertyModelUID: + if (*(CFStringRef *)data) CFRelease(*(CFStringRef *)data); + } + } + free(data); + ++checks; + } + } + } + } + printf("Driver property bounds: %u cases passed\n", checks); +} + +static OSStatus io(UInt32 operation, double time, UInt32 frames, Float32 *data) { + AudioServerPlugInIOCycleInfo cycle = {0}; + cycle.mInputTime.mSampleTime = cycle.mOutputTime.mSampleTime = time; + return MacStereoFix_DoIOOperation(gAudioServerPlugInDriverRef, kObjectID_Device, + operation == kAudioServerPlugInIOOperationWriteMix ? kObjectID_Stream_Output : kObjectID_Stream_Input, + 0, operation, frames, &cycle, data, NULL); +} + +static void test_loopback(void) { + Float32 written[32 * kChannelCount], read[32 * kChannelCount]; + for (unsigned i = 0; i < 32 * kChannelCount; ++i) written[i] = (Float32)i / 256; + assert(io(kAudioServerPlugInIOOperationWriteMix, 4090, 32, written) == noErr); + assert(io(kAudioServerPlugInIOOperationReadInput, 4090, 32, read) == noErr); + assert(memcmp(written, read, sizeof(read)) == 0); + assert(io(kAudioServerPlugInIOOperationReadInput, 4090 + kRingBufferFrameCount, 32, read) == noErr); + for (unsigned i = 0; i < 32 * kChannelCount; ++i) assert(read[i] == 0); + assert(io(kAudioServerPlugInIOOperationReadInput, -1, 32, read) == noErr); + for (unsigned i = 0; i < 32 * kChannelCount; ++i) assert(read[i] == 0); + const double invalid[] = {NAN, INFINITY, 0.5, 0x1p64}; + for (unsigned i = 0; i < sizeof(invalid) / sizeof(invalid[0]); ++i) { + assert(io(kAudioServerPlugInIOOperationWriteMix, invalid[i], 32, written) != noErr); + assert(io(kAudioServerPlugInIOOperationReadInput, invalid[i], 32, read) != noErr); + } + assert(io(kAudioServerPlugInIOOperationWriteMix, 0, kRingBufferFrameCount + 1, written) != noErr); + puts("Loopback wrap, stale audio, invalid timestamps and frame limits passed"); +} + +static void test_formats_and_volume(void) { + int number = 42; + CFNumberRef invalidUID = CFNumberCreate(NULL, kCFNumberIntType, &number); + AudioObjectID result; + UInt32 resultSize; + AudioObjectPropertyAddress lookup = address(kAudioPlugInPropertyTranslateUIDToDevice); + assert(MacStereoFix_GetPropertyData(gAudioServerPlugInDriverRef, kObjectID_PlugIn, 0, + &lookup, sizeof(invalidUID), &invalidUID, sizeof(result), &resultSize, &result) == kAudioHardwareIllegalOperationError); + CFRelease(invalidUID); + AudioStreamBasicDescription fmt; + UInt32 size = 0; + assert(read_property(kObjectID_Stream_Output, kAudioStreamPropertyPhysicalFormat, + sizeof(fmt), &size, &fmt) == noErr); + assert(set_property(kObjectID_Stream_Output, kAudioStreamPropertyPhysicalFormat, + sizeof(fmt), &fmt) == noErr); + AudioStreamBasicDescription bad = fmt; + bad.mFormatFlags |= kAudioFormatFlagIsNonInterleaved; + assert(set_property(kObjectID_Stream_Output, kAudioStreamPropertyPhysicalFormat, + sizeof(bad), &bad) == kAudioDeviceUnsupportedFormatError); + bad = fmt; bad.mBytesPerFrame = 1; + assert(set_property(kObjectID_Stream_Output, kAudioStreamPropertyPhysicalFormat, + sizeof(bad), &bad) == kAudioDeviceUnsupportedFormatError); + bad = fmt; bad.mBitsPerChannel = 16; + assert(set_property(kObjectID_Stream_Output, kAudioStreamPropertyPhysicalFormat, + sizeof(bad), &bad) == kAudioDeviceUnsupportedFormatError); + Float32 invalid = NAN; + assert(set_property(kObjectID_Volume_Output_Master, kAudioLevelControlPropertyScalarValue, + sizeof(invalid), &invalid) != noErr); + invalid = INFINITY; + assert(set_property(kObjectID_Volume_Output_Master, kAudioLevelControlPropertyDecibelValue, + sizeof(invalid), &invalid) != noErr); + puts("Strict stream format and finite volume validation passed"); +} + +static _Atomic unsigned long long published_time; +static _Atomic bool writer_finished; +static void *concurrent_writer(void *unused) { + (void)unused; + Float32 frame[kChannelCount]; + for (UInt64 time = 100000; time < 300000; ++time) { + for (unsigned c = 0; c < kChannelCount; ++c) frame[c] = (Float32)time; + assert(io(kAudioServerPlugInIOOperationWriteMix, (double)time, 1, frame) == noErr); + atomic_store(&published_time, time); + } + atomic_store(&writer_finished, true); + return NULL; +} + +static void test_concurrent_io(void) { + pthread_t writer; + assert(pthread_create(&writer, NULL, concurrent_writer, NULL) == 0); + do { + UInt64 time = atomic_load(&published_time); + if (time == 0) continue; + Float32 frame[kChannelCount]; + assert(io(kAudioServerPlugInIOOperationReadInput, (double)time, 1, frame) == noErr); + // Under contention a frame may be silenced, but never contain a mix + // of old/new channels or samples from another lap of the ring. + assert(frame[0] == 0 || frame[0] == (Float32)time); + for (unsigned c = 1; c < kChannelCount; ++c) assert(frame[c] == frame[0]); + } while (!atomic_load(&writer_finished)); + assert(pthread_join(writer, NULL) == 0); + puts("200,000 concurrent loopback writes passed"); +} + +static void test_clock_and_controls(void) { + Float64 sampleTime; + UInt64 hostTime, seed; + UInt64 anchor = mach_absolute_time() - (UInt64)(gDevice_HostTicksPerFrame * kSampleRate * 5); + atomic_store(&gDevice_AnchorHostTime, anchor); + assert(MacStereoFix_GetZeroTimeStamp(gAudioServerPlugInDriverRef, kObjectID_Device, 0, + &sampleTime, &hostTime, &seed) == noErr); + assert(sampleTime >= kSampleRate * 4.9 && hostTime >= anchor); + assert(MacStereoFix_StopIO(gAudioServerPlugInDriverRef, kObjectID_Device, 0) == noErr); + assert(MacStereoFix_StartIO(gAudioServerPlugInDriverRef, kObjectID_Device, 0) == noErr); + UInt64 nextSeed; + assert(MacStereoFix_GetZeroTimeStamp(gAudioServerPlugInDriverRef, kObjectID_Device, 0, + &sampleTime, &hostTime, &nextSeed) == noErr && nextSeed != seed); + UInt32 value = 1, result = 0, size = 0; + assert(set_property(kObjectID_Mute_Output_Master, kAudioBooleanControlPropertyValue, + sizeof(value), &value) == noErr); + assert(read_property(kObjectID_Mute_Output_Master, kAudioBooleanControlPropertyValue, + sizeof(result), &size, &result) == noErr && result == 1); + value = 0; + assert(set_property(kObjectID_Stream_Input, kAudioStreamPropertyIsActive, sizeof(value), &value) == noErr); + Float32 frame[kChannelCount] = {1, 1, 1, 1, 1, 1, 1, 1}; + assert(io(kAudioServerPlugInIOOperationWriteMix, 100, 1, frame) == noErr); + assert(io(kAudioServerPlugInIOOperationReadInput, 100, 1, frame) == noErr); + for (unsigned c = 0; c < kChannelCount; ++c) assert(frame[c] == 0); + puts("Clock catch-up, restart seed, mute and inactive input passed"); +} + +int main(void) { + assert(MacStereoFix_Initialize(gAudioServerPlugInDriverRef, NULL) == noErr); + assert(MacStereoFix_StartIO(gAudioServerPlugInDriverRef, kObjectID_Device, 0) == noErr); + test_property_bounds(); + test_loopback(); + test_formats_and_volume(); + test_concurrent_io(); + test_clock_and_controls(); + assert(MacStereoFix_StopIO(gAudioServerPlugInDriverRef, kObjectID_Device, 0) == noErr); + return 0; +} diff --git a/Tests/installer_quote_tests.swift b/Tests/installer_quote_tests.swift new file mode 100644 index 0000000..f67dc25 --- /dev/null +++ b/Tests/installer_quote_tests.swift @@ -0,0 +1,30 @@ +import Foundation + +enum SystemAudio { static func macStereoFixDriverVersion() -> String? { nil } } +@main +struct QuoteTests { + static func main() throws { + for path in ["", "/Applications/MacStereoFix.app", "spaces and 'single' and \"double\" quotes", + "literal $(echo INJECTED); `echo BAD`; $HOME & | > < ! \\ backslash", + "new\nline\rreturn\ttab", "日本語 🎧"] { + let shell = "printf '%s' " + DriverManager.shellQuote(path) + let script = DriverManager.appleScript(shell: shell) + .replacingOccurrences(of: " with administrator privileges", with: "") + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + assert(process.terminationStatus == 0) + // do shell script normalizes LF to CR by default; osascript prints + // the result plus a trailing LF. Compare that documented behavior. + let actual = String(data: data, encoding: .utf8)! + let expected = path.replacingOccurrences(of: "\n", with: "\r") + assert(actual == expected + "\n", "Quoting changed a literal path: \(actual.debugDescription)") + } + print("Shell + AppleScript quoting passed for spaces, quotes, metacharacters, newlines and Unicode (no administrator access)") + } +} diff --git a/Tests/installer_tests.py b/Tests/installer_tests.py new file mode 100644 index 0000000..75a5efb --- /dev/null +++ b/Tests/installer_tests.py @@ -0,0 +1,113 @@ +"""Exercise the real installer control flow in temporary directories, without sudo. + +Only root identity/ownership checks, system paths and process-control commands are +substituted. Copying, signature verification, link checks, modes and rollback use +macOS's actual tools. Nothing accesses the system HAL directory or coreaudiod. +""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = (ROOT / "Scripts/install-driver.sh").read_text() +UNINSTALL = (ROOT / "Scripts/uninstall-driver.sh").read_text() +FIXTURE = ROOT / "build/MacStereoFix.driver" +assert FIXTURE.is_dir(), "Run ./build.sh first" + + +def run_case(name, setup=None, environment=None, succeed=False, strict=False): + with tempfile.TemporaryDirectory(prefix="msf-installer-") as temporary: + base = Path(temporary) + library = base / "Library" + hal = library / "Audio/Plug-Ins/HAL" + hal.mkdir(parents=True) + destination = hal / "MacStereoFix.driver" + destination.mkdir() + (destination / "old-marker").write_text("previous driver") + source = base / "App with 'quotes' $() `literal` and spaces.driver" + shutil.copytree(FIXTURE, source) + # Keep the rejection test meaningful even when release.sh built with a + # real Developer ID. Only this disposable fixture is re-signed. + subprocess.run(["/usr/bin/codesign", "--force", "--sign", "-", "--timestamp=none", str(source)], + check=True, capture_output=True) + tools = base / "tools" + tools.mkdir() + wrappers = { + "/usr/sbin/chown": "exit 0", + "/usr/bin/pgrep": 'exit "${MSF_TEST_PGREP_EXIT:-1}"', + "/usr/bin/killall": 'exit "${MSF_TEST_KILL_EXIT:-0}"', + "/bin/mv": '''if [[ ${MSF_TEST_MOVE_FAIL:-0} == 1 && "$1" == */driver ]]; then exit 73; fi +exec /bin/mv "$@"''', + "/usr/bin/ditto": '''if [[ ${MSF_TEST_COPY_FAIL:-0} == 1 ]]; then exit 73; fi +/usr/bin/ditto "$@" +result=$? +if [[ $result == 0 && ${MSF_TEST_MUTATE_SOURCE:-0} == 1 ]]; then + /bin/chmod u+w "${@: -2:1}/Contents/MacOS/MacStereoFix" + /usr/bin/printf 'tampered after copy' > "${@: -2:1}/Contents/MacOS/MacStereoFix" +fi +exit "$result"''', + } + for index, (command, body) in enumerate(wrappers.items()): + wrapper = tools / str(index) + wrapper.write_text("#!/bin/bash\n" + body + "\n") + wrapper.chmod(0o755) + wrappers[command] = str(wrapper) + def sandboxed(text): + text = text.replace("/Library", str(library)) + text = text.replace("$EUID -eq 0", f"$EUID -eq {os.geteuid()}") + text = text.replace('$(/usr/bin/stat -f %u "$directory") == 0', + f'$(/usr/bin/stat -f %u "$directory") == {os.geteuid()}') + for command, replacement in wrappers.items(): + text = text.replace(command, replacement) + return text + script = base / "install.sh" + script.write_text(sandboxed(SCRIPT)) + if setup: + setup(base, source, destination, hal) + requirement = ('identifier "com.macstereofix.driver" and anchor apple generic and ' + 'certificate leaf[subject.OU] = "MD83L42DNL"') if strict else '--allow-adhoc' + result = subprocess.run(["/bin/bash", str(script), str(source), requirement], + capture_output=True, text=True, env={**os.environ, **(environment or {})}, timeout=20) + assert (result.returncode == 0) == succeed, (name, result.returncode, result.stderr) + if succeed: + assert not (destination / "old-marker").exists() + subprocess.run(["/usr/bin/codesign", "--verify", "--strict", str(destination)], check=True) + for item in destination.rglob("*"): + assert not item.is_symlink() and item.stat().st_mode & 0o022 == 0 + removal = base / "uninstall.sh" + removal.write_text(sandboxed(UNINSTALL)) + subprocess.run(["/bin/bash", str(removal)], check=True, timeout=10) + assert not destination.exists() + elif name == "restart failure is reported": + assert (destination / "Contents/MacOS/MacStereoFix").exists() + elif name != "destination link is refused": + assert (destination / "old-marker").read_text() == "previous driver" + assert not list((library / "Audio").glob(".MacStereoFix.*")), "Staging directory leaked" + print(f"Installer: {name} passed") + + +run_case("successful staged install and uninstall", succeed=True) +run_case("copy failure preserves existing driver", environment={"MSF_TEST_COPY_FAIL": "1"}) +run_case("replacement failure rolls back", environment={"MSF_TEST_MOVE_FAIL": "1"}) +run_case("source changes after copy cannot change installed payload", + environment={"MSF_TEST_MUTATE_SOURCE": "1"}, succeed=True) +run_case("invalid signature preserves existing driver", + setup=lambda b, s, d, h: (s / "Contents/MacOS/MacStereoFix").write_bytes(b"tampered")) +run_case("unsigned developer build rejected by distribution requirement", strict=True) +run_case("nested symlinks are refused", + setup=lambda b, s, d, h: (s / "Contents/Resources/link").symlink_to("/etc/passwd")) +run_case("writable HAL directory is refused", setup=lambda b, s, d, h: h.chmod(0o777)) +run_case("directory ACLs are refused", setup=lambda b, s, d, h: subprocess.run( + ["/bin/chmod", "+a", "everyone allow add_file", str(h)], check=True)) +run_case("restart failure is reported", environment={"MSF_TEST_PGREP_EXIT": "0", "MSF_TEST_KILL_EXIT": "1"}) + + +def destination_link(base, source, destination, hal): + shutil.rmtree(destination) + victim = base / "untouched" + victim.mkdir() + (victim / "keep").write_text("keep") + destination.symlink_to(victim) +run_case("destination link is refused", setup=destination_link) diff --git a/Tests/recovery_harness.swift b/Tests/recovery_harness.swift new file mode 100644 index 0000000..d1b08fe --- /dev/null +++ b/Tests/recovery_harness.swift @@ -0,0 +1,12 @@ +import Foundation +@main +struct RecoveryHarness { + static func main() throws { + let recovery = AudioRecovery() + try recovery.arm(preferredUID: "test-headphones") + FileHandle.standardOutput.write(Data("ready\n".utf8)) + let command = readLine() + if command == "disarm" { recovery.disarm() } + // Otherwise closing the owner (or SIGKILL from the test) triggers recovery. + } +} diff --git a/Tests/recovery_system_audio.swift b/Tests/recovery_system_audio.swift new file mode 100644 index 0000000..f82541d --- /dev/null +++ b/Tests/recovery_system_audio.swift @@ -0,0 +1,9 @@ +import Foundation +// The recovery binary uses this double in tests; no CoreAudio calls are linked. +enum SystemAudio { + static func restoreOutput(preferredUID: String?) -> Bool { + guard let path = ProcessInfo.processInfo.environment["MSF_RECOVERY_TEST_LOG"] else { fatalError("Missing test log") } + try! Data((preferredUID ?? "nil").utf8).write(to: URL(fileURLWithPath: path), options: .atomic) + return true + } +} diff --git a/Tests/recovery_tests.py b/Tests/recovery_tests.py new file mode 100644 index 0000000..ef9e3e3 --- /dev/null +++ b/Tests/recovery_tests.py @@ -0,0 +1,31 @@ +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +harness = Path(sys.argv[1]).resolve() +with tempfile.TemporaryDirectory(prefix="msf-recovery-") as temporary: + for action in ["disarm", "close", "kill"]: + log = Path(temporary) / action + process = subprocess.Popen([str(harness)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + env={**os.environ, "MSF_RECOVERY_TEST_LOG": str(log)}) + assert process.stdout.readline() == b"ready\n" + if action == "kill": + process.kill() + process.stdin.close() + else: + if action == "disarm": + process.stdin.write(b"disarm\n") + process.stdin.flush() + process.stdin.close() + process.wait(timeout=5) + for _ in range(100): + if log.exists(): + break + time.sleep(0.02) + assert log.exists() == (action != "disarm"), action + if log.exists(): + assert log.read_text() == "test-headphones" + print(f"Recovery helper: {action} passed (no audio settings changed)") diff --git a/Tests/release_tests.py b/Tests/release_tests.py new file mode 100644 index 0000000..a56a7e0 --- /dev/null +++ b/Tests/release_tests.py @@ -0,0 +1,28 @@ +import os +from pathlib import Path +import plistlib +import re +import subprocess + +root = Path(__file__).resolve().parents[1] +base = {k: v for k, v in os.environ.items() if k not in ["SIGN_IDENTITY", "NOTARY_PROFILE", "RELEASE_TESTED_COMMIT"]} +cases = [({}, "SIGN_IDENTITY"), ({"SIGN_IDENTITY": "-"}, "Ad-hoc"), + ({"SIGN_IDENTITY": "Developer ID Application: Example"}, "NOTARY_PROFILE"), + ({"SIGN_IDENTITY": "Developer ID Application: Example", "NOTARY_PROFILE": "example"}, "RELEASE_TESTED_COMMIT"), + ({"SIGN_IDENTITY": "Developer ID Application: Example", "NOTARY_PROFILE": "example", + "RELEASE_TESTED_COMMIT": "not-the-tested-commit"}, "exact commit")] +for variables, expected in cases: + result = subprocess.run(["/bin/bash", str(root / "release.sh")], cwd=root, + env={**base, **variables}, capture_output=True, text=True, timeout=10) + assert result.returncode != 0 and expected in result.stderr, result + +app = plistlib.loads((root / "App/Info.plist").read_bytes()) +driver = plistlib.loads((root / "Driver/Info.plist").read_bytes()) +for key in ["CFBundleShortVersionString", "CFBundleVersion"]: + assert app[key] == driver[key] +assert "virtual" in app["NSMicrophoneUsageDescription"] +runtime_version = re.search(r'#define kDriverVersion\s+"([^"]+)"', (root / "Driver/MacStereoFixDriver.c").read_text()).group(1) +assert runtime_version == driver["CFBundleVersion"] +entitlements = plistlib.loads((root / "App/MacStereoFix.entitlements").read_bytes()) +assert entitlements == {"com.apple.security.device.audio-input": True} +print("Release gates refuse missing signing, ad-hoc identity, missing notarization and untested source; metadata and entitlements passed") diff --git a/Tests/state_tests.swift b/Tests/state_tests.swift new file mode 100644 index 0000000..abc4dd1 --- /dev/null +++ b/Tests/state_tests.swift @@ -0,0 +1,260 @@ +import AppKit +import CoreAudio +import Foundation + +// Test doubles replace CoreAudio and installer side effects. AppState itself is +// the production file; no test changes the Mac's output or requests permission. +struct AudioOutputDevice: Identifiable, Hashable { + let id: AudioDeviceID + let uid: String + let name: String +} +enum SystemAudio { + static let speakers = AudioOutputDevice(id: 10, uid: "speakers", name: "Speakers") + static let headphones = AudioOutputDevice(id: 11, uid: "headphones", name: "Headphones") + static var devices = [speakers, headphones] + static var current: AudioDeviceID = 10 + static var rejectDefault = false + static var virtualWrites = 0 + static func allOutputDevices() -> [AudioOutputDevice] { devices } + static func macStereoFixDeviceID() -> AudioDeviceID? { 99 } + static func defaultOutputDevice() -> AudioDeviceID { current } + static func deviceUID(_ id: AudioDeviceID) -> String? { devices.first { $0.id == id }?.uid } + static func sampleRate(_ id: AudioDeviceID) -> Float64? { 48000 } + static func setDefaultOutputDevice(_ id: AudioDeviceID) -> Bool { + if rejectDefault { return false } + current = id + if id == 99 { virtualWrites += 1 } + return true + } + static func restoreOutput(preferredUID: String?) -> Bool { + if current != 99 { return true } + guard let target = devices.first(where: { $0.uid == preferredUID }) ?? devices.first else { return false } + return setDefaultOutputDevice(target.id) + } + static func outputControlID(for id: AudioDeviceID, class expectedClass: AudioClassID) -> AudioObjectID? { + expectedClass == kAudioMuteControlClassID ? 21 : 20 + } + static func setControlScalarValue(_ id: AudioObjectID, _ value: Float) {} + static func controlScalarValue(_ id: AudioObjectID) -> Float? { 0.5 } + static func setControlMuted(_ id: AudioObjectID, _ muted: Bool) {} + static func controlMuted(_ id: AudioObjectID) -> Bool? { true } + static func reset() { devices = [speakers, headphones]; current = 10; rejectDefault = false; virtualWrites = 0 } +} +final class AudioObservation { + static var handlers: [AudioObjectPropertySelector: @MainActor () -> Void] = [:] + init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject), + selector: AudioObjectPropertySelector, handler: @escaping @MainActor () -> Void) { + Self.handlers[selector] = handler + } +} +final class AudioRouter { + static var failStart = false + static var startCount = 0 + static var stopCount = 0 + static var volume: Float = 1 + var audioFailed = false + var outputSampleRate: Float64 = 48000 + var progress: (capture: UInt64, render: UInt64) { (1, 1) } + func start(outputDevice: AudioDeviceID) throws { + Self.startCount += 1 + if Self.failStart { throw NSError(domain: "Injected audio failure", code: 1) } + } + func stop() { Self.stopCount += 1 } + func setDialogueBoostDB(_ value: Float) {} + func setOutputVolume(_ value: Float) { Self.volume = value } + func updateClockDrift() {} +} +final class AudioRecovery { + static var failArm = false + var isRunning = false + func arm(preferredUID: String?) throws { + if Self.failArm { throw NSError(domain: "Injected recovery failure", code: 1) } + isRunning = true + } + func disarm() { isRunning = false } +} +enum DriverManager { + static var installed = true + static func isInstalled() -> Bool { installed } + static func installDriver() -> String? { "Injected installer failure" } + static func uninstallDriver() -> String? { "Injected removal failure" } +} + +@main +struct StateTests { + @MainActor static func main() async { + let suite = "com.macstereofix.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + func reset() { + defaults.removePersistentDomain(forName: suite) + SystemAudio.reset() + AudioRouter.failStart = false + AudioRouter.startCount = 0 + AudioRouter.stopCount = 0 + AudioRecovery.failArm = false + DriverManager.installed = true + } + reset() + do { + defaults.set("headphones", forKey: "selectedOutputUID") + let state = AppState(defaults: defaults, requestPermission: { true }) + assert(state.selectedOutputUID == "headphones") + assert(defaults.string(forKey: "selectedOutputUID") == "headphones") + assert(state.dialogueBoostDB == 0, "Dialogue boost must be opt-in") + state.dialogueBoostDB = 6 + let restored = AppState(defaults: defaults, requestPermission: { true }) + assert(restored.dialogueBoostDB == 6, "Keep an explicitly saved boost") + } + reset() + do { + SystemAudio.current = 99 + defaults.set("headphones", forKey: "recoveryOutputUID") + defaults.set(true, forKey: "wasOn") + let state = AppState(defaults: defaults, requestPermission: { true }) + assert(!state.isOn && SystemAudio.current == 11 && AudioRouter.startCount == 0) + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { false }) + state.turnOn() + await settle(state) + assert(!state.isOn && state.lastError != nil && SystemAudio.current == 10 && AudioRouter.startCount == 0) + } + reset() + do { + AudioRecovery.failArm = true + let state = AppState(defaults: defaults, requestPermission: { true }) + state.turnOn() + await settle(state) + assert(!state.isOn && SystemAudio.virtualWrites == 0 && state.lastError != nil) + } + reset() + do { + AudioRouter.failStart = true + let state = AppState(defaults: defaults, requestPermission: { true }) + state.turnOn() + await settle(state) + assert(!state.isOn && SystemAudio.virtualWrites == 0 && AudioRouter.stopCount > 0) + } + reset() + do { + SystemAudio.rejectDefault = true + let state = AppState(defaults: defaults, requestPermission: { true }) + state.turnOn() + await settle(state) + assert(!state.isOn && SystemAudio.current == 10 && state.lastError != nil) + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { true }) + state.turnOn() + await settle(state) + assert(state.isOn && SystemAudio.current == 99) + state.outputVolume = 0.3 + state.isMuted = true + assert(AudioRouter.volume == 0) + state.isMuted = false + assert(AudioRouter.volume == 0.3) + state.turnOff() + assert(SystemAudio.current == 10 && !state.isOn) + // A second Off must not override a later manual output selection. + SystemAudio.current = 11 + state.turnOff() + assert(SystemAudio.current == 11) + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { true }) + state.turnOn() + await settle(state) + AudioRouter.failStart = true + state.selectOutput("headphones") + await settle(state) + assert(!state.isOn && SystemAudio.current != 99 && state.lastError != nil) + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { true }) + state.selectOutput("headphones") + state.turnOn() + await settle(state) + SystemAudio.devices = [SystemAudio.speakers] + state.refreshDevices() + assert(!state.isOn && SystemAudio.current == 10) + SystemAudio.devices = [] + state.refreshDevices() + assert(state.selectedOutputUID == nil) + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { true }) + state.turnOn() + await settle(state) + SystemAudio.current = 11 + AudioObservation.handlers[kAudioHardwarePropertyDefaultOutputDevice]?() + assert(!state.isOn && SystemAudio.current == 11) + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { + try? await Task.sleep(nanoseconds: 100_000_000) + return true + }) + state.turnOn() + state.turnOff() + try? await Task.sleep(nanoseconds: 200_000_000) + assert(!state.isOn && !state.isBusy && AudioRouter.startCount == 0) + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { true }) + state.turnOn() + await settle(state) + state.installDriver() + await settle(state) + assert(!state.isOn && SystemAudio.current == 10 && state.lastError == "Injected installer failure") + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { + try? await Task.sleep(nanoseconds: 100_000_000) + return true + }) + state.selectOutput("headphones") + state.turnOn() + SystemAudio.devices = [SystemAudio.speakers] + state.refreshDevices() + await settle(state) + assert(!state.isOn && AudioRouter.startCount == 0 && SystemAudio.virtualWrites == 0) + } + reset() + do { + let state = AppState(defaults: defaults, requestPermission: { true }) + state.turnOn() + await settle(state) + SystemAudio.devices = [] + state.refreshDevices() + assert(!state.isOn && state.selectedOutputUID == nil && state.lastError?.contains("System Settings") == true) + } + reset() + do { + SystemAudio.devices = [] + SystemAudio.current = 99 + let state = AppState(defaults: defaults, requestPermission: { true }) + state.uninstallDriver() + await settle(state) + assert(state.lastError == "Injected removal failure") // removal was attempted + } + print("15 routing scenarios passed: preferences, startup recovery, permissions, start failures, mute, Off, switching, disconnect, manual change, cancellation, reinstall, startup device loss and removal without an output") + } + + @MainActor static func settle(_ state: AppState) async { + for _ in 0..<200 { + if !state.isBusy { return } + try? await Task.sleep(nanoseconds: 5_000_000) + } + assertionFailure("State did not finish its operation") + } +} diff --git a/ThirdParty/Apple-NullAudio-LICENSE.txt b/ThirdParty/Apple-NullAudio-LICENSE.txt new file mode 100644 index 0000000..16d25ca --- /dev/null +++ b/ThirdParty/Apple-NullAudio-LICENSE.txt @@ -0,0 +1,8 @@ +Copyright © 2024 Apple Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/ThirdParty/README.md b/ThirdParty/README.md new file mode 100644 index 0000000..9a8c16f --- /dev/null +++ b/ThirdParty/README.md @@ -0,0 +1,13 @@ +# Apple NullAudio attribution + +`Driver/MacStereoFixDriver.c` is modeled on Apple's NullAudio sample. The current +Apple distribution of that same C sample includes the notice preserved in +`Apple-NullAudio-LICENSE.txt`. + +Source: [Creating an Audio Server Driver Plug-in](https://developer.apple.com/documentation/coreaudio/creating-an-audio-server-driver-plug-in) + +Archive retrieved September 9, 2026: +[Apple sample download](https://docs-assets.developer.apple.com/published/430ad6501f6f/CreatingAnAudioServerDriverPlugIn.zip) + +This notice covers Apple's sample code. It does not change the licensing of +the independently written portions of MacStereoFix. diff --git a/build.sh b/build.sh index b26e3c0..bb8cd34 100755 --- a/build.sh +++ b/build.sh @@ -1,15 +1,8 @@ #!/usr/bin/env bash # -# build.sh — builds MacStereoFix.driver and MacStereoFix.app from source. -# -# Output goes into ./build/. After running this you can: -# sudo ./install.sh # copy driver into /Library/Audio/Plug-Ins/HAL -# and then drag build/MacStereoFix.app into /Applications. -# -# To produce a signed build for distribution to friends, set: -# SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" -# in your environment before running. Without that, the build is ad-hoc signed -# (works on your own Mac but friends will see Gatekeeper warnings). +# Build the universal app and audio driver into build/. +# Set SIGN_IDENTITY to a Developer ID Application identity for a signed build. +# Without it, the build is ad-hoc signed for local development. set -euo pipefail @@ -24,7 +17,13 @@ APP_BUNDLE="$BUILD_DIR/MacStereoFix.app" MIN_MACOS="13.0" ARCHS=( "arm64" "x86_64" ) -SIGN_IDENTITY="${SIGN_IDENTITY:--}" # `-` means ad-hoc +SIGN_IDENTITY="${SIGN_IDENTITY:--}" # `-` means local development only +SIGN_FLAGS=( --force --sign "$SIGN_IDENTITY" --options runtime ) +if [[ "$SIGN_IDENTITY" == - ]]; then + SIGN_FLAGS+=( --timestamp=none ) +else + SIGN_FLAGS+=( --timestamp ) +fi echo "==> Cleaning $BUILD_DIR" rm -rf "$BUILD_DIR" @@ -40,6 +39,7 @@ mkdir -p "$DRIVER_BUNDLE/Contents/MacOS" mkdir -p "$DRIVER_BUNDLE/Contents/Resources" cp "$DRIVER_SRC_DIR/Info.plist" "$DRIVER_BUNDLE/Contents/Info.plist" +cp "$ROOT_DIR/ThirdParty/Apple-NullAudio-LICENSE.txt" "$DRIVER_BUNDLE/Contents/Resources/" ARCH_FLAGS=() for a in "${ARCHS[@]}"; do @@ -49,7 +49,7 @@ done clang \ -bundle \ -O2 \ - -Wall \ + -Wall -Wextra -Werror \ -Wno-unused-parameter \ -fvisibility=hidden \ "${ARCH_FLAGS[@]}" \ @@ -60,10 +60,7 @@ clang \ "$DRIVER_SRC_DIR/MacStereoFixDriver.c" # Sign the driver bundle. Drivers loaded by coreaudiod must be signed. -codesign --force --sign "$SIGN_IDENTITY" \ - --timestamp \ - --options runtime \ - "$DRIVER_BUNDLE" +codesign "${SIGN_FLAGS[@]}" "$DRIVER_BUNDLE" ##################################################################### # 2. Build the SwiftUI app (.app bundle) @@ -75,12 +72,27 @@ mkdir -p "$APP_BUNDLE/Contents/MacOS" mkdir -p "$APP_BUNDLE/Contents/Resources" cp "$APP_SRC_DIR/Info.plist" "$APP_BUNDLE/Contents/Info.plist" +cp "$APP_SRC_DIR/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/" +cp "$APP_SRC_DIR/PrivacyInfo.xcprivacy" "$APP_BUNDLE/Contents/Resources/" +cp -R "$ROOT_DIR/ThirdParty" "$APP_BUNDLE/Contents/Resources/" # Bundle the freshly-built driver inside the app's Resources so the in-app # installer can copy it into /Library/Audio/Plug-Ins/HAL when the user clicks # "Install Driver". cp -R "$DRIVER_BUNDLE" "$APP_BUNDLE/Contents/Resources/MacStereoFix.driver" +# Embed the installer in the signed executable, never run a writable script +# from Resources with administrator privileges. +INSTALL_SCRIPT=$(/usr/bin/base64 < "$ROOT_DIR/Scripts/install-driver.sh" | /usr/bin/tr -d '\n') +UNINSTALL_SCRIPT=$(/usr/bin/base64 < "$ROOT_DIR/Scripts/uninstall-driver.sh" | /usr/bin/tr -d '\n') +cat > "$BUILD_DIR/InstallerScripts.swift" < Build complete:" echo " $DRIVER_BUNDLE" echo " $APP_BUNDLE" echo -echo "Next:" -echo " sudo ./install.sh # install the driver system-wide" -echo " cp -R build/MacStereoFix.app /Applications/" +echo "Next: ./check.sh" +if [[ "$SIGN_IDENTITY" == - ]]; then + echo "Local development only. Explicit local install: sudo ./install.sh --allow-adhoc" +else + echo "Signed test build. Hardware validation and Apple notarization are still required before distribution." +fi diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..6c67ab0 --- /dev/null +++ b/check.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# All checks run without sudo, hardware capture, or changes to Mac audio settings. +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT_DIR" +[[ -f build/InstallerScripts.swift && -d build/MacStereoFix.app ]] || { + echo 'Run ./build.sh before ./check.sh.' >&2; exit 1; +} +CHECK_DIR="$ROOT_DIR/.checks" +mkdir -p "$CHECK_DIR" +for script in build.sh release.sh install.sh uninstall.sh check.sh Scripts/*.sh; do /bin/bash -n "$script"; done +for plist in App/Info.plist App/MacStereoFix.entitlements App/PrivacyInfo.xcprivacy Driver/Info.plist; do + /usr/bin/plutil -lint "$plist" +done +xcrun clang --analyze -Xanalyzer -analyzer-output=text -Wno-unused-parameter Driver/MacStereoFixDriver.c +xcrun clang -g -O1 -Wall -Wextra -Werror -Wno-unused-parameter \ + -fsanitize=address,undefined -fno-omit-frame-pointer \ + -framework CoreAudio -framework CoreFoundation Tests/driver_tests.c -o "$CHECK_DIR/driver-tests" +"$CHECK_DIR/driver-tests" +xcrun swiftc -g -sanitize=address -D MSF_TESTING -import-objc-header App/MSFAtomic.h \ + -framework CoreAudio -framework AudioToolbox App/RingBuffer.swift App/StereoMix.swift \ + App/AudioRouter.swift App/SystemAudio.swift Tests/audio_tests.swift -o "$CHECK_DIR/audio-tests" +"$CHECK_DIR/audio-tests" +xcrun swiftc -g -framework AppKit -framework AVFoundation -framework CoreAudio \ + App/AppState.swift Tests/state_tests.swift -o "$CHECK_DIR/state-tests" +"$CHECK_DIR/state-tests" +xcrun swiftc -g App/DriverManager.swift build/InstallerScripts.swift Tests/installer_quote_tests.swift \ + -o "$CHECK_DIR/quote-tests" +"$CHECK_DIR/quote-tests" +python3 Tests/installer_tests.py +HELPER_DIR="$CHECK_DIR/RecoveryTests.app/Contents/MacOS" +mkdir -p "$HELPER_DIR" +xcrun swiftc Recovery/main.swift Tests/recovery_system_audio.swift -o "$HELPER_DIR/MacStereoFixRecovery" +xcrun swiftc App/AudioRecovery.swift Tests/recovery_harness.swift -o "$HELPER_DIR/RecoveryTests" +python3 Tests/recovery_tests.py "$HELPER_DIR/RecoveryTests" +# ThreadSanitizer is separate from AddressSanitizer and instruments the C atomics. +xcrun clang -g -O1 -fsanitize=thread -framework CoreAudio -framework CoreFoundation \ + Tests/driver_tests.c -o "$CHECK_DIR/driver-tsan" +"$CHECK_DIR/driver-tsan" +xcrun swiftc -g -sanitize=thread -D MSF_TESTING -import-objc-header App/MSFAtomic.h \ + -framework CoreAudio -framework AudioToolbox App/RingBuffer.swift App/StereoMix.swift \ + App/AudioRouter.swift App/SystemAudio.swift Tests/audio_tests.swift -o "$CHECK_DIR/audio-tsan" +"$CHECK_DIR/audio-tsan" +python3 Tests/release_tests.py +/usr/bin/codesign --verify --deep --strict build/MacStereoFix.app +for binary in build/MacStereoFix.app/Contents/MacOS/MacStereoFix \ + build/MacStereoFix.app/Contents/MacOS/MacStereoFixRecovery \ + build/MacStereoFix.driver/Contents/MacOS/MacStereoFix; do + architectures=$(/usr/bin/lipo -archs "$binary") + [[ "$architectures" == 'x86_64 arm64' || "$architectures" == 'arm64 x86_64' ]] || { + echo "Missing universal architectures in $binary" >&2; exit 1; + } +done +echo 'All automated checks passed. Physical-device release checks are in docs/RELEASE_CHECKLIST.md.' diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..df63e37 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,75 @@ +# Development + +## Build + +Use Xcode 15 or newer, or the matching Command Line Tools, with Swift 5.9+ and +Python 3. Both Apple Silicon and Intel binaries target macOS 13. + +```sh +./build.sh +./check.sh +``` + +The app and bundled driver are written to `build/`. Without `SIGN_IDENTITY`, +builds use ad-hoc signatures and are for local development. The in-app installer +requires the project's Developer ID. To test your own ad-hoc build locally: + +```sh +sudo ./install.sh --allow-adhoc +``` + +Installation restarts CoreAudio and briefly interrupts all Mac audio. Select a +normal output and quit the app before running `sudo ./uninstall.sh`. + +## Tests + +`check.sh` covers driver property bounds, concurrent audio buffers, channel +mixing, sample-rate conversion, routing failures, installer rollback, quoting, +and crash recovery. It runs AddressSanitizer, UndefinedBehaviorSanitizer, +ThreadSanitizer, static analysis, and bundle checks. + +Tests use temporary directories and substitutes for system-changing operations. +They do not install a driver, request microphone permission, or change the Mac's +audio settings. Real device testing is recorded in the +[release checklist](RELEASE_CHECKLIST.md). CI runs on Apple Silicon and Intel. + +## Audio path + +The virtual device carries eight-channel, 48 kHz PCM. The app reads it through +AUHAL, mixes to stereo, and uses Apple's converter at the physical output rate. +The supported output range is 32–192 kHz. Bounded clock correction compensates +for small differences between the virtual and physical device clocks. + +Channel order is `L, R, C, LFE, Ls, Rs, Lsr, Rsr`. The mix is: + +```text +Left = L + Cgain*C + 0.707*Ls + 0.5*Lsr +Right = R + Cgain*C + 0.707*Rs + 0.5*Rsr +Cgain = 0.707 * 10^(boost_dB / 20) +``` + +Boost defaults to 0 dB and ranges from 0 to 9 dB. LFE is omitted. Invalid samples +are silenced; peaks are clipped before software volume attenuation. Higher boost +can cause audible distortion in loud scenes. + +## Release + +Complete the hardware checklist on the source revision being released, then run: + +```sh +SIGN_IDENTITY='Developer ID Application: Your Name (TEAMID)' \ +NOTARY_PROFILE='your-saved-keychain-profile' \ +RELEASE_TESTED_COMMIT='full-tested-commit-sha' \ +./release.sh +``` + +The script requires a clean checkout, rebuilds and tests the app, checks the +signatures, submits to Apple, staples the notarization ticket, and checks +Gatekeeper. It also verifies the final extracted ZIP and writes a SHA-256 +checksum. Publishing that ZIP to GitHub is a separate step. + +Driver installation is pinned to Developer ID team `MD83L42DNL`. Changing the +signing team also requires updating that verification requirement. + +Keep signing keys and notarization credentials out of the repository. Use a saved +Keychain profile for `notarytool`. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..9c03208 --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,72 @@ +# Release checklist + +Version 1.3: hardware testing and notarization pending. + +For each release, record the commit, app and driver versions, macOS version, +Mac model, output device, and test result. Re-run affected tests after code changes. + +## Automated gates + +- [ ] Both GitHub Actions jobs pass on the exact commit (Apple Silicon and Intel). +- [ ] A clean local `./build.sh && ./check.sh` passes. +- [ ] The new app, recovery executable and driver contain both architectures. +- [ ] Source, bundled driver, installed driver and loaded driver versions agree. + +## Fresh installation and privacy + +Use a spare/test Mac or a controlled session with calls and recordings finished. +Keep System Settings → Sound → Output available for immediate recovery. Begin +with the physical device at a low volume. The installer needs a local administrator. + +- [ ] Fresh signed build launches using normal Gatekeeper settings. +- [ ] Install succeeds; cancelling authorization leaves the old driver intact. +- [ ] New install and upgrade from v1.2 both load the current driver. +- [ ] Deny microphone access: routing stays off and normal sound continues. +- [ ] Grant access and start: the selected physical output receives stereo. +- [ ] Physical microphone/default input remains unchanged. +- [ ] No unknown network activity, audio files, login items or elevated helper persist. + +## Sound and recovery + +- [ ] Stereo left/right and 5.1/7.1 channel-identification clips route correctly. +- [ ] Center dialogue is present; LFE omission and high-boost clipping are understood. +- [ ] Volume keys, mute key, UI volume and mute work, including a fixed-volume output. +- [ ] Turning Off returns to the device's normal volume; channel balance is unchanged. +- [ ] Repeated On/Off, Quit, relaunch and a second app instance behave correctly. +- [ ] Keyboard and VoiceOver can operate the toggle, picker, sliders, mute and recovery instructions. +- [ ] Force Quit while on restores normal output without relaunching the app. +- [ ] Change output in Sound settings: routing stops and preserves the new selection. +- [ ] Switch outputs; unplug the active one; disconnect all available outputs. +- [ ] Reconnect, sleep/wake, logout/login, reboot, and a CoreAudio restart recover. +- [ ] Reinstall and uninstall while on first stop routing and restore output. +- [ ] Run a representative game for at least 60 minutes: no accumulating latency, + repeated dropouts, clicks, excessive CPU, or unexpected loudness changes. + +## Compatibility matrix + +The build targets macOS 13. Record actual hardware results below; successful +compilation alone does not establish compatibility. + +| Mac / OS | Built-in speakers | Wired / USB | Bluetooth stereo | 44.1 kHz | 48 kHz | 96/192 kHz | +|---|---|---|---|---|---|---| +| Apple Silicon / macOS 13 | Pending | Pending | Pending | Pending | Pending | Pending | +| Intel / macOS 13 | Pending | Pending | Pending | Pending | Pending | Pending | +| Apple Silicon / current public macOS | Pending | Pending | Pending | Pending | Pending | Pending | +| Intel / supported public macOS | Pending | Pending | Pending | Pending | Pending | Pending | + +Bluetooth mono call modes, virtual/aggregate destinations and per-app-selected +outputs are intentionally outside the supported routing path. + +## Final artifact + +- [ ] Record the hardware sign-off for the exact commit above. +- [ ] Run `release.sh` with Developer ID, the saved notarization profile and + `RELEASE_TESTED_COMMIT` equal to that full commit SHA. +- [ ] Apple reports Accepted; the ticket is stapled and Gatekeeper accepts the app. +- [ ] Download the final ZIP on a clean Mac and open it under normal security settings. +- [ ] Check the SHA-256 checksum against the published file. +- [ ] Publish accurate release notes and the matching source; no security-bypass instructions. + +The release script checks the tested revision, clean checkout, signatures, +notarization, and final archive. Notarization does not replace the hardware tests. +[Apple's notarization documentation](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) diff --git a/docs/images/stereo-dark.png b/docs/images/stereo-dark.png new file mode 100644 index 0000000..2426aec Binary files /dev/null and b/docs/images/stereo-dark.png differ diff --git a/docs/images/stereo-light.png b/docs/images/stereo-light.png new file mode 100644 index 0000000..08e1d1a Binary files /dev/null and b/docs/images/stereo-light.png differ diff --git a/install.sh b/install.sh index 3bf9bc2..c7b9c23 100755 --- a/install.sh +++ b/install.sh @@ -1,45 +1,14 @@ -#!/usr/bin/env bash -# -# install.sh — copy MacStereoFix.driver into the system HAL plug-in directory -# and restart coreaudiod so the device shows up immediately. -# -# This is the manual install path. The app's "Install Driver" button does the -# same thing through an authenticated AppleScript prompt. -# -# Usage: -# ./build.sh -# sudo ./install.sh - +#!/bin/bash +# Developer-only manual installation; prefer the signed app's Install Driver button. set -euo pipefail - -if [[ $EUID -ne 0 ]]; then - echo "install.sh must be run as root." - echo "Try: sudo $0" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +requirement='identifier "com.macstereofix.driver" and anchor apple generic and certificate leaf[subject.OU] = "MD83L42DNL" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists' +if [[ ${1:-} == --allow-adhoc ]]; then + requirement=--allow-adhoc +elif [[ $# -ne 0 ]]; then + echo 'Usage: sudo ./install.sh [--allow-adhoc]' >&2 exit 1 fi - -ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -SRC="$ROOT_DIR/build/MacStereoFix.driver" -DST_DIR="/Library/Audio/Plug-Ins/HAL" -DST="$DST_DIR/MacStereoFix.driver" - -if [[ ! -d "$SRC" ]]; then - echo "Driver bundle not found at $SRC" - echo "Run ./build.sh first." - exit 1 -fi - -echo "==> Installing MacStereoFix driver to $DST" -mkdir -p "$DST_DIR" -rm -rf "$DST" -cp -R "$SRC" "$DST" -chown -R root:wheel "$DST" - -echo "==> Restarting coreaudiod" -# launchctl kickstart is blocked by SIP for coreaudiod on modern macOS. -# killall is the supported way: launchd respawns coreaudiod automatically. -killall coreaudiod 2>/dev/null || true - -echo -echo "Done. Open System Settings > Sound > Output and you should see" -echo "'MacStereoFix' in the device list. Then launch MacStereoFix.app." +echo 'Installing the driver will briefly interrupt all Mac audio.' +/bin/bash "$ROOT_DIR/Scripts/install-driver.sh" "$ROOT_DIR/build/MacStereoFix.driver" "$requirement" +echo 'Driver installed. Open MacStereoFix to select your output.' diff --git a/release.sh b/release.sh index f5f1ff8..f3837cb 100755 --- a/release.sh +++ b/release.sh @@ -1,44 +1,55 @@ -#!/usr/bin/env bash -# -# release.sh — produce a friend-distributable MacStereoFix.zip. -# -# Builds the app + driver, ad-hoc signs them, drops a plain-English -# instructions file next to the app, zips the whole thing. -# -# Output: ./build/MacStereoFix.zip -# -# Friends unzip, follow MacStereoFix-INSTRUCTIONS.txt, and they're done. - +#!/bin/bash +# Produce a distributable archive only after signing, tests, notarization and Gatekeeper pass. set -euo pipefail - -ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -BUILD_DIR="$ROOT_DIR/build" -RELEASE_DIR="$BUILD_DIR/release" -ZIP_OUT="$BUILD_DIR/MacStereoFix.zip" - -echo "==> Building app + driver" -"$ROOT_DIR/build.sh" - -echo "==> Staging release directory" -rm -rf "$RELEASE_DIR" "$ZIP_OUT" -mkdir -p "$RELEASE_DIR" -cp -R "$BUILD_DIR/MacStereoFix.app" "$RELEASE_DIR/MacStereoFix.app" -cp "$ROOT_DIR/FRIENDS_README.txt" "$RELEASE_DIR/MacStereoFix-INSTRUCTIONS.txt" - -# Strip any extended attributes (quarantine, etc.) from the staged copy so -# the zip is as clean as possible. Friends' Macs will re-quarantine on -# download anyway, but starting clean avoids weird leftover xattrs. -xattr -cr "$RELEASE_DIR/MacStereoFix.app" 2>/dev/null || true - -echo "==> Zipping into $ZIP_OUT" -( cd "$RELEASE_DIR" && zip -qry "$ZIP_OUT" . ) - -rm -rf "$RELEASE_DIR" - -echo -echo "Release ready:" -echo " $ZIP_OUT" -ls -lh "$ZIP_OUT" -echo -echo "AirDrop or send this file to a friend. They follow the instructions" -echo "inside MacStereoFix-INSTRUCTIONS.txt." +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +: "${SIGN_IDENTITY:?Set SIGN_IDENTITY to your Developer ID Application identity.}" +[[ "$SIGN_IDENTITY" != - ]] || { echo 'Ad-hoc builds cannot be released.' >&2; exit 1; } +: "${NOTARY_PROFILE:?Set NOTARY_PROFILE to the saved notarytool Keychain profile name.}" +: "${RELEASE_TESTED_COMMIT:?Complete docs/RELEASE_CHECKLIST.md and set RELEASE_TESTED_COMMIT to the tested commit SHA.}" +cd "$ROOT_DIR" +[[ "$RELEASE_TESTED_COMMIT" == "$(git rev-parse HEAD)" ]] || { echo 'The hardware checklist must cover this exact commit.' >&2; exit 1; } +[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || { echo 'Commit the complete tested source before releasing.' >&2; exit 1; } + +./build.sh +./check.sh +APP="$ROOT_DIR/build/MacStereoFix.app" +VERSION=$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP/Contents/Info.plist") +TEAM_REQUIREMENT='anchor apple generic and certificate leaf[subject.OU] = "MD83L42DNL" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists' +/usr/bin/codesign --verify --deep --strict -R "$TEAM_REQUIREMENT" "$APP" +/usr/bin/codesign --verify --strict -R "$TEAM_REQUIREMENT" "$APP/Contents/Resources/MacStereoFix.driver" +/usr/bin/codesign --verify --strict -R "$TEAM_REQUIREMENT" "$APP/Contents/MacOS/MacStereoFixRecovery" + +SUBMISSION="$ROOT_DIR/build/notary-submission.zip" +/usr/bin/ditto -c -k --keepParent "$APP" "$SUBMISSION" +xcrun notarytool submit "$SUBMISSION" --keychain-profile "$NOTARY_PROFILE" \ + --wait --timeout 30m --output-format json > "$ROOT_DIR/build/notary-result.json" +[[ $(/usr/bin/plutil -extract status raw -o - "$ROOT_DIR/build/notary-result.json") == Accepted ]] || { + echo 'Apple did not accept the build. See build/notary-result.json.' >&2; exit 1; +} +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +/usr/bin/codesign --verify --deep --strict "$APP" +/usr/sbin/spctl --assess --type execute --verbose=2 "$APP" + +STAGE="$ROOT_DIR/build/release" +mkdir -p "$STAGE" +/usr/bin/ditto "$APP" "$STAGE/MacStereoFix.app" +cp "$ROOT_DIR/INSTALL.txt" "$STAGE/MacStereoFix-INSTRUCTIONS.txt" +cp -R "$ROOT_DIR/ThirdParty" "$STAGE/ThirdParty" +cp "$ROOT_DIR/PRIVACY.md" "$STAGE/PRIVACY.md" +{ + git rev-parse HEAD + xcrun swiftc --version + xcrun --show-sdk-version +} > "$STAGE/BUILD-INFO.txt" +ZIP="$ROOT_DIR/build/MacStereoFix-$VERSION.zip" +/usr/bin/ditto -c -k "$STAGE" "$ZIP" +# Validate the artifact users will actually extract, not only the build directory. +VERIFY="$ROOT_DIR/build/release-check" +/usr/bin/ditto -x -k "$ZIP" "$VERIFY" +xcrun stapler validate "$VERIFY/MacStereoFix.app" +/usr/bin/codesign --verify --deep --strict "$VERIFY/MacStereoFix.app" +/usr/sbin/spctl --assess --type execute --verbose=2 "$VERIFY/MacStereoFix.app" +(cd "$ROOT_DIR/build" && /usr/bin/shasum -a 256 "MacStereoFix-$VERSION.zip" > "MacStereoFix-$VERSION.zip.sha256") +echo "Verified release archive: $ZIP" +echo 'Publishing to GitHub is a separate step.' diff --git a/uninstall.sh b/uninstall.sh index 29ca471..5a89d88 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,30 +1,6 @@ -#!/usr/bin/env bash -# -# uninstall.sh — remove the MacStereoFix driver from /Library/Audio/Plug-Ins/HAL -# and restart coreaudiod. Does not touch /Applications/MacStereoFix.app. -# -# Usage: sudo ./uninstall.sh - +#!/bin/bash set -euo pipefail - -if [[ $EUID -ne 0 ]]; then - echo "uninstall.sh must be run as root." - echo "Try: sudo $0" - exit 1 -fi - -DST="/Library/Audio/Plug-Ins/HAL/MacStereoFix.driver" - -if [[ -d "$DST" ]]; then - echo "==> Removing $DST" - rm -rf "$DST" -else - echo "Driver not installed at $DST — nothing to remove." -fi - -echo "==> Restarting coreaudiod" -# launchctl kickstart is blocked by SIP for coreaudiod on modern macOS. -killall coreaudiod 2>/dev/null || true - -echo -echo "Done. You can also drag MacStereoFix.app out of /Applications if you want." +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +echo 'Quit MacStereoFix and select a physical output in Sound settings before removing the driver.' +/bin/bash "$ROOT_DIR/Scripts/uninstall-driver.sh" +echo 'Driver removed. You may move MacStereoFix.app to the Trash.'