From 8d9594f820b34d8e322436f90176d204a5492dc0 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 15 Aug 2026 12:07:07 -0400 Subject: [PATCH] Juke follows the Mac's ears: DJ deck engines restart on the CoreAudio route change that used to strand them on a dead output when AirPods arrived, the focus ding checks isRunning instead of a one-shot flag that kept it silent forever, and room audio reopens Neo's renderer whenever the system default output moves --- .../JukeWizard/AudioOutputDevice.swift | 41 +++++++++++++++++++ .../Sources/JukeWizard/DJFocusFeedback.swift | 6 +-- .../Sources/JukeWizard/DJMixerView.swift | 27 ++++++++++++ .../Sources/JukeWizard/JukeRoomAudio.swift | 16 +++++++- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/juke-wizard/Sources/JukeWizard/AudioOutputDevice.swift b/juke-wizard/Sources/JukeWizard/AudioOutputDevice.swift index dfef7f7b20..39c7843904 100644 --- a/juke-wizard/Sources/JukeWizard/AudioOutputDevice.swift +++ b/juke-wizard/Sources/JukeWizard/AudioOutputDevice.swift @@ -137,6 +137,47 @@ enum MacAudioOutput { } } +extension Notification.Name { + /// Posted on the main queue after macOS's default audio output changes + /// (AirPods arriving, a cable pulled, a Sound-settings pick) and the HAL + /// has had a moment to settle the new route. + static let macDefaultOutputDeviceDidChange = + Notification.Name("MacAudioOutputDefaultDidChange") +} + +/// Turns Core Audio's default-output property change into a debounced app +/// notification, so engines pinned to a dead route can follow the Mac's +/// output the way AVAudioPlayer does on its own. +final class DefaultOutputWatcher { + static let shared = DefaultOutputWatcher() + private var pending = 0 + + private init() { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), &address, .main + ) { [weak self] _, _ in self?.scheduleNotify() } + } + + /// Referencing `shared` starts the listener; this makes call sites read + /// as intent instead of a bare property access. + func activate() {} + + private func scheduleNotify() { + pending += 1 + let generation = pending + // A single AirPods hop produces a burst of property changes, and + // Bluetooth/virtual routes settle slowly — coalesce before posting. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self, self.pending == generation else { return } + NotificationCenter.default.post(name: .macDefaultOutputDeviceDidChange, object: nil) + } + } +} + /// Refreshes the hardware list immediately before AppKit opens the menu, so a /// newly connected Bluetooth headset appears without relaunching JukeWizard. final class AudioOutputPopUpButton: NSPopUpButton { diff --git a/juke-wizard/Sources/JukeWizard/DJFocusFeedback.swift b/juke-wizard/Sources/JukeWizard/DJFocusFeedback.swift index 0d344dd545..4c496f323c 100644 --- a/juke-wizard/Sources/JukeWizard/DJFocusFeedback.swift +++ b/juke-wizard/Sources/JukeWizard/DJFocusFeedback.swift @@ -42,7 +42,6 @@ final class DJFocusDing { private let engine = AVAudioEngine() private let player = AVAudioPlayerNode() private let sampleRate = 44_100.0 - private var started = false private init() { engine.attach(player) @@ -52,9 +51,10 @@ final class DJFocusDing { } func play(rising: Bool) { - if !started { + // isRunning, not a one-shot flag: the HAL stops this engine whenever + // the default output changes, and a dead flag kept it silent forever. + if !engine.isRunning { guard (try? engine.start()) != nil else { return } - started = true } let frames = AVAudioFrameCount(sampleRate * 0.12) guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1), diff --git a/juke-wizard/Sources/JukeWizard/DJMixerView.swift b/juke-wizard/Sources/JukeWizard/DJMixerView.swift index 7c7cafcc3b..f89d5623ac 100644 --- a/juke-wizard/Sources/JukeWizard/DJMixerView.swift +++ b/juke-wizard/Sources/JukeWizard/DJMixerView.swift @@ -258,6 +258,7 @@ final class DJDeckPlayer: NSObject { private(set) var pitchSemitones: Double = 0 private(set) var motorEnabled = false var onStateChange: (() -> Void)? + private var routeObserver: NSObjectProtocol? var duration: Double { pcm.duration } var currentTime: Double { pcm.currentTime } @@ -322,10 +323,36 @@ final class DJDeckPlayer: NSObject { engine.mainMixerNode.outputVolume = gain engine.prepare() try? engine.start() + observeRouteChanges(of: engine) applyRate() onStateChange?() } + /// The HAL stops an AVAudioEngine when its output route goes away — + /// AirPods arriving make the default output a different device. Restart + /// on the new route so the deck follows the Mac's output instead of + /// staying silently wedged on the old one while the transport spins. + private func observeRouteChanges(of engine: AVAudioEngine) { + if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) } + routeObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main + ) { [weak self] _ in self?.restartAfterRouteChange(engine) } + } + + private func restartAfterRouteChange(_ engine: AVAudioEngine) { + // Let Core Audio settle the new route, and keep the HAL-blocking + // start() off AppKit's main thread — same rules as JukeRoomAudio's + // refreshLocalOutputDevice. + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self, engine === self.engine, !engine.isRunning else { return } + try? engine.start() + } + } + + deinit { + if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) } + } + func toggle() { isPlaying ? pause() : play() } func play() { diff --git a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift index 578cfa2f62..4ac929cfd7 100644 --- a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift +++ b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift @@ -50,6 +50,17 @@ final class JukeRoomAudio { private var spotifyTap: AnyObject? private var remoteReceiver: Process? private var localOutputGeneration = 0 + private var routeObserver: NSObjectProtocol? + + init() { + // Follow the Mac's default output when it changes under us (AirPods + // hop, unplugged interface) — the same reopen the manual picker does. + DefaultOutputWatcher.shared.activate() + routeObserver = NotificationCenter.default.addObserver( + forName: .macDefaultOutputDeviceDidChange, object: nil, queue: .main + ) { [weak self] _ in self?.refreshLocalOutputDevice() } + } + var isDistributing: Bool { guard case .live = state else { return false } @@ -235,7 +246,10 @@ final class JukeRoomAudio { neo: label(mix.local), blueberry: label(mix.remote)) } - deinit { stop(notify: false) } + deinit { + if let routeObserver { NotificationCenter.default.removeObserver(routeObserver) } + stop(notify: false) + } private static func jukedPID() -> pid_t? { let installed = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".local/bin/juked") -- 2.51.2