diff --git a/juke-wizard/Sources/JukeWizard/AudioOutputDevice.swift b/juke-wizard/Sources/JukeWizard/AudioOutputDevice.swift new file mode 100644 --- /dev/null +++ b/juke-wizard/Sources/JukeWizard/AudioOutputDevice.swift @@ -0,0 +1,148 @@ +import AppKit +import CoreAudio + +/// The same Core Audio output-device surface used by macOS Sound settings. +/// Selecting here intentionally changes the Mac's default output so every +/// JukeWizard source (AVAudioPlayer, juked/Spotify, and room receivers) agrees. +enum MacAudioOutput { + struct Device: Equatable { + let id: AudioDeviceID + let uid: String + let name: String + let transport: UInt32 + + var symbolName: String { + switch transport { + case kAudioDeviceTransportTypeBluetooth, kAudioDeviceTransportTypeBluetoothLE: + return "headphones" + case kAudioDeviceTransportTypeUSB, kAudioDeviceTransportTypeThunderbolt: + return "cable.connector" + case kAudioDeviceTransportTypeAirPlay: + return "airplayaudio" + case kAudioDeviceTransportTypeHDMI, kAudioDeviceTransportTypeDisplayPort: + return "display" + case kAudioDeviceTransportTypeBuiltIn: + return "laptopcomputer" + default: + return "speaker.wave.2" + } + } + } + + struct DeviceError: LocalizedError { + let action: String + let status: OSStatus + var errorDescription: String? { "Could not \(action) (Core Audio \(status))" } + } + + static func devices() -> [Device] { + let address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + guard let ids = deviceIDs(address: address) else { return [] } + let active = defaultDeviceID() + return ids.compactMap { id in + guard outputChannelCount(id) > 0 else { return nil } + let name = string(id, selector: kAudioObjectPropertyName) ?? "Audio Device" + let uid = string(id, selector: kAudioDevicePropertyDeviceUID) ?? String(id) + let transport = uint32(id, selector: kAudioDevicePropertyTransportType) ?? 0 + return Device(id: id, uid: uid, name: name, transport: transport) + }.sorted { + if $0.id == active { return true } + if $1.id == active { return false } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + static func defaultDeviceID() -> AudioDeviceID? { + uint32(AudioObjectID(kAudioObjectSystemObject), + selector: kAudioHardwarePropertyDefaultOutputDevice) + } + + static func select(_ device: Device) throws { + try setSystemDevice(device.id, selector: kAudioHardwarePropertyDefaultOutputDevice, + action: "select \(device.name)") + // Alert sounds should follow the same destination, matching the Sound + // control-center selector. Some virtual devices reject this secondary + // property, so the main music route remains authoritative. + try? setSystemDevice(device.id, selector: kAudioHardwarePropertyDefaultSystemOutputDevice, + action: "route system sounds") + } + + private static func setSystemDevice(_ id: AudioDeviceID, selector: AudioObjectPropertySelector, + action: String) throws { + var mutableID = id + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + let status = AudioObjectSetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, + 0, nil, + UInt32(MemoryLayout.size), &mutableID) + guard status == noErr else { throw DeviceError(action: action, status: status) } + } + + private static func outputChannelCount(_ id: AudioDeviceID) -> UInt32 { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioObjectPropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0 else { return 0 } + let raw = UnsafeMutableRawPointer.allocate( + byteCount: Int(size), alignment: MemoryLayout.alignment) + defer { raw.deallocate() } + guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, raw) == noErr else { return 0 } + return UnsafeMutableAudioBufferListPointer(raw.assumingMemoryBound(to: AudioBufferList.self)) + .reduce(0) { $0 + $1.mNumberChannels } + } + + private static func string(_ id: AudioObjectID, + selector: AudioObjectPropertySelector) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var value: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &value) == noErr, + let value else { return nil } + return value.takeUnretainedValue() as String + } + + private static func uint32(_ id: AudioObjectID, + selector: AudioObjectPropertySelector) -> UInt32? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var value: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(id, &address, 0, nil, &size, &value) + return status == noErr ? value : nil + } + + private static func deviceIDs(address original: AudioObjectPropertyAddress) -> [AudioDeviceID]? { + var address = original + var size: UInt32 = 0 + let system = AudioObjectID(kAudioObjectSystemObject) + guard AudioObjectGetPropertyDataSize(system, &address, 0, nil, &size) == noErr else { return nil } + var output = [AudioDeviceID](repeating: 0, + count: Int(size) / MemoryLayout.size) + let status = output.withUnsafeMutableBytes { bytes in + AudioObjectGetPropertyData(system, &address, 0, nil, &size, bytes.baseAddress!) + } + return status == noErr ? output : 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 { + var prepareMenu: (() -> Void)? + override func mouseDown(with event: NSEvent) { + prepareMenu?() + super.mouseDown(with: event) + } +} diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -100,6 +100,8 @@ var spotifyProgress: SpotifyProgressView! var spotifySearchField: NSSearchField! var sourceTabs: NSSegmentedControl! var appearanceTabs: NSSegmentedControl! + var outputPopup: AudioOutputPopUpButton! + var outputDevices: [MacAudioOutput.Device] = [] var playButton: NSButton! var ledLabel: NSTextField! var notesToggle: NSButton! @@ -353,6 +355,17 @@ appearanceTabs.controlSize = .small appearanceTabs.toolTip = "Follow macOS, or pin JukeWizard to light or dark" content.addSubview(appearanceTabs) + outputPopup = AudioOutputPopUpButton(frame: .zero, pullsDown: false) + outputPopup.controlSize = .small + outputPopup.bezelStyle = .rounded + outputPopup.font = NSFont.systemFont(ofSize: 11, weight: .medium) + outputPopup.target = self + outputPopup.action = #selector(outputDeviceChanged(_:)) + outputPopup.toolTip = "Mac audio output · speakers, headphones, Bluetooth, USB, and displays" + outputPopup.prepareMenu = { [weak self] in self?.reloadOutputDevices() } + content.addSubview(outputPopup) + reloadOutputDevices() + spotifySearchField = NSSearchField(frame: .zero) spotifySearchField.placeholderString = "Search Spotify" spotifySearchField.sendsSearchStringImmediately = false @@ -508,6 +521,10 @@ let topBarH: CGFloat = 34 let contentTop = H - topBarH sourceTabs.frame = NSRect(x: pad, y: H - 27, width: 170, height: 22) appearanceTabs.frame = NSRect(x: W - pad - 172, y: H - 27, width: 172, height: 22) + let outputX = pad + 178 + let outputRight = appearanceTabs.frame.minX - 8 + outputPopup.frame = NSRect(x: outputX, y: H - 27, + width: max(110, min(260, outputRight - outputX)), height: 22) // ── header (now-playing) across the top ─────────────────────────────── let headerH = max(178, min(245, (H - topBarH) * 0.39)) let headerBottom = contentTop - headerH @@ -857,6 +874,59 @@ @objc func quickVolumeUp() { setQuickVolume(quickVolume + 0.1) } @objc func quickVolumeDown() { setQuickVolume(quickVolume - 0.1) } + private func reloadOutputDevices() { + let current = MacAudioOutput.defaultDeviceID() + outputDevices = MacAudioOutput.devices() + outputPopup.removeAllItems() + guard !outputDevices.isEmpty else { + outputPopup.addItem(withTitle: "No audio outputs") + outputPopup.isEnabled = false + return + } + outputPopup.isEnabled = true + for device in outputDevices { + outputPopup.addItem(withTitle: device.name) + outputPopup.lastItem?.image = NSImage(systemSymbolName: device.symbolName, + accessibilityDescription: device.name) + } + if let index = outputDevices.firstIndex(where: { $0.id == current }) { + outputPopup.selectItem(at: index) + outputPopup.toolTip = "Mac audio output · \(outputDevices[index].name)" + } + } + + @objc private func outputDeviceChanged(_ sender: NSPopUpButton) { + guard outputDevices.indices.contains(sender.indexOfSelectedItem) else { return } + chooseOutput(outputDevices[sender.indexOfSelectedItem]) + } + + @objc private func outputDeviceMenuItem(_ sender: NSMenuItem) { + guard let id = (sender.representedObject as? NSNumber)?.uint32Value, + let device = MacAudioOutput.devices().first(where: { $0.id == id }) else { return } + chooseOutput(device) + } + + private func chooseOutput(_ device: MacAudioOutput.Device) { + guard device.id != MacAudioOutput.defaultDeviceID() else { reloadOutputDevices(); return } + do { + try MacAudioOutput.select(device) + reloadOutputDevices() + wave.reopenAudioOutput() + if roomAudio.isDistributing { + roomAudio.refreshLocalOutputDevice() + } else if spotifyMode { + spotify.refreshOutputDevice(resuming: spotifyState) + } + activityLabel.stringValue = "● output · \(device.name)" + activityLabel.textColor = Palette.teal + } catch { + reloadOutputDevices() + activityLabel.stringValue = "⚠ \(error.localizedDescription)" + activityLabel.textColor = .systemRed + NSSound.beep() + } + } + private func setQuickVolume(_ value: Float) { quickVolume = max(0, min(1, value)) wave.volume = quickVolume @@ -892,6 +962,20 @@ menu.addItem(.separator()) let room = NSMenuItem(title: quickRoomSummary, action: nil, keyEquivalent: "") room.isEnabled = false menu.addItem(room) + let output = NSMenuItem(title: "Audio Output", action: nil, keyEquivalent: "") + let outputMenu = NSMenu(title: "Audio Output") + let currentOutput = MacAudioOutput.defaultDeviceID() + for device in MacAudioOutput.devices() { + let item = NSMenuItem(title: device.name, action: #selector(outputDeviceMenuItem(_:)), + keyEquivalent: "") + item.target = self + item.representedObject = NSNumber(value: device.id) + item.state = device.id == currentOutput ? .on : .off + item.image = NSImage(systemSymbolName: device.symbolName, accessibilityDescription: device.name) + outputMenu.addItem(item) + } + output.submenu = outputMenu + menu.addItem(output) let open = NSMenuItem(title: "Open JukeWizard", action: #selector(quickOpenFull), keyEquivalent: "") open.target = self menu.addItem(open) diff --git a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift --- a/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift +++ b/juke-wizard/Sources/JukeWizard/JukeRoomAudio.swift @@ -49,6 +49,12 @@ private var sender: ACAudioRoomSender? private var localReceiver: ACAudioRoomReceiver? private var spotifyTap: AnyObject? private var remoteReceiver: Process? + private var localOutputGeneration = 0 + + var isDistributing: Bool { + guard case .live = state else { return false } + return layout != .neoStereo + } func useSource(_ nextSource: Source) { guard source != nextSource else { return } @@ -141,7 +147,45 @@ } } func stop() { stop(notify: true) } + + /// Reopen only Neo's renderer on the newly selected Core Audio output. + /// The sender, clock, and Blueberry receiver continue uninterrupted. + func refreshLocalOutputDevice() { + guard isDistributing, let mix = channels(for: layout).local else { return } + localOutputGeneration += 1 + let generation = localOutputGeneration + // Retain the old engine for the worker closure before clearing the + // property. Both AVAudioEngine.stop() and deinit may wait on the HAL + // while a route changes, so neither belongs on AppKit's main thread. + let previousReceiver = localReceiver + localReceiver = nil + // Core Audio can take a moment to settle a Bluetooth/virtual-device + // route change. AVAudioEngine may block while opening during that + // interval, so never make AppKit's menu action wait for the HAL. + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.35) { [weak self] in + guard let self else { return } + previousReceiver?.stop() + let receiver = ACAudioRoomReceiver(configuration: .init( + host: "127.0.0.1", name: "Neo", channel: mix.channel, gain: mix.gain)) + receiver.onLog = { NSLog("JukeWizard room Neo: \($0)") } + do { + try receiver.start() + DispatchQueue.main.async { [weak self] in + guard let self else { receiver.stop(); return } + guard self.localOutputGeneration == generation else { receiver.stop(); return } + self.localReceiver = receiver + } + } catch { + DispatchQueue.main.async { [weak self] in + guard let self, self.localOutputGeneration == generation else { return } + self.state = .failed("Neo output: \(error.localizedDescription)") + } + } + } + } + private func stop(notify: Bool) { + localOutputGeneration += 1 if #available(macOS 14.2, *), let tap = spotifyTap as? ACProcessAudioTap { tap.stop() } spotifyTap = nil localReceiver?.stop(); localReceiver = nil diff --git a/juke-wizard/Sources/JukeWizard/JukeSpotify.swift b/juke-wizard/Sources/JukeWizard/JukeSpotify.swift --- a/juke-wizard/Sources/JukeWizard/JukeSpotify.swift +++ b/juke-wizard/Sources/JukeWizard/JukeSpotify.swift @@ -76,6 +76,30 @@ func previous() { command(["previous"]); pollSoon() } func seek(offsetMS: Int) { command(["seek", String(offsetMS)]); pollSoon() } func volume(percent: Int) { command(["volume", String(max(0, min(100, percent)))]) } + /// spotify_player binds its Core Audio stream when the daemon starts. + /// Reopen that stream on a newly selected device, then restore the staged + /// track, position, and paused/playing state. + func refreshOutputDevice(resuming state: SpotifyPlaybackState?) { + command(["restart"]) { [weak self] result in + guard let self, case .success = result else { return } + guard let state, let trackID = state.trackID else { self.pollSoon(); return } + self.command(["play-id", trackID]) { [weak self] result in + guard let self, case .success = result else { return } + let offset = max(0, Int((state.position * 1000).rounded())) + let restorePlayState = { [weak self] in + guard let self else { return } + if !state.isPlaying { self.command(["pause"]) } + self.pollSoon() + } + if offset > 500 { + self.command(["seek", String(offset)]) { _ in restorePlayState() } + } else { + restorePlayState() + } + } + } + } + func daemonPID() -> pid_t? { guard let data = try? Self.run(["pid"]), let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/juke-wizard/Sources/JukeWizard/WaveformView.swift b/juke-wizard/Sources/JukeWizard/WaveformView.swift --- a/juke-wizard/Sources/JukeWizard/WaveformView.swift +++ b/juke-wizard/Sources/JukeWizard/WaveformView.swift @@ -16,6 +16,7 @@ final class WaveformView: NSView, AVAudioPlayerDelegate { weak var delegate: WaveformViewDelegate? private var player: AVAudioPlayer? + private var loadedURL: URL? private var peaks: [Float] = [] private var peaksToken = 0 private var timer: Timer? @@ -47,13 +48,31 @@ // ── load / transport ──────────────────────────────────────────────── func load(url: URL) { stop() + loadedURL = url peaks = [] + openPlayer(url: url) + needsDisplay = true + computePeaks(url: url) + } + + /// AVAudioPlayer opens the current Core Audio default when it is created. + /// Recreate it after an output-device change while preserving transport. + func reopenAudioOutput() { + guard let loadedURL else { return } + let position = currentTime + let resume = isPlaying + player?.stop() + openPlayer(url: loadedURL) + player?.currentTime = min(position, duration) + if resume { player?.play(); startTimer() } + needsDisplay = true + } + + private func openPlayer(url: URL) { player = try? AVAudioPlayer(contentsOf: url) player?.volume = preferredVolume player?.delegate = self player?.prepareToPlay() - needsDisplay = true - computePeaks(url: url) } func play() { player?.play(); startTimer() }