diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 021b45f..f6bc9ac 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; + 7EF10C122F817ECF004229A0 /* Accelerate.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EF10C112F817ECF004229A0 /* Accelerate.framework */; }; 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; /* End PBXBuildFile section */ @@ -53,6 +54,7 @@ 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = ""; }; 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; 7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + 7EF10C112F817ECF004229A0 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; }; F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -113,6 +115,7 @@ buildActionMask = 2147483647; files = ( 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */, + 7EF10C122F817ECF004229A0 /* Accelerate.framework in Frameworks */, 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, @@ -143,6 +146,7 @@ 7E81304F2E3FB42700E08F3F /* Frameworks */ = { isa = PBXGroup; children = ( + 7EF10C112F817ECF004229A0 /* Accelerate.framework */, 7E25ECA02E6C6C3800700E72 /* CoreBrightness.framework */, 7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */, 7E25ECA22E6C6E2600700E72 /* OSD.framework */, @@ -504,6 +508,7 @@ RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "DynamicIsland/DynamicIsland-Bridging-Header.h"; SWIFT_VERSION = 5.0; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( "$(SDKROOT)/System/Library/Frameworks", @@ -576,6 +581,7 @@ RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "DynamicIsland/DynamicIsland-Bridging-Header.h"; SWIFT_VERSION = 5.0; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( "$(SDKROOT)/System/Library/Frameworks", diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index c8059cd..790abc7 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -1292,7 +1292,7 @@ struct ContentView: View { .frame(width: 50, alignment: .center) .matchedGeometryEffect(id: "spectrum", in: albumArtNamespace) .mask { - AudioSpectrumView(isPlaying: $musicManager.isPlaying) + AudioVisualizerView(isPlaying: $musicManager.isPlaying) .frame(width: 16, height: 12) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/DynamicIsland/DynamicIsland-Bridging-Header.h b/DynamicIsland/DynamicIsland-Bridging-Header.h new file mode 100644 index 0000000..9438e57 --- /dev/null +++ b/DynamicIsland/DynamicIsland-Bridging-Header.h @@ -0,0 +1,18 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Swift bridging header for Objective-C/C++ code. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#ifndef DynamicIsland_Bridging_Header_h +#define DynamicIsland_Bridging_Header_h + +#import "audio/AudioBridge.h" + +#endif /* DynamicIsland_Bridging_Header_h */ diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 0cac7ea..8fcb9c0 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -155,6 +155,58 @@ class AppDelegate: NSObject, NSApplicationDelegate { installTopMenuItemsIfNeeded() } + /// Setup observers for music player state changes to restart AudioTap capture + private func setupAudioTapMusicObservers() { + // Listen for app launches to restart capture when music apps are opened + let targetBundleIDs = [ + "com.apple.Music", + "com.spotify.client", + "com.apple.Safari", + "com.tidal.desktop", + "tv.plex.plexamp", + "com.roon.Roon", + "com.audirvana.Audirvana-Studio", + "com.vox.vox", + "com.coppertino.Vox", + ] + + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleID = app.bundleIdentifier, + targetBundleIDs.contains(bundleID) else { return } + + // A target music app was launched, restart capture to include it + if Defaults[.enableRealTimeWaveform] { + print("🎵 [AudioTap] Music app launched: \(bundleID), restarting capture...") + // Give the app a moment to fully launch + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + AudioTap.shared.restartCapture() + } + } + } + + // Also observe app terminations to restart capture + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, + queue: .main + ) { notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleID = app.bundleIdentifier, + targetBundleIDs.contains(bundleID) else { return } + + // A target music app was terminated, restart capture to update the list + if Defaults[.enableRealTimeWaveform] { + print("🎵 [AudioTap] Music app terminated: \(bundleID), restarting capture...") + AudioTap.shared.restartCapture() + } + } + } + func applicationWillTerminate(_ notification: Notification) { let userInfo: [String: Any] = [ AtollDistributedNotifications.UserInfoKey.sourcePID: NSNumber(value: ProcessInfo.processInfo.processIdentifier) @@ -171,6 +223,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { NotificationCenter.default.removeObserver(self) extensionXPCServiceHost.stop() extensionRPCServer.stop() + + // Stop AudioTap capture + AudioTap.shared.stopCapture() // Restore Lunar's native OSD if integration was active LunarManager.shared.appWillTerminate() @@ -471,6 +526,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Setup Privacy Indicator Manager (camera and microphone monitoring) PrivacyIndicatorManager.shared.startMonitoring() + // Setup Real-time Audio Waveform capture if enabled + if Defaults[.enableRealTimeWaveform] { + Task { + await AudioTap.shared.startCapture() + } + setupAudioTapMusicObservers() + } + + // Observe enableRealTimeWaveform changes + Defaults.publisher(.enableRealTimeWaveform, options: []) + .sink { [weak self] change in + if change.newValue { + Task { + await AudioTap.shared.startCapture() + } + self?.setupAudioTapMusicObservers() + } else { + AudioTap.shared.stopCapture() + } + } + .store(in: &cancellables) + // Observe tab changes - use immediate resize to keep the notch pinned // Deferred to next run loop tick because @Published fires on willSet, // so coordinator.currentView still holds the OLD value at emission time. diff --git a/DynamicIsland/Info.plist b/DynamicIsland/Info.plist index 150ed99..92fe265 100644 --- a/DynamicIsland/Info.plist +++ b/DynamicIsland/Info.plist @@ -7,6 +7,8 @@ NSAllowsArbitraryLoads + NSAudioCaptureUsageDescription + Atoll needs audio capture permission to display the real-time music waveform visualizer. NSScreenCaptureUsageDescription Atoll needs screen recording permission to capture screenshots for the AI assistant feature. SUEnableDownloaderService diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 24b99b9..d8adb7d 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -14697,6 +14697,9 @@ } } } + }, + "Enable real-time waveform" : { + }, "Enable reminder live activity" : { "localizations" : { @@ -22984,6 +22987,9 @@ } } } + }, + "Music Visualizer" : { + }, "muted" : { "localizations" : { @@ -37227,6 +37233,9 @@ } } } + }, + "When enabled, the music visualizer displays real-time audio spectrum data synced to your music. Requires macOS 14.2+ and uses minimal CPU/GPU resources via the Accelerate framework." : { + }, "When enabled, the notch slides up and hides on external (non-notch) displays until you hover over it." : { "comment" : "A description of the feature that hides the notch on non-notch displays.", diff --git a/DynamicIsland/audio/AudioBridge.h b/DynamicIsland/audio/AudioBridge.h new file mode 100644 index 0000000..a2fef37 --- /dev/null +++ b/DynamicIsland/audio/AudioBridge.h @@ -0,0 +1,27 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Objective-C bridge to expose C++ AudioProcessor to Swift. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface AudioBridge : NSObject + +- (void)processBuffer:(const float *)buffer count:(int)count; + +// Reads atomically from the processor — safe to call from any thread +- (simd_float4)getSmoothedMagnitudes; + +@end + +NS_ASSUME_NONNULL_END diff --git a/DynamicIsland/audio/AudioBridge.mm b/DynamicIsland/audio/AudioBridge.mm new file mode 100644 index 0000000..c781c58 --- /dev/null +++ b/DynamicIsland/audio/AudioBridge.mm @@ -0,0 +1,47 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Objective-C++ implementation bridging AudioProcessor to Swift. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#import "AudioBridge.h" +#import "AudioProcessor.hpp" + +@implementation AudioBridge { + AudioProcessor *processor; +} + +- (instancetype)init { + self = [super init]; + if (self) { + processor = new AudioProcessor(); + } + return self; +} + +- (void)processBuffer:(const float *)buffer count:(int)count { + processor->process(buffer, count); +} + +- (simd_float4)getSmoothedMagnitudes { + // Calls getBand() which does memory_order_relaxed atomic loads — + // no heap allocation, safe to call from the render thread + return simd_make_float4( + processor->getBand(0), + processor->getBand(1), + processor->getBand(2), + processor->getBand(3) + ); +} + +- (void)dealloc { + delete processor; +} + +@end diff --git a/DynamicIsland/audio/AudioProcessor.cpp b/DynamicIsland/audio/AudioProcessor.cpp new file mode 100644 index 0000000..0f9f9d0 --- /dev/null +++ b/DynamicIsland/audio/AudioProcessor.cpp @@ -0,0 +1,11 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#include "AudioProcessor.hpp" diff --git a/DynamicIsland/audio/AudioProcessor.hpp b/DynamicIsland/audio/AudioProcessor.hpp new file mode 100644 index 0000000..970b9f0 --- /dev/null +++ b/DynamicIsland/audio/AudioProcessor.hpp @@ -0,0 +1,134 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Real-time audio processor using Accelerate framework for efficient FFT + * and band filtering. Adapted from rtaudio project. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#ifndef AudioProcessor_hpp +#define AudioProcessor_hpp + +#include +#include +#include + +class AudioProcessor { +private: + static constexpr int kBlockSize = 512; // lower latency than 1024 + static constexpr int kBands = 4; + static constexpr float kAttack = 0.85f; // fast rise + static constexpr float kRelease = 0.40f; // slower fall — more musical + static constexpr float kGains[kBands] = {3.5f, 6.0f, 9.0f, 20.0f}; + + float sampleRate; + int writePos = 0; + float mono[kBlockSize] = {}; + float filtered[kBlockSize] = {}; + + vDSP_biquad_Setup setups[kBands]; + alignas(16) float delays[kBands][4] = {}; + + float envelopes[kBands] = {}; + + // Lock-free: audio thread writes, render thread reads + // One atomic per band — fits in a cache line + alignas(64) std::atomic bandParams[kBands] = {}; + +public: + explicit AudioProcessor(float sr = 48000.0f) : sampleRate(sr) { + // Single biquad per band — just enough separation, minimal CPU + setupBiquad(0, FilterType::LowPass, 250.0f, 0.707f); + setupBiquad(1, FilterType::BandPass, 707.0f, 0.40f); + setupBiquad(2, FilterType::BandPass, 3464.0f, 0.85f); + setupBiquad(3, FilterType::HighPass, 6000.0f, 0.707f); + } + + ~AudioProcessor() { + for (int i = 0; i < kBands; ++i) + vDSP_biquad_DestroySetup(setups[i]); + } + + // Called by CoreAudio — must be real-time safe (no alloc, no locks) + void process(const float* __restrict__ buffer, int totalSamples) { + if (__builtin_expect(totalSamples <= 0, 0)) return; + + const float* src = buffer; + int remaining = totalSamples; + + while (remaining > 0) { + int toCopy = std::min(remaining, kBlockSize - writePos); + memcpy(mono + writePos, src, toCopy * sizeof(float)); + writePos += toCopy; + src += toCopy; + remaining -= toCopy; + + if (writePos >= kBlockSize) { + processBlock(); + writePos = 0; + } + } + } + + // Called by render thread — lock-free read + float getBand(int i) const { + return bandParams[i].load(std::memory_order_relaxed); + } + +private: + enum class FilterType { LowPass, BandPass, HighPass }; + + void processBlock() { + for (int i = 0; i < kBands; ++i) { + vDSP_biquad(setups[i], delays[i], mono, 1, filtered, 1, kBlockSize); + + // RMS over the block — more stable than peak for driving animation + float rms = 0.0f; + vDSP_rmsqv(filtered, 1, &rms, kBlockSize); + + if (__builtin_expect(!std::isfinite(rms), 0)) rms = 0.0f; + + float boostedRms = rms * kGains[i]; + boostedRms = std::min(boostedRms, 1.0f); + + // Asymmetric envelope: attack fast, release slow + float prev = envelopes[i]; + envelopes[i] = (boostedRms > prev) + ? prev * (1.0f - kAttack) + boostedRms * kAttack + : prev * (1.0f - kRelease) + boostedRms * kRelease; + + bandParams[i].store(envelopes[i], std::memory_order_relaxed); + } + } + + void setupBiquad(int idx, FilterType type, float freq, float q) { + const double w0 = 2.0 * M_PI * freq / sampleRate; + const double sinw = std::sin(w0); + const double cosw = std::cos(w0); + const double alpha = sinw / (2.0 * q); + + double b0, b1, b2; + const double a0 = 1.0 + alpha; + const double a1 = -2.0 * cosw; + const double a2 = 1.0 - alpha; + + switch (type) { + case FilterType::LowPass: + b0 = (1.0 - cosw) * 0.5; b1 = 1.0 - cosw; b2 = b0; break; + case FilterType::HighPass: + b0 = (1.0 + cosw) * 0.5; b1 = -(1.0 + cosw); b2 = b0; break; + case FilterType::BandPass: + b0 = alpha; b1 = 0.0; b2 = -alpha; break; + } + + const double coeffs[5] = { b0/a0, b1/a0, b2/a0, a1/a0, a2/a0 }; + setups[idx] = vDSP_biquad_CreateSetup(coeffs, 1); + } +}; + +#endif // !AudioProcessor_hpp diff --git a/DynamicIsland/audio/AudioTap.swift b/DynamicIsland/audio/AudioTap.swift new file mode 100644 index 0000000..69ea2d3 --- /dev/null +++ b/DynamicIsland/audio/AudioTap.swift @@ -0,0 +1,346 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * CoreAudio tap for capturing real-time audio from music applications. + * Uses macOS 14.2+ Process Tap API for efficient audio capture. + * Adapted from rtaudio project. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import AppKit +import AudioToolbox +import CoreAudio +import simd +import os.log + +private let audioTapLog = OSLog(subsystem: "com.atoll.dynamicisland", category: "AudioTap") + +// Debug: track callback invocations +private var callbackCount: Int = 0 + +// CoreAudio fires this on a high-priority background real-time thread. +let audioIOProc: AudioDeviceIOProc = { + inDevice, inNow, inInputData, inInputTime, outOutputData, inOutputTime, clientData in + + guard let clientData = clientData else { return noErr } + let scanner = Unmanaged.fromOpaque(clientData).takeUnretainedValue() + + if scanner.isPaused { return noErr } + + let mutableInputData = UnsafeMutablePointer(mutating: inInputData) + let bufferList = UnsafeMutableAudioBufferListPointer(mutableInputData) + + if let firstBuffer = bufferList.first, let data = firstBuffer.mData { + // CoreAudio gives us byte size, divide by 4 (Float size) to get array length + let floatCount = Int32(firstBuffer.mDataByteSize) / Int32(MemoryLayout.size) + + let floatData = data.assumingMemoryBound(to: Float.self) + + // Pass the mono array directly to C++ + scanner.bridge.processBuffer(floatData, count: floatCount) + + // Debug: log periodically with audio level info + callbackCount += 1 + if callbackCount % 1000 == 0 { + // Calculate max absolute value in buffer to check if audio is present + var maxVal: Float = 0.0 + for i in 0.. maxVal { maxVal = absVal } + } + os_log(.debug, log: audioTapLog, "🔊 Audio callback fired %d times, buffer size: %d, max amplitude: %f", callbackCount, floatCount, maxVal) + + // Also log the current band values from the bridge + let mags = scanner.bridge.getSmoothedMagnitudes() + os_log(.debug, log: audioTapLog, "🎚️ Bridge magnitudes: [%f, %f, %f, %f]", mags.x, mags.y, mags.z, mags.w) + } + } + + return noErr +} + +private func getAudioObjectID(for pid: pid_t) -> AudioObjectID? { + var audioObjectID: AudioObjectID = kAudioObjectUnknown + var pidValue = pid + + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var size = UInt32(MemoryLayout.size) + let qualifierSize = UInt32(MemoryLayout.size) + + // We query the global system object (kAudioObjectSystemObject) + // We pass the PID as the "qualifier", and it returns the AudioObjectID + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + qualifierSize, + &pidValue, + &size, + &audioObjectID + ) + + if status == noErr && audioObjectID != kAudioObjectUnknown { + return audioObjectID + } + + return nil +} + +/// Singleton class for real-time audio capture from music apps +class AudioTap: NSObject { + static let shared = AudioTap() + + let bridge = AudioBridge() + var isPaused: Bool = false + private var displayMagnitudes = simd_float4(0, 0, 0, 0) + + // CoreAudio stuff + private var tapID: AudioObjectID = kAudioObjectUnknown + private var aggregateDeviceID: AudioObjectID = kAudioObjectUnknown + private var ioProcID: AudioDeviceIOProcID? = nil + private var captureIsRunning = false + + // Serial queue to prevent race conditions + private let audioQueue = DispatchQueue(label: "com.atoll.audiotap", qos: .userInitiated) + + // Debounce restart requests + private var pendingRestartWorkItem: DispatchWorkItem? + + private let targetBundleIDs = [ + "com.apple.Music", + "com.spotify.client", + "com.apple.Safari", + "com.tidal.desktop", + "tv.plex.plexamp", + "com.roon.Roon", + "com.audirvana.Audirvana-Studio", + "com.vox.vox", + "com.coppertino.Vox", + ] + + private override init() { + super.init() + } + + // Helper function to smooth out the magnitudes for prettifying purposes + func getSmoothedMagnitudes() -> simd_float4 { + // Zero bridging overhead. Just passing 16 bytes of memory. + let targetLevels = bridge.getSmoothedMagnitudes() + + let smoothingFactor: Float = 0.4 + + // Vector math! This does all 4 calculations simultaneously. + let difference = targetLevels - displayMagnitudes + displayMagnitudes += difference * smoothingFactor + + return displayMagnitudes + } + + func startCapture() async { + await withCheckedContinuation { continuation in + audioQueue.async { [weak self] in + self?.startCaptureSync() + continuation.resume() + } + } + } + + private func startCaptureSync() { + guard !captureIsRunning else { + print("⚠️ [AudioTap] Capture already running, skipping start") + return + } + + let runningApps = NSWorkspace.shared.runningApplications + var targetPIDs: [AudioDeviceID] = [] + + for app in runningApps { + if let bundleID = app.bundleIdentifier, targetBundleIDs.contains(bundleID) { + if let deviceID = getAudioObjectID(for: app.processIdentifier) { + targetPIDs.append(deviceID) + print("🎯 [AudioTap] Found \(app.localizedName ?? "App") with PID: \(app.processIdentifier), AudioObjectID: \(deviceID)") + } + } + } + + if targetPIDs.isEmpty { + print("⚠️ [AudioTap] None of our target apps are running right now.") + return + } + + let description = CATapDescription() + description.processes = targetPIDs + description.isMixdown = true + description.isMono = true + + print("📋 [AudioTap] Creating tap for \(targetPIDs.count) processes: \(targetPIDs)") + + tapID = AudioObjectID(kAudioObjectUnknown) + var status = AudioHardwareCreateProcessTap(description, &tapID) + guard status == noErr else { + print("🛑 [AudioTap] Tap Error: \(status) (\(fourCharCodeToString(status)))") + return + } + print("✅ [AudioTap] Created process tap with ID: \(tapID)") + + // Get the tap's unique hardware UID + var tapUID: CFString = "" as CFString + var propertySize = UInt32(MemoryLayout.stride) + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioTapPropertyUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + status = withUnsafeMutablePointer(to: &tapUID) { uidPtr in + AudioObjectGetPropertyData(tapID, &propertyAddress, 0, nil, &propertySize, uidPtr) + } + guard status == noErr else { + print("🛑 [AudioTap] UID Error: \(status) (\(fourCharCodeToString(status)))") + cleanupPartialSetup() + return + } + print("✅ [AudioTap] Got tap UID: \(tapUID)") + + // Create the Aggregate Device (a "virtual microphone" that we can route the tap into) + let tapList = [[kAudioSubTapUIDKey: tapUID]] + let aggregateDict: [String: Any] = [ + kAudioAggregateDeviceNameKey: "Atoll_Virtual_Tap", + kAudioAggregateDeviceUIDKey: UUID().uuidString, + kAudioAggregateDeviceIsPrivateKey: true, // Hides it from the user's sound settings + kAudioAggregateDeviceTapListKey: tapList, + ] + + aggregateDeviceID = AudioObjectID(kAudioObjectUnknown) + status = AudioHardwareCreateAggregateDevice( + aggregateDict as CFDictionary, &aggregateDeviceID) + guard status == noErr else { + print("🛑 [AudioTap] Aggregate Error: \(status) (\(fourCharCodeToString(status)))") + cleanupPartialSetup() + return + } + print("✅ [AudioTap] Created aggregate device with ID: \(aggregateDeviceID)") + + // Bind the Callback to the device + let selfPointer = Unmanaged.passUnretained(self).toOpaque() + status = AudioDeviceCreateIOProcID(aggregateDeviceID, audioIOProc, selfPointer, &ioProcID) + + guard status == noErr, let validIOProcID = ioProcID else { + print("🛑 [AudioTap] IOProc Error: \(status) (\(fourCharCodeToString(status)))") + cleanupPartialSetup() + return + } + print("✅ [AudioTap] Created IO proc") + + // Start listening + status = AudioDeviceStart(aggregateDeviceID, validIOProcID) + guard status == noErr else { + print("🛑 [AudioTap] Start Error: \(status) (\(fourCharCodeToString(status)))") + cleanupPartialSetup() + return + } + + captureIsRunning = true + callbackCount = 0 + print("🟢 [AudioTap] CoreAudio CATap flowing through Aggregate Device!") + } + + private func cleanupPartialSetup() { + if let validIOProcID = ioProcID, aggregateDeviceID != kAudioObjectUnknown { + AudioDeviceDestroyIOProcID(aggregateDeviceID, validIOProcID) + } + if aggregateDeviceID != kAudioObjectUnknown { + AudioHardwareDestroyAggregateDevice(aggregateDeviceID) + } + if tapID != kAudioObjectUnknown { + AudioHardwareDestroyProcessTap(tapID) + } + tapID = kAudioObjectUnknown + aggregateDeviceID = kAudioObjectUnknown + ioProcID = nil + } + + func restartCapture() { + // Cancel any pending restart + pendingRestartWorkItem?.cancel() + + // Debounce: wait 500ms before actually restarting + let workItem = DispatchWorkItem { [weak self] in + self?.audioQueue.async { + print("🔄 [AudioTap] Restarting capture...") + self?.stopCaptureSync() + // Small delay to let CoreAudio fully release resources + Thread.sleep(forTimeInterval: 0.1) + self?.startCaptureSync() + } + } + pendingRestartWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) + } + + func stopCapture() { + audioQueue.sync { [weak self] in + self?.stopCaptureSync() + } + } + + private func stopCaptureSync() { + guard captureIsRunning else { return } + + // Stop listening + if let validIOProcID = ioProcID, aggregateDeviceID != kAudioObjectUnknown { + AudioDeviceStop(aggregateDeviceID, validIOProcID) + AudioDeviceDestroyIOProcID(aggregateDeviceID, validIOProcID) + } + + // Destroy resources + if aggregateDeviceID != kAudioObjectUnknown { + AudioHardwareDestroyAggregateDevice(aggregateDeviceID) + } + + if tapID != kAudioObjectUnknown { + AudioHardwareDestroyProcessTap(tapID) + } + + tapID = kAudioObjectUnknown + aggregateDeviceID = kAudioObjectUnknown + ioProcID = nil + captureIsRunning = false + + // Reset display magnitudes + displayMagnitudes = simd_float4(0, 0, 0, 0) + + print("🔴 [AudioTap] CoreAudio CATap capture stopped") + } + + var isCapturing: Bool { + captureIsRunning + } + + deinit { + stopCaptureSync() + } +} + +// Helper to convert OSStatus to readable string +private func fourCharCodeToString(_ code: OSStatus) -> String { + let bytes = [ + UInt8((code >> 24) & 0xFF), + UInt8((code >> 16) & 0xFF), + UInt8((code >> 8) & 0xFF), + UInt8(code & 0xFF) + ] + if bytes.allSatisfy({ $0 >= 32 && $0 < 127 }) { + return String(bytes: bytes, encoding: .ascii) ?? "????" + } + return String(code) +} diff --git a/DynamicIsland/audio/AudioVisualizerView.swift b/DynamicIsland/audio/AudioVisualizerView.swift new file mode 100644 index 0000000..48fa1f1 --- /dev/null +++ b/DynamicIsland/audio/AudioVisualizerView.swift @@ -0,0 +1,35 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Unified audio visualizer that conditionally uses real-time audio spectrum + * or the original animated spectrum based on user preference. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import SwiftUI +import Defaults + +/// Unified audio visualizer view that switches between real-time and animated based on user preference +struct AudioVisualizerView: View { + @Binding var isPlaying: Bool + @Default(.enableRealTimeWaveform) private var enableRealTimeWaveform + + var body: some View { + if enableRealTimeWaveform { + RealTimeAudioSpectrumView(isPlaying: $isPlaying) + } else { + AudioSpectrumView(isPlaying: $isPlaying) + } + } +} + +#Preview("Animated") { + AudioVisualizerView(isPlaying: .constant(true)) + .frame(width: 16, height: 20) + .padding() +} diff --git a/DynamicIsland/audio/RealTimeAudioSpectrum.swift b/DynamicIsland/audio/RealTimeAudioSpectrum.swift new file mode 100644 index 0000000..ddb4e8b --- /dev/null +++ b/DynamicIsland/audio/RealTimeAudioSpectrum.swift @@ -0,0 +1,161 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * Real-time audio spectrum visualization using CoreAudio tap data. + * Adapted from rtaudio project. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import AppKit +import Cocoa +import SwiftUI +import simd + +/// NSView-based real-time audio spectrum visualizer +class RealTimeAudioSpectrum: NSView { + private var barLayers: [CAShapeLayer] = [] + private var isPlaying: Bool = true + private var animationTimer: Timer? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + setupBars() + } + + deinit { + stopAnimating() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + setupBars() + } + + private func setupBars() { + let barWidth: CGFloat = 2 + let barCount = 4 + let spacing: CGFloat = barWidth + let totalWidth = CGFloat(barCount) * (barWidth + spacing) + let totalHeight: CGFloat = 14 + frame.size = CGSize(width: totalWidth, height: totalHeight) + + for i in 0 ..< barCount { + let xPosition = CGFloat(i) * (barWidth + spacing) + let barLayer = CAShapeLayer() + barLayer.frame = CGRect(x: xPosition, y: 0, width: barWidth, height: totalHeight) + barLayer.position = CGPoint(x: xPosition + barWidth / 2, y: totalHeight / 2) + barLayer.fillColor = NSColor.white.cgColor + + let path = NSBezierPath(roundedRect: CGRect(x: 0, y: 0, width: barWidth, height: totalHeight), + xRadius: barWidth / 2, + yRadius: barWidth / 2) + barLayer.path = path.cgPath + + barLayers.append(barLayer) + layer?.addSublayer(barLayer) + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + stopAnimating() + } else if isPlaying { + startAnimating() + } + } + + private func startAnimating() { + guard animationTimer == nil else { return } + // Use a timer at ~30fps for smooth animation + animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in + self?.updateBarsFromAudio() + } + } + + private func stopAnimating() { + animationTimer?.invalidate() + animationTimer = nil + resetBars() + } + + private var debugLogCounter = 0 + + private func updateBarsFromAudio() { + guard isPlaying else { + resetBars() + return + } + + // Get real-time magnitudes from AudioTap + let magnitudes = AudioTap.shared.getSmoothedMagnitudes() + + // Debug: log magnitudes periodically + debugLogCounter += 1 + if debugLogCounter % 60 == 0 { // Every 2 seconds at 30fps + print("📊 [Spectrum] Magnitudes: [\(magnitudes.x), \(magnitudes.y), \(magnitudes.z), \(magnitudes.w)]") + } + + // Update each bar with its corresponding band magnitude + for (index, barLayer) in barLayers.enumerated() { + let magnitude = magnitudes[index] + // Map magnitude (0-1) to scale (0.2 - 1.0) for visual appeal + let scale = max(0.2, min(1.0, CGFloat(magnitude) * 1.5 + 0.2)) + + CATransaction.begin() + CATransaction.setDisableActions(true) + barLayer.transform = CATransform3DMakeScale(1, scale, 1) + CATransaction.commit() + } + } + + private func resetBars() { + for barLayer in barLayers { + CATransaction.begin() + CATransaction.setDisableActions(true) + barLayer.transform = CATransform3DMakeScale(1, 0.2, 1) + CATransaction.commit() + } + } + + func setPlaying(_ playing: Bool) { + isPlaying = playing + if isPlaying { + startAnimating() + } else { + stopAnimating() + } + } +} + +/// SwiftUI wrapper for RealTimeAudioSpectrum +struct RealTimeAudioSpectrumView: NSViewRepresentable { + @Binding var isPlaying: Bool + + func makeNSView(context: Context) -> RealTimeAudioSpectrum { + let spectrum = RealTimeAudioSpectrum() + spectrum.setPlaying(isPlaying) + return spectrum + } + + func updateNSView(_ nsView: RealTimeAudioSpectrum, context: Context) { + nsView.setPlaying(isPlaying) + } + + static func dismantleNSView(_ nsView: RealTimeAudioSpectrum, coordinator: ()) { + nsView.setPlaying(false) + } +} + +#Preview { + RealTimeAudioSpectrumView(isPlaying: .constant(true)) + .frame(width: 16, height: 20) + .padding() +} diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift index 2f6f7f9..346d8a5 100644 --- a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -476,7 +476,7 @@ struct ExtensionEdgeContentView: View { .fill((colorDescriptor.isAccent ? accent : colorDescriptor.swiftUIColor).gradient) .frame(width: 48, height: 14) .mask { - AudioSpectrumView(isPlaying: .constant(true)) + AudioVisualizerView(isPlaying: .constant(true)) .frame(width: 16, height: 12) } case let .animation(data, size): diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 4dc6eae..3d8ea23 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -326,7 +326,7 @@ struct LockScreenMusicPanel: View { Rectangle() .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) .mask { - AudioSpectrumView(isPlaying: .constant(musicManager.isPlaying)) + AudioVisualizerView(isPlaying: .constant(musicManager.isPlaying)) .frame(width: width, height: height) } .frame(width: width, height: height) diff --git a/DynamicIsland/components/MinimalisticMusicView.swift b/DynamicIsland/components/MinimalisticMusicView.swift index 43b8a13..23e364a 100644 --- a/DynamicIsland/components/MinimalisticMusicView.swift +++ b/DynamicIsland/components/MinimalisticMusicView.swift @@ -110,7 +110,7 @@ struct MinimalisticMusicView: View { .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) .frame(width: 50, alignment: .center) .mask { - AudioSpectrumView(isPlaying: $musicManager.isPlaying) + AudioVisualizerView(isPlaying: $musicManager.isPlaying) .frame(width: 16, height: 12) } .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index fc57c91..672b497 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -640,7 +640,7 @@ private struct MinimalisticReminderDetailsView: View { Rectangle() .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: MusicManager.shared.avgColor).gradient : Color.gray.gradient) .mask { - AudioSpectrumView(isPlaying: .constant(MusicManager.shared.isPlaying)) + AudioVisualizerView(isPlaying: .constant(MusicManager.shared.isPlaying)) .frame(width: 20, height: 16) } .frame(width: 20, height: 16) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 8e5aa18..39692f5 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2615,6 +2615,20 @@ struct Media: View { Text("Media playback live activity") } + Section { + Defaults.Toggle(key: .enableRealTimeWaveform) { + HStack { + Text("Enable real-time waveform") + customBadge(text: "Beta") + } + } + .settingsHighlight(id: highlightID("Enable real-time waveform")) + } header: { + Text("Music Visualizer") + } footer: { + Text("When enabled, the music visualizer displays real-time audio spectrum data synced to your music. Requires macOS 14.2+ and uses minimal CPU/GPU resources via the Accelerate framework.") + } + Section { Defaults.Toggle(key: .enableLockScreenMediaWidget) { Text("Show lock screen media panel") diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index c03afc5..0ffefa5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -774,6 +774,7 @@ extension Defaults.Keys { // MARK: Media playback static let coloredSpectrogram = Key("coloredSpectrogram", default: true) + static let enableRealTimeWaveform = Key("enableRealTimeWaveform", default: false) static let enableSneakPeek = Key("enableSneakPeek", default: false) static let sneakPeekStyles = Key("sneakPeekStyles", default: .standard) static let showSneakPeekOnTrackChange = Key("showSneakPeekOnTrackChange", default: true)