From d5e330b11399365d930bc39bf72348ebea73145c Mon Sep 17 00:00:00 2001 From: Alex Hohnhorst Date: Mon, 25 May 2026 22:45:55 +0200 Subject: [PATCH 1/3] feat: add virtual loopback audio device for lossless inter-app routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CoreAudio HAL plugin (FineTuneLoopback.driver) that creates a virtual input device called 'FineTune Loopback'. This enables DAWs like Ableton Live to record audio from any app that FineTune taps — lossless, 32-bit float, with zero additional latency. Architecture: - FineTune app taps audio → processes (vol/EQ) → writes to POSIX shared memory - HAL driver reads from shared memory → presents as virtual mic input to DAWs - Lock-free SPSC ring buffer with atomic head counters (no locks on RT thread) Components: - FineTuneLoopback/: CoreAudio HAL plugin (C++, ~1200 LOC) - FTLoopbackDriver.cpp: Full AudioServerPlugInDriverInterface implementation - SharedTypes.h: Shared memory layout (binary protocol between app & driver) - Info.plist: CFPlugIn metadata for CoreAudio discovery - FineTune/Audio/Loopback/: - LoopbackRingBuffer.swift: RT-safe shared memory writer (memcpy + atomics only) - LoopbackDeviceManager.swift: Driver lifecycle, install/uninstall, app tracking - ShmHelper.h: C wrapper for variadic shm_open (Swift interop) - AudioEngine integration: auto-enables loopback for first non-DAW app - ProcessTapController: forks processed audio to ring buffer after EQ/limiting Safety: - SPSC single-writer enforcement (one app at a time) - DAW exclusion list prevents feedback loops - Stale shared memory cleanup on restart (shm_unlink before create) - RT-safe write path: no alloc, no locks, no ObjC, no logging --- FineTune.xcodeproj/project.pbxproj | 150 +- FineTune/Audio/Engine/AudioEngine.swift | 89 ++ .../Audio/Engine/ProcessTapController.swift | 30 +- .../Audio/Engine/ProcessTapControlling.swift | 10 + .../Loopback/LoopbackDeviceManager.swift | 238 ++++ .../Audio/Loopback/LoopbackRingBuffer.swift | 273 ++++ FineTune/Audio/Loopback/ShmHelper.h | 15 + FineTune/FineTune-Bridging-Header.h | 9 + FineTuneLoopback/FTLoopbackDriver.cpp | 1242 +++++++++++++++++ FineTuneLoopback/FTLoopbackDriver.h | 48 + FineTuneLoopback/Info.plist | 49 + FineTuneLoopback/SharedTypes.h | 90 ++ 12 files changed, 2233 insertions(+), 10 deletions(-) create mode 100644 FineTune/Audio/Loopback/LoopbackDeviceManager.swift create mode 100644 FineTune/Audio/Loopback/LoopbackRingBuffer.swift create mode 100644 FineTune/Audio/Loopback/ShmHelper.h create mode 100644 FineTune/FineTune-Bridging-Header.h create mode 100644 FineTuneLoopback/FTLoopbackDriver.cpp create mode 100644 FineTuneLoopback/FTLoopbackDriver.h create mode 100644 FineTuneLoopback/Info.plist create mode 100644 FineTuneLoopback/SharedTypes.h diff --git a/FineTune.xcodeproj/project.pbxproj b/FineTune.xcodeproj/project.pbxproj index ea6fa1b1..675bf448 100644 --- a/FineTune.xcodeproj/project.pbxproj +++ b/FineTune.xcodeproj/project.pbxproj @@ -13,6 +13,10 @@ 79B1000B2F35000000000002 /* IOBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79B1000A2F35000000000001 /* IOBluetooth.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 79C80E5A2F12FF510021442B /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 79SNAP0012F0600000008D52A /* SnapshotTesting */; }; 79FLUID0012F0600000008D52 /* FluidMenuBarExtra in Frameworks */ = {isa = PBXBuildFile; productRef = 79FLUID0022F0600000008D52 /* FluidMenuBarExtra */; }; + 79LB00012F50000000000001 /* FTLoopbackDriver.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 79LB00022F50000000000002 /* FTLoopbackDriver.cpp */; }; + 79LB00032F50000000000003 /* FineTuneLoopback.driver in Copy Loopback Driver */ = {isa = PBXBuildFile; fileRef = 79LB00042F50000000000004 /* FineTuneLoopback.driver */; }; + 79LB00202F50000000000020 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79LB00212F50000000000021 /* CoreAudio.framework */; }; + 79LB00222F50000000000022 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79LB00232F50000000000023 /* CoreFoundation.framework */; }; D60793509DEB13A2E97D5EBF /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = C18F337FFBE2D40467959144 /* KeyboardShortcuts */; }; /* End PBXBuildFile section */ @@ -31,14 +35,42 @@ remoteGlobalIDString = 79A607AE2F05C9E00008D52A; remoteInfo = FineTune; }; + 79LB00052F50000000000005 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 79A607A72F05C9E00008D52A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 79LB00062F50000000000006; + remoteInfo = FineTuneLoopback; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 79LB000B2F5000000000000B /* Copy Loopback Driver */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 7; + files = ( + 79LB00032F50000000000003 /* FineTuneLoopback.driver in Copy Loopback Driver */, + ); + name = "Copy Loopback Driver"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 7988AE512F1CC55900E6D086 /* fineTuneIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = fineTuneIcon.icon; sourceTree = ""; }; 79A607AF2F05C9E00008D52A /* FineTune.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FineTune.app; sourceTree = BUILT_PRODUCTS_DIR; }; 79A607BC2F05C9E10008D52A /* FineTuneTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FineTuneTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 79A607C62F05C9E10008D52A /* FineTuneUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FineTuneUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 79B1000A2F35000000000001 /* IOBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOBluetooth.framework; path = System/Library/Frameworks/IOBluetooth.framework; sourceTree = SDKROOT; }; + 79LB00022F50000000000002 /* FTLoopbackDriver.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = FTLoopbackDriver.cpp; sourceTree = ""; }; + 79LB00042F50000000000004 /* FineTuneLoopback.driver */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FineTuneLoopback.driver; sourceTree = BUILT_PRODUCTS_DIR; }; + 79LB00072F50000000000007 /* FTLoopbackDriver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FTLoopbackDriver.h; sourceTree = ""; }; + 79LB00082F50000000000008 /* SharedTypes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SharedTypes.h; sourceTree = ""; }; + 79LB00092F50000000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 79LB00212F50000000000021 /* CoreAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreAudio.framework; path = System/Library/Frameworks/CoreAudio.framework; sourceTree = SDKROOT; }; + 79LB00232F50000000000023 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -87,6 +119,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79LB000F2F5000000000000F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 79LB00202F50000000000020 /* CoreAudio.framework in Frameworks */, + 79LB00222F50000000000022 /* CoreFoundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -94,6 +135,8 @@ isa = PBXGroup; children = ( 79B1000A2F35000000000001 /* IOBluetooth.framework */, + 79LB00212F50000000000021 /* CoreAudio.framework */, + 79LB00232F50000000000023 /* CoreFoundation.framework */, ); name = "Recovered References"; sourceTree = ""; @@ -102,6 +145,7 @@ isa = PBXGroup; children = ( 79A607B12F05C9E00008D52A /* FineTune */, + 79LB000A2F5000000000000A /* FineTuneLoopback */, 79A607BF2F05C9E10008D52A /* FineTuneTests */, 79A607C92F05C9E10008D52A /* FineTuneUITests */, 79A607B02F05C9E00008D52A /* Products */, @@ -116,10 +160,22 @@ 79A607AF2F05C9E00008D52A /* FineTune.app */, 79A607BC2F05C9E10008D52A /* FineTuneTests.xctest */, 79A607C62F05C9E10008D52A /* FineTuneUITests.xctest */, + 79LB00042F50000000000004 /* FineTuneLoopback.driver */, ); name = Products; sourceTree = ""; }; + 79LB000A2F5000000000000A /* FineTuneLoopback */ = { + isa = PBXGroup; + children = ( + 79LB00022F50000000000002 /* FTLoopbackDriver.cpp */, + 79LB00072F50000000000007 /* FTLoopbackDriver.h */, + 79LB00082F50000000000008 /* SharedTypes.h */, + 79LB00092F50000000000009 /* Info.plist */, + ); + path = FineTuneLoopback; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -130,10 +186,12 @@ 79A607AB2F05C9E00008D52A /* Sources */, 79A607AC2F05C9E00008D52A /* Frameworks */, 79A607AD2F05C9E00008D52A /* Resources */, + 79LB000B2F5000000000000B /* Copy Loopback Driver */, ); buildRules = ( ); dependencies = ( + 79LB000C2F5000000000000C /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 79A607B12F05C9E00008D52A /* FineTune */, @@ -194,6 +252,22 @@ productReference = 79A607C62F05C9E10008D52A /* FineTuneUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 79LB00062F50000000000006 /* FineTuneLoopback */ = { + isa = PBXNativeTarget; + buildConfigurationList = 79LB000D2F5000000000000D /* Build configuration list for PBXNativeTarget "FineTuneLoopback" */; + buildPhases = ( + 79LB000E2F5000000000000E /* Sources */, + 79LB000F2F5000000000000F /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FineTuneLoopback; + productName = FineTuneLoopback; + productReference = 79LB00042F50000000000004 /* FineTuneLoopback.driver */; + productType = "com.apple.product-type.bundle"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -215,6 +289,9 @@ CreatedOnToolsVersion = 26.1.1; TestTargetID = 79A607AE2F05C9E00008D52A; }; + 79LB00062F50000000000006 = { + CreatedOnToolsVersion = 26.1.1; + }; }; }; buildConfigurationList = 79A607AA2F05C9E00008D52A /* Build configuration list for PBXProject "FineTune" */; @@ -238,6 +315,7 @@ projectRoot = ""; targets = ( 79A607AE2F05C9E00008D52A /* FineTune */, + 79LB00062F50000000000006 /* FineTuneLoopback */, 79A607BB2F05C9E10008D52A /* FineTuneTests */, 79A607C52F05C9E10008D52A /* FineTuneUITests */, ); @@ -291,6 +369,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 79LB000E2F5000000000000E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 79LB00012F50000000000001 /* FTLoopbackDriver.cpp in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -304,6 +390,11 @@ target = 79A607AE2F05C9E00008D52A /* FineTune */; targetProxy = 79A607C72F05C9E10008D52A /* PBXContainerItemProxy */; }; + 79LB000C2F5000000000000C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 79LB00062F50000000000006 /* FineTuneLoopback */; + targetProxy = 79LB00052F50000000000005 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -433,12 +524,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = fineTuneIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = FineTune/FineTune.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; + DEVELOPMENT_TEAM = T9MUAV83Z3; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -453,6 +543,7 @@ ); MARKETING_VERSION = 1.0.0; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; + SWIFT_OBJC_BRIDGING_HEADER = "FineTune/FineTune-Bridging-Header.h"; PRODUCT_BUNDLE_IDENTIFIER = com.finetuneapp.FineTune; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -472,12 +563,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = fineTuneIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = FineTune/FineTune.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; + DEVELOPMENT_TEAM = T9MUAV83Z3; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -492,6 +582,7 @@ ); MARKETING_VERSION = 1.0.0; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; + SWIFT_OBJC_BRIDGING_HEADER = "FineTune/FineTune-Bridging-Header.h"; PRODUCT_BUNDLE_IDENTIFIER = com.finetuneapp.FineTune; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -585,6 +676,40 @@ }; name = Release; }; + 79LB00102F50000000000010 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = "$(SRCROOT)/FineTuneLoopback"; + INFOPLIST_FILE = FineTuneLoopback/Info.plist; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.finetuneapp.FineTuneLoopback; + PRODUCT_NAME = "$(TARGET_NAME)"; + WRAPPER_EXTENSION = driver; + }; + name = Debug; + }; + 79LB00112F50000000000011 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = "$(SRCROOT)/FineTuneLoopback"; + INFOPLIST_FILE = FineTuneLoopback/Info.plist; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.finetuneapp.FineTuneLoopback; + PRODUCT_NAME = "$(TARGET_NAME)"; + WRAPPER_EXTENSION = driver; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -624,6 +749,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 79LB000D2F5000000000000D /* Build configuration list for PBXNativeTarget "FineTuneLoopback" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 79LB00102F50000000000010 /* Debug */, + 79LB00112F50000000000011 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/FineTune/Audio/Engine/AudioEngine.swift b/FineTune/Audio/Engine/AudioEngine.swift index 062c99f0..385e238c 100644 --- a/FineTune/Audio/Engine/AudioEngine.swift +++ b/FineTune/Audio/Engine/AudioEngine.swift @@ -15,6 +15,7 @@ final class AudioEngine { let settingsManager: SettingsManager let autoEQProfileManager: AutoEQProfileManager let permission: AudioRecordingPermission + let loopbackManager: LoopbackDeviceManager #if !APP_STORE let ddcController: DDCController @@ -182,6 +183,7 @@ final class AudioEngine { startMonitorsAutomatically: Bool = true ) { self.permission = permission ?? AudioRecordingPermission() + self.loopbackManager = LoopbackDeviceManager() let manager = settingsManager ?? SettingsManager() self.settingsManager = manager self.autoEQProfileManager = autoEQProfileManager ?? AutoEQProfileManager() @@ -618,11 +620,65 @@ final class AudioEngine { /// Call from applicationWillTerminate or equivalent lifecycle hook. /// Note: For menu bar apps, process exit cleans up resources anyway, so this is optional. func shutdown() { + loopbackManager.disableLoopback() stop() deviceVolumeMonitor.stop() logger.info("AudioEngine shutdown complete") } + // MARK: - Loopback Audio Routing + + /// Whether the loopback HAL plugin driver is installed. + var isLoopbackDriverInstalled: Bool { + loopbackManager.isDriverInstalled + } + + /// Whether any app is currently routing audio to loopback. + var isLoopbackActive: Bool { + loopbackManager.isActive + } + + /// Installs the FineTune Loopback HAL plugin driver (requires admin privileges). + func installLoopbackDriver() async throws { + try await loopbackManager.installDriver() + } + + /// Enables loopback routing for a specific app. + /// Creates the shared memory ring buffer if not already active. + func enableLoopback(for app: AudioApp) throws { + let buffer = try loopbackManager.enableLoopback() + loopbackManager.addApp(app.id) + + // Assign the ring buffer to the app's tap + if let tap = taps[app.id] { + tap.setLoopbackBuffer(buffer) + } + + logger.info("Loopback enabled for \(app.name)") + } + + /// Disables loopback routing for a specific app. + func disableLoopback(for app: AudioApp) { + loopbackManager.removeApp(app.id) + + // Remove the ring buffer from the tap + if let tap = taps[app.id] { + tap.setLoopbackBuffer(nil) + } + + // If no more apps are using loopback, disable the system + if loopbackManager.activeApps.isEmpty { + loopbackManager.disableLoopback() + } + + logger.info("Loopback disabled for \(app.name)") + } + + /// Checks if an app is currently routing to loopback. + func isLoopbackEnabled(for app: AudioApp) -> Bool { + loopbackManager.isAppRouted(app.id) + } + // MARK: - Settings Reset /// Resets all persisted settings and synchronizes in-memory engine state. @@ -1223,6 +1279,39 @@ final class AudioEngine { enabled: settingsManager.appSettings.loudnessCompensationEnabled ) + // Auto-enable loopback: route this app's audio to the shared memory ring + // buffer so DAWs can record from it via the FineTune Loopback device. + // + // CONSTRAINTS: + // 1. Ring buffer is SPSC (single producer, single consumer) — only ONE app + // can write at a time. Multiple writers corrupt the data. + // 2. DAWs must be excluded to prevent feedback loops (they read from the + // loopback device, so writing their output back creates distortion). + if loopbackManager.isDriverInstalled && loopbackManager.activeApps.isEmpty { + let bundleID = (app.bundleID ?? "").lowercased() + let isDAW = bundleID.contains("ableton") || + bundleID.contains("apple.logic") || + bundleID.contains("steinberg") || + bundleID.contains("bitwig") || + bundleID.contains("reaper") || + bundleID.contains("image-line") // FL Studio + if !isDAW { + do { + let buffer = try loopbackManager.enableLoopback( + sampleRate: Float64(44100.0), // Match common macOS sample rate + channels: 2 + ) + tap.setLoopbackBuffer(buffer) + loopbackManager.addApp(app.id) + logger.info("Loopback auto-enabled for \(app.name) (exclusive)") + } catch { + logger.error("Failed to auto-enable loopback for \(app.name): \(error)") + } + } else { + logger.debug("Skipping loopback for DAW: \(app.name)") + } + } + logger.debug("Created tap for \(app.name)") } catch { logger.error("Failed to create tap for \(app.name): \(error.localizedDescription)") diff --git a/FineTune/Audio/Engine/ProcessTapController.swift b/FineTune/Audio/Engine/ProcessTapController.swift index 394709cb..43aa1db1 100644 --- a/FineTune/Audio/Engine/ProcessTapController.swift +++ b/FineTune/Audio/Engine/ProcessTapController.swift @@ -118,6 +118,11 @@ final class ProcessTapController: ProcessTapControlling { private nonisolated(unsafe) var secondaryLoudnessCompensator: LoudnessCompensator? private nonisolated(unsafe) var secondaryLoudnessEqualizerProcessor: LoudnessEqualizer? + /// Loopback ring buffer for cross-process audio routing to virtual device. + /// When non-nil, processed audio is forked to shared memory in the IO callback. + /// Set from main thread; read from HAL I/O thread (pointer read is atomic on ARM64/x86-64). + private nonisolated(unsafe) var _loopbackBuffer: LoopbackRingBuffer? + // Target device UIDs for synchronized multi-output (first is clock source) private var targetDeviceUIDs: [String] // Current active device UIDs @@ -270,6 +275,18 @@ final class ProcessTapController: ProcessTapControlling { } } + // MARK: - Loopback + + /// Assigns or removes the loopback ring buffer for virtual device routing. + /// The audio callback will fork processed samples to shared memory when this is set. + /// + /// **Thread safety:** Pointer write is atomic on ARM64/x86-64. The IO callback + /// reads the pointer each cycle — if it sees non-nil, it writes. A brief race + /// where the old/new pointer is seen is harmless (one buffer of silence or duplicate). + func setLoopbackBuffer(_ buffer: LoopbackRingBuffer?) { + _loopbackBuffer = buffer + } + // MARK: - Multi-Device Aggregate Configuration /// Builds aggregate device description for synchronized multi-device output. @@ -1069,7 +1086,8 @@ final class ProcessTapController: ProcessTapControlling { eqProc: EQProcessor?, autoEQProc: AutoEQProcessor?, loudnessEqualizerProc: LoudnessEqualizer?, - loudnessCompensatorProc: LoudnessCompensator? + loudnessCompensatorProc: LoudnessCompensator?, + loopbackBuffer: LoopbackRingBuffer? ) { let inputBufferCount = inputBuffers.count let outputBufferCount = outputBuffers.count @@ -1209,6 +1227,13 @@ final class ProcessTapController: ProcessTapControlling { let writtenSampleCount = frameCount * outputChannels SoftLimiter.processBuffer(outputSamples, sampleCount: writtenSampleCount) + + // Fork processed audio to loopback virtual device (RT-safe: atomic + memcpy only). + // Only write from the first output buffer to avoid sending duplicate frames + // for multi-buffer device configurations (e.g., multi-channel aggregates). + if outputIndex == 0, let loopback = loopbackBuffer { + loopback.write(outputSamples, frameCount: frameCount, channels: outputChannels) + } } } @@ -1348,7 +1373,8 @@ final class ProcessTapController: ProcessTapControlling { eqProc: eqProc, autoEQProc: autoEQProc, loudnessEqualizerProc: loudnessEqualizerProc, - loudnessCompensatorProc: loudnessCompensatorProc + loudnessCompensatorProc: loudnessCompensatorProc, + loopbackBuffer: _loopbackBuffer ) if isPrimary { diff --git a/FineTune/Audio/Engine/ProcessTapControlling.swift b/FineTune/Audio/Engine/ProcessTapControlling.swift index 4c0b895a..96761d96 100644 --- a/FineTune/Audio/Engine/ProcessTapControlling.swift +++ b/FineTune/Audio/Engine/ProcessTapControlling.swift @@ -30,6 +30,12 @@ protocol ProcessTapControlling: AnyObject { var tapSourceDeviceUID: String? { get } func refreshTapSource(_ preferredDeviceUID: String?) async throws + + /// Assigns a loopback ring buffer for cross-process audio routing. + /// When set, the audio callback forks processed samples to shared memory + /// so the FineTuneLoopback HAL plugin can read them as an input device. + /// Pass nil to disconnect from loopback. + func setLoopbackBuffer(_ buffer: LoopbackRingBuffer?) } extension ProcessTapControlling { @@ -50,4 +56,8 @@ extension ProcessTapControlling { func refreshTapSource(_ preferredDeviceUID: String?) async throws { // Default no-op for mocks that don't override } + + func setLoopbackBuffer(_ buffer: LoopbackRingBuffer?) { + // Default no-op for mocks that don't override + } } diff --git a/FineTune/Audio/Loopback/LoopbackDeviceManager.swift b/FineTune/Audio/Loopback/LoopbackDeviceManager.swift new file mode 100644 index 00000000..9ace1da4 --- /dev/null +++ b/FineTune/Audio/Loopback/LoopbackDeviceManager.swift @@ -0,0 +1,238 @@ +// FineTune/Audio/Loopback/LoopbackDeviceManager.swift +// +// Manages the lifecycle of the FineTune Loopback virtual audio device system: +// - Checks if the HAL plugin driver is installed +// - Installs the driver (with admin privileges) +// - Creates/destroys the shared memory ring buffer +// - Tracks which apps are routing audio to loopback + +import Foundation +import os + +/// Path where CoreAudio HAL plugins are installed +private let kHALPluginDir = "/Library/Audio/Plug-Ins/HAL" +private let kDriverBundleName = "FineTuneLoopback.driver" +private let kDriverInstallPath = "\(kHALPluginDir)/\(kDriverBundleName)" + +@Observable +@MainActor +final class LoopbackDeviceManager { + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "FineTune", category: "LoopbackDeviceManager") + + // MARK: - Observable State + + /// Whether the HAL plugin driver is installed at /Library/Audio/Plug-Ins/HAL/ + private(set) var isDriverInstalled: Bool = false + + /// Whether the loopback system is currently active (ring buffer exists and is accepting audio) + private(set) var isActive: Bool = false + + /// PIDs of apps currently routing audio to loopback + private(set) var activeApps: Set = [] + + // MARK: - Internal State + + /// The shared memory ring buffer (created when loopback is enabled) + private var ringBuffer: LoopbackRingBuffer? + + /// Current configuration + private(set) var currentSampleRate: Float64 = 48000.0 + private(set) var currentChannels: UInt32 = 2 + + // MARK: - Init + + init() { + isDriverInstalled = checkDriverInstalled() + if isDriverInstalled { + logger.info("FineTune Loopback driver found at \(kDriverInstallPath)") + } else { + logger.info("FineTune Loopback driver not installed") + } + } + + // MARK: - Driver Installation + + /// Checks if the HAL plugin is installed at the expected path. + func checkDriverInstalled() -> Bool { + FileManager.default.fileExists(atPath: kDriverInstallPath) + } + + /// Installs the HAL plugin driver from the app bundle to /Library/Audio/Plug-Ins/HAL/. + /// Requires admin privileges — uses osascript to prompt for password. + /// + /// After installation, restarts coreaudiod so the HAL picks up the new plugin. + func installDriver() async throws { + // Find the driver bundle in our app resources + guard let driverSourceURL = Bundle.main.url(forResource: "FineTuneLoopback", withExtension: "driver") else { + logger.error("FineTuneLoopback.driver not found in app bundle") + throw LoopbackError.driverNotInstalled + } + + let sourcePath = driverSourceURL.path + let installPath = kDriverInstallPath + + logger.info("Installing driver from \(sourcePath) to \(installPath)") + + // Build the shell command for privileged installation + // 1. Remove old driver if present + // 2. Copy new driver + // 3. Set correct ownership + // 4. Restart coreaudiod to load the new plugin + let shellScript = """ + rm -rf '\(installPath)' && \ + cp -R '\(sourcePath)' '\(installPath)' && \ + chown -R root:wheel '\(installPath)' && \ + launchctl kickstart -kp system/com.apple.audio.coreaudiod + """ + + // Use osascript to get admin privileges + let script = "do shell script \"\(shellScript.replacingOccurrences(of: "\"", with: "\\\""))\" with administrator privileges" + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + + let pipe = Pipe() + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let errorData = pipe.fileHandleForReading.readDataToEndOfFile() + let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" + logger.error("Driver installation failed: \(errorString)") + throw LoopbackInstallError.installFailed(errorString) + } + + // Wait a moment for coreaudiod to restart and load the plugin + try await Task.sleep(for: .seconds(2)) + + self.isDriverInstalled = checkDriverInstalled() + if self.isDriverInstalled { + logger.info("Driver installed successfully") + } else { + logger.error("Driver installation verification failed — file not found after install") + throw LoopbackInstallError.verificationFailed + } + } + + /// Uninstalls the HAL plugin driver. + func uninstallDriver() async throws { + let installPath = kDriverInstallPath + + let shellScript = """ + rm -rf '\(installPath)' && \ + launchctl kickstart -kp system/com.apple.audio.coreaudiod + """ + + let script = "do shell script \"\(shellScript.replacingOccurrences(of: "\"", with: "\\\""))\" with administrator privileges" + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + + try process.run() + process.waitUntilExit() + + try await Task.sleep(for: .seconds(2)) + + self.isDriverInstalled = checkDriverInstalled() + logger.info("Driver uninstalled: \(!self.isDriverInstalled)") + } + + // MARK: - Loopback Lifecycle + + /// Enables the loopback system — creates the shared memory ring buffer. + /// Call this before routing any apps to loopback. + /// + /// - Parameters: + /// - sampleRate: Audio sample rate (default: 48000.0) + /// - channels: Number of channels (default: 2 for stereo) + /// - Returns: The ring buffer for assigning to ProcessTapControllers + @discardableResult + func enableLoopback(sampleRate: Float64 = 48000.0, channels: UInt32 = 2) throws -> LoopbackRingBuffer { + guard isDriverInstalled else { + throw LoopbackError.driverNotInstalled + } + + // Reuse existing buffer if configuration matches + if let existing = ringBuffer, + existing.sampleRate == sampleRate, + existing.channels == channels { + return existing + } + + // Tear down existing buffer if configuration changed + if ringBuffer != nil { + disableLoopback() + } + + let buffer = try LoopbackRingBuffer(sampleRate: sampleRate, channels: channels) + buffer.activate() + + ringBuffer = buffer + currentSampleRate = sampleRate + currentChannels = channels + isActive = true + + logger.info("Loopback enabled: \(sampleRate)Hz, \(channels)ch") + return buffer + } + + /// Disables the loopback system — destroys the shared memory ring buffer. + /// All apps routing to loopback will stop. + func disableLoopback() { + ringBuffer?.deactivate() + ringBuffer = nil + activeApps.removeAll() + isActive = false + logger.info("Loopback disabled") + } + + /// Gets the current ring buffer (for assigning to taps). + /// Returns nil if loopback is not enabled. + func getRingBuffer() -> LoopbackRingBuffer? { + ringBuffer + } + + // MARK: - App Tracking + + /// Registers an app as routing to loopback. + func addApp(_ pid: pid_t) { + activeApps.insert(pid) + logger.debug("App \(pid) added to loopback routing") + } + + /// Unregisters an app from loopback routing. + func removeApp(_ pid: pid_t) { + activeApps.remove(pid) + logger.debug("App \(pid) removed from loopback routing") + + // If no apps are using loopback, we keep the buffer alive + // but the HAL plugin will receive silence (no audio being written). + // The user can explicitly disable loopback in settings to tear down. + } + + /// Checks if an app is currently routing to loopback. + func isAppRouted(_ pid: pid_t) -> Bool { + activeApps.contains(pid) + } +} + +// MARK: - Installation Errors + +enum LoopbackInstallError: Error, LocalizedError { + case installFailed(String) + case verificationFailed + + var errorDescription: String? { + switch self { + case .installFailed(let detail): + return "Failed to install loopback driver: \(detail)" + case .verificationFailed: + return "Driver installation could not be verified" + } + } +} diff --git a/FineTune/Audio/Loopback/LoopbackRingBuffer.swift b/FineTune/Audio/Loopback/LoopbackRingBuffer.swift new file mode 100644 index 00000000..2b314c3c --- /dev/null +++ b/FineTune/Audio/Loopback/LoopbackRingBuffer.swift @@ -0,0 +1,273 @@ +// FineTune/Audio/Loopback/LoopbackRingBuffer.swift +// +// Lock-free SPSC ring buffer over POSIX shared memory for lossless +// cross-process audio transfer to the FineTuneLoopback HAL plugin. +// +// Threading model: +// - write() is called from CoreAudio's real-time HAL I/O thread (RT-safe) +// - The HAL plugin reads from the other side via SharedMemoryReader +// - Single producer, single consumer — no locks needed +// +// RT-SAFETY: write() must NEVER allocate, lock, log, or call ObjC. + +import Foundation +import Darwin +import os + +/// POSIX shared memory name — must match kFTLoopbackShmName in SharedTypes.h +private let kShmName = "/finetune_loopback" + +/// Mirror of FTLoopbackSharedHeader from SharedTypes.h. +/// Layout must be identical — any mismatch breaks the binary protocol. +struct FTLoopbackSharedHeader { + var writeHead: UInt64 // atomic, frames written (monotonic) + var readHead: UInt64 // atomic, frames read (monotonic) + var sampleRate: Float64 // e.g. 48000.0 + var channels: UInt32 // e.g. 2 + var isActive: UInt32 // 1 = connected, 0 = idle + var bufferFrames: UInt32 // ring buffer capacity in frames + var _padding: UInt32 // alignment +} + +/// Lock-free single-producer, single-consumer ring buffer backed by POSIX shared memory. +/// +/// The FineTune app acts as the producer (writes processed audio in the HAL I/O callback). +/// The FineTuneLoopback HAL plugin acts as the consumer (reads audio in its IO cycle). +/// +/// Data flows through shared memory with zero copies beyond the initial memcpy: +/// App audio callback → memcpy → shared memory → memcpy → HAL plugin IO +/// +/// The ring buffer uses monotonically increasing head counters (writeHead, readHead) +/// with modular indexing (`head % bufferFrames`) to avoid the full/empty ambiguity +/// problem. Available frames = writeHead - readHead. +final class LoopbackRingBuffer: @unchecked Sendable { + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "FineTune", category: "LoopbackRingBuffer") + + /// Mapped shared memory region + private var shmFD: Int32 = -1 + private var mappedBase: UnsafeMutableRawPointer? + private var mappedSize: Int = 0 + + /// Typed pointers into the mapped region (set during init, never change) + private var header: UnsafeMutablePointer? + private var audioData: UnsafeMutablePointer? + + /// Cached configuration (immutable after init) + let bufferFrames: UInt32 + let channels: UInt32 + let sampleRate: Float64 + + /// Whether this buffer is currently active (set by activate/deactivate) + private(set) var isActive: Bool = false + + /// Creates a new loopback ring buffer backed by POSIX shared memory. + /// + /// - Parameters: + /// - sampleRate: Audio sample rate (e.g. 48000.0) + /// - channels: Number of audio channels (e.g. 2 for stereo) + /// - bufferFrames: Ring buffer capacity in frames (default: 48000 = 1 second at 48kHz) + init(sampleRate: Float64 = 48000.0, channels: UInt32 = 2, bufferFrames: UInt32 = 48000) throws { + self.sampleRate = sampleRate + self.channels = channels + self.bufferFrames = bufferFrames + + let totalSize = MemoryLayout.size + + Int(bufferFrames) * Int(channels) * MemoryLayout.size + + // Always unlink any stale segment from a previous app run. + // coreaudiod (HAL driver) may still hold an old mapping which prevents + // ftruncate from resizing the segment (EINVAL). Unlinking removes the + // name; existing mappings stay valid until unmapped, but our new + // shm_open(O_CREAT) creates a brand-new segment. + shm_unlink(kShmName) // OK if it fails (segment doesn't exist) + + // Create fresh shared memory + // O_CREAT | O_RDWR: create new segment for read+write + // 0o666: readable/writable by all (HAL plugin runs as coreaudiod) + let fd = ft_shm_open(kShmName, O_CREAT | O_RDWR, mode_t(0o666)) + guard fd >= 0 else { + throw LoopbackError.shmOpenFailed(errno) + } + shmFD = fd + + // Set the size + guard ftruncate(fd, off_t(totalSize)) == 0 else { + let savedErrno = errno // Save before close/unlink overwrite it + Darwin.close(fd) + shmFD = -1 // Prevent double-close in deinit + shm_unlink(kShmName) + throw LoopbackError.ftruncateFailed(savedErrno) + } + + // Map into our address space + let mapped = mmap(nil, totalSize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0) + guard mapped != MAP_FAILED, let base = mapped else { + let savedErrno = errno + Darwin.close(fd) + shmFD = -1 + shm_unlink(kShmName) + throw LoopbackError.mmapFailed(savedErrno) + } + + mappedBase = base + mappedSize = totalSize + + // Set up typed pointers + header = base.assumingMemoryBound(to: FTLoopbackSharedHeader.self) + audioData = base.advanced(by: MemoryLayout.size) + .assumingMemoryBound(to: Float.self) + + // Initialize header + header!.pointee.writeHead = 0 + header!.pointee.readHead = 0 + header!.pointee.sampleRate = sampleRate + header!.pointee.channels = channels + header!.pointee.isActive = 0 + header!.pointee.bufferFrames = bufferFrames + header!.pointee._padding = 0 + + // Zero the audio buffer + memset(audioData!, 0, Int(bufferFrames) * Int(channels) * MemoryLayout.size) + + logger.info("Loopback ring buffer created: \(sampleRate)Hz, \(channels)ch, \(bufferFrames) frames (\(totalSize) bytes)") + } + + deinit { + deactivate() + + if let base = mappedBase { + munmap(base, mappedSize) + } + if shmFD >= 0 { + Darwin.close(shmFD) + shm_unlink(kShmName) + } + + logger.info("Loopback ring buffer destroyed") + } + + /// Marks the ring buffer as active. The HAL plugin will start reading audio. + func activate() { + guard let header else { return } + // Reset heads to start clean + header.pointee.writeHead = 0 + header.pointee.readHead = 0 + header.pointee.sampleRate = sampleRate + header.pointee.channels = channels + OSMemoryBarrier() // Ensure all writes are visible before setting active flag + header.pointee.isActive = 1 + isActive = true + logger.info("Loopback activated") + } + + /// Marks the ring buffer as inactive. The HAL plugin will output silence. + func deactivate() { + guard let header else { return } + header.pointee.isActive = 0 + OSMemoryBarrier() + isActive = false + logger.info("Loopback deactivated") + } + + // MARK: - RT-Safe Audio Write + + /// Writes audio frames to the ring buffer. + /// + /// **RT-SAFETY: This method is called from CoreAudio's real-time HAL I/O thread.** + /// It performs ONLY: + /// - Pointer arithmetic + /// - memcpy + /// - Atomic-width stores (UInt64 on ARM64/x86-64) + /// + /// It does NOT: allocate, lock, log, call ObjC, or perform I/O. + /// + /// - Parameters: + /// - samples: Pointer to interleaved Float32 audio samples + /// - frameCount: Number of frames to write + /// - channels: Number of channels per frame (must match buffer configuration) + @inline(__always) + func write(_ samples: UnsafePointer, frameCount: Int, channels channelCount: Int) { + guard let header, let audioData else { return } + + let bufFrames = Int(header.pointee.bufferFrames) + let chans = Int(header.pointee.channels) + guard bufFrames > 0, chans > 0 else { return } + + let currentWrite = header.pointee.writeHead + let currentRead = header.pointee.readHead + + // Check available space (prevent overwriting unread data) + let available = Int(Int64(bufFrames) - Int64(currentWrite - currentRead)) + let framesToWrite = min(frameCount, max(available, 0)) + guard framesToWrite > 0 else { return } + + let writePos = Int(currentWrite % UInt64(bufFrames)) + let samplesPerFrame = min(channelCount, chans) + + // Calculate how many frames fit before wrap-around + let framesBeforeWrap = bufFrames - writePos + let firstChunkFrames = min(framesToWrite, framesBeforeWrap) + let secondChunkFrames = framesToWrite - firstChunkFrames + + if channelCount == chans { + // Fast path: channel counts match, direct memcpy + let firstChunkSamples = firstChunkFrames * chans + memcpy( + audioData.advanced(by: writePos * chans), + samples, + firstChunkSamples * MemoryLayout.size + ) + + if secondChunkFrames > 0 { + let secondChunkSamples = secondChunkFrames * chans + memcpy( + audioData, + samples.advanced(by: firstChunkSamples), + secondChunkSamples * MemoryLayout.size + ) + } + } else { + // Slow path: channel count mismatch, copy per-frame + for frame in 0.. +#include + +/// Wrapper for shm_open since Swift cannot call C variadic functions. +static inline int ft_shm_open(const char *name, int oflag, mode_t mode) { + return shm_open(name, oflag, mode); +} + +#endif /* ShmHelper_h */ diff --git a/FineTune/FineTune-Bridging-Header.h b/FineTune/FineTune-Bridging-Header.h new file mode 100644 index 00000000..18c53d89 --- /dev/null +++ b/FineTune/FineTune-Bridging-Header.h @@ -0,0 +1,9 @@ +// +// FineTune-Bridging-Header.h +// FineTune +// +// Exposes C helpers to Swift that cannot be called directly +// (e.g., variadic C functions like shm_open). +// + +#import "Audio/Loopback/ShmHelper.h" diff --git a/FineTuneLoopback/FTLoopbackDriver.cpp b/FineTuneLoopback/FTLoopbackDriver.cpp new file mode 100644 index 00000000..6e3ab2ec --- /dev/null +++ b/FineTuneLoopback/FTLoopbackDriver.cpp @@ -0,0 +1,1242 @@ +// FineTuneLoopback/FTLoopbackDriver.cpp +// +// CoreAudio AudioServerPlugIn implementation for the FineTune Loopback virtual device. +// Creates a virtual input device that reads audio from POSIX shared memory. +// +// Architecture: +// - Static object model: PlugIn → Device → Stream (fixed IDs, no dynamic creation) +// - IO reads from shared memory ring buffer written by FineTune app +// - When FineTune is not connected, outputs silence +// +// Reference: Apple's NullAudio sample driver (simplified) + +#include "FTLoopbackDriver.h" +#include "SharedTypes.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// MARK: - Globals +// ============================================================================ + +static os_log_t sLog = NULL; + +// The host interface passed to us by CoreAudio +static AudioServerPlugInHostRef sHost = NULL; + +// Driver state +static std::atomic sRefCount{0}; +static Float64 sSampleRate = kFTDefaultSampleRate; +static UInt32 sBufferFrameSize = kFTDefaultBufferFrames; +static Float32 sVolumeLevel = 1.0f; +static bool sMuteState = false; + +// IO state (following BlackHole's proven pattern) +static UInt64 gDevice_IOIsRunning = 0; +static pthread_mutex_t gDevice_IOMutex = PTHREAD_MUTEX_INITIALIZER; + +// Zero timestamp tracking — period must be independent of IO buffer size +static const UInt32 kFTZeroTimeStampPeriod = 16384; +static Float64 gDevice_HostTicksPerFrame = 0.0; +static UInt64 gDevice_AnchorHostTime = 0; +static Float64 gDevice_PreviousTicks = 0.0; +static UInt64 gDevice_NumberTimeStamps = 0; + +// Shared memory +static int sShmFD = -1; +static FTLoopbackSharedHeader* sShmHeader = NULL; +static float* sShmAudioData = NULL; +static size_t sShmSize = 0; + +// Timing +static mach_timebase_info_data_t sTimebaseInfo = {0, 0}; + +static inline UInt64 HostTimeToNanos(UInt64 hostTime) { + if (sTimebaseInfo.denom == 0) mach_timebase_info(&sTimebaseInfo); + return hostTime * sTimebaseInfo.numer / sTimebaseInfo.denom; +} + +static inline UInt64 NanosToHostTime(UInt64 nanos) { + if (sTimebaseInfo.denom == 0) mach_timebase_info(&sTimebaseInfo); + return nanos * sTimebaseInfo.denom / sTimebaseInfo.numer; +} + +// ============================================================================ +// MARK: - Shared Memory Helpers +// ============================================================================ + +static void OpenSharedMemory() { + if (sShmHeader != NULL) return; // Already open + + int fd = shm_open(kFTLoopbackShmName, O_RDWR, 0); + if (fd < 0) { + // FineTune app hasn't created the shm yet — this is normal at startup + return; + } + + // Read the header first to get buffer dimensions + // We'll map the minimum header size first, then remap with full size + size_t headerSize = sizeof(FTLoopbackSharedHeader); + void* headerMap = mmap(NULL, headerSize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (headerMap == MAP_FAILED) { + close(fd); + return; + } + + FTLoopbackSharedHeader* hdr = (FTLoopbackSharedHeader*)headerMap; + uint32_t bufFrames = hdr->bufferFrames; + uint32_t channels = hdr->channels; + munmap(headerMap, headerSize); + + if (bufFrames == 0 || channels == 0) { + close(fd); + return; + } + + // Now map the full region + size_t fullSize = FTLoopbackShmSize(bufFrames, channels); + void* fullMap = mmap(NULL, fullSize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (fullMap == MAP_FAILED) { + close(fd); + return; + } + + sShmFD = fd; + sShmHeader = (FTLoopbackSharedHeader*)fullMap; + sShmAudioData = FTLoopbackAudioData(sShmHeader); + sShmSize = fullSize; + + os_log_info(sLog, "Shared memory opened: %u frames, %u channels, %.0f Hz", + bufFrames, channels, sShmHeader->sampleRate); +} + +static void CloseSharedMemory() { + if (sShmHeader != NULL) { + munmap(sShmHeader, sShmSize); + sShmHeader = NULL; + sShmAudioData = NULL; + sShmSize = 0; + } + if (sShmFD >= 0) { + close(sShmFD); + sShmFD = -1; + } +} + +/// Read frames from the shared memory ring buffer into the output buffer. +/// Returns the number of frames actually read (may be less than requested on underflow). +static UInt32 ReadFromRingBuffer(float* outBuffer, UInt32 framesToRead, UInt32 channels) { + if (sShmHeader == NULL || sShmAudioData == NULL) return 0; + + // Check if producer is active + uint32_t isActive = __atomic_load_n(&sShmHeader->isActive, __ATOMIC_ACQUIRE); + if (!isActive) return 0; + + uint32_t bufFrames = sShmHeader->bufferFrames; + uint32_t shmChannels = sShmHeader->channels; + if (bufFrames == 0 || shmChannels == 0) return 0; + + uint64_t writeHead = __atomic_load_n(&sShmHeader->writeHead, __ATOMIC_ACQUIRE); + uint64_t readHead = __atomic_load_n(&sShmHeader->readHead, __ATOMIC_RELAXED); + + // Available frames = writeHead - readHead + int64_t available = (int64_t)(writeHead - readHead); + if (available <= 0) return 0; + + UInt32 framesToCopy = (UInt32)((available < (int64_t)framesToRead) ? available : framesToRead); + UInt32 minChannels = (channels < shmChannels) ? channels : shmChannels; + + for (UInt32 frame = 0; frame < framesToCopy; frame++) { + UInt32 ringPos = (UInt32)((readHead + frame) % bufFrames); + UInt32 outPos = frame * channels; + UInt32 shmPos = ringPos * shmChannels; + + for (UInt32 ch = 0; ch < minChannels; ch++) { + outBuffer[outPos + ch] = sShmAudioData[shmPos + ch]; + } + // Zero extra output channels + for (UInt32 ch = minChannels; ch < channels; ch++) { + outBuffer[outPos + ch] = 0.0f; + } + } + + // Update read head + __atomic_store_n(&sShmHeader->readHead, readHead + framesToCopy, __ATOMIC_RELEASE); + + return framesToCopy; +} + +// ============================================================================ +// MARK: - AudioServerPlugInDriverInterface Implementation +// ============================================================================ + +// Forward declarations +static HRESULT FT_QueryInterface(void* inDriver, REFIID inUUID, LPVOID* outInterface); +static ULONG FT_AddRef(void* inDriver); +static ULONG FT_Release(void* inDriver); +static OSStatus FT_Initialize(AudioServerPlugInDriverRef inDriver, AudioServerPlugInHostRef inHost); +static OSStatus FT_CreateDevice(AudioServerPlugInDriverRef inDriver, CFDictionaryRef inDescription, + const AudioServerPlugInClientInfo* inClientInfo, AudioObjectID* outDeviceObjectID); +static OSStatus FT_DestroyDevice(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID); +static OSStatus FT_AddDeviceClient(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + const AudioServerPlugInClientInfo* inClientInfo); +static OSStatus FT_RemoveDeviceClient(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + const AudioServerPlugInClientInfo* inClientInfo); +static OSStatus FT_PerformDeviceConfigurationChange(AudioServerPlugInDriverRef inDriver, + AudioObjectID inDeviceObjectID, + UInt64 inChangeAction, void* inChangeInfo); +static OSStatus FT_AbortDeviceConfigurationChange(AudioServerPlugInDriverRef inDriver, + AudioObjectID inDeviceObjectID, + UInt64 inChangeAction, void* inChangeInfo); +static Boolean FT_HasProperty(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress); +static OSStatus FT_IsPropertySettable(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress, Boolean* outIsSettable); +static OSStatus FT_GetPropertyDataSize(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress, UInt32 inQualifierDataSize, + const void* inQualifierData, UInt32* outDataSize); +static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress, UInt32 inQualifierDataSize, + const void* inQualifierData, UInt32 inDataSize, UInt32* outDataSize, void* outData); +static OSStatus FT_SetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress, UInt32 inQualifierDataSize, + const void* inQualifierData, UInt32 inDataSize, const void* inData); +static OSStatus FT_StartIO(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID); +static OSStatus FT_StopIO(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID); +static OSStatus FT_GetZeroTimeStamp(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + UInt32 inClientID, + Float64* outSampleTime, UInt64* outHostTime, UInt64* outSeed); +static OSStatus FT_WillDoIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + UInt32 inClientID, UInt32 inOperationID, Boolean* outWillDo, + Boolean* outIsInput); +static OSStatus FT_BeginIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + UInt32 inClientID, UInt32 inOperationID, UInt32 inIOBufferFrameSize, + const AudioServerPlugInIOCycleInfo* inIOCycleInfo); +static OSStatus FT_DoIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + AudioObjectID inStreamObjectID, UInt32 inClientID, UInt32 inOperationID, + UInt32 inIOBufferFrameSize, const AudioServerPlugInIOCycleInfo* inIOCycleInfo, + void* ioMainBuffer, void* ioSecondaryBuffer); +static OSStatus FT_EndIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + UInt32 inClientID, UInt32 inOperationID, UInt32 inIOBufferFrameSize, + const AudioServerPlugInIOCycleInfo* inIOCycleInfo); + +// The vtable +static AudioServerPlugInDriverInterface sDriverInterface = { + NULL, // _reserved + FT_QueryInterface, + FT_AddRef, + FT_Release, + FT_Initialize, + FT_CreateDevice, + FT_DestroyDevice, + FT_AddDeviceClient, + FT_RemoveDeviceClient, + FT_PerformDeviceConfigurationChange, + FT_AbortDeviceConfigurationChange, + FT_HasProperty, + FT_IsPropertySettable, + FT_GetPropertyDataSize, + FT_GetPropertyData, + FT_SetPropertyData, + FT_StartIO, + FT_StopIO, + FT_GetZeroTimeStamp, + FT_WillDoIOOperation, + FT_BeginIOOperation, + FT_DoIOOperation, + FT_EndIOOperation, +}; + +static AudioServerPlugInDriverInterface* sDriverInterfacePtr = &sDriverInterface; + +// ============================================================================ +// MARK: - IUnknown +// ============================================================================ + +static HRESULT FT_QueryInterface(void* inDriver, REFIID inUUID, LPVOID* outInterface) { + // The UUIDs we need to match + CFUUIDRef requestedUUID = CFUUIDCreateFromUUIDBytes(NULL, inUUID); + + // IUnknown UUID + CFUUIDRef iunknownUUID = CFUUIDGetConstantUUIDWithBytes(NULL, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46); + + // AudioServerPlugInDriverInterface UUID (this is what coreaudiod queries with!) + CFUUIDRef driverInterfaceUUID = kAudioServerPlugInDriverInterfaceUUID; + + HRESULT result = E_NOINTERFACE; + + if (CFEqual(requestedUUID, iunknownUUID) || CFEqual(requestedUUID, driverInterfaceUUID)) { + FT_AddRef(inDriver); + *outInterface = &sDriverInterfacePtr; + result = S_OK; + } + + CFRelease(requestedUUID); + return result; +} + +static ULONG FT_AddRef(void* inDriver) { + return ++sRefCount; +} + +static ULONG FT_Release(void* inDriver) { + UInt32 count = --sRefCount; + if (count == 0) { + CloseSharedMemory(); + } + return count; +} + +// ============================================================================ +// MARK: - Initialization +// ============================================================================ + +static OSStatus FT_Initialize(AudioServerPlugInDriverRef inDriver, AudioServerPlugInHostRef inHost) { + sHost = inHost; + sLog = os_log_create("com.finetuneapp.FineTuneLoopback", "Driver"); + mach_timebase_info(&sTimebaseInfo); + + FILE* f = fopen("/tmp/ftloopback_init.txt", "w"); + if (f) { fprintf(f, "Initialize called. Host=%p\n", inHost); fclose(f); } + syslog(LOG_ERR, "FTLoopback: Initialize called! Driver is loaded."); + os_log_info(sLog, "FineTune Loopback driver initialized"); + return kAudioHardwareNoError; +} + +static OSStatus FT_CreateDevice(AudioServerPlugInDriverRef inDriver, CFDictionaryRef inDescription, + const AudioServerPlugInClientInfo* inClientInfo, AudioObjectID* outDeviceObjectID) { + // Our device is created statically — nothing to do + return kAudioHardwareUnsupportedOperationError; +} + +static OSStatus FT_DestroyDevice(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID) { + return kAudioHardwareUnsupportedOperationError; +} + +static OSStatus FT_AddDeviceClient(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + const AudioServerPlugInClientInfo* inClientInfo) { + return kAudioHardwareNoError; +} + +static OSStatus FT_RemoveDeviceClient(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + const AudioServerPlugInClientInfo* inClientInfo) { + return kAudioHardwareNoError; +} + +static OSStatus FT_PerformDeviceConfigurationChange(AudioServerPlugInDriverRef inDriver, + AudioObjectID inDeviceObjectID, + UInt64 inChangeAction, void* inChangeInfo) { + return kAudioHardwareNoError; +} + +static OSStatus FT_AbortDeviceConfigurationChange(AudioServerPlugInDriverRef inDriver, + AudioObjectID inDeviceObjectID, + UInt64 inChangeAction, void* inChangeInfo) { + return kAudioHardwareNoError; +} + +// ============================================================================ +// MARK: - Property Support: HasProperty +// ============================================================================ + +static Boolean FT_HasProperty(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress) { + switch (inObjectID) { + + // --- Plugin --- + case kFTObjectID_PlugIn: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + case kAudioObjectPropertyClass: + case kAudioObjectPropertyOwner: + case kAudioObjectPropertyOwnedObjects: + case kAudioObjectPropertyManufacturer: + case kAudioPlugInPropertyDeviceList: + case kAudioPlugInPropertyTranslateUIDToDevice: + case kAudioPlugInPropertyResourceBundle: + return true; + } + break; + + // --- Device --- + case kFTObjectID_Device: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + case kAudioObjectPropertyClass: + case kAudioObjectPropertyOwner: + case kAudioObjectPropertyOwnedObjects: + case kAudioObjectPropertyName: + case kAudioObjectPropertyManufacturer: + case kAudioDevicePropertyDeviceUID: + case kAudioDevicePropertyModelUID: + case kAudioDevicePropertyTransportType: + case kAudioDevicePropertyDeviceCanBeDefaultDevice: + case kAudioDevicePropertyDeviceCanBeDefaultSystemDevice: + case kAudioDevicePropertyStreams: + case kAudioDevicePropertyNominalSampleRate: + case kAudioDevicePropertyAvailableNominalSampleRates: + case kAudioDevicePropertyLatency: + case kAudioDevicePropertySafetyOffset: + case kAudioDevicePropertyClockDomain: + case kAudioDevicePropertyDeviceIsAlive: + case kAudioDevicePropertyDeviceIsRunning: + case kAudioDevicePropertyIsHidden: + case kAudioObjectPropertyElementName: + case kAudioDevicePropertyBufferFrameSize: + case kAudioDevicePropertyBufferFrameSizeRange: + case kAudioDevicePropertyZeroTimeStampPeriod: + case kAudioDevicePropertyIcon: + case kAudioDevicePropertyRelatedDevices: + case kAudioDevicePropertyClockIsStable: + case kAudioDevicePropertyClockAlgorithm: + case kAudioObjectPropertyControlList: + return true; + } + break; + + // --- Stream --- + case kFTObjectID_Stream: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + case kAudioObjectPropertyClass: + case kAudioObjectPropertyOwner: + case kAudioStreamPropertyDirection: + case kAudioStreamPropertyTerminalType: + case kAudioStreamPropertyStartingChannel: + case kAudioStreamPropertyLatency: + case kAudioStreamPropertyVirtualFormat: + case kAudioStreamPropertyPhysicalFormat: + case kAudioStreamPropertyAvailableVirtualFormats: + case kAudioStreamPropertyAvailablePhysicalFormats: + case kAudioStreamPropertyIsActive: + return true; + } + break; + + // --- Volume Control --- + case kFTObjectID_Volume: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + case kAudioObjectPropertyClass: + case kAudioObjectPropertyOwner: + case kAudioObjectPropertyElementName: + case kAudioLevelControlPropertyScalarValue: + case kAudioLevelControlPropertyDecibelValue: + case kAudioLevelControlPropertyDecibelRange: + case kAudioObjectPropertyScopeGlobal: + return true; + } + break; + } + + return false; +} + +// ============================================================================ +// MARK: - Property Support: IsPropertySettable +// ============================================================================ + +static OSStatus FT_IsPropertySettable(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress, Boolean* outIsSettable) { + *outIsSettable = false; + + switch (inObjectID) { + case kFTObjectID_Device: + switch (inAddress->mSelector) { + case kAudioDevicePropertyNominalSampleRate: + case kAudioDevicePropertyBufferFrameSize: + *outIsSettable = true; + break; + } + break; + + case kFTObjectID_Stream: + switch (inAddress->mSelector) { + case kAudioStreamPropertyVirtualFormat: + case kAudioStreamPropertyPhysicalFormat: + *outIsSettable = true; + break; + } + break; + + case kFTObjectID_Volume: + switch (inAddress->mSelector) { + case kAudioLevelControlPropertyScalarValue: + case kAudioLevelControlPropertyDecibelValue: + *outIsSettable = true; + break; + } + break; + } + + return kAudioHardwareNoError; +} + +// ============================================================================ +// MARK: - Property Support: GetPropertyDataSize +// ============================================================================ + +static OSStatus FT_GetPropertyDataSize(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress, UInt32 inQualifierDataSize, + const void* inQualifierData, UInt32* outDataSize) { + + switch (inObjectID) { + + // --- Plugin --- + case kFTObjectID_PlugIn: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + case kAudioObjectPropertyClass: + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + case kAudioObjectPropertyOwner: + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + case kAudioObjectPropertyOwnedObjects: + case kAudioPlugInPropertyDeviceList: + *outDataSize = sizeof(AudioObjectID); // 1 device + return kAudioHardwareNoError; + case kAudioObjectPropertyManufacturer: + case kAudioPlugInPropertyResourceBundle: + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + case kAudioPlugInPropertyTranslateUIDToDevice: + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + } + break; + + // --- Device --- + case kFTObjectID_Device: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + case kAudioObjectPropertyClass: + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + case kAudioObjectPropertyOwner: + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + case kAudioObjectPropertyOwnedObjects: + // stream + volume control + *outDataSize = 2 * sizeof(AudioObjectID); + return kAudioHardwareNoError; + case kAudioObjectPropertyName: + case kAudioObjectPropertyManufacturer: + case kAudioObjectPropertyElementName: + case kAudioDevicePropertyDeviceUID: + case kAudioDevicePropertyModelUID: + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + case kAudioDevicePropertyTransportType: + case kAudioDevicePropertyClockDomain: + case kAudioDevicePropertyLatency: + case kAudioDevicePropertySafetyOffset: + case kAudioDevicePropertyBufferFrameSize: + case kAudioDevicePropertyZeroTimeStampPeriod: + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + case kAudioDevicePropertyDeviceCanBeDefaultDevice: + case kAudioDevicePropertyDeviceCanBeDefaultSystemDevice: + case kAudioDevicePropertyDeviceIsAlive: + case kAudioDevicePropertyDeviceIsRunning: + case kAudioDevicePropertyIsHidden: + case kAudioDevicePropertyClockIsStable: + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + case kAudioDevicePropertyClockAlgorithm: + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + case kAudioDevicePropertyStreams: + *outDataSize = sizeof(AudioObjectID); // 1 stream + return kAudioHardwareNoError; + case kAudioDevicePropertyNominalSampleRate: + *outDataSize = sizeof(Float64); + return kAudioHardwareNoError; + case kAudioDevicePropertyAvailableNominalSampleRates: + *outDataSize = (UInt32)(kFTNumSampleRates * sizeof(AudioValueRange)); + return kAudioHardwareNoError; + case kAudioDevicePropertyBufferFrameSizeRange: + *outDataSize = sizeof(AudioValueRange); + return kAudioHardwareNoError; + case kAudioDevicePropertyIcon: + *outDataSize = sizeof(CFURLRef); + return kAudioHardwareNoError; + case kAudioDevicePropertyRelatedDevices: + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + case kAudioObjectPropertyControlList: + *outDataSize = sizeof(AudioObjectID); // 1 control (volume) + return kAudioHardwareNoError; + } + break; + + // --- Stream --- + case kFTObjectID_Stream: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + case kAudioObjectPropertyClass: + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + case kAudioObjectPropertyOwner: + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + case kAudioStreamPropertyDirection: + case kAudioStreamPropertyTerminalType: + case kAudioStreamPropertyStartingChannel: + case kAudioStreamPropertyLatency: + case kAudioStreamPropertyIsActive: + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + case kAudioStreamPropertyVirtualFormat: + case kAudioStreamPropertyPhysicalFormat: + *outDataSize = sizeof(AudioStreamBasicDescription); + return kAudioHardwareNoError; + case kAudioStreamPropertyAvailableVirtualFormats: + case kAudioStreamPropertyAvailablePhysicalFormats: + *outDataSize = (UInt32)(kFTNumSampleRates * sizeof(AudioStreamRangedDescription)); + return kAudioHardwareNoError; + } + break; + + // --- Volume --- + case kFTObjectID_Volume: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + case kAudioObjectPropertyClass: + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + case kAudioObjectPropertyOwner: + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + case kAudioObjectPropertyElementName: + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + case kAudioLevelControlPropertyScalarValue: + *outDataSize = sizeof(Float32); + return kAudioHardwareNoError; + case kAudioLevelControlPropertyDecibelValue: + *outDataSize = sizeof(Float32); + return kAudioHardwareNoError; + case kAudioLevelControlPropertyDecibelRange: + *outDataSize = sizeof(AudioValueRange); + return kAudioHardwareNoError; + } + break; + } + + return kAudioHardwareUnknownPropertyError; +} + +// ============================================================================ +// MARK: - Property Support: GetPropertyData +// ============================================================================ + +static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress, UInt32 inQualifierDataSize, + const void* inQualifierData, UInt32 inDataSize, UInt32* outDataSize, void* outData) { + + switch (inObjectID) { + + // ==== Plugin Properties ==== + case kFTObjectID_PlugIn: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + *((AudioClassID*)outData) = kAudioObjectClassID; + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyClass: + *((AudioClassID*)outData) = kAudioPlugInClassID; + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyOwner: + *((AudioObjectID*)outData) = kAudioObjectUnknown; + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyOwnedObjects: + case kAudioPlugInPropertyDeviceList: + *((AudioObjectID*)outData) = kFTObjectID_Device; + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyManufacturer: + *((CFStringRef*)outData) = CFSTR(kFTLoopbackManufacturer); + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + + case kAudioPlugInPropertyResourceBundle: + *((CFStringRef*)outData) = CFSTR(""); + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + + case kAudioPlugInPropertyTranslateUIDToDevice: { + CFStringRef uid = *((CFStringRef*)inQualifierData); + if (CFStringCompare(uid, CFSTR(kFTLoopbackDeviceUID), 0) == kCFCompareEqualTo) { + *((AudioObjectID*)outData) = kFTObjectID_Device; + } else { + *((AudioObjectID*)outData) = kAudioObjectUnknown; + } + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + } + } + break; + + // ==== Device Properties ==== + case kFTObjectID_Device: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + *((AudioClassID*)outData) = kAudioObjectClassID; + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyClass: + *((AudioClassID*)outData) = kAudioDeviceClassID; + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyOwner: + *((AudioObjectID*)outData) = kFTObjectID_PlugIn; + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyOwnedObjects: { + AudioObjectID* ids = (AudioObjectID*)outData; + UInt32 count = 0; + if (inAddress->mScope == kAudioObjectPropertyScopeGlobal || + inAddress->mScope == kAudioObjectPropertyScopeInput) { + ids[count++] = kFTObjectID_Stream; + } + if (inAddress->mScope == kAudioObjectPropertyScopeGlobal) { + ids[count++] = kFTObjectID_Volume; + } + *outDataSize = count * sizeof(AudioObjectID); + return kAudioHardwareNoError; + } + + case kAudioObjectPropertyName: + *((CFStringRef*)outData) = CFSTR(kFTLoopbackDeviceName); + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + + case kAudioObjectPropertyManufacturer: + *((CFStringRef*)outData) = CFSTR(kFTLoopbackManufacturer); + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + + case kAudioObjectPropertyElementName: + *((CFStringRef*)outData) = CFSTR(""); + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + + case kAudioDevicePropertyDeviceUID: + *((CFStringRef*)outData) = CFSTR(kFTLoopbackDeviceUID); + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + + case kAudioDevicePropertyModelUID: + *((CFStringRef*)outData) = CFSTR(kFTLoopbackDeviceModelUID); + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + + case kAudioDevicePropertyTransportType: + *((UInt32*)outData) = kAudioDeviceTransportTypeVirtual; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyDeviceCanBeDefaultDevice: + // Input device CAN be default input (so DAWs can find it) + *((UInt32*)outData) = (inAddress->mScope == kAudioObjectPropertyScopeInput) ? 1 : 0; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyDeviceCanBeDefaultSystemDevice: + *((UInt32*)outData) = 0; // Not suitable as system default + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyStreams: { + if (inAddress->mScope == kAudioObjectPropertyScopeInput || + inAddress->mScope == kAudioObjectPropertyScopeGlobal) { + *((AudioObjectID*)outData) = kFTObjectID_Stream; + *outDataSize = sizeof(AudioObjectID); + } else { + *outDataSize = 0; // No output streams + } + return kAudioHardwareNoError; + } + + case kAudioDevicePropertyNominalSampleRate: + *((Float64*)outData) = sSampleRate; + *outDataSize = sizeof(Float64); + return kAudioHardwareNoError; + + case kAudioDevicePropertyAvailableNominalSampleRates: { + AudioValueRange* ranges = (AudioValueRange*)outData; + for (UInt32 i = 0; i < kFTNumSampleRates; i++) { + ranges[i].mMinimum = kFTSupportedSampleRates[i]; + ranges[i].mMaximum = kFTSupportedSampleRates[i]; + } + *outDataSize = (UInt32)(kFTNumSampleRates * sizeof(AudioValueRange)); + return kAudioHardwareNoError; + } + + case kAudioDevicePropertyLatency: + *((UInt32*)outData) = 0; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertySafetyOffset: + *((UInt32*)outData) = 0; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyClockDomain: + *((UInt32*)outData) = kFTClockDomain; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyDeviceIsAlive: + *((UInt32*)outData) = 1; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyDeviceIsRunning: + *((UInt32*)outData) = (gDevice_IOIsRunning > 0) ? 1 : 0; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyIsHidden: + *((UInt32*)outData) = 0; // Visible in device pickers + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyClockIsStable: + *((UInt32*)outData) = 1; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyClockAlgorithm: + *((UInt32*)outData) = kAudioDeviceClockAlgorithmSimpleIIR; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyBufferFrameSize: + *((UInt32*)outData) = sBufferFrameSize; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyBufferFrameSizeRange: { + AudioValueRange* range = (AudioValueRange*)outData; + range->mMinimum = kFTMinBufferFrames; + range->mMaximum = kFTMaxBufferFrames; + *outDataSize = sizeof(AudioValueRange); + return kAudioHardwareNoError; + } + + case kAudioDevicePropertyZeroTimeStampPeriod: + *((UInt32*)outData) = kFTZeroTimeStampPeriod; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioDevicePropertyRelatedDevices: + *((AudioObjectID*)outData) = kFTObjectID_Device; + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + + case kAudioDevicePropertyIcon: + *((CFURLRef*)outData) = NULL; + *outDataSize = sizeof(CFURLRef); + return kAudioHardwareNoError; + + case kAudioObjectPropertyControlList: + *((AudioObjectID*)outData) = kFTObjectID_Volume; + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + } + break; + + // ==== Stream Properties ==== + case kFTObjectID_Stream: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + *((AudioClassID*)outData) = kAudioObjectClassID; + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyClass: + *((AudioClassID*)outData) = kAudioStreamClassID; + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyOwner: + *((AudioObjectID*)outData) = kFTObjectID_Device; + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + + case kAudioStreamPropertyDirection: + // 1 = input (this is an input stream — DAWs record FROM it) + *((UInt32*)outData) = 1; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioStreamPropertyTerminalType: + *((UInt32*)outData) = kAudioStreamTerminalTypeLine; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioStreamPropertyStartingChannel: + *((UInt32*)outData) = 1; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioStreamPropertyLatency: + *((UInt32*)outData) = 0; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioStreamPropertyIsActive: + *((UInt32*)outData) = 1; + *outDataSize = sizeof(UInt32); + return kAudioHardwareNoError; + + case kAudioStreamPropertyVirtualFormat: + case kAudioStreamPropertyPhysicalFormat: { + AudioStreamBasicDescription* desc = (AudioStreamBasicDescription*)outData; + desc->mSampleRate = sSampleRate; + desc->mFormatID = kAudioFormatLinearPCM; + desc->mFormatFlags = kAudioFormatFlagIsFloat | + kAudioFormatFlagsNativeEndian | + kAudioFormatFlagIsPacked; + desc->mBytesPerPacket = kFTChannelCount * sizeof(Float32); + desc->mFramesPerPacket = 1; + desc->mBytesPerFrame = kFTChannelCount * sizeof(Float32); + desc->mChannelsPerFrame = kFTChannelCount; + desc->mBitsPerChannel = 32; + *outDataSize = sizeof(AudioStreamBasicDescription); + return kAudioHardwareNoError; + } + + case kAudioStreamPropertyAvailableVirtualFormats: + case kAudioStreamPropertyAvailablePhysicalFormats: { + AudioStreamRangedDescription* descs = (AudioStreamRangedDescription*)outData; + for (UInt32 i = 0; i < kFTNumSampleRates; i++) { + descs[i].mFormat.mSampleRate = kFTSupportedSampleRates[i]; + descs[i].mFormat.mFormatID = kAudioFormatLinearPCM; + descs[i].mFormat.mFormatFlags = kAudioFormatFlagIsFloat | + kAudioFormatFlagsNativeEndian | + kAudioFormatFlagIsPacked; + descs[i].mFormat.mBytesPerPacket = kFTChannelCount * sizeof(Float32); + descs[i].mFormat.mFramesPerPacket = 1; + descs[i].mFormat.mBytesPerFrame = kFTChannelCount * sizeof(Float32); + descs[i].mFormat.mChannelsPerFrame = kFTChannelCount; + descs[i].mFormat.mBitsPerChannel = 32; + descs[i].mSampleRateRange.mMinimum = kFTSupportedSampleRates[i]; + descs[i].mSampleRateRange.mMaximum = kFTSupportedSampleRates[i]; + } + *outDataSize = (UInt32)(kFTNumSampleRates * sizeof(AudioStreamRangedDescription)); + return kAudioHardwareNoError; + } + } + break; + + // ==== Volume Control ==== + case kFTObjectID_Volume: + switch (inAddress->mSelector) { + case kAudioObjectPropertyBaseClass: + *((AudioClassID*)outData) = kAudioObjectClassID; + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyClass: + *((AudioClassID*)outData) = kAudioLevelControlClassID; + *outDataSize = sizeof(AudioClassID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyOwner: + *((AudioObjectID*)outData) = kFTObjectID_Device; + *outDataSize = sizeof(AudioObjectID); + return kAudioHardwareNoError; + + case kAudioObjectPropertyElementName: + *((CFStringRef*)outData) = CFSTR("Volume"); + *outDataSize = sizeof(CFStringRef); + return kAudioHardwareNoError; + + case kAudioLevelControlPropertyScalarValue: + *((Float32*)outData) = sVolumeLevel; + *outDataSize = sizeof(Float32); + return kAudioHardwareNoError; + + case kAudioLevelControlPropertyDecibelValue: + // Simple linear-to-dB: 20*log10(volume). Clamp to -96dB for zero. + *((Float32*)outData) = (sVolumeLevel > 0.0001f) + ? (Float32)(20.0 * log10((double)sVolumeLevel)) + : -96.0f; + *outDataSize = sizeof(Float32); + return kAudioHardwareNoError; + + case kAudioLevelControlPropertyDecibelRange: { + AudioValueRange* range = (AudioValueRange*)outData; + range->mMinimum = -96.0; + range->mMaximum = 0.0; + *outDataSize = sizeof(AudioValueRange); + return kAudioHardwareNoError; + } + } + break; + } + + return kAudioHardwareUnknownPropertyError; +} + +// ============================================================================ +// MARK: - Property Support: SetPropertyData +// ============================================================================ + +static OSStatus FT_SetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObjectID inObjectID, pid_t inClientPID, + const AudioObjectPropertyAddress* inAddress, UInt32 inQualifierDataSize, + const void* inQualifierData, UInt32 inDataSize, const void* inData) { + + switch (inObjectID) { + case kFTObjectID_Device: + switch (inAddress->mSelector) { + case kAudioDevicePropertyNominalSampleRate: { + Float64 newRate = *((const Float64*)inData); + // Validate it's a supported rate + bool valid = false; + for (UInt32 i = 0; i < kFTNumSampleRates; i++) { + if (kFTSupportedSampleRates[i] == newRate) { valid = true; break; } + } + if (!valid) return kAudioHardwareIllegalOperationError; + sSampleRate = newRate; + os_log_info(sLog, "Sample rate changed to %.0f", newRate); + return kAudioHardwareNoError; + } + + case kAudioDevicePropertyBufferFrameSize: { + UInt32 newSize = *((const UInt32*)inData); + if (newSize < kFTMinBufferFrames || newSize > kFTMaxBufferFrames) + return kAudioHardwareIllegalOperationError; + sBufferFrameSize = newSize; + os_log_info(sLog, "Buffer frame size changed to %u", newSize); + return kAudioHardwareNoError; + } + } + break; + + case kFTObjectID_Stream: + switch (inAddress->mSelector) { + case kAudioStreamPropertyVirtualFormat: + case kAudioStreamPropertyPhysicalFormat: { + const AudioStreamBasicDescription* desc = (const AudioStreamBasicDescription*)inData; + // Only allow changing sample rate, everything else is fixed + bool validRate = false; + for (UInt32 i = 0; i < kFTNumSampleRates; i++) { + if (kFTSupportedSampleRates[i] == desc->mSampleRate) { validRate = true; break; } + } + if (!validRate) return kAudioHardwareIllegalOperationError; + sSampleRate = desc->mSampleRate; + return kAudioHardwareNoError; + } + } + break; + + case kFTObjectID_Volume: + switch (inAddress->mSelector) { + case kAudioLevelControlPropertyScalarValue: { + Float32 val = *((const Float32*)inData); + sVolumeLevel = (val < 0.0f) ? 0.0f : ((val > 1.0f) ? 1.0f : val); + return kAudioHardwareNoError; + } + case kAudioLevelControlPropertyDecibelValue: { + Float32 dB = *((const Float32*)inData); + sVolumeLevel = (Float32)pow(10.0, (double)dB / 20.0); + if (sVolumeLevel > 1.0f) sVolumeLevel = 1.0f; + if (sVolumeLevel < 0.0f) sVolumeLevel = 0.0f; + return kAudioHardwareNoError; + } + } + break; + } + + return kAudioHardwareUnknownPropertyError; +} + +// ============================================================================ +// MARK: - IO Operations +// ============================================================================ + +static OSStatus FT_StartIO(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID) { + os_log_info(sLog, "StartIO: client=%u", inClientID); + + // Try to open shared memory when IO starts + OpenSharedMemory(); + + pthread_mutex_lock(&gDevice_IOMutex); + + if (gDevice_IOIsRunning == 0) { + // First client starting IO — initialize timing anchor + // Compute host ticks per frame from the host clock frequency + Float64 theHostClockFrequency = 0.0; + mach_timebase_info_data_t tbInfo; + mach_timebase_info(&tbInfo); + theHostClockFrequency = (Float64)tbInfo.denom / (Float64)tbInfo.numer * 1000000000.0; + gDevice_HostTicksPerFrame = theHostClockFrequency / sSampleRate; + + gDevice_NumberTimeStamps = 0; + gDevice_AnchorHostTime = mach_absolute_time(); + gDevice_PreviousTicks = 0.0; + } + + gDevice_IOIsRunning += 1; + + pthread_mutex_unlock(&gDevice_IOMutex); + return kAudioHardwareNoError; +} + +static OSStatus FT_StopIO(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID) { + os_log_info(sLog, "StopIO: client=%u", inClientID); + + pthread_mutex_lock(&gDevice_IOMutex); + if (gDevice_IOIsRunning > 0) { + gDevice_IOIsRunning -= 1; + } + pthread_mutex_unlock(&gDevice_IOMutex); + + return kAudioHardwareNoError; +} + +static OSStatus FT_GetZeroTimeStamp(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + UInt32 inClientID, + Float64* outSampleTime, UInt64* outHostTime, UInt64* outSeed) { + // Following BlackHole's proven pattern: + // The zero time stamps are spaced kFTZeroTimeStampPeriod frames apart. + // We only advance the counter when the next timestamp's host time has passed. + + pthread_mutex_lock(&gDevice_IOMutex); + + UInt64 theCurrentHostTime = mach_absolute_time(); + + // Calculate host ticks for one zero-timestamp period + Float64 theHostTicksPerPeriod = gDevice_HostTicksPerFrame * (Float64)kFTZeroTimeStampPeriod; + + // Calculate the next timestamp offset + Float64 theNextTickOffset = gDevice_PreviousTicks + theHostTicksPerPeriod; + UInt64 theNextHostTime = gDevice_AnchorHostTime + (UInt64)theNextTickOffset; + + // Advance the counter if the next timestamp is in the past + if (theNextHostTime <= theCurrentHostTime) { + ++gDevice_NumberTimeStamps; + gDevice_PreviousTicks = theNextTickOffset; + } + + // Set the return values + *outSampleTime = (Float64)(gDevice_NumberTimeStamps * kFTZeroTimeStampPeriod); + *outHostTime = gDevice_AnchorHostTime + (UInt64)gDevice_PreviousTicks; + *outSeed = 1; + + pthread_mutex_unlock(&gDevice_IOMutex); + + return kAudioHardwareNoError; +} + +static OSStatus FT_WillDoIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + UInt32 inClientID, UInt32 inOperationID, Boolean* outWillDo, + Boolean* outWillDoInPlace) { + // We only do ReadInput (in place) + switch (inOperationID) { + case kAudioServerPlugInIOOperationReadInput: + *outWillDo = true; + *outWillDoInPlace = true; + break; + default: + *outWillDo = false; + *outWillDoInPlace = true; + break; + } + return kAudioHardwareNoError; +} + +static OSStatus FT_BeginIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + UInt32 inClientID, UInt32 inOperationID, UInt32 inIOBufferFrameSize, + const AudioServerPlugInIOCycleInfo* inIOCycleInfo) { + return kAudioHardwareNoError; +} + +static OSStatus FT_DoIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + AudioObjectID inStreamObjectID, UInt32 inClientID, UInt32 inOperationID, + UInt32 inIOBufferFrameSize, const AudioServerPlugInIOCycleInfo* inIOCycleInfo, + void* ioMainBuffer, void* ioSecondaryBuffer) { + + if (inOperationID != kAudioServerPlugInIOOperationReadInput) { + return kAudioHardwareNoError; + } + + float* outBuffer = (float*)ioMainBuffer; + UInt32 totalSamples = inIOBufferFrameSize * kFTChannelCount; + + // SAFETY: Always zero the entire buffer first to guarantee silence + // if anything goes wrong below. + memset(outBuffer, 0, totalSamples * sizeof(float)); + + // Try to connect to shared memory if not already + if (sShmHeader == NULL) { + OpenSharedMemory(); + } + + // Read from ring buffer (overwrites zeroed buffer with real audio if available) + UInt32 framesRead = ReadFromRingBuffer(outBuffer, inIOBufferFrameSize, kFTChannelCount); + + // Apply volume (only if we got real data and volume is reduced) + if (framesRead > 0 && sVolumeLevel < 0.999f) { + UInt32 samplesToScale = framesRead * kFTChannelCount; + for (UInt32 i = 0; i < samplesToScale; i++) { + outBuffer[i] *= sVolumeLevel; + } + } + + return kAudioHardwareNoError; +} + +static OSStatus FT_EndIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, + UInt32 inClientID, UInt32 inOperationID, UInt32 inIOBufferFrameSize, + const AudioServerPlugInIOCycleInfo* inIOCycleInfo) { + return kAudioHardwareNoError; +} + +// ============================================================================ +// MARK: - Plugin Factory (Entry Point) +// ============================================================================ + +extern "C" void* FTLoopbackDriverFactory(CFAllocatorRef allocator, CFUUIDRef requestedTypeUUID) { + // Verify this is the AudioServerPlugIn type + CFUUIDRef pluginTypeUUID = CFUUIDCreateFromString(NULL, CFSTR("443ABAB8-E7B3-491A-B985-BEB9187030DB")); + if (!CFEqual(requestedTypeUUID, pluginTypeUUID)) { + CFRelease(pluginTypeUUID); + return NULL; + } + CFRelease(pluginTypeUUID); + + syslog(LOG_ERR, "FTLoopback: Factory called successfully, returning driver interface"); + // Debug: write a marker file to confirm factory was called + FILE* f = fopen("/tmp/ftloopback_factory_called.txt", "w"); + if (f) { fprintf(f, "Factory called at %lu\n", (unsigned long)time(NULL)); fclose(f); } + FT_AddRef(NULL); + return &sDriverInterfacePtr; +} diff --git a/FineTuneLoopback/FTLoopbackDriver.h b/FineTuneLoopback/FTLoopbackDriver.h new file mode 100644 index 00000000..a2f2c5ac --- /dev/null +++ b/FineTuneLoopback/FTLoopbackDriver.h @@ -0,0 +1,48 @@ +// FineTuneLoopback/FTLoopbackDriver.h +// +// CoreAudio AudioServerPlugIn driver for the FineTune Loopback virtual audio device. +// This creates a virtual input device that reads audio from POSIX shared memory +// written by the FineTune app's audio callback. + +#ifndef FTLOOPBACK_DRIVER_H +#define FTLOOPBACK_DRIVER_H + +#include +#include + +// Plugin UUID (factory): 5A8B4F1E-3C9D-4E2A-B7F6-8D1E0A2C4B6E +#define kFTLoopbackPluginFactoryUUID \ + CFUUIDGetConstantUUIDWithBytes(NULL, \ + 0x5A, 0x8B, 0x4F, 0x1E, 0x3C, 0x9D, 0x4E, 0x2A, \ + 0xB7, 0xF6, 0x8D, 0x1E, 0x0A, 0x2C, 0x4B, 0x6E) + +// Object IDs — fixed static layout +enum { + kFTObjectID_PlugIn = kAudioObjectPlugInObject, // 1 (required by HAL) + kFTObjectID_Device = 2, + kFTObjectID_Stream = 3, + // Volume control on the virtual device (optional, useful for DAW level) + kFTObjectID_Volume = 4, +}; + +// Device constants +#define kFTLoopbackDeviceUID "com.finetuneapp.loopback" +#define kFTLoopbackDeviceModelUID "FTLoopbackModel" +#define kFTLoopbackDeviceName "FineTune Loopback" +#define kFTLoopbackManufacturer "FineTune" + +// Supported sample rates +static const Float64 kFTSupportedSampleRates[] = { 44100.0, 48000.0, 96000.0 }; +#define kFTNumSampleRates (sizeof(kFTSupportedSampleRates) / sizeof(Float64)) + +// Default configuration +#define kFTDefaultSampleRate 44100.0 +#define kFTDefaultBufferFrames 512 +#define kFTMinBufferFrames 64 +#define kFTMaxBufferFrames 4096 +#define kFTChannelCount 2 + +// Custom clock domain (non-zero, non-default) +#define kFTClockDomain 0xF17E7001 + +#endif // FTLOOPBACK_DRIVER_H diff --git a/FineTuneLoopback/Info.plist b/FineTuneLoopback/Info.plist new file mode 100644 index 00000000..fcf114b0 --- /dev/null +++ b/FineTuneLoopback/Info.plist @@ -0,0 +1,49 @@ + + + + + AudioServerPlugIn_LoadingConditions + + IOService Matching + + + IOProviderClass + IOPlatformExpertDevice + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + FineTuneLoopback + CFBundleIdentifier + com.finetuneapp.FineTuneLoopback + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + FineTune Loopback + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + CFPlugInFactories + + + 5A8B4F1E-3C9D-4E2A-B7F6-8D1E0A2C4B6E + FTLoopbackDriverFactory + + CFPlugInTypes + + + 443ABAB8-E7B3-491A-B985-BEB9187030DB + + + 5A8B4F1E-3C9D-4E2A-B7F6-8D1E0A2C4B6E + + + LSMinimumSystemVersion + 14.0 + + diff --git a/FineTuneLoopback/SharedTypes.h b/FineTuneLoopback/SharedTypes.h new file mode 100644 index 00000000..7ee3318b --- /dev/null +++ b/FineTuneLoopback/SharedTypes.h @@ -0,0 +1,90 @@ +// FineTuneLoopback/SharedTypes.h +// Shared memory layout for FineTune ↔ HAL plugin audio transfer. +// +// This header defines the binary protocol between the FineTune app (producer) +// and the FineTuneLoopback HAL plugin (consumer). Both sides must agree on +// this layout exactly — any mismatch causes silent corruption or crashes. +// +// Threading model: +// Producer (FineTune audio callback): writes audio data, updates writeHead +// Consumer (HAL plugin IO thread): reads audio data, updates readHead +// Single-producer, single-consumer — no locks needed, only atomics on heads. + +#ifndef FINETUNE_LOOPBACK_SHARED_TYPES_H +#define FINETUNE_LOOPBACK_SHARED_TYPES_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// POSIX shared memory name (used with shm_open) +#define kFTLoopbackShmName "/finetune_loopback" + +// Default configuration +#define kFTLoopbackDefaultBufferFrames 48000 // 1 second at 48kHz +#define kFTLoopbackDefaultChannels 2 +#define kFTLoopbackDefaultSampleRate 48000.0 + +/// Shared memory header — sits at the start of the mapped region. +/// All fields are naturally aligned for atomic access on ARM64/x86-64. +/// +/// Memory layout: +/// [FTLoopbackSharedHeader] (48 bytes) +/// [float audio data] (bufferFrames * channels * sizeof(float)) +typedef struct { + /// Monotonically increasing frame counter written by the producer. + /// Consumer reads this atomically to determine available frames. + /// Ring buffer write position = writeHead % bufferFrames + volatile uint64_t writeHead; // offset 0 + + /// Monotonically increasing frame counter written by the consumer. + /// Producer reads this atomically to determine free space. + /// Ring buffer read position = readHead % bufferFrames + volatile uint64_t readHead; // offset 8 + + /// Sample rate of the audio data (e.g. 44100.0, 48000.0, 96000.0). + /// Set by producer when activating, read by consumer to configure streams. + volatile double sampleRate; // offset 16 + + /// Number of audio channels (e.g. 2 for stereo). + /// Set by producer when activating. + volatile uint32_t channels; // offset 24 + + /// 1 = producer is connected and writing audio, 0 = idle/disconnected. + /// Consumer outputs silence when this is 0. + volatile uint32_t isActive; // offset 28 + + /// Total capacity of the ring buffer in frames. + /// Set by producer on creation, immutable after that. + volatile uint32_t bufferFrames; // offset 32 + + /// Reserved for future use, ensures 8-byte alignment of audio data. + uint32_t _padding; // offset 36 +} FTLoopbackSharedHeader; // total: 40 bytes + +// Compile-time size check +_Static_assert(sizeof(FTLoopbackSharedHeader) == 40, + "FTLoopbackSharedHeader size mismatch — binary protocol broken"); + +/// Returns the total shared memory size needed for a given configuration. +static inline size_t FTLoopbackShmSize(uint32_t bufferFrames, uint32_t channels) { + return sizeof(FTLoopbackSharedHeader) + (size_t)bufferFrames * channels * sizeof(float); +} + +/// Returns a pointer to the start of the audio ring buffer data. +static inline float* FTLoopbackAudioData(FTLoopbackSharedHeader* header) { + return (float*)((uint8_t*)header + sizeof(FTLoopbackSharedHeader)); +} + +/// Returns a const pointer to the start of the audio ring buffer data. +static inline const float* FTLoopbackAudioDataConst(const FTLoopbackSharedHeader* header) { + return (const float*)((const uint8_t*)header + sizeof(FTLoopbackSharedHeader)); +} + +#ifdef __cplusplus +} +#endif + +#endif // FINETUNE_LOOPBACK_SHARED_TYPES_H From 125ca95e8e7616e69e782cdb481b0ee69270e0e2 Mon Sep 17 00:00:00 2001 From: Alex Hohnhorst Date: Tue, 26 May 2026 23:09:01 +0200 Subject: [PATCH 2/3] Enhance FineTune Loopback Driver and Add Diagnostic Script - Updated FTLoopbackDriver.h to support bidirectional audio streaming, allowing applications to output to and record from the virtual device. - Changed default buffer size from 512 to 256 frames for improved performance. - Updated Info.plist to reflect version change from 1.0 to 2.0. - Modified SharedTypes.h to align default buffer frames and sample rate with driver settings, and added a new field for tracking the most recent write time. - Introduced diagnose_audio.sh script for real-time monitoring of audio issues, capturing relevant log messages for troubleshooting. --- FineTune/Audio/Engine/AudioEngine.swift | 69 ++- .../Audio/Engine/ProcessTapController.swift | 44 ++ .../Loopback/LoopbackDeviceManager.swift | 214 ++++++++ .../Audio/Loopback/LoopbackRingBuffer.swift | 4 + FineTune/FineTuneApp.swift | 24 +- FineTune/Settings/SettingsManager.swift | 6 + FineTune/Views/Settings/Tabs/AudioTab.swift | 84 +++ FineTuneLoopback/FTLoopbackDriver.cpp | 510 ++++++++++++------ FineTuneLoopback/FTLoopbackDriver.h | 16 +- FineTuneLoopback/Info.plist | 4 +- FineTuneLoopback/SharedTypes.h | 16 +- diagnose_audio.sh | 25 + 12 files changed, 814 insertions(+), 202 deletions(-) create mode 100644 diagnose_audio.sh diff --git a/FineTune/Audio/Engine/AudioEngine.swift b/FineTune/Audio/Engine/AudioEngine.swift index 385e238c..581e0478 100644 --- a/FineTune/Audio/Engine/AudioEngine.swift +++ b/FineTune/Audio/Engine/AudioEngine.swift @@ -7,6 +7,20 @@ import UserNotifications @Observable @MainActor final class AudioEngine { + + /// Known DAW bundle-ID substrings. Used to skip loopback routing for apps that + /// read from the loopback device (prevents feedback loops). + private static let dawBundleIDSubstrings: Set = [ + "ableton", + "apple.logic", + "steinberg", + "bitwig", + "reaper", + "image-line" // FL Studio + ] + + /// Default sample rate for loopback ring buffers. Must match enableLoopback()'s default. + private static let kDefaultLoopbackSampleRate: Float64 = 48000.0 let processMonitor: any AudioProcessMonitoring let deviceMonitor: any AudioDeviceProviding let bluetoothDeviceMonitor: BluetoothDeviceMonitor @@ -679,11 +693,44 @@ final class AudioEngine { loopbackManager.isAppRouted(app.id) } + /// Reassigns loopback to the best available non-DAW app after the current + /// loopback source is removed. Called when a loopback-routed app quits. + private func reassignLoopback() { + guard loopbackManager.isDriverInstalled, loopbackManager.activeApps.isEmpty else { return } + + for (pid, tap) in taps { + let bundleID = (tap.app.bundleID ?? "").lowercased() + let isDAW = Self.dawBundleIDSubstrings.contains(where: { bundleID.contains($0) }) + if !isDAW { + do { + let buffer = try loopbackManager.enableLoopback( + sampleRate: Self.kDefaultLoopbackSampleRate, + channels: 2 + ) + tap.setLoopbackBuffer(buffer) + loopbackManager.addApp(pid) + logger.info("Loopback reassigned to \(tap.app.name)") + return + } catch { + logger.error("Failed to reassign loopback to \(tap.app.name): \(error)") + } + } + } + logger.debug("No eligible app found for loopback reassignment") + } + // MARK: - Settings Reset /// Resets all persisted settings and synchronizes in-memory engine state. /// Active taps are kept alive but reverted to defaults (unity volume, unmuted, flat EQ). func handleSettingsReset() { + // 0. Restore output device before resetting settings + if loopbackManager.isLosslessRecordingActive { + loopbackManager.disableLosslessRecording( + restoreDeviceUID: settingsManager.appSettings.previousOutputDeviceUID + ) + } + // 1. Clear persisted state settingsManager.resetAllSettings() @@ -1289,22 +1336,19 @@ final class AudioEngine { // loopback device, so writing their output back creates distortion). if loopbackManager.isDriverInstalled && loopbackManager.activeApps.isEmpty { let bundleID = (app.bundleID ?? "").lowercased() - let isDAW = bundleID.contains("ableton") || - bundleID.contains("apple.logic") || - bundleID.contains("steinberg") || - bundleID.contains("bitwig") || - bundleID.contains("reaper") || - bundleID.contains("image-line") // FL Studio + let isDAW = Self.dawBundleIDSubstrings.contains(where: { bundleID.contains($0) }) if !isDAW { do { let buffer = try loopbackManager.enableLoopback( - sampleRate: Float64(44100.0), // Match common macOS sample rate + sampleRate: Self.kDefaultLoopbackSampleRate, channels: 2 ) tap.setLoopbackBuffer(buffer) loopbackManager.addApp(app.id) logger.info("Loopback auto-enabled for \(app.name) (exclusive)") } catch { + // TODO: M10 — Surface this error to the user via notification or UI state + // so they know loopback auto-enable failed (e.g. shared memory exhaustion). logger.error("Failed to auto-enable loopback for \(app.name): \(error)") } } else { @@ -1927,9 +1971,20 @@ final class AudioEngine { // Now safe to cleanup if let tap = self.taps.removeValue(forKey: pid) { + tap.setLoopbackBuffer(nil) tap.invalidate() self.logger.debug("Cleaned up stale tap for PID \(pid)") } + // Clean up loopback routing for this app + if self.loopbackManager.isAppRouted(pid) { + self.loopbackManager.removeApp(pid) + if self.loopbackManager.activeApps.isEmpty { + self.loopbackManager.disableLoopback() + self.logger.debug("Loopback disabled after stale app cleanup") + // Re-enable loopback for an existing non-DAW tap + self.reassignLoopback() + } + } self.appDeviceRouting.removeValue(forKey: pid) self.followsDefault.remove(pid) self.appliedPIDs.remove(pid) // Allow re-initialization if app resumes diff --git a/FineTune/Audio/Engine/ProcessTapController.swift b/FineTune/Audio/Engine/ProcessTapController.swift index 43aa1db1..e61b072e 100644 --- a/FineTune/Audio/Engine/ProcessTapController.swift +++ b/FineTune/Audio/Engine/ProcessTapController.swift @@ -457,6 +457,30 @@ final class ProcessTapController: ProcessTapControlling { throw NSError(domain: "ProcessTapController", code: -1, userInfo: [NSLocalizedDescriptionKey: "Aggregate device not ready within timeout"]) } + // Force aggregate device buffer size to 2048 frames (~46ms at 44.1kHz). + // Without this, macOS negotiates the buffer to the smallest sub-device + // value (often 128 frames = 2.9ms), which causes client timeout overloads + // because rekordbox/Ableton can't complete their IO callback in 2.9ms + // under any CPU pressure. + var preferredBufferSize: UInt32 = 2048 + var bufferSizeAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyBufferFrameSize, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let setErr = AudioObjectSetPropertyData( + aggID, + &bufferSizeAddress, + 0, nil, + UInt32(MemoryLayout.size), + &preferredBufferSize + ) + if setErr != noErr { + logger.warning("Failed to set aggregate buffer size to \(preferredBufferSize): \(setErr)") + } else { + logger.info("Set aggregate buffer size to \(preferredBufferSize) frames") + } + logger.debug("Created aggregate device #\(self.primaryResources.aggregateDeviceID)") // Compute ramp coefficient from actual device sample rate. @@ -809,6 +833,16 @@ final class ProcessTapController: ProcessTapControlling { throw CrossfadeError.deviceNotReady } + // Force buffer size on secondary aggregate too (same reason as primary) + var secBufferSize: UInt32 = 2048 + var secBufAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyBufferFrameSize, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectSetPropertyData(aggID, &secBufAddress, 0, nil, + UInt32(MemoryLayout.size), &secBufferSize) + logger.debug("[CROSSFADE] Created secondary aggregate #\(self.secondaryResources.aggregateDeviceID)") let sampleRate: Double @@ -1020,6 +1054,16 @@ final class ProcessTapController: ProcessTapControlling { throw CrossfadeError.deviceNotReady } + // Force buffer size (same reason as primary) + var switchBufSize: UInt32 = 2048 + var switchBufAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyBufferFrameSize, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectSetPropertyData(aggID, &switchBufAddr, 0, nil, + UInt32(MemoryLayout.size), &switchBufSize) + nextCallbackID += 1 _primaryCallbackID = nextCallbackID let switchCallbackID = nextCallbackID diff --git a/FineTune/Audio/Loopback/LoopbackDeviceManager.swift b/FineTune/Audio/Loopback/LoopbackDeviceManager.swift index 9ace1da4..bc16ff03 100644 --- a/FineTune/Audio/Loopback/LoopbackDeviceManager.swift +++ b/FineTune/Audio/Loopback/LoopbackDeviceManager.swift @@ -7,6 +7,7 @@ // - Tracks which apps are routing audio to loopback import Foundation +import AudioToolbox import os /// Path where CoreAudio HAL plugins are installed @@ -97,6 +98,8 @@ final class LoopbackDeviceManager { process.standardError = pipe try process.run() + // TODO: M12 — Replace waitUntilExit() with async continuation using process.terminationHandler + // to avoid blocking the main thread during driver installation. process.waitUntilExit() if process.terminationStatus != 0 { @@ -134,8 +137,16 @@ final class LoopbackDeviceManager { process.arguments = ["-e", script] try process.run() + // TODO: M12 — Replace waitUntilExit() with async continuation using process.terminationHandler + // to avoid blocking the main thread during driver uninstallation. process.waitUntilExit() + guard process.terminationStatus == 0 else { + logger.error("Driver uninstall failed with exit code \(process.terminationStatus)") + self.isDriverInstalled = checkDriverInstalled() + return + } + try await Task.sleep(for: .seconds(2)) self.isDriverInstalled = checkDriverInstalled() @@ -219,6 +230,209 @@ final class LoopbackDeviceManager { func isAppRouted(_ pid: pid_t) -> Bool { activeApps.contains(pid) } + + // MARK: - Lossless Recording Mode (Virtual Audio Cable) + + /// The UID of the FineTune Loopback virtual audio device. + static let loopbackDeviceUID = "com.finetuneapp.loopback" + + /// Enables lossless recording mode: saves the current system output device, + /// then switches system output to FineTune Loopback so apps output through + /// the virtual cable. + /// + /// Returns the UID of the previous output device (for later restoration). + @discardableResult + func enableLosslessRecording() -> String? { + guard isDriverInstalled else { + logger.error("Cannot enable lossless recording: driver not installed") + return nil + } + + // Save current default output device UID + let previousUID = Self.currentDefaultOutputDeviceUID() + logger.info("Lossless recording: saving previous output device: \(previousUID ?? "nil")") + + // Find the FineTune Loopback device and set it as default output + guard let loopbackID = Self.findDeviceByUID(Self.loopbackDeviceUID) else { + logger.error("FineTune Loopback device not found in audio system") + return nil + } + + let err = Self.setDefaultOutputDevice(loopbackID) + if err == noErr { + isLosslessRecordingActive = true + logger.info("Lossless recording enabled: system output → FineTune Loopback") + } else { + logger.error("Failed to set FineTune Loopback as default output: \(err)") + } + + return previousUID + } + + /// Disables lossless recording mode: restores the specified output device + /// as the system default. + func disableLosslessRecording(restoreDeviceUID: String?) { + if let uid = restoreDeviceUID, let deviceID = Self.findDeviceByUID(uid) { + let err = Self.setDefaultOutputDevice(deviceID) + if err == noErr { + logger.info("Lossless recording disabled: restored output → \(uid)") + } else { + logger.error("Failed to restore output device \(uid): \(err)") + } + } else { + // Fallback: query the current system default output device. + // If it's still our loopback device, enumerate all outputs + // and pick the first non-loopback device. + var fallbackID = AudioObjectID(kAudioObjectUnknown) + var fbSize = UInt32(MemoryLayout.size) + var fbAddr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let fbErr = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &fbAddr, 0, nil, &fbSize, &fallbackID) + + if fbErr == noErr && fallbackID != kAudioObjectUnknown { + // Check if the current default is our loopback device + let currentUID = Self.deviceUID(for: fallbackID) + if currentUID == Self.loopbackDeviceUID { + // Find first non-loopback output device + if let nonLoopback = Self.firstNonLoopbackOutputDevice() { + Self.setDefaultOutputDevice(nonLoopback) + logger.info("Lossless recording disabled: restored to first non-loopback device") + } else { + logger.warning("Lossless recording disabled: no non-loopback device found") + } + } else { + Self.setDefaultOutputDevice(fallbackID) + logger.info("Lossless recording disabled: restored to current default") + } + } else { + logger.warning("Lossless recording disabled: could not query default output device") + } + } + + isLosslessRecordingActive = false + } + + /// Whether lossless recording mode is currently active (system output is FineTune Loopback). + private(set) var isLosslessRecordingActive: Bool = false + + // MARK: - CoreAudio Helpers + + /// Returns the UID of the current default output device. + private static func currentDefaultOutputDeviceUID() -> String? { + var deviceID: AudioDeviceID = 0 + var size = UInt32(MemoryLayout.size) + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let err = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &deviceID) + guard err == noErr, deviceID != 0 else { return nil } + + var uidAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var uid: CFTypeRef? = nil + var uidSize = UInt32(MemoryLayout.size) + let uidErr = AudioObjectGetPropertyData(deviceID, &uidAddr, 0, nil, &uidSize, &uid) + guard uidErr == noErr else { return nil } + guard let uidString = uid as? String else { return nil } + return uidString + } + + /// Finds an AudioDeviceID by its UID string. + private static func findDeviceByUID(_ uid: String) -> AudioDeviceID? { + var propSize: UInt32 = 0 + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &propSize) + let count = Int(propSize) / MemoryLayout.size + var devices = [AudioDeviceID](repeating: 0, count: count) + AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &propSize, &devices) + + for dev in devices { + var uidAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var devUID: CFTypeRef? = nil + var uidSize = UInt32(MemoryLayout.size) + AudioObjectGetPropertyData(dev, &uidAddr, 0, nil, &uidSize, &devUID) + if let devUIDString = devUID as? String, devUIDString == uid { + return dev + } + } + return nil + } + + /// Sets the default system output device. + @discardableResult + private static func setDefaultOutputDevice(_ deviceID: AudioDeviceID) -> OSStatus { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var devID = deviceID + return AudioObjectSetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, + UInt32(MemoryLayout.size), &devID + ) + } + + /// Returns the UID string for a given AudioDeviceID, or nil. + private static func deviceUID(for deviceID: AudioDeviceID) -> String? { + var uidAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var uid: CFTypeRef? = nil + var uidSize = UInt32(MemoryLayout.size) + let err = AudioObjectGetPropertyData(deviceID, &uidAddr, 0, nil, &uidSize, &uid) + guard err == noErr else { return nil } + return uid as? String + } + + /// Returns the first non-loopback output device ID by enumerating all devices. + private static func firstNonLoopbackOutputDevice() -> AudioDeviceID? { + var propSize: UInt32 = 0 + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &propSize) + let count = Int(propSize) / MemoryLayout.size + var devices = [AudioDeviceID](repeating: 0, count: count) + AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &propSize, &devices) + + for dev in devices { + guard let uid = deviceUID(for: dev), uid != loopbackDeviceUID else { continue } + // Check this device has output streams + var streamAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, + mScope: kAudioObjectPropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + var streamSize: UInt32 = 0 + AudioObjectGetPropertyDataSize(dev, &streamAddr, 0, nil, &streamSize) + if streamSize > 0 { + return dev + } + } + return nil + } } // MARK: - Installation Errors diff --git a/FineTune/Audio/Loopback/LoopbackRingBuffer.swift b/FineTune/Audio/Loopback/LoopbackRingBuffer.swift index 2b314c3c..8e481fd2 100644 --- a/FineTune/Audio/Loopback/LoopbackRingBuffer.swift +++ b/FineTune/Audio/Loopback/LoopbackRingBuffer.swift @@ -27,6 +27,7 @@ struct FTLoopbackSharedHeader { var isActive: UInt32 // 1 = connected, 0 = idle var bufferFrames: UInt32 // ring buffer capacity in frames var _padding: UInt32 // alignment + var hostTime: UInt64 // mach_absolute_time of last write (for clock sync) } /// Lock-free single-producer, single-consumer ring buffer backed by POSIX shared memory. @@ -126,6 +127,7 @@ final class LoopbackRingBuffer: @unchecked Sendable { header!.pointee.isActive = 0 header!.pointee.bufferFrames = bufferFrames header!.pointee._padding = 0 + header!.pointee.hostTime = 0 // Zero the audio buffer memset(audioData!, 0, Int(bufferFrames) * Int(channels) * MemoryLayout.size) @@ -247,6 +249,8 @@ final class LoopbackRingBuffer: @unchecked Sendable { // audio data — this ordering guarantees it sees complete frames. OSMemoryBarrier() header.pointee.writeHead = currentWrite + UInt64(framesToWrite) + // Write host time AFTER writeHead so consumer sees consistent data + header.pointee.hostTime = mach_absolute_time() } } diff --git a/FineTune/FineTuneApp.swift b/FineTune/FineTuneApp.swift index 6dfbbfea..a87efb63 100644 --- a/FineTune/FineTuneApp.swift +++ b/FineTune/FineTuneApp.swift @@ -111,6 +111,16 @@ struct FineTuneApp: App { let engine = AudioEngine(permission: permission, settingsManager: settings, autoEQProfileManager: profileManager) _audioEngine = State(initialValue: engine) + // H1: Crash recovery — if lossless was active when we last quit/crashed, + // restore the previous output device immediately before the UI renders. + if settings.appSettings.losslessRecordingEnabled { + engine.loopbackManager.disableLosslessRecording( + restoreDeviceUID: settings.appSettings.previousOutputDeviceUID) + settings.appSettings.losslessRecordingEnabled = false + settings.appSettings.previousOutputDeviceUID = nil + settings.flushSync() + } + // Media keys / HUD services — instantiated at app scope so the tap // and HUD panel outlive popup open/close cycles. let accessibilityService = AccessibilityPermissionService() @@ -225,12 +235,24 @@ struct FineTuneApp: App { } // Flush debounced settings + tear down the CGEventTap before dealloc. + // H7: Capture lossless state values upfront so they survive teardown. + let wasLosslessEnabled = settings.appSettings.losslessRecordingEnabled + let previousDeviceUID = settings.appSettings.previousOutputDeviceUID NotificationCenter.default.addObserver( forName: NSApplication.willTerminateNotification, object: nil, queue: .main - ) { [settings, monitor, accessibilityService, hud, coordinator] _ in + ) { [settings, monitor, accessibilityService, hud, coordinator, engine] _ in MainActor.assumeIsolated { + // Restore output device if lossless recording was active + if settings.appSettings.losslessRecordingEnabled || wasLosslessEnabled { + engine.loopbackManager.disableLosslessRecording( + restoreDeviceUID: settings.appSettings.previousOutputDeviceUID ?? previousDeviceUID + ) + settings.appSettings.losslessRecordingEnabled = false + settings.appSettings.previousOutputDeviceUID = nil + } + coordinator.stop() monitor.stop() accessibilityService.stop() diff --git a/FineTune/Settings/SettingsManager.swift b/FineTune/Settings/SettingsManager.swift index 4587cefa..fdd21468 100644 --- a/FineTune/Settings/SettingsManager.swift +++ b/FineTune/Settings/SettingsManager.swift @@ -223,6 +223,10 @@ struct AppSettings: Codable, Equatable { // Popup var popupSize: MenuBarPopupSize = .comfortable // Overall menu bar popup size and density + // Lossless Recording (Virtual Audio Cable) + var losslessRecordingEnabled: Bool = false // Route system output through FineTune Loopback + var previousOutputDeviceUID: String? = nil // UID of the output device to restore on disable + init() {} mutating func setUnifiedLoudnessEnabled(_ enabled: Bool) { @@ -245,6 +249,8 @@ struct AppSettings: Codable, Equatable { customShortcuts = try c.decodeIfPresent([String: ShortcutCodable].self, forKey: .customShortcuts) ?? [:] appearance = try c.decodeIfPresent(AppearancePreference.self, forKey: .appearance) ?? .system popupSize = try c.decodeIfPresent(MenuBarPopupSize.self, forKey: .popupSize) ?? .comfortable + losslessRecordingEnabled = try c.decodeIfPresent(Bool.self, forKey: .losslessRecordingEnabled) ?? false + previousOutputDeviceUID = try c.decodeIfPresent(String.self, forKey: .previousOutputDeviceUID) } } diff --git a/FineTune/Views/Settings/Tabs/AudioTab.swift b/FineTune/Views/Settings/Tabs/AudioTab.swift index 2c3597fe..ae86653b 100644 --- a/FineTune/Views/Settings/Tabs/AudioTab.swift +++ b/FineTune/Views/Settings/Tabs/AudioTab.swift @@ -27,6 +27,7 @@ struct AudioTab: View { VStack(alignment: .leading, spacing: 24) { volumeSection devicesSection + losslessRecordingSection } .padding(.horizontal, 20) .padding(.vertical, 20) @@ -132,6 +133,89 @@ struct AudioTab: View { } } + // MARK: - Lossless Recording + + private var losslessRecordingSection: some View { + SettingsSection("Lossless Recording") { + SettingsRow( + "Virtual Audio Cable", + description: losslessDescription + ) { + Toggle("", isOn: losslessToggleBinding) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + .disabled(!audioEngine.isLoopbackDriverInstalled) + } + + if audioEngine.loopbackManager.isLosslessRecordingActive { + SettingsRowDivider() + HStack(spacing: 6) { + Circle() + .fill(Color.green) + .frame(width: 6, height: 6) + Text("Active — system audio routing through FineTune Loopback") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + SettingsRowDivider() + + // Per-app routing tip + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + Image(systemName: "lightbulb.fill") + .font(.system(size: 10)) + .foregroundStyle(.yellow) + Text("Tip: Per-App Audio Routing") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(DesignTokens.Colors.textPrimary) + } + Text("On macOS Sonoma 14+, you can route individual apps to FineTune Loopback without changing the system output. Go to System Settings → Sound, then assign specific apps to different output devices.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + } + + private var losslessDescription: String { + if !audioEngine.isLoopbackDriverInstalled { + return "Driver not installed — restart FineTune to install" + } + if audioEngine.loopbackManager.isLosslessRecordingActive { + return "System output → FineTune Loopback → your DAW" + } + return "Route system audio through FineTune Loopback for lossless DAW recording" + } + + private var losslessToggleBinding: Binding { + Binding( + get: { + settings.appSettings.losslessRecordingEnabled + }, + set: { enabled in + if enabled { + let previousUID = audioEngine.loopbackManager.enableLosslessRecording() + guard audioEngine.loopbackManager.isLosslessRecordingActive else { return } + settings.appSettings.losslessRecordingEnabled = true + settings.appSettings.previousOutputDeviceUID = previousUID + } else { + audioEngine.loopbackManager.disableLosslessRecording( + restoreDeviceUID: settings.appSettings.previousOutputDeviceUID + ) + settings.appSettings.losslessRecordingEnabled = false + settings.appSettings.previousOutputDeviceUID = nil + } + } + ) + } + private func updateSortedDevices() { sortedOutputDevices = audioEngine.prioritySortedOutputDevices } diff --git a/FineTuneLoopback/FTLoopbackDriver.cpp b/FineTuneLoopback/FTLoopbackDriver.cpp index 6e3ab2ec..e1502ca2 100644 --- a/FineTuneLoopback/FTLoopbackDriver.cpp +++ b/FineTuneLoopback/FTLoopbackDriver.cpp @@ -1,14 +1,19 @@ // FineTuneLoopback/FTLoopbackDriver.cpp // -// CoreAudio AudioServerPlugIn implementation for the FineTune Loopback virtual device. -// Creates a virtual input device that reads audio from POSIX shared memory. +// CoreAudio AudioServerPlugIn — FineTune Loopback Virtual Audio Cable // // Architecture: -// - Static object model: PlugIn → Device → Stream (fixed IDs, no dynamic creation) -// - IO reads from shared memory ring buffer written by FineTune app -// - When FineTune is not connected, outputs silence +// Bidirectional virtual device: apps output TO the device, other apps record +// FROM it. Internally routes output→input via a lock-free ring buffer. +// No shared memory, no aggregate device — pure memcpy in coreaudiod's process. // -// Reference: Apple's NullAudio sample driver (simplified) +// This is the same architecture as BlackHole / Rogue Amoeba ACE. +// Result: truly lossless recording immune to CPU spikes. +// +// Object model: +// PlugIn (1) → Device (2) → Stream_Input (3) [direction=1, DAWs read] +// → Volume (4) +// → Stream_Output (5) [direction=0, apps write] #include "FTLoopbackDriver.h" #include "SharedTypes.h" @@ -31,117 +36,138 @@ // ============================================================================ static os_log_t sLog = NULL; - -// The host interface passed to us by CoreAudio static AudioServerPlugInHostRef sHost = NULL; -// Driver state static std::atomic sRefCount{0}; static Float64 sSampleRate = kFTDefaultSampleRate; static UInt32 sBufferFrameSize = kFTDefaultBufferFrames; static Float32 sVolumeLevel = 1.0f; -static bool sMuteState = false; -// IO state (following BlackHole's proven pattern) +// IO state static UInt64 gDevice_IOIsRunning = 0; static pthread_mutex_t gDevice_IOMutex = PTHREAD_MUTEX_INITIALIZER; -// Zero timestamp tracking — period must be independent of IO buffer size +// Zero timestamp tracking (lock-free for RT safety) +// These are accessed from the RT IO thread via GetZeroTimeStamp, +// so they must NOT be behind a mutex. StartIO/StopIO write them +// under gDevice_IOMutex, but GetZeroTimeStamp reads/updates +// them lock-free. This is safe because: +// - GetZeroTimeStamp is the ONLY reader/writer on the RT thread +// - StartIO resets them to 0 before IO begins (no concurrent readers) +// L2: 16384 doesn't divide 44100 evenly, but Float64 arithmetic in +// gDevice_PreviousTicks accumulates fractional ticks, so the clock +// stays accurate. Using a power-of-two avoids expensive divisions. static const UInt32 kFTZeroTimeStampPeriod = 16384; static Float64 gDevice_HostTicksPerFrame = 0.0; static UInt64 gDevice_AnchorHostTime = 0; static Float64 gDevice_PreviousTicks = 0.0; static UInt64 gDevice_NumberTimeStamps = 0; -// Shared memory +// Timing +static mach_timebase_info_data_t sTimebaseInfo = {0, 0}; + +// ============================================================================ +// MARK: - Direct Passthrough Buffer (Virtual Audio Cable) +// ============================================================================ +// +// Zero-latency approach: a flat buffer shared between WriteMix and ReadInput. +// WriteMix copies audio IN, ReadInput copies audio OUT — same IO cycle, +// same memory, no ring, no heads, no atomics. This is exactly how BlackHole +// achieves zero additional latency. +// +// If WriteMix runs before ReadInput in the cycle → true zero latency. +// If ReadInput runs first → reads previous cycle's data (1 cycle = 64 frames = 1.45ms). + +static float sPassthroughBuffer[kFTMaxBufferFrames * kFTChannelCount]; +static std::atomic sPassthroughFrameCount{0}; // atomic: written by WriteMix, read by ReadInput + +// ============================================================================ +// MARK: - Shared Memory Helpers (Legacy fallback) +// ============================================================================ +// Kept for backward compatibility: when FineTune app writes processed audio +// via shared memory, the input stream can still read it. The internal ring +// buffer (from output stream writes) takes priority. + static int sShmFD = -1; static FTLoopbackSharedHeader* sShmHeader = NULL; static float* sShmAudioData = NULL; static size_t sShmSize = 0; - -// Timing -static mach_timebase_info_data_t sTimebaseInfo = {0, 0}; - -static inline UInt64 HostTimeToNanos(UInt64 hostTime) { - if (sTimebaseInfo.denom == 0) mach_timebase_info(&sTimebaseInfo); - return hostTime * sTimebaseInfo.numer / sTimebaseInfo.denom; -} +static uint64_t sShmRetryHostTime = 0; +static const uint64_t kShmRetryCooldownNs = 500000000ULL; static inline UInt64 NanosToHostTime(UInt64 nanos) { if (sTimebaseInfo.denom == 0) mach_timebase_info(&sTimebaseInfo); return nanos * sTimebaseInfo.denom / sTimebaseInfo.numer; } -// ============================================================================ -// MARK: - Shared Memory Helpers -// ============================================================================ +static void CloseSharedMemory() { + if (sShmHeader != NULL) { + munmap(sShmHeader, sShmSize); + sShmHeader = NULL; + sShmAudioData = NULL; + sShmSize = 0; + } + if (sShmFD >= 0) { + close(sShmFD); + sShmFD = -1; + } +} static void OpenSharedMemory() { - if (sShmHeader != NULL) return; // Already open + if (sShmHeader != NULL) return; + + uint64_t now = mach_absolute_time(); + if (now < sShmRetryHostTime) return; int fd = shm_open(kFTLoopbackShmName, O_RDWR, 0); if (fd < 0) { - // FineTune app hasn't created the shm yet — this is normal at startup + sShmRetryHostTime = now + NanosToHostTime(kShmRetryCooldownNs); return; } - // Read the header first to get buffer dimensions - // We'll map the minimum header size first, then remap with full size - size_t headerSize = sizeof(FTLoopbackSharedHeader); - void* headerMap = mmap(NULL, headerSize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); - if (headerMap == MAP_FAILED) { + FTLoopbackSharedHeader tempHeader; + ssize_t bytesRead = read(fd, &tempHeader, sizeof(tempHeader)); + if (bytesRead < (ssize_t)sizeof(tempHeader) || + tempHeader.bufferFrames == 0 || + tempHeader.channels == 0) { close(fd); + sShmRetryHostTime = now + NanosToHostTime(kShmRetryCooldownNs); return; } - FTLoopbackSharedHeader* hdr = (FTLoopbackSharedHeader*)headerMap; - uint32_t bufFrames = hdr->bufferFrames; - uint32_t channels = hdr->channels; - munmap(headerMap, headerSize); - - if (bufFrames == 0 || channels == 0) { - close(fd); - return; - } + size_t totalSize = sizeof(FTLoopbackSharedHeader) + + (size_t)tempHeader.bufferFrames * tempHeader.channels * sizeof(float); - // Now map the full region - size_t fullSize = FTLoopbackShmSize(bufFrames, channels); - void* fullMap = mmap(NULL, fullSize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); - if (fullMap == MAP_FAILED) { + void* mapped = mmap(NULL, totalSize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (mapped == MAP_FAILED) { close(fd); + sShmRetryHostTime = now + NanosToHostTime(kShmRetryCooldownNs); return; } sShmFD = fd; - sShmHeader = (FTLoopbackSharedHeader*)fullMap; - sShmAudioData = FTLoopbackAudioData(sShmHeader); - sShmSize = fullSize; - - os_log_info(sLog, "Shared memory opened: %u frames, %u channels, %.0f Hz", - bufFrames, channels, sShmHeader->sampleRate); + sShmSize = totalSize; + sShmHeader = (FTLoopbackSharedHeader*)mapped; + sShmAudioData = (float*)((uint8_t*)mapped + sizeof(FTLoopbackSharedHeader)); } -static void CloseSharedMemory() { - if (sShmHeader != NULL) { - munmap(sShmHeader, sShmSize); - sShmHeader = NULL; - sShmAudioData = NULL; - sShmSize = 0; - } - if (sShmFD >= 0) { - close(sShmFD); - sShmFD = -1; - } -} +static bool sLegacyReadStarted = false; +static const UInt32 kLegacyTargetLatency = 16384; +static const UInt32 kLegacyMaxLatency = 32768; +static bool sShmNeedsClose = false; // Flag for deferred cleanup (H8: never call CloseSharedMemory on RT thread) -/// Read frames from the shared memory ring buffer into the output buffer. -/// Returns the number of frames actually read (may be less than requested on underflow). -static UInt32 ReadFromRingBuffer(float* outBuffer, UInt32 framesToRead, UInt32 channels) { +/// Read from shared memory ring buffer (legacy path). +static UInt32 ReadFromSharedMemory(float* outBuffer, UInt32 framesToRead, UInt32 channels) { if (sShmHeader == NULL || sShmAudioData == NULL) return 0; - // Check if producer is active uint32_t isActive = __atomic_load_n(&sShmHeader->isActive, __ATOMIC_ACQUIRE); - if (!isActive) return 0; + if (!isActive) { + // H8: Don't call CloseSharedMemory() here — we're on the RT thread. + // Set a flag for deferred cleanup in StopIO. + sShmNeedsClose = true; + sLegacyReadStarted = false; + return 0; + } uint32_t bufFrames = sShmHeader->bufferFrames; uint32_t shmChannels = sShmHeader->channels; @@ -150,8 +176,22 @@ static UInt32 ReadFromRingBuffer(float* outBuffer, UInt32 framesToRead, UInt32 c uint64_t writeHead = __atomic_load_n(&sShmHeader->writeHead, __ATOMIC_ACQUIRE); uint64_t readHead = __atomic_load_n(&sShmHeader->readHead, __ATOMIC_RELAXED); - // Available frames = writeHead - readHead int64_t available = (int64_t)(writeHead - readHead); + + if (!sLegacyReadStarted) { + if (available < (int64_t)kLegacyTargetLatency) return 0; + sLegacyReadStarted = true; + readHead = writeHead - kLegacyTargetLatency; + __atomic_store_n(&sShmHeader->readHead, readHead, __ATOMIC_RELEASE); + available = kLegacyTargetLatency; + } + + if (available > (int64_t)kLegacyMaxLatency) { + readHead = writeHead - kLegacyTargetLatency; + __atomic_store_n(&sShmHeader->readHead, readHead, __ATOMIC_RELEASE); + available = kLegacyTargetLatency; + } + if (available <= 0) return 0; UInt32 framesToCopy = (UInt32)((available < (int64_t)framesToRead) ? available : framesToRead); @@ -161,19 +201,15 @@ static UInt32 ReadFromRingBuffer(float* outBuffer, UInt32 framesToRead, UInt32 c UInt32 ringPos = (UInt32)((readHead + frame) % bufFrames); UInt32 outPos = frame * channels; UInt32 shmPos = ringPos * shmChannels; - for (UInt32 ch = 0; ch < minChannels; ch++) { outBuffer[outPos + ch] = sShmAudioData[shmPos + ch]; } - // Zero extra output channels for (UInt32 ch = minChannels; ch < channels; ch++) { outBuffer[outPos + ch] = 0.0f; } } - // Update read head __atomic_store_n(&sShmHeader->readHead, readHead + framesToCopy, __ATOMIC_RELEASE); - return framesToCopy; } @@ -219,7 +255,7 @@ static OSStatus FT_GetZeroTimeStamp(AudioServerPlugInDriverRef inDriver, AudioOb Float64* outSampleTime, UInt64* outHostTime, UInt64* outSeed); static OSStatus FT_WillDoIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID, UInt32 inOperationID, Boolean* outWillDo, - Boolean* outIsInput); + Boolean* outWillDoInPlace); static OSStatus FT_BeginIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID, UInt32 inOperationID, UInt32 inIOBufferFrameSize, const AudioServerPlugInIOCycleInfo* inIOCycleInfo); @@ -265,15 +301,12 @@ static AudioServerPlugInDriverInterface* sDriverInterfacePtr = &sDriverInterface // ============================================================================ static HRESULT FT_QueryInterface(void* inDriver, REFIID inUUID, LPVOID* outInterface) { - // The UUIDs we need to match CFUUIDRef requestedUUID = CFUUIDCreateFromUUIDBytes(NULL, inUUID); - - // IUnknown UUID + CFUUIDRef iunknownUUID = CFUUIDGetConstantUUIDWithBytes(NULL, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46); - - // AudioServerPlugInDriverInterface UUID (this is what coreaudiod queries with!) + CFUUIDRef driverInterfaceUUID = kAudioServerPlugInDriverInterfaceUUID; HRESULT result = E_NOINTERFACE; @@ -309,16 +342,16 @@ static OSStatus FT_Initialize(AudioServerPlugInDriverRef inDriver, AudioServerPl sLog = os_log_create("com.finetuneapp.FineTuneLoopback", "Driver"); mach_timebase_info(&sTimebaseInfo); - FILE* f = fopen("/tmp/ftloopback_init.txt", "w"); - if (f) { fprintf(f, "Initialize called. Host=%p\n", inHost); fclose(f); } - syslog(LOG_ERR, "FTLoopback: Initialize called! Driver is loaded."); - os_log_info(sLog, "FineTune Loopback driver initialized"); + // Zero the passthrough buffer on init + memset(sPassthroughBuffer, 0, sizeof(sPassthroughBuffer)); + + syslog(LOG_ERR, "FTLoopback: Initialize called! Bidirectional virtual audio cable loaded."); + os_log_info(sLog, "FineTune Loopback driver initialized (bidirectional)"); return kAudioHardwareNoError; } static OSStatus FT_CreateDevice(AudioServerPlugInDriverRef inDriver, CFDictionaryRef inDescription, const AudioServerPlugInClientInfo* inClientInfo, AudioObjectID* outDeviceObjectID) { - // Our device is created statically — nothing to do return kAudioHardwareUnsupportedOperationError; } @@ -328,11 +361,13 @@ static OSStatus FT_DestroyDevice(AudioServerPlugInDriverRef inDriver, AudioObjec static OSStatus FT_AddDeviceClient(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, const AudioServerPlugInClientInfo* inClientInfo) { + os_log_info(sLog, "AddDeviceClient: pid=%d", inClientInfo->mProcessID); return kAudioHardwareNoError; } static OSStatus FT_RemoveDeviceClient(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, const AudioServerPlugInClientInfo* inClientInfo) { + os_log_info(sLog, "RemoveDeviceClient: pid=%d", inClientInfo->mProcessID); return kAudioHardwareNoError; } @@ -348,6 +383,14 @@ static OSStatus FT_AbortDeviceConfigurationChange(AudioServerPlugInDriverRef inD return kAudioHardwareNoError; } +// ============================================================================ +// MARK: - Helper: is this a stream object? +// ============================================================================ + +static inline bool IsStreamObject(AudioObjectID objectID) { + return objectID == kFTObjectID_Stream_Input || objectID == kFTObjectID_Stream_Output; +} + // ============================================================================ // MARK: - Property Support: HasProperty // ============================================================================ @@ -398,7 +441,7 @@ static Boolean FT_HasProperty(AudioServerPlugInDriverRef inDriver, AudioObjectID case kAudioDevicePropertyBufferFrameSize: case kAudioDevicePropertyBufferFrameSizeRange: case kAudioDevicePropertyZeroTimeStampPeriod: - case kAudioDevicePropertyIcon: + // case kAudioDevicePropertyIcon: // L4: removed — we don't provide an icon case kAudioDevicePropertyRelatedDevices: case kAudioDevicePropertyClockIsStable: case kAudioDevicePropertyClockAlgorithm: @@ -407,8 +450,9 @@ static Boolean FT_HasProperty(AudioServerPlugInDriverRef inDriver, AudioObjectID } break; - // --- Stream --- - case kFTObjectID_Stream: + // --- Streams (Input and Output share the same property set) --- + case kFTObjectID_Stream_Input: + case kFTObjectID_Stream_Output: switch (inAddress->mSelector) { case kAudioObjectPropertyBaseClass: case kAudioObjectPropertyClass: @@ -463,7 +507,8 @@ static OSStatus FT_IsPropertySettable(AudioServerPlugInDriverRef inDriver, Audio } break; - case kFTObjectID_Stream: + case kFTObjectID_Stream_Input: + case kFTObjectID_Stream_Output: switch (inAddress->mSelector) { case kAudioStreamPropertyVirtualFormat: case kAudioStreamPropertyPhysicalFormat: @@ -530,8 +575,14 @@ static OSStatus FT_GetPropertyDataSize(AudioServerPlugInDriverRef inDriver, Audi *outDataSize = sizeof(AudioObjectID); return kAudioHardwareNoError; case kAudioObjectPropertyOwnedObjects: - // stream + volume control - *outDataSize = 2 * sizeof(AudioObjectID); + // Scope-aware: input stream + output stream + volume = 3 max + if (inAddress->mScope == kAudioObjectPropertyScopeInput) { + *outDataSize = sizeof(AudioObjectID); // just input stream + } else if (inAddress->mScope == kAudioObjectPropertyScopeOutput) { + *outDataSize = sizeof(AudioObjectID); // just output stream + } else { + *outDataSize = 3 * sizeof(AudioObjectID); // both streams + volume + } return kAudioHardwareNoError; case kAudioObjectPropertyName: case kAudioObjectPropertyManufacturer: @@ -560,7 +611,13 @@ static OSStatus FT_GetPropertyDataSize(AudioServerPlugInDriverRef inDriver, Audi *outDataSize = sizeof(UInt32); return kAudioHardwareNoError; case kAudioDevicePropertyStreams: - *outDataSize = sizeof(AudioObjectID); // 1 stream + if (inAddress->mScope == kAudioObjectPropertyScopeInput) { + *outDataSize = sizeof(AudioObjectID); // 1 input stream + } else if (inAddress->mScope == kAudioObjectPropertyScopeOutput) { + *outDataSize = sizeof(AudioObjectID); // 1 output stream + } else { + *outDataSize = 2 * sizeof(AudioObjectID); // both streams + } return kAudioHardwareNoError; case kAudioDevicePropertyNominalSampleRate: *outDataSize = sizeof(Float64); @@ -571,9 +628,7 @@ static OSStatus FT_GetPropertyDataSize(AudioServerPlugInDriverRef inDriver, Audi case kAudioDevicePropertyBufferFrameSizeRange: *outDataSize = sizeof(AudioValueRange); return kAudioHardwareNoError; - case kAudioDevicePropertyIcon: - *outDataSize = sizeof(CFURLRef); - return kAudioHardwareNoError; + // case kAudioDevicePropertyIcon: // L4: removed\n // *outDataSize = sizeof(CFURLRef);\n // return kAudioHardwareNoError; case kAudioDevicePropertyRelatedDevices: *outDataSize = sizeof(AudioObjectID); return kAudioHardwareNoError; @@ -583,8 +638,9 @@ static OSStatus FT_GetPropertyDataSize(AudioServerPlugInDriverRef inDriver, Audi } break; - // --- Stream --- - case kFTObjectID_Stream: + // --- Streams (Input and Output) --- + case kFTObjectID_Stream_Input: + case kFTObjectID_Stream_Output: switch (inAddress->mSelector) { case kAudioObjectPropertyBaseClass: case kAudioObjectPropertyClass: @@ -701,7 +757,8 @@ static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj case kFTObjectID_Device: switch (inAddress->mSelector) { case kAudioObjectPropertyBaseClass: - *((AudioClassID*)outData) = kAudioObjectClassID; + // M5: Device's base class is kAudioDeviceClassID, not kAudioObjectClassID + *((AudioClassID*)outData) = kAudioDeviceClassID; *outDataSize = sizeof(AudioClassID); return kAudioHardwareNoError; @@ -718,11 +775,14 @@ static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj case kAudioObjectPropertyOwnedObjects: { AudioObjectID* ids = (AudioObjectID*)outData; UInt32 count = 0; - if (inAddress->mScope == kAudioObjectPropertyScopeGlobal || - inAddress->mScope == kAudioObjectPropertyScopeInput) { - ids[count++] = kFTObjectID_Stream; - } - if (inAddress->mScope == kAudioObjectPropertyScopeGlobal) { + if (inAddress->mScope == kAudioObjectPropertyScopeInput) { + ids[count++] = kFTObjectID_Stream_Input; + } else if (inAddress->mScope == kAudioObjectPropertyScopeOutput) { + ids[count++] = kFTObjectID_Stream_Output; + } else { + // Global scope: return all objects + ids[count++] = kFTObjectID_Stream_Input; + ids[count++] = kFTObjectID_Stream_Output; ids[count++] = kFTObjectID_Volume; } *outDataSize = count * sizeof(AudioObjectID); @@ -760,23 +820,29 @@ static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj return kAudioHardwareNoError; case kAudioDevicePropertyDeviceCanBeDefaultDevice: - // Input device CAN be default input (so DAWs can find it) - *((UInt32*)outData) = (inAddress->mScope == kAudioObjectPropertyScopeInput) ? 1 : 0; + // Can be default for BOTH input AND output + *((UInt32*)outData) = 1; *outDataSize = sizeof(UInt32); return kAudioHardwareNoError; case kAudioDevicePropertyDeviceCanBeDefaultSystemDevice: - *((UInt32*)outData) = 0; // Not suitable as system default + *((UInt32*)outData) = 0; // Not suitable as system alert device *outDataSize = sizeof(UInt32); return kAudioHardwareNoError; case kAudioDevicePropertyStreams: { - if (inAddress->mScope == kAudioObjectPropertyScopeInput || - inAddress->mScope == kAudioObjectPropertyScopeGlobal) { - *((AudioObjectID*)outData) = kFTObjectID_Stream; + AudioObjectID* ids = (AudioObjectID*)outData; + if (inAddress->mScope == kAudioObjectPropertyScopeInput) { + ids[0] = kFTObjectID_Stream_Input; + *outDataSize = sizeof(AudioObjectID); + } else if (inAddress->mScope == kAudioObjectPropertyScopeOutput) { + ids[0] = kFTObjectID_Stream_Output; *outDataSize = sizeof(AudioObjectID); } else { - *outDataSize = 0; // No output streams + // Global: both streams + ids[0] = kFTObjectID_Stream_Output; + ids[1] = kFTObjectID_Stream_Input; + *outDataSize = 2 * sizeof(AudioObjectID); } return kAudioHardwareNoError; } @@ -859,10 +925,12 @@ static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj *outDataSize = sizeof(AudioObjectID); return kAudioHardwareNoError; - case kAudioDevicePropertyIcon: - *((CFURLRef*)outData) = NULL; - *outDataSize = sizeof(CFURLRef); - return kAudioHardwareNoError; + // L4: Icon property removed — returning NULL causes Audio MIDI Setup + // to show no icon. Remove from HasProperty if we don't provide one. + // case kAudioDevicePropertyIcon: + // *((CFURLRef*)outData) = NULL; + // *outDataSize = sizeof(CFURLRef); + // return kAudioHardwareNoError; case kAudioObjectPropertyControlList: *((AudioObjectID*)outData) = kFTObjectID_Volume; @@ -871,11 +939,13 @@ static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj } break; - // ==== Stream Properties ==== - case kFTObjectID_Stream: + // ==== Stream Properties (Input & Output share logic, differ only in direction) ==== + case kFTObjectID_Stream_Input: + case kFTObjectID_Stream_Output: switch (inAddress->mSelector) { case kAudioObjectPropertyBaseClass: - *((AudioClassID*)outData) = kAudioObjectClassID; + // M5: Stream's base class is kAudioStreamClassID, not kAudioObjectClassID + *((AudioClassID*)outData) = kAudioStreamClassID; *outDataSize = sizeof(AudioClassID); return kAudioHardwareNoError; @@ -890,8 +960,9 @@ static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj return kAudioHardwareNoError; case kAudioStreamPropertyDirection: - // 1 = input (this is an input stream — DAWs record FROM it) - *((UInt32*)outData) = 1; + // Input stream: direction=1 (recording FROM device) + // Output stream: direction=0 (playing TO device) + *((UInt32*)outData) = (inObjectID == kFTObjectID_Stream_Input) ? 1 : 0; *outDataSize = sizeof(UInt32); return kAudioHardwareNoError; @@ -984,7 +1055,6 @@ static OSStatus FT_GetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj return kAudioHardwareNoError; case kAudioLevelControlPropertyDecibelValue: - // Simple linear-to-dB: 20*log10(volume). Clamp to -96dB for zero. *((Float32*)outData) = (sVolumeLevel > 0.0001f) ? (Float32)(20.0 * log10((double)sVolumeLevel)) : -96.0f; @@ -1017,8 +1087,9 @@ static OSStatus FT_SetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj case kFTObjectID_Device: switch (inAddress->mSelector) { case kAudioDevicePropertyNominalSampleRate: { + // H4: Reject sample rate change while IO is running + if (gDevice_IOIsRunning > 0) return kAudioHardwareNotRunningError; Float64 newRate = *((const Float64*)inData); - // Validate it's a supported rate bool valid = false; for (UInt32 i = 0; i < kFTNumSampleRates; i++) { if (kFTSupportedSampleRates[i] == newRate) { valid = true; break; } @@ -1026,32 +1097,61 @@ static OSStatus FT_SetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj if (!valid) return kAudioHardwareIllegalOperationError; sSampleRate = newRate; os_log_info(sLog, "Sample rate changed to %.0f", newRate); + // H3: Notify clients of property change + if (sHost != NULL) { + AudioObjectPropertyAddress addr = { kAudioDevicePropertyNominalSampleRate, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain }; + sHost->PropertiesChanged(sHost, kFTObjectID_Device, 1, &addr); + } return kAudioHardwareNoError; } case kAudioDevicePropertyBufferFrameSize: { + // H4: Reject buffer size change while IO is running + if (gDevice_IOIsRunning > 0) return kAudioHardwareNotRunningError; UInt32 newSize = *((const UInt32*)inData); if (newSize < kFTMinBufferFrames || newSize > kFTMaxBufferFrames) return kAudioHardwareIllegalOperationError; sBufferFrameSize = newSize; os_log_info(sLog, "Buffer frame size changed to %u", newSize); + // H3: Notify clients of property change + if (sHost != NULL) { + AudioObjectPropertyAddress addr = { kAudioDevicePropertyBufferFrameSize, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain }; + sHost->PropertiesChanged(sHost, kFTObjectID_Device, 1, &addr); + } return kAudioHardwareNoError; } } break; - case kFTObjectID_Stream: + case kFTObjectID_Stream_Input: + case kFTObjectID_Stream_Output: switch (inAddress->mSelector) { case kAudioStreamPropertyVirtualFormat: case kAudioStreamPropertyPhysicalFormat: { const AudioStreamBasicDescription* desc = (const AudioStreamBasicDescription*)inData; - // Only allow changing sample rate, everything else is fixed + // M6: Validate full stream format, not just sample rate + if (desc->mFormatID != kAudioFormatLinearPCM || + desc->mBitsPerChannel != 32 || + desc->mChannelsPerFrame != kFTChannelCount || + !(desc->mFormatFlags & kAudioFormatFlagIsFloat)) { + return kAudioDeviceUnsupportedFormatError; + } bool validRate = false; for (UInt32 i = 0; i < kFTNumSampleRates; i++) { if (kFTSupportedSampleRates[i] == desc->mSampleRate) { validRate = true; break; } } - if (!validRate) return kAudioHardwareIllegalOperationError; + if (!validRate) return kAudioDeviceUnsupportedFormatError; + // H4: Reject while IO is running + if (gDevice_IOIsRunning > 0) return kAudioHardwareNotRunningError; sSampleRate = desc->mSampleRate; + // H3: Notify clients of format change + if (sHost != NULL) { + AudioObjectPropertyAddress addr = { inAddress->mSelector, + inAddress->mScope, inAddress->mElement }; + sHost->PropertiesChanged(sHost, inObjectID, 1, &addr); + } return kAudioHardwareNoError; } } @@ -1062,6 +1162,12 @@ static OSStatus FT_SetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj case kAudioLevelControlPropertyScalarValue: { Float32 val = *((const Float32*)inData); sVolumeLevel = (val < 0.0f) ? 0.0f : ((val > 1.0f) ? 1.0f : val); + // H3: Notify clients of volume change + if (sHost != NULL) { + AudioObjectPropertyAddress addr = { kAudioLevelControlPropertyScalarValue, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain }; + sHost->PropertiesChanged(sHost, kFTObjectID_Volume, 1, &addr); + } return kAudioHardwareNoError; } case kAudioLevelControlPropertyDecibelValue: { @@ -1069,6 +1175,12 @@ static OSStatus FT_SetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj sVolumeLevel = (Float32)pow(10.0, (double)dB / 20.0); if (sVolumeLevel > 1.0f) sVolumeLevel = 1.0f; if (sVolumeLevel < 0.0f) sVolumeLevel = 0.0f; + // H3: Notify clients of volume change + if (sHost != NULL) { + AudioObjectPropertyAddress addr = { kAudioLevelControlPropertyDecibelValue, + kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain }; + sHost->PropertiesChanged(sHost, kFTObjectID_Volume, 1, &addr); + } return kAudioHardwareNoError; } } @@ -1085,86 +1197,97 @@ static OSStatus FT_SetPropertyData(AudioServerPlugInDriverRef inDriver, AudioObj static OSStatus FT_StartIO(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID) { os_log_info(sLog, "StartIO: client=%u", inClientID); - // Try to open shared memory when IO starts - OpenSharedMemory(); - pthread_mutex_lock(&gDevice_IOMutex); - + if (gDevice_IOIsRunning == 0) { - // First client starting IO — initialize timing anchor - // Compute host ticks per frame from the host clock frequency + // First client — initialize timing anchor Float64 theHostClockFrequency = 0.0; mach_timebase_info_data_t tbInfo; mach_timebase_info(&tbInfo); theHostClockFrequency = (Float64)tbInfo.denom / (Float64)tbInfo.numer * 1000000000.0; gDevice_HostTicksPerFrame = theHostClockFrequency / sSampleRate; - + gDevice_NumberTimeStamps = 0; gDevice_AnchorHostTime = mach_absolute_time(); gDevice_PreviousTicks = 0.0; + + // Reset passthrough buffer for fresh IO session + memset(sPassthroughBuffer, 0, sizeof(sPassthroughBuffer)); + sPassthroughFrameCount.store(0, std::memory_order_relaxed); + sLegacyReadStarted = false; + sShmNeedsClose = false; + + // C4: Open shared memory here (non-RT thread), not in DoIOOperation + if (sShmHeader == NULL) { + OpenSharedMemory(); + } } - + gDevice_IOIsRunning += 1; - + pthread_mutex_unlock(&gDevice_IOMutex); return kAudioHardwareNoError; } static OSStatus FT_StopIO(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID) { os_log_info(sLog, "StopIO: client=%u", inClientID); - + pthread_mutex_lock(&gDevice_IOMutex); if (gDevice_IOIsRunning > 0) { gDevice_IOIsRunning -= 1; } + + // H6: Clean up shared memory when all IO clients have disconnected + // H8: This is the deferred cleanup from the RT thread flag + if (gDevice_IOIsRunning == 0) { + if (sShmNeedsClose) { + CloseSharedMemory(); + sShmRetryHostTime = 0; + sShmNeedsClose = false; + } + } + pthread_mutex_unlock(&gDevice_IOMutex); - + return kAudioHardwareNoError; } static OSStatus FT_GetZeroTimeStamp(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID, Float64* outSampleTime, UInt64* outHostTime, UInt64* outSeed) { - // Following BlackHole's proven pattern: - // The zero time stamps are spaced kFTZeroTimeStampPeriod frames apart. - // We only advance the counter when the next timestamp's host time has passed. - - pthread_mutex_lock(&gDevice_IOMutex); - + // C2: Lock-free — this runs on the RT IO thread every cycle. + // No mutex here. GetZeroTimeStamp is the sole RT-thread accessor; + // StartIO resets the state before any IO begins (under mutex, no race). + UInt64 theCurrentHostTime = mach_absolute_time(); - - // Calculate host ticks for one zero-timestamp period Float64 theHostTicksPerPeriod = gDevice_HostTicksPerFrame * (Float64)kFTZeroTimeStampPeriod; - - // Calculate the next timestamp offset Float64 theNextTickOffset = gDevice_PreviousTicks + theHostTicksPerPeriod; UInt64 theNextHostTime = gDevice_AnchorHostTime + (UInt64)theNextTickOffset; - - // Advance the counter if the next timestamp is in the past + if (theNextHostTime <= theCurrentHostTime) { ++gDevice_NumberTimeStamps; gDevice_PreviousTicks = theNextTickOffset; } - - // Set the return values + *outSampleTime = (Float64)(gDevice_NumberTimeStamps * kFTZeroTimeStampPeriod); *outHostTime = gDevice_AnchorHostTime + (UInt64)gDevice_PreviousTicks; *outSeed = 1; - - pthread_mutex_unlock(&gDevice_IOMutex); - + return kAudioHardwareNoError; } static OSStatus FT_WillDoIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjectID inDeviceObjectID, UInt32 inClientID, UInt32 inOperationID, Boolean* outWillDo, Boolean* outWillDoInPlace) { - // We only do ReadInput (in place) switch (inOperationID) { case kAudioServerPlugInIOOperationReadInput: *outWillDo = true; *outWillDoInPlace = true; break; + case kAudioServerPlugInIOOperationWriteMix: + *outWillDo = true; + *outWillDoInPlace = true; + break; default: *outWillDo = false; *outWillDoInPlace = true; @@ -1184,31 +1307,65 @@ static OSStatus FT_DoIOOperation(AudioServerPlugInDriverRef inDriver, AudioObjec UInt32 inIOBufferFrameSize, const AudioServerPlugInIOCycleInfo* inIOCycleInfo, void* ioMainBuffer, void* ioSecondaryBuffer) { - if (inOperationID != kAudioServerPlugInIOOperationReadInput) { + // === OUTPUT: App writing audio TO the device (rekordbox → buffer) === + if (inOperationID == kAudioServerPlugInIOOperationWriteMix) { + // C1: Clamp to max buffer size to prevent overflow + UInt32 clampedFrames = (inIOBufferFrameSize > kFTMaxBufferFrames) + ? kFTMaxBufferFrames : inIOBufferFrameSize; + UInt32 bytesToCopy = clampedFrames * kFTChannelCount * sizeof(float); + memcpy(sPassthroughBuffer, ioMainBuffer, bytesToCopy); + // C3: Release semantics — ensures buffer data is visible before count + sPassthroughFrameCount.store(clampedFrames, std::memory_order_release); return kAudioHardwareNoError; } - float* outBuffer = (float*)ioMainBuffer; - UInt32 totalSamples = inIOBufferFrameSize * kFTChannelCount; - - // SAFETY: Always zero the entire buffer first to guarantee silence - // if anything goes wrong below. - memset(outBuffer, 0, totalSamples * sizeof(float)); - - // Try to connect to shared memory if not already - if (sShmHeader == NULL) { - OpenSharedMemory(); - } - - // Read from ring buffer (overwrites zeroed buffer with real audio if available) - UInt32 framesRead = ReadFromRingBuffer(outBuffer, inIOBufferFrameSize, kFTChannelCount); + // === INPUT: App reading audio FROM the device (buffer → Ableton) === + if (inOperationID == kAudioServerPlugInIOOperationReadInput) { + float* outBuffer = (float*)ioMainBuffer; + UInt32 totalSamples = inIOBufferFrameSize * kFTChannelCount; + + // C3: Acquire semantics — ensures we see the buffer data written before count + UInt32 availableFrames = sPassthroughFrameCount.load(std::memory_order_acquire); + + // Direct copy from passthrough buffer — zero overhead + if (availableFrames > 0) { + // C1: Also clamp read side for safety + UInt32 framesToCopy = (inIOBufferFrameSize < availableFrames) + ? inIOBufferFrameSize : availableFrames; + if (framesToCopy > kFTMaxBufferFrames) framesToCopy = kFTMaxBufferFrames; + UInt32 bytesToCopy = framesToCopy * kFTChannelCount * sizeof(float); + memcpy(outBuffer, sPassthroughBuffer, bytesToCopy); + + // Zero remaining frames if buffer sizes differ + if (framesToCopy < inIOBufferFrameSize) { + UInt32 remainingSamples = (inIOBufferFrameSize - framesToCopy) * kFTChannelCount; + memset(outBuffer + framesToCopy * kFTChannelCount, 0, remainingSamples * sizeof(float)); + } - // Apply volume (only if we got real data and volume is reduced) - if (framesRead > 0 && sVolumeLevel < 0.999f) { - UInt32 samplesToScale = framesRead * kFTChannelCount; - for (UInt32 i = 0; i < samplesToScale; i++) { - outBuffer[i] *= sVolumeLevel; + // M3: Volume is intentionally applied only in ReadInput, not WriteMix. + // This preserves the raw audio in the passthrough buffer (lossless), + // and applies volume control at the consumer side. + // Apply volume + if (sVolumeLevel < 0.999f) { + for (UInt32 i = 0; i < framesToCopy * kFTChannelCount; i++) { + outBuffer[i] *= sVolumeLevel; + } + } + } else { + // No data from passthrough — try shared memory (legacy fallback) + memset(outBuffer, 0, totalSamples * sizeof(float)); + // C4: Don't call OpenSharedMemory() here — it was opened in StartIO + if (sShmHeader != NULL) { + UInt32 framesRead = ReadFromSharedMemory(outBuffer, inIOBufferFrameSize, kFTChannelCount); + if (framesRead > 0 && sVolumeLevel < 0.999f) { + for (UInt32 i = 0; i < framesRead * kFTChannelCount; i++) { + outBuffer[i] *= sVolumeLevel; + } + } + } } + + return kAudioHardwareNoError; } return kAudioHardwareNoError; @@ -1225,18 +1382,13 @@ static OSStatus FT_EndIOOperation(AudioServerPlugInDriverRef inDriver, AudioObje // ============================================================================ extern "C" void* FTLoopbackDriverFactory(CFAllocatorRef allocator, CFUUIDRef requestedTypeUUID) { - // Verify this is the AudioServerPlugIn type - CFUUIDRef pluginTypeUUID = CFUUIDCreateFromString(NULL, CFSTR("443ABAB8-E7B3-491A-B985-BEB9187030DB")); + // L6: Cache the UUID to avoid repeated Create/Release + static CFUUIDRef pluginTypeUUID = CFUUIDCreateFromString(NULL, CFSTR("443ABAB8-E7B3-491A-B985-BEB9187030DB")); if (!CFEqual(requestedTypeUUID, pluginTypeUUID)) { - CFRelease(pluginTypeUUID); return NULL; } - CFRelease(pluginTypeUUID); - syslog(LOG_ERR, "FTLoopback: Factory called successfully, returning driver interface"); - // Debug: write a marker file to confirm factory was called - FILE* f = fopen("/tmp/ftloopback_factory_called.txt", "w"); - if (f) { fprintf(f, "Factory called at %lu\n", (unsigned long)time(NULL)); fclose(f); } + os_log_info(sLog, "Factory called, returning bidirectional driver interface"); FT_AddRef(NULL); return &sDriverInterfacePtr; } diff --git a/FineTuneLoopback/FTLoopbackDriver.h b/FineTuneLoopback/FTLoopbackDriver.h index a2f2c5ac..39e3c99d 100644 --- a/FineTuneLoopback/FTLoopbackDriver.h +++ b/FineTuneLoopback/FTLoopbackDriver.h @@ -1,8 +1,8 @@ // FineTuneLoopback/FTLoopbackDriver.h // // CoreAudio AudioServerPlugIn driver for the FineTune Loopback virtual audio device. -// This creates a virtual input device that reads audio from POSIX shared memory -// written by the FineTune app's audio callback. +// Bidirectional virtual audio cable: apps can output TO the device (like BlackHole) +// and other apps can record FROM the device's input stream. #ifndef FTLOOPBACK_DRIVER_H #define FTLOOPBACK_DRIVER_H @@ -18,11 +18,11 @@ // Object IDs — fixed static layout enum { - kFTObjectID_PlugIn = kAudioObjectPlugInObject, // 1 (required by HAL) - kFTObjectID_Device = 2, - kFTObjectID_Stream = 3, - // Volume control on the virtual device (optional, useful for DAW level) - kFTObjectID_Volume = 4, + kFTObjectID_PlugIn = kAudioObjectPlugInObject, // 1 (required by HAL) + kFTObjectID_Device = 2, + kFTObjectID_Stream_Input = 3, // Input stream (Ableton reads FROM this) + kFTObjectID_Volume = 4, + kFTObjectID_Stream_Output = 5, // Output stream (rekordbox writes TO this) }; // Device constants @@ -37,7 +37,7 @@ static const Float64 kFTSupportedSampleRates[] = { 44100.0, 48000.0, 96000.0 }; // Default configuration #define kFTDefaultSampleRate 44100.0 -#define kFTDefaultBufferFrames 512 +#define kFTDefaultBufferFrames 256 #define kFTMinBufferFrames 64 #define kFTMaxBufferFrames 4096 #define kFTChannelCount 2 diff --git a/FineTuneLoopback/Info.plist b/FineTuneLoopback/Info.plist index fcf114b0..a01ed54c 100644 --- a/FineTuneLoopback/Info.plist +++ b/FineTuneLoopback/Info.plist @@ -25,9 +25,9 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.0 + 2.0 CFBundleVersion - 1 + 2 CFPlugInFactories diff --git a/FineTuneLoopback/SharedTypes.h b/FineTuneLoopback/SharedTypes.h index 7ee3318b..3e74011b 100644 --- a/FineTuneLoopback/SharedTypes.h +++ b/FineTuneLoopback/SharedTypes.h @@ -22,10 +22,10 @@ extern "C" { // POSIX shared memory name (used with shm_open) #define kFTLoopbackShmName "/finetune_loopback" -// Default configuration -#define kFTLoopbackDefaultBufferFrames 48000 // 1 second at 48kHz +// Default configuration — these must match the driver defaults in FTLoopbackDriver.h +#define kFTLoopbackDefaultBufferFrames 48000 // 1 second at 48kHz (ring buffer capacity) #define kFTLoopbackDefaultChannels 2 -#define kFTLoopbackDefaultSampleRate 48000.0 +#define kFTLoopbackDefaultSampleRate 44100.0 // M2: Must match kFTDefaultSampleRate in driver /// Shared memory header — sits at the start of the mapped region. /// All fields are naturally aligned for atomic access on ARM64/x86-64. @@ -62,10 +62,16 @@ typedef struct { /// Reserved for future use, ensures 8-byte alignment of audio data. uint32_t _padding; // offset 36 -} FTLoopbackSharedHeader; // total: 40 bytes + + /// mach_absolute_time() of the most recent write() call. + /// Updated atomically by the producer after writing audio data. + /// The HAL driver uses this to derive its clock from the producer's + /// clock, eliminating drift between independent clocks. + volatile uint64_t hostTime; // offset 40 +} FTLoopbackSharedHeader; // total: 48 bytes // Compile-time size check -_Static_assert(sizeof(FTLoopbackSharedHeader) == 40, +_Static_assert(sizeof(FTLoopbackSharedHeader) == 48, "FTLoopbackSharedHeader size mismatch — binary protocol broken"); /// Returns the total shared memory size needed for a given configuration. diff --git a/diagnose_audio.sh b/diagnose_audio.sh new file mode 100644 index 00000000..9db90092 --- /dev/null +++ b/diagnose_audio.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Audio crackle diagnostic — run this in Terminal.app while playing audio +# It captures coreaudiod IO cycle overloads, aggregate device issues, and CPU spikes + +echo "🔍 Audio Crackle Diagnostic — press Ctrl+C to stop" +echo " Listening for: IO overloads, xruns, aggregate device glitches..." +echo "" + +# Stream all audio-related log messages in real-time +sudo log stream \ + --predicate 'process == "coreaudiod" + OR sender contains "IOAudio" + OR sender contains "HALC" + OR eventMessage contains "overload" + OR eventMessage contains "xrun" + OR eventMessage contains "skipping cycle" + OR eventMessage contains "aggregate" + OR eventMessage contains "glitch" + OR eventMessage contains "underrun" + OR eventMessage contains "IOWorkLoop" + OR eventMessage contains "cycle budget" + OR eventMessage contains "took too long" + OR (process == "FineTune" AND eventMessage contains "audio")' \ + --level debug \ + --style compact From 94f0f94a1c43c1c64f80f0a14c0d28845aa45358 Mon Sep 17 00:00:00 2001 From: Alex Hohnhorst Date: Thu, 28 May 2026 04:33:49 +0200 Subject: [PATCH 3/3] feat: Add Audio Debugging Panel and Loopback Controls - Introduced `AudioDebugView` for real-time audio routing diagnostics, displaying app tap status, device routing, audio levels, and loopback state. - Enhanced `ProcessTapControlling` protocol with `isUnmutedCapture`, `ioStats`, and `primaryAggregateDeviceID` properties. - Implemented `BufferStats` in `LoopbackRingBuffer` for diagnostics on buffer state, including fill level, overruns, and underruns. - Added debug button to `MenuBarPopupView` to open the audio debug panel. - Integrated loopback toggle functionality in `AppRow`, `AppRowControls`, and related views, allowing users to enable/disable loopback routing. - Updated UI components to reflect loopback availability and status, ensuring a cohesive user experience. --- FineTune.xcodeproj/project.pbxproj | 4 +- FineTune/Audio/Engine/AudioEngine.swift | 137 +++- .../Audio/Engine/ProcessTapController.swift | 202 ++++- .../Audio/Engine/ProcessTapControlling.swift | 27 + .../Audio/Loopback/LoopbackRingBuffer.swift | 51 ++ FineTune/Views/Debug/AudioDebugView.swift | 774 ++++++++++++++++++ FineTune/Views/MenuBarPopupView.swift | 35 +- FineTune/Views/Rows/AppRow.swift | 14 +- FineTune/Views/Rows/AppRowControls.swift | 32 + .../Views/Rows/AppRowWithLevelPolling.swift | 16 +- FineTune/Views/Rows/InactiveAppRow.swift | 3 + 11 files changed, 1271 insertions(+), 24 deletions(-) create mode 100644 FineTune/Views/Debug/AudioDebugView.swift diff --git a/FineTune.xcodeproj/project.pbxproj b/FineTune.xcodeproj/project.pbxproj index 675bf448..5f999521 100644 --- a/FineTune.xcodeproj/project.pbxproj +++ b/FineTune.xcodeproj/project.pbxproj @@ -543,7 +543,6 @@ ); MARKETING_VERSION = 1.0.0; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; - SWIFT_OBJC_BRIDGING_HEADER = "FineTune/FineTune-Bridging-Header.h"; PRODUCT_BUNDLE_IDENTIFIER = com.finetuneapp.FineTune; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -552,6 +551,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "FineTune/FineTune-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; @@ -582,7 +582,6 @@ ); MARKETING_VERSION = 1.0.0; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; - SWIFT_OBJC_BRIDGING_HEADER = "FineTune/FineTune-Bridging-Header.h"; PRODUCT_BUNDLE_IDENTIFIER = com.finetuneapp.FineTune; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -591,6 +590,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "FineTune/FineTune-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; }; diff --git a/FineTune/Audio/Engine/AudioEngine.swift b/FineTune/Audio/Engine/AudioEngine.swift index 581e0478..3b53c52d 100644 --- a/FineTune/Audio/Engine/AudioEngine.swift +++ b/FineTune/Audio/Engine/AudioEngine.swift @@ -600,6 +600,77 @@ final class AudioEngine { taps[app.id]?.audioLevel ?? 0.0 } + // MARK: - Debug Info + + /// Snapshot of a single tap's state for the debug panel. + struct TapDebugInfo { + let pid: pid_t + let appName: String + let bundleID: String? + let audioLevel: Float + let volume: Float + let isMuted: Bool + let isUnmutedCapture: Bool + let isLoopbackEnabled: Bool + let deviceUID: String? + let deviceUIDs: [String] + let tapSourceDeviceUID: String? + let isFollowingDefault: Bool + let ioStats: ProcessTapController.IOStats? + let sampleRate: Float64 + let deviceSampleRate: Float64 + let sampleRateMismatch: Bool + } + + /// Returns debug info for all active taps. + var debugTapInfos: [TapDebugInfo] { + apps.map { app in + let tap = taps[app.id] + let stats = tap?.ioStats + let tapSR = stats?.tapSampleRate ?? 0 + var deviceSR: Float64 = 0 + if let aggID = tap?.primaryAggregateDeviceID, aggID != .unknown { + deviceSR = (try? aggID.readNominalSampleRate()) ?? 0 + } + let mismatch = tapSR > 0 && deviceSR > 0 && abs(tapSR - deviceSR) > 0.1 + + return TapDebugInfo( + pid: app.id, + appName: app.name, + bundleID: app.bundleID, + audioLevel: tap?.audioLevel ?? 0, + volume: tap?.volume ?? 0, + isMuted: tap?.isMuted ?? false, + isUnmutedCapture: tap?.isUnmutedCapture ?? false, + isLoopbackEnabled: loopbackManager.isAppRouted(app.id), + deviceUID: tap?.currentDeviceUID, + deviceUIDs: tap?.currentDeviceUIDs ?? [], + tapSourceDeviceUID: tap?.tapSourceDeviceUID, + isFollowingDefault: followsDefault.contains(app.id), + ioStats: stats, + sampleRate: tapSR, + deviceSampleRate: deviceSR, + sampleRateMismatch: mismatch + ) + } + } + + struct LoopbackDebugInfo { + let isDriverInstalled: Bool + let isActive: Bool + let activeAppPIDs: Set + let bufferStats: LoopbackRingBuffer.BufferStats? + } + + var debugLoopbackInfo: LoopbackDebugInfo { + LoopbackDebugInfo( + isDriverInstalled: loopbackManager.isDriverInstalled, + isActive: loopbackManager.isActive, + activeAppPIDs: loopbackManager.activeApps, + bufferStats: loopbackManager.getRingBuffer()?.stats + ) + } + func start() { // Monitors have internal guards against double-starting if permission.status == .authorized { @@ -658,29 +729,70 @@ final class AudioEngine { } /// Enables loopback routing for a specific app. - /// Creates the shared memory ring buffer if not already active. + /// Switches the tap to `.unmuted` so the app plays through its own device normally. + /// The IO callback captures raw audio to the ring buffer for the loopback virtual device. func enableLoopback(for app: AudioApp) throws { - let buffer = try loopbackManager.enableLoopback() + guard let tap = taps[app.id] else { + logger.warning("Cannot enable loopback for \(app.name): no active tap") + return + } + + // Disable any existing loopback routing first (only one app at a time — SPSC ring buffer) + for existingPID in loopbackManager.activeApps { + if existingPID != app.id, let existingTap = taps[existingPID] { + existingTap.setLoopbackBuffer(nil) + // Restore muted behavior on the evicted app + if let ptc = existingTap as? ProcessTapController { + ptc.isUnmutedCapture = false + Task { try? await existingTap.refreshTapSource(existingTap.tapSourceDeviceUID) } + } + } + loopbackManager.removeApp(existingPID) + } + + // Read the actual device sample rate from the tap's aggregate device + let actualSampleRate: Float64 + if let aggID = (tap as? ProcessTapController)?.primaryAggregateDeviceID, + let sr = try? aggID.readNominalSampleRate() { + actualSampleRate = sr + } else { + actualSampleRate = Self.kDefaultLoopbackSampleRate + } + + let buffer = try loopbackManager.enableLoopback(sampleRate: actualSampleRate, channels: 2) loopbackManager.addApp(app.id) + tap.setLoopbackBuffer(buffer) - // Assign the ring buffer to the app's tap - if let tap = taps[app.id] { - tap.setLoopbackBuffer(buffer) + // Switch to unmuted capture: app plays normally, we just observe and capture + if let ptc = tap as? ProcessTapController, !ptc.isUnmutedCapture { + ptc.isUnmutedCapture = true + Task { + try? await tap.refreshTapSource(tap.tapSourceDeviceUID) + logger.info("Tap switched to unmuted capture for \(app.name)") + } } - logger.info("Loopback enabled for \(app.name)") + logger.info("Loopback enabled for \(app.name) at \(actualSampleRate)Hz") } /// Disables loopback routing for a specific app. + /// Restores `.mutedWhenTapped` so FineTune volume/EQ controls work again. func disableLoopback(for app: AudioApp) { loopbackManager.removeApp(app.id) - // Remove the ring buffer from the tap if let tap = taps[app.id] { tap.setLoopbackBuffer(nil) + + // Restore muted behavior for normal FineTune control + if let ptc = tap as? ProcessTapController, ptc.isUnmutedCapture { + ptc.isUnmutedCapture = false + Task { + try? await tap.refreshTapSource(tap.tapSourceDeviceUID) + logger.info("Tap restored to muted for \(app.name)") + } + } } - // If no more apps are using loopback, disable the system if loopbackManager.activeApps.isEmpty { loopbackManager.disableLoopback() } @@ -709,6 +821,10 @@ final class AudioEngine { ) tap.setLoopbackBuffer(buffer) loopbackManager.addApp(pid) + if let ptc = tap as? ProcessTapController { + ptc.isUnmutedCapture = true + Task { try? await tap.refreshTapSource(tap.tapSourceDeviceUID) } + } logger.info("Loopback reassigned to \(tap.app.name)") return } catch { @@ -1345,6 +1461,11 @@ final class AudioEngine { ) tap.setLoopbackBuffer(buffer) loopbackManager.addApp(app.id) + // Enable unmuted capture so app plays normally + if let ptc = tap as? ProcessTapController { + ptc.isUnmutedCapture = true + Task { try? await tap.refreshTapSource(tap.tapSourceDeviceUID) } + } logger.info("Loopback auto-enabled for \(app.name) (exclusive)") } catch { // TODO: M10 — Surface this error to the user via notification or UI state diff --git a/FineTune/Audio/Engine/ProcessTapController.swift b/FineTune/Audio/Engine/ProcessTapController.swift index e61b072e..7f6e645c 100644 --- a/FineTune/Audio/Engine/ProcessTapController.swift +++ b/FineTune/Audio/Engine/ProcessTapController.swift @@ -38,6 +38,10 @@ final class ProcessTapController: ProcessTapControlling { /// Non-nil means stream-specific tap; nil means stereo mixdown. var tapSourceDeviceUID: String? { preferredTapSourceDeviceUID } + /// Exposes the primary aggregate device ID for sample rate queries. + /// Used by AudioEngine to create loopback ring buffers at the actual device sample rate. + var primaryAggregateDeviceID: AudioObjectID { primaryResources.aggregateDeviceID } + // MARK: - RT-Safe State (nonisolated(unsafe) for lock-free audio thread access) // // These variables are accessed from CoreAudio's real-time thread without locks. @@ -76,6 +80,22 @@ final class ProcessTapController: ProcessTapControlling { /// Set once any audio callback has rendered at least one buffer. private nonisolated(unsafe) var _hasRenderedAudio: Bool = false + // MARK: - IO Diagnostics (written from RT thread, read from main thread) + + /// Total number of IO callbacks fired since activation. + private nonisolated(unsafe) var _ioCallbackCount: UInt64 = 0 + /// Total audio frames processed since activation. + private nonisolated(unsafe) var _ioTotalFrames: UInt64 = 0 + /// mach_absolute_time of the very first callback. + private nonisolated(unsafe) var _ioFirstTimestamp: UInt64 = 0 + /// Peak level of the raw input signal (before processing). + private nonisolated(unsafe) var _inputPeakLevel: Float = 0 + /// Per-output-buffer peak levels after processing (up to 8 sub-devices). + /// Using a fixed-size tuple for RT-safety (no heap allocation). + private nonisolated(unsafe) var _outputPeaks: (Float, Float, Float, Float, Float, Float, Float, Float) = (0,0,0,0,0,0,0,0) + /// Number of output buffers in the current aggregate device. + private nonisolated(unsafe) var _outputBufferCount: Int = 0 + /// Callback role identification — RT-safe via atomic UInt32 reads. /// Each IO proc closure captures an immutable callbackID at creation. /// The callback compares against these to determine primary/secondary role. @@ -123,6 +143,15 @@ final class ProcessTapController: ProcessTapControlling { /// Set from main thread; read from HAL I/O thread (pointer read is atomic on ARM64/x86-64). private nonisolated(unsafe) var _loopbackBuffer: LoopbackRingBuffer? + /// When true, the process tap uses `.unmuted` behavior so the app's own audio + /// continues to play normally. The IO callback captures raw audio to the loopback + /// ring buffer and writes silence to the aggregate output (prevents double audio). + private nonisolated(unsafe) var _unmutedCapture: Bool = false + var isUnmutedCapture: Bool { + get { _unmutedCapture } + set { _unmutedCapture = newValue } + } + // Target device UIDs for synchronized multi-output (first is clock source) private var targetDeviceUIDs: [String] // Current active device UIDs @@ -155,6 +184,84 @@ final class ProcessTapController: ProcessTapControlling { return Double(info.numer) / Double(info.denom) }() + // MARK: - IO Stats (read-only diagnostic snapshot) + + /// Diagnostic snapshot of IO callback health. + struct IOStats { + let callbackCount: UInt64 + let totalFrames: UInt64 + let callbacksPerSecond: Double + let framesPerCallback: Double + let inputPeak: Float + let outputPeaks: [Float] + let lastCallbackAgo: Double // seconds since last callback + let tapSampleRate: Float64 + } + + /// Returns a non-blocking snapshot of IO callback statistics. + var ioStats: IOStats { + let count = _ioCallbackCount + let frames = _ioTotalFrames + let first = _ioFirstTimestamp + let last = _lastRenderHostTime + let now = mach_absolute_time() + + let elapsedNanos = first > 0 ? Double(now - first) * Self.hostTimeNanosScale : 0 + let elapsedSec = elapsedNanos / 1_000_000_000.0 + let cps = elapsedSec > 0.1 ? Double(count) / elapsedSec : 0 + let fpc = count > 0 ? Double(frames) / Double(count) : 0 + + let lastAgoNanos = last > 0 ? Double(now - last) * Self.hostTimeNanosScale : Double.infinity + let lastAgo = lastAgoNanos / 1_000_000_000.0 + + let peaks = _outputPeaks + var peakArray: [Float] = [] + let subDeviceCount = currentDeviceUIDs.count + if subDeviceCount > 0 { + let primaryPeak = peaks.0 + for i in 0.. 0 ? peaks.1 : primaryPeak) + case 2: peakArray.append(peaks.2 > 0 ? peaks.2 : primaryPeak) + case 3: peakArray.append(peaks.3 > 0 ? peaks.3 : primaryPeak) + case 4: peakArray.append(peaks.4 > 0 ? peaks.4 : primaryPeak) + case 5: peakArray.append(peaks.5 > 0 ? peaks.5 : primaryPeak) + case 6: peakArray.append(peaks.6 > 0 ? peaks.6 : primaryPeak) + case 7: peakArray.append(peaks.7 > 0 ? peaks.7 : primaryPeak) + default: peakArray.append(primaryPeak) + } + } + } + } else { + let bufCount = _outputBufferCount + if bufCount > 0 { peakArray.append(peaks.0) } + if bufCount > 1 { peakArray.append(peaks.1) } + if bufCount > 2 { peakArray.append(peaks.2) } + if bufCount > 3 { peakArray.append(peaks.3) } + if bufCount > 4 { peakArray.append(peaks.4) } + if bufCount > 5 { peakArray.append(peaks.5) } + if bufCount > 6 { peakArray.append(peaks.6) } + if bufCount > 7 { peakArray.append(peaks.7) } + } + + return IOStats( + callbackCount: count, + totalFrames: frames, + callbacksPerSecond: cps, + framesPerCallback: fpc, + inputPeak: _inputPeakLevel, + outputPeaks: peakArray, + lastCallbackAgo: lastAgo, + tapSampleRate: _tapSampleRate + ) + } + + /// The sample rate the tap was created at. + private nonisolated(unsafe) var _tapSampleRate: Float64 = 0 + /// Returns true when the audio callback has run within the requested interval. func hasRecentAudioCallback(within seconds: Double) -> Bool { let last = _lastRenderHostTime @@ -371,7 +478,7 @@ final class ProcessTapController: ProcessTapControlling { if let outputStream = outputStreamIndex(for: deviceUID) { let streamTap = CATapDescription(processes: app.processObjectIDs, deviceUID: deviceUID, stream: outputStream) streamTap.uuid = UUID() - streamTap.muteBehavior = .mutedWhenTapped + streamTap.muteBehavior = _unmutedCapture ? .unmuted : .mutedWhenTapped streamTap.isPrivate = true var tapID: AudioObjectID = .unknown @@ -391,7 +498,7 @@ final class ProcessTapController: ProcessTapControlling { let mixdownTap = CATapDescription(stereoMixdownOfProcesses: app.processObjectIDs) mixdownTap.uuid = UUID() - mixdownTap.muteBehavior = .mutedWhenTapped + mixdownTap.muteBehavior = _unmutedCapture ? .unmuted : .mutedWhenTapped mixdownTap.isPrivate = true var mixdownTapID: AudioObjectID = .unknown @@ -433,6 +540,9 @@ final class ProcessTapController: ProcessTapControlling { primaryResources.tapID = tapID logger.debug("Created process tap #\(tapID)") + if let asbd = try? tapID.readAudioTapStreamBasicDescription() { + _tapSampleRate = asbd.mSampleRate + } // Build multi-device aggregate description // First device is clock source, others have drift compensation for sync @@ -457,12 +567,10 @@ final class ProcessTapController: ProcessTapControlling { throw NSError(domain: "ProcessTapController", code: -1, userInfo: [NSLocalizedDescriptionKey: "Aggregate device not ready within timeout"]) } - // Force aggregate device buffer size to 2048 frames (~46ms at 44.1kHz). - // Without this, macOS negotiates the buffer to the smallest sub-device - // value (often 128 frames = 2.9ms), which causes client timeout overloads - // because rekordbox/Ableton can't complete their IO callback in 2.9ms - // under any CPU pressure. - var preferredBufferSize: UInt32 = 2048 + // Set aggregate buffer size to 128 frames (~2.9ms at 44.1kHz). + // Without a floor, macOS negotiates to sub-device minimum (sometimes 16-32 frames) + // which causes callback overloads and distortion. 128 is the smallest safe value. + var preferredBufferSize: UInt32 = 128 var bufferSizeAddress = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyBufferFrameSize, mScope: kAudioObjectPropertyScopeGlobal, @@ -477,8 +585,6 @@ final class ProcessTapController: ProcessTapControlling { ) if setErr != noErr { logger.warning("Failed to set aggregate buffer size to \(preferredBufferSize): \(setErr)") - } else { - logger.info("Set aggregate buffer size to \(preferredBufferSize) frames") } logger.debug("Created aggregate device #\(self.primaryResources.aggregateDeviceID)") @@ -927,6 +1033,10 @@ final class ProcessTapController: ProcessTapControlling { private func promoteSecondaryToPrimary() { primaryResources = secondaryResources secondaryResources = TapResources() + let tapID = primaryResources.tapID + if tapID.isValid, let asbd = try? tapID.readAudioTapStreamBasicDescription() { + _tapSampleRate = asbd.mSampleRate + } if let deviceSampleRate = try? primaryResources.aggregateDeviceID.readNominalSampleRate() { let rampTimeSeconds: Float = 0.030 @@ -1305,6 +1415,8 @@ final class ProcessTapController: ProcessTapControlling { ) { _lastRenderHostTime = mach_absolute_time() _hasRenderedAudio = true + _ioCallbackCount &+= 1 + if _ioFirstTimestamp == 0 { _ioFirstTimestamp = _lastRenderHostTime } let isPrimary = (callbackID == _primaryCallbackID) let isSecondary = !isPrimary && (callbackID == _secondaryCallbackID) @@ -1334,6 +1446,35 @@ final class ProcessTapController: ProcessTapControlling { return } + // Unmuted capture mode: the app plays through its own device (tap is .unmuted). + // We capture raw input audio to the loopback ring buffer and silence the + // aggregate output to prevent double audio. + if _unmutedCapture { + if isPrimary, let loopback = _loopbackBuffer { + // Write raw input directly to ring buffer (no volume/EQ processing) + if let firstInput = inputBuffers.first, let inputData = firstInput.mData { + let inputSamples = inputData.assumingMemoryBound(to: Float.self) + let channels = max(1, Int(firstInput.mNumberChannels)) + let sampleCount = Int(firstInput.mDataByteSize) / MemoryLayout.size + let frameCount = sampleCount / channels + loopback.write(inputSamples, frameCount: frameCount, channels: channels) + + // Track peak level for VU meter even in unmuted mode + var maxPeak: Float = 0.0 + for i in stride(from: 0, to: sampleCount, by: channels) { + let absSample = abs(inputSamples[i]) + if absSample > maxPeak { maxPeak = absSample } + } + _peakLevel = _peakLevel + levelSmoothingFactor * (min(maxPeak, 1.0) - _peakLevel) + } + } + // Silence output to prevent double audio + for buf in outputBuffers { + if let data = buf.mData { memset(data, 0, Int(buf.mDataByteSize)) } + } + return + } + // Track peak level for VU meter var maxPeak: Float = 0.0 var totalSamplesThisBuffer: Int = 0 @@ -1354,6 +1495,8 @@ final class ProcessTapController: ProcessTapControlling { if isPrimary { _peakLevel = _peakLevel + levelSmoothingFactor * (rawPeak - _peakLevel) + _inputPeakLevel = _inputPeakLevel + levelSmoothingFactor * (rawPeak - _inputPeakLevel) + _ioTotalFrames &+= UInt64(totalSamplesThisBuffer) } else { _secondaryPeakLevel = _secondaryPeakLevel + levelSmoothingFactor * (rawPeak - _secondaryPeakLevel) // Only the secondary callback advances crossfade progress (single-writer pattern). @@ -1423,6 +1566,45 @@ final class ProcessTapController: ProcessTapControlling { if isPrimary { _primaryCurrentVolume = currentVol + + // Per-output peak measurement (RT-safe: tuple of Floats) + _outputBufferCount = outputBuffers.count + var peaks: (Float, Float, Float, Float, Float, Float, Float, Float) = (0,0,0,0,0,0,0,0) + for i in 0...size + var peak: Float = 0 + for s in 0.. peak { peak = a } + } + let smoothed: Float + switch i { + case 0: smoothed = _outputPeaks.0 + levelSmoothingFactor * (peak - _outputPeaks.0) + case 1: smoothed = _outputPeaks.1 + levelSmoothingFactor * (peak - _outputPeaks.1) + case 2: smoothed = _outputPeaks.2 + levelSmoothingFactor * (peak - _outputPeaks.2) + case 3: smoothed = _outputPeaks.3 + levelSmoothingFactor * (peak - _outputPeaks.3) + case 4: smoothed = _outputPeaks.4 + levelSmoothingFactor * (peak - _outputPeaks.4) + case 5: smoothed = _outputPeaks.5 + levelSmoothingFactor * (peak - _outputPeaks.5) + case 6: smoothed = _outputPeaks.6 + levelSmoothingFactor * (peak - _outputPeaks.6) + case 7: smoothed = _outputPeaks.7 + levelSmoothingFactor * (peak - _outputPeaks.7) + default: smoothed = 0 + } + switch i { + case 0: peaks.0 = smoothed + case 1: peaks.1 = smoothed + case 2: peaks.2 = smoothed + case 3: peaks.3 = smoothed + case 4: peaks.4 = smoothed + case 5: peaks.5 = smoothed + case 6: peaks.6 = smoothed + case 7: peaks.7 = smoothed + default: break + } + } + _outputPeaks = peaks } else { _secondaryCurrentVolume = currentVol } diff --git a/FineTune/Audio/Engine/ProcessTapControlling.swift b/FineTune/Audio/Engine/ProcessTapControlling.swift index 96761d96..93c47075 100644 --- a/FineTune/Audio/Engine/ProcessTapControlling.swift +++ b/FineTune/Audio/Engine/ProcessTapControlling.swift @@ -1,3 +1,5 @@ +import AudioToolbox + /// Abstraction over process tap controllers for testability. /// /// **Threading:** Intentionally NOT `@MainActor`. Concrete implementations straddle @@ -36,9 +38,32 @@ protocol ProcessTapControlling: AnyObject { /// so the FineTuneLoopback HAL plugin can read them as an input device. /// Pass nil to disconnect from loopback. func setLoopbackBuffer(_ buffer: LoopbackRingBuffer?) + + /// Enables unmuted capture mode. When true, the process tap uses `.unmuted` + /// behavior so the app's own audio output is NOT silenced. The IO callback + /// captures raw audio to the loopback ring buffer and writes silence to the + /// aggregate output (preventing double audio). Requires tap recreation. + var isUnmutedCapture: Bool { get } + var ioStats: ProcessTapController.IOStats { get } + var primaryAggregateDeviceID: AudioObjectID { get } } extension ProcessTapControlling { + var primaryAggregateDeviceID: AudioObjectID { .unknown } + + var ioStats: ProcessTapController.IOStats { + ProcessTapController.IOStats( + callbackCount: 0, + totalFrames: 0, + callbacksPerSecond: 0, + framesPerCallback: 0, + inputPeak: 0, + outputPeaks: [], + lastCallbackAgo: Double.infinity, + tapSampleRate: 0 + ) + } + /// Convenience: defaults sourceDeviceDead to false. func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String?) async throws { try await switchDevice(to: newDeviceUID, preferredTapSourceDeviceUID: preferredTapSourceDeviceUID, sourceDeviceDead: false) @@ -60,4 +85,6 @@ extension ProcessTapControlling { func setLoopbackBuffer(_ buffer: LoopbackRingBuffer?) { // Default no-op for mocks that don't override } + + var isUnmutedCapture: Bool { false } } diff --git a/FineTune/Audio/Loopback/LoopbackRingBuffer.swift b/FineTune/Audio/Loopback/LoopbackRingBuffer.swift index 8e481fd2..fc1431dc 100644 --- a/FineTune/Audio/Loopback/LoopbackRingBuffer.swift +++ b/FineTune/Audio/Loopback/LoopbackRingBuffer.swift @@ -252,6 +252,57 @@ final class LoopbackRingBuffer: @unchecked Sendable { // Write host time AFTER writeHead so consumer sees consistent data header.pointee.hostTime = mach_absolute_time() } + + // MARK: - Ring Buffer Diagnostics + + struct BufferStats { + let writeHead: UInt64 + let readHead: UInt64 + let fillLevel: Double // (writeHead - readHead) / bufferFrames + let isActive: Bool + let sampleRate: Float64 + let channels: UInt32 + let bufferFrames: UInt32 + let isOverrun: Bool // fillLevel > 0.9 + let isUnderrun: Bool // fillLevel < 0.05 && isActive + } + + var stats: BufferStats { + guard let header else { + return BufferStats( + writeHead: 0, + readHead: 0, + fillLevel: 0.0, + isActive: false, + sampleRate: sampleRate, + channels: channels, + bufferFrames: bufferFrames, + isOverrun: false, + isUnderrun: false + ) + } + let wh = header.pointee.writeHead + let rh = header.pointee.readHead + let active = header.pointee.isActive != 0 + let sr = header.pointee.sampleRate + let ch = header.pointee.channels + let bf = header.pointee.bufferFrames + + let framesAvailable = wh >= rh ? Double(wh - rh) : 0.0 + let fill = bf > 0 ? framesAvailable / Double(bf) : 0.0 + + return BufferStats( + writeHead: wh, + readHead: rh, + fillLevel: fill, + isActive: active, + sampleRate: sr, + channels: ch, + bufferFrames: bf, + isOverrun: fill > 0.9, + isUnderrun: fill < 0.05 && active + ) + } } // MARK: - Errors diff --git a/FineTune/Views/Debug/AudioDebugView.swift b/FineTune/Views/Debug/AudioDebugView.swift new file mode 100644 index 00000000..909d88c0 --- /dev/null +++ b/FineTune/Views/Debug/AudioDebugView.swift @@ -0,0 +1,774 @@ +// FineTune/Views/Debug/AudioDebugView.swift +// Real-time audio routing debug panel for FineTune + +import SwiftUI +import Combine + +/// Floating debug panel showing real-time audio routing state. +/// Shows per-app tap status, device routing, audio levels, and loopback state. +struct AudioDebugView: View { + let audioEngine: AudioEngine + + @State private var refreshTick = 0 + @State private var selectedAppPID: pid_t? = nil + private let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Header & App Selector + headerSection + + if let appInfo = selectedAppInfo { + // 1. Visual Routing Diagram + routingDiagramSection(appInfo) + + // 2. Sample Rate Chain View + sampleRateChainSection(appInfo) + + // 3. IO Callback Stats Card + ioStatsCard(appInfo) + + // 4. Per-Output Signal Card + outputSignalCard(appInfo) + } else { + noAppSelectedView + } + + // 5. Ring Buffer Card (Global / Loopback diagnostics) + loopbackCard + } + .padding(16) + } + .frame(minWidth: 620, idealWidth: 700, minHeight: 500, idealHeight: 750) + .darkGlassBackground() + .onReceive(timer) { _ in + refreshTick += 1 + // Auto-select first app if none selected + let infos = audioEngine.debugTapInfos + if selectedAppPID == nil, let first = infos.first { + selectedAppPID = first.pid + } else if let currentPID = selectedAppPID, !infos.contains(where: { $0.pid == currentPID }) { + // If selected app disappeared, fallback to first + selectedAppPID = infos.first?.pid + } + } + } + + // MARK: - Header & Selector + + private var headerSection: some View { + HStack { + Image(systemName: "terminal.fill") + .vibrancyIcon(.primary) + .font(.title2) + Text("FineTune Diagnostics") + .font(.title3) + .fontWeight(.bold) + .foregroundStyle(DesignTokens.Colors.textPrimary) + + Spacer() + + let infos = audioEngine.debugTapInfos + if !infos.isEmpty { + Picker("Inspect App:", selection: $selectedAppPID) { + ForEach(infos, id: \.pid) { info in + Text("\(info.appName) (PID \(info.pid))") + .tag(info.pid as pid_t?) + } + } + .pickerStyle(.menu) + .frame(width: 220) + } + } + .padding(.bottom, 8) + } + + private var noAppSelectedView: some View { + VStack(spacing: 12) { + Image(systemName: "waveform.badge.exclamationmark") + .font(.largeTitle) + .vibrancyIcon(.tertiary) + Text("No active audio apps or taps found") + .font(.headline) + .foregroundStyle(DesignTokens.Colors.textPrimary) + Text("Open an app configured in FineTune and play audio to begin diagnostics.") + .font(.subheadline) + .foregroundStyle(DesignTokens.Colors.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(40) + .eqCardBackground() + } + + private var selectedAppInfo: AudioEngine.TapDebugInfo? { + audioEngine.debugTapInfos.first(where: { $0.pid == selectedAppPID }) ?? audioEngine.debugTapInfos.first + } + + // MARK: - Visual Routing Diagram + + @ViewBuilder + private func routingDiagramSection(_ info: AudioEngine.TapDebugInfo) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Audio Signal Routing") + .sectionHeaderStyle() + + VStack(spacing: 0) { + // Top Row: App -> Tap -> Aggregate + HStack(alignment: .center, spacing: 0) { + // Node 1: App + DiagnosticNode( + title: info.appName, + subtitle: "PID \(info.pid)", + icon: "app.window.description", + statusColor: info.audioLevel > 0.01 ? .green : .orange + ) { + HStack { + Text(info.bundleID ?? "no bundle ID") + .font(.system(size: 8)) + .foregroundStyle(DesignTokens.Colors.textTertiary) + .lineLimit(1) + Spacer() + } + } + + ConnectorArrow(direction: .right) + + // Node 2: Process Tap + let isMutedText = info.isMuted ? "muted" : "unmuted" + let captureText = info.isUnmutedCapture ? "passthrough" : "controlled" + DiagnosticNode( + title: "Process Tap", + subtitle: String(format: "%.1f kHz", info.sampleRate / 1000.0), + icon: "waveform.path", + statusColor: info.ioStats != nil ? .green : .orange + ) { + HStack { + Text("\(isMutedText) • \(captureText)") + .font(.system(size: 8)) + .foregroundStyle(info.isMuted ? Color.orange : Color.green) + Spacer() + } + } + + ConnectorArrow(direction: .right) + + // Node 3: Aggregate Output Device + let deviceName = info.deviceUID.flatMap { + audioEngine.deviceMonitor.device(for: $0)?.name + } ?? "None" + DiagnosticNode( + title: "Aggregate Out", + subtitle: deviceName, + icon: "speaker.wave.2.fill" + ) { + VStack(alignment: .leading, spacing: 3) { + ForEach(info.deviceUIDs, id: \.self) { uid in + let name = audioEngine.deviceMonitor.device(for: uid)?.name ?? uid + HStack(spacing: 4) { + Text("▸") + .font(.caption) + .foregroundStyle(DesignTokens.Colors.accentPrimary) + Text(name) + .font(.system(size: 8)) + .lineLimit(1) + Spacer() + Circle() + .fill(Color.green) + .frame(width: 4, height: 4) + } + } + } + } + } + + // Down connection from Tap + HStack(spacing: 0) { + Spacer().frame(width: 170 + 30 + 85) // align to center of Tap Node + ConnectorArrow(direction: .down) + Spacer() + } + + // Bottom Row: Ring Buffer -> Ableton + HStack(alignment: .center, spacing: 0) { + Spacer().frame(width: 170 + 30) // App node width + arrow + + let loopbackInfo = audioEngine.debugLoopbackInfo + let ringStats = loopbackInfo.bufferStats + let isBufferActive = loopbackInfo.isActive && info.isLoopbackEnabled + + // Node 4: Ring Buffer + DiagnosticNode( + title: "Ring Buffer", + subtitle: ringStats != nil ? String(format: "%.1f kHz", ringStats!.sampleRate / 1000.0) : "Offline", + icon: "arrow.triangle.2.circlepath", + statusColor: isBufferActive ? .green : .orange + ) { + VStack(alignment: .leading, spacing: 2) { + if let stats = ringStats, info.isLoopbackEnabled { + Text(String(format: "%.0f%% full", stats.fillLevel * 100)) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(stats.isOverrun ? .red : (stats.isUnderrun ? .orange : .green)) + } else { + Text("disabled") + .font(.system(size: 8)) + .foregroundStyle(DesignTokens.Colors.textTertiary) + } + } + } + + ConnectorArrow(direction: .right) + + // Node 5: Ableton / DAW + DiagnosticNode( + title: "DAW (Ableton)", + subtitle: "Virtual Input", + icon: "waveform.circle", + statusColor: isBufferActive ? .green : .secondary + ) { + HStack { + Text(isBufferActive ? "Receiving Loopback" : "Idle / Stopped") + .font(.system(size: 8)) + .foregroundStyle(isBufferActive ? .green : .secondary) + Spacer() + } + } + + Spacer() // fill remainder to keep layout balanced + } + } + } + } + + // MARK: - Sample Rate Chain View + + @ViewBuilder + private func sampleRateChainSection(_ info: AudioEngine.TapDebugInfo) -> some View { + let appSR = info.sampleRate + let tapSR = info.sampleRate + let aggSR = info.deviceSampleRate + + let loopbackInfo = audioEngine.debugLoopbackInfo + let bufferSR = loopbackInfo.bufferStats?.sampleRate ?? 0.0 + + let hasMismatch = info.sampleRateMismatch || (info.isLoopbackEnabled && bufferSR > 0 && abs(aggSR - bufferSR) > 0.1) + + VStack(alignment: .leading, spacing: 6) { + Text("Sample Rate Chain") + .sectionHeaderStyle() + + HStack(spacing: 8) { + VStack(spacing: 4) { + Text("App Source") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Text(appSR > 0 ? String(format: "%.1f kHz", appSR / 1000.0) : "Auto") + .font(.system(size: 11, design: .monospaced)) + } + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(DesignTokens.Colors.textTertiary) + + VStack(spacing: 4) { + Text("Process Tap") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Text(tapSR > 0 ? String(format: "%.1f kHz", tapSR / 1000.0) : "Unknown") + .font(.system(size: 11, design: .monospaced)) + } + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(DesignTokens.Colors.textTertiary) + + VStack(spacing: 4) { + Text("Aggregate") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Text(aggSR > 0 ? String(format: "%.1f kHz", aggSR / 1000.0) : "Offline") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(info.sampleRateMismatch ? Color.red : Color.primary) + } + + if info.isLoopbackEnabled { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(DesignTokens.Colors.textTertiary) + + VStack(spacing: 4) { + Text("Ring Buffer") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Text(bufferSR > 0 ? String(format: "%.1f kHz", bufferSR / 1000.0) : "Offline") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle((bufferSR > 0 && abs(aggSR - bufferSR) > 0.1) ? Color.red : Color.primary) + } + } + + Spacer() + + if hasMismatch { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text("SR MISMATCH") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.red) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.red.opacity(0.15)) + .cornerRadius(4) + } else { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("ALIGNED") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.green) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.green.opacity(0.15)) + .cornerRadius(4) + } + } + .padding(10) + .eqCardBackground() + } + } + + // MARK: - IO Callback Stats Card + + @ViewBuilder + private func ioStatsCard(_ info: AudioEngine.TapDebugInfo) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label("IO Callback Metrics", systemImage: "clock.fill") + .font(DesignTokens.Typography.cardHeader) + .foregroundStyle(DesignTokens.Colors.textPrimary) + + if let stats = info.ioStats { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + metricRow("Callbacks Fired", "\(stats.callbackCount)") + metricRow("Callbacks / Sec", String(format: "%.2f Hz", stats.callbacksPerSecond)) + metricRow("Frames / Callback", String(format: "%.1f", stats.framesPerCallback)) + metricRow("Total Frames", "\(stats.totalFrames)") + } + + Spacer() + Divider() + Spacer() + + VStack(alignment: .leading, spacing: 6) { + metricRow("Last Callback", stats.lastCallbackAgo == Double.infinity ? "never" : String(format: "%.3f s ago", stats.lastCallbackAgo)) + + let level = stats.inputPeak + VStack(alignment: .leading, spacing: 3) { + Text("Input Peak Level") + .font(.system(size: 10)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + HStack { + VUMeterView(level: level, isMuted: info.isMuted) + Text(String(format: "%.2f", level)) + .font(.system(size: 10, design: .monospaced)) + .frame(width: 32, alignment: .trailing) + } + } + } + } + } else { + Text("No IO callback stats recorded. Play audio to start callback metrics.") + .font(.system(size: 11)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + } + } + .padding(16) + .eqCardBackground() + } + + @ViewBuilder + private func metricRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .font(.system(size: 10)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Spacer() + Text(value) + .font(.system(size: 10, design: .monospaced)) + .fontWeight(.medium) + } + } + + // MARK: - Per-Output Signal Card + + @ViewBuilder + private func outputSignalCard(_ info: AudioEngine.TapDebugInfo) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label("Sub-Device Outputs", systemImage: "speaker.wave.2.fill") + .font(DesignTokens.Typography.cardHeader) + .foregroundStyle(DesignTokens.Colors.textPrimary) + + if info.deviceUIDs.isEmpty { + Text("No output aggregate sub-devices connected.") + .font(.system(size: 11)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + } else { + VStack(spacing: 8) { + ForEach(0.. 0 && info.sampleRate > 0 && abs(subSampleRate - info.sampleRate) > 0.1 + + let peak = info.ioStats?.outputPeaks[safe: index] ?? 0.0 + + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading, spacing: 1) { + Text(name) + .font(.system(size: 11, weight: .semibold)) + Text(uid) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(DesignTokens.Colors.textTertiary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 1) { + HStack(spacing: 4) { + Text(subSampleRate > 0 ? String(format: "%.1f kHz", subSampleRate / 1000.0) : "Offline") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(isMismatch ? Color.red : DesignTokens.Colors.textSecondary) + + if isMismatch { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 9)) + .foregroundStyle(.red) + } + } + + Text(isMismatch ? "Mismatch" : "Aligned") + .font(.system(size: 8)) + .foregroundStyle(isMismatch ? Color.red : Color.green) + } + } + + HStack { + VUMeterView(level: peak, isMuted: info.isMuted) + Text(String(format: "%.2f", peak)) + .font(.system(size: 9, design: .monospaced)) + .frame(width: 32, alignment: .trailing) + } + } + .padding(8) + .background(DesignTokens.Colors.recessedBackground) + .cornerRadius(6) + } + } + } + } + .padding(16) + .eqCardBackground() + } + + // MARK: - Ring Buffer Card + + @ViewBuilder + private var loopbackCard: some View { + let info = audioEngine.debugLoopbackInfo + + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: "arrow.triangle.2.circlepath") + .vibrancyIcon(.primary) + Text("Loopback Ring Buffer Diagnostics") + .font(DesignTokens.Typography.cardHeader) + .foregroundStyle(DesignTokens.Colors.textPrimary) + Spacer() + if info.isActive { + Text("🟢 ACTIVE") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.green) + } else { + Text("⚪ INACTIVE") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + } + } + + if !info.isDriverInstalled { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text("FineTune Loopback driver is not installed.") + .font(.system(size: 11)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + } + .padding(10) + .background(Color.red.opacity(0.1)) + .cornerRadius(6) + } else if let stats = info.bufferStats { + HStack(spacing: 20) { + // Gauge representation + VStack(spacing: 4) { + ZStack { + Circle() + .stroke(DesignTokens.Colors.sliderTrack, lineWidth: 6) + .frame(width: 60, height: 60) + Circle() + .trim(from: 0, to: CGFloat(min(max(stats.fillLevel, 0), 1))) + .stroke(stats.isOverrun ? Color.red : (stats.isUnderrun ? Color.orange : DesignTokens.Colors.accentPrimary), lineWidth: 6) + .rotationEffect(.degrees(-90)) + .frame(width: 60, height: 60) + .animation(.spring(), value: stats.fillLevel) + + Text(String(format: "%.0f%%", stats.fillLevel * 100)) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + } + Text("Fill Level") + .font(.system(size: 9)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Write Head:") + .font(.system(size: 10)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Spacer() + Text("\(stats.writeHead)") + .font(.system(size: 10, design: .monospaced)) + } + HStack { + Text("Read Head:") + .font(.system(size: 10)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Spacer() + Text("\(stats.readHead)") + .font(.system(size: 10, design: .monospaced)) + } + HStack { + Text("Buffer Frames:") + .font(.system(size: 10)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Spacer() + Text("\(stats.bufferFrames) frames") + .font(.system(size: 10, design: .monospaced)) + } + HStack { + Text("Sample Rate:") + .font(.system(size: 10)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + Spacer() + Text(String(format: "%.1f kHz", stats.sampleRate / 1000.0)) + .font(.system(size: 10, design: .monospaced)) + } + } + .frame(maxWidth: .infinity) + + VStack(spacing: 8) { + if stats.isOverrun { + Label("OVERRUN", systemImage: "exclamationmark.octagon.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.red) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red.opacity(0.1)) + .cornerRadius(4) + } + if stats.isUnderrun { + Label("UNDERRUN", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.orange) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.orange.opacity(0.1)) + .cornerRadius(4) + } + + if !stats.isOverrun && !stats.isUnderrun { + Label("HEALTHY", systemImage: "checkmark.circle.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.green) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.green.opacity(0.1)) + .cornerRadius(4) + } + } + } + } else { + Text("Loopback ring buffer is currently inactive/unallocated.") + .font(.system(size: 11)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + } + } + .padding(16) + .eqCardBackground() + } +} + +// MARK: - Diagnostic Node Component + +struct DiagnosticNode: View { + let title: String + let subtitle: String? + let icon: String + let statusColor: Color + let content: Content + + init(title: String, subtitle: String? = nil, icon: String, statusColor: Color = .green, @ViewBuilder content: () -> Content) { + self.title = title + self.subtitle = subtitle + self.icon = icon + self.statusColor = statusColor + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: icon) + .vibrancyIcon(.primary) + .font(.system(size: 11)) + Text(title) + .font(.system(size: 11, weight: .bold)) + .lineLimit(1) + Spacer() + Circle() + .fill(statusColor) + .frame(width: 6, height: 6) + } + if let subtitle { + Text(subtitle) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(DesignTokens.Colors.textSecondary) + .lineLimit(1) + } + content + } + .padding(10) + .frame(width: 170, height: 80) + .eqCardBackground() + } +} + +// MARK: - Connector Arrow Component + +struct ConnectorArrow: View { + let direction: Direction + + enum Direction { + case right, down + } + + var body: some View { + Group { + if direction == .right { + HStack(spacing: 0) { + Rectangle() + .fill(DesignTokens.Colors.textTertiary.opacity(0.5)) + .frame(height: 1.5) + Image(systemName: "play.fill") + .font(.system(size: 8)) + .foregroundStyle(DesignTokens.Colors.textTertiary.opacity(0.5)) + .offset(x: -2) + } + .frame(width: 30) + } else { + VStack(spacing: 0) { + Rectangle() + .fill(DesignTokens.Colors.textTertiary.opacity(0.5)) + .frame(width: 1.5, height: 25) + Image(systemName: "triangle.fill") + .font(.system(size: 8)) + .rotationEffect(.degrees(180)) + .foregroundStyle(DesignTokens.Colors.textTertiary.opacity(0.5)) + .offset(y: -2) + } + } + } + } +} + +// MARK: - Professional Audio VU Meter + +struct VUMeterView: View { + let level: Float + let isMuted: Bool + + var body: some View { + HStack(spacing: 3) { + ForEach(0..<8) { index in + let threshold = Float(index) / 8.0 + let isLit = level > threshold + + RoundedRectangle(cornerRadius: 1.5) + .fill(colorForSegment(index, isLit: isLit)) + .frame(height: 6) + } + } + } + + private func colorForSegment(_ index: Int, isLit: Bool) -> Color { + guard isLit else { return DesignTokens.Colors.vuUnlit } + if isMuted { return DesignTokens.Colors.vuMuted } + + switch index { + case 0...3: return DesignTokens.Colors.vuGreen + case 4...5: return DesignTokens.Colors.vuYellow + case 6: return DesignTokens.Colors.vuOrange + default: return DesignTokens.Colors.vuRed + } + } +} + +// MARK: - Safe Subscript Extension + +extension Array { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +// MARK: - Debug Window Controller + +@MainActor +final class AudioDebugWindowController { + static let shared = AudioDebugWindowController() + private var window: NSWindow? + + func showWindow(audioEngine: AudioEngine) { + if let existing = window, existing.isVisible { + existing.orderFrontRegardless() + return + } + + let debugView = AudioDebugView(audioEngine: audioEngine) + let hostingView = NSHostingView(rootView: debugView) + hostingView.frame = NSRect(x: 0, y: 0, width: 700, height: 750) + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 750), + styleMask: [.titled, .closable, .resizable, .nonactivatingPanel, .utilityWindow], + backing: .buffered, + defer: false + ) + panel.title = "FineTune Diagnostics" + panel.contentView = hostingView + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.center() + panel.orderFrontRegardless() + + self.window = panel + } +} diff --git a/FineTune/Views/MenuBarPopupView.swift b/FineTune/Views/MenuBarPopupView.swift index 01ab0cde..7498db65 100644 --- a/FineTune/Views/MenuBarPopupView.swift +++ b/FineTune/Views/MenuBarPopupView.swift @@ -116,6 +116,7 @@ struct MenuBarPopupView: View { } Spacer() editPriorityButton + debugButton settingsButton } .padding(.bottom, DesignTokens.Spacing.xs) @@ -266,6 +267,29 @@ struct MenuBarPopupView: View { .help(isEditingDevicePriority ? "Done reordering" : "Reorder devices") } + // MARK: - Debug Button + + private var debugButton: some View { + Button(action: { + AudioDebugWindowController.shared.showWindow(audioEngine: audioEngine) + }) { + Image(systemName: "ant.fill") + .font(.system(size: 11)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(DesignTokens.Colors.interactiveDefault) + } + .buttonStyle(.plain) + .font(.system(size: 11)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(DesignTokens.Colors.interactiveDefault) + .frame( + minWidth: DesignTokens.Dimensions.minTouchTarget, + minHeight: DesignTokens.Dimensions.minTouchTarget + ) + .contentShape(Rectangle()) + .help("Audio Debug Panel") + } + // MARK: - Settings Button private var settingsButton: some View { @@ -901,7 +925,16 @@ struct MenuBarPopupView: View { onEQToggle: { toggleEQ(for: displayableApp.id, scrollProxy: scrollProxy) }, - isFocused: hasKeyboardEngaged && selectedRow == .app(persistenceID: displayableApp.id) + isFocused: hasKeyboardEngaged && selectedRow == .app(persistenceID: displayableApp.id), + isLoopbackAvailable: audioEngine.isLoopbackDriverInstalled, + isLoopbackEnabled: audioEngine.isLoopbackEnabled(for: app), + onLoopbackToggle: { + if audioEngine.isLoopbackEnabled(for: app) { + audioEngine.disableLoopback(for: app) + } else { + try? audioEngine.enableLoopback(for: app) + } + } ) .id(PopupKeyboardNavModel.RowID.app(persistenceID: displayableApp.id)) } diff --git a/FineTune/Views/Rows/AppRow.swift b/FineTune/Views/Rows/AppRow.swift index 06ebe2ad..62b8dc9c 100644 --- a/FineTune/Views/Rows/AppRow.swift +++ b/FineTune/Views/Rows/AppRow.swift @@ -32,6 +32,9 @@ struct AppRow: View { let onRenameUserPreset: (UUID, String) -> Void let isEQExpanded: Bool let onEQToggle: () -> Void + let isLoopbackAvailable: Bool + let isLoopbackEnabled: Bool + let onLoopbackToggle: () -> Void let isFocused: Bool @State private var isIconHovered = false @@ -66,7 +69,10 @@ struct AppRow: View { onRenameUserPreset: @escaping (UUID, String) -> Void = { _, _ in }, isEQExpanded: Bool = false, onEQToggle: @escaping () -> Void = {}, - isFocused: Bool = false + isFocused: Bool = false, + isLoopbackAvailable: Bool = false, + isLoopbackEnabled: Bool = false, + onLoopbackToggle: @escaping () -> Void = {} ) { self.app = app self.volume = volume @@ -97,6 +103,9 @@ struct AppRow: View { self.isEQExpanded = isEQExpanded self.onEQToggle = onEQToggle self.isFocused = isFocused + self.isLoopbackAvailable = isLoopbackAvailable + self.isLoopbackEnabled = isLoopbackEnabled + self.onLoopbackToggle = onLoopbackToggle // Initialize local EQ state for reactive UI updates self._localEQSettings = State(initialValue: eqSettings) } @@ -162,6 +171,8 @@ struct AppRow: View { defaultDeviceUID: defaultDeviceUID, deviceSelectionMode: deviceSelectionMode, boost: boost, + isLoopbackAvailable: isLoopbackAvailable, + isLoopbackEnabled: isLoopbackEnabled, isEQExpanded: isEQExpanded, onVolumeChange: onVolumeChange, onMuteChange: onMuteChange, @@ -170,6 +181,7 @@ struct AppRow: View { onDevicesSelected: onDevicesSelected, onDeviceModeChange: onDeviceModeChange, onSelectFollowDefault: onSelectFollowDefault, + onLoopbackToggle: onLoopbackToggle, onEQToggle: onEQToggle ) } diff --git a/FineTune/Views/Rows/AppRowControls.swift b/FineTune/Views/Rows/AppRowControls.swift index 7dc9dbc1..fc71bf5c 100644 --- a/FineTune/Views/Rows/AppRowControls.swift +++ b/FineTune/Views/Rows/AppRowControls.swift @@ -13,6 +13,8 @@ struct AppRowControls: View { let defaultDeviceUID: String? let deviceSelectionMode: DeviceSelectionMode let boost: BoostLevel + let isLoopbackAvailable: Bool + let isLoopbackEnabled: Bool let isEQExpanded: Bool let onVolumeChange: (Float) -> Void let onMuteChange: (Bool) -> Void @@ -21,10 +23,12 @@ struct AppRowControls: View { let onDevicesSelected: (Set) -> Void let onDeviceModeChange: (DeviceSelectionMode) -> Void let onSelectFollowDefault: () -> Void + let onLoopbackToggle: () -> Void let onEQToggle: () -> Void @State private var dragOverrideValue: Double? @State private var isEQButtonHovered = false + @State private var isLoopbackButtonHovered = false private var sliderValue: Double { dragOverrideValue ?? VolumeMapping.gainToSlider(volume) @@ -125,6 +129,34 @@ struct AppRowControls: View { triggerStyle: .iconOnly ) + // Loopback routing button (only visible when driver is installed) + if isLoopbackAvailable { + Button { + onLoopbackToggle() + } label: { + Image(systemName: isLoopbackEnabled ? "record.circle.fill" : "record.circle") + .font(.system(size: 12)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle( + isLoopbackEnabled + ? Color.red + : (isLoopbackButtonHovered + ? DesignTokens.Colors.interactiveHover + : DesignTokens.Colors.interactiveDefault) + ) + .frame( + minWidth: DesignTokens.Dimensions.minTouchTarget, + minHeight: DesignTokens.Dimensions.minTouchTarget + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isLoopbackEnabled ? "Disable Loopback" : "Enable Loopback") + .onHover { isLoopbackButtonHovered = $0 } + .help(isLoopbackEnabled ? "Disable loopback routing" : "Route to FineTune Loopback") + .animation(DesignTokens.Animation.hover, value: isLoopbackButtonHovered) + } + // EQ button Button { onEQToggle() diff --git a/FineTune/Views/Rows/AppRowWithLevelPolling.swift b/FineTune/Views/Rows/AppRowWithLevelPolling.swift index 6ffd8fff..9551e372 100644 --- a/FineTune/Views/Rows/AppRowWithLevelPolling.swift +++ b/FineTune/Views/Rows/AppRowWithLevelPolling.swift @@ -33,6 +33,9 @@ struct AppRowWithLevelPolling: View { let isEQExpanded: Bool let onEQToggle: () -> Void let isFocused: Bool + let isLoopbackAvailable: Bool + let isLoopbackEnabled: Bool + let onLoopbackToggle: () -> Void @State private var displayLevel: Float = 0 @State private var levelTimer: Timer? @@ -67,7 +70,10 @@ struct AppRowWithLevelPolling: View { onRenameUserPreset: @escaping (UUID, String) -> Void = { _, _ in }, isEQExpanded: Bool = false, onEQToggle: @escaping () -> Void = {}, - isFocused: Bool = false + isFocused: Bool = false, + isLoopbackAvailable: Bool = false, + isLoopbackEnabled: Bool = false, + onLoopbackToggle: @escaping () -> Void = {} ) { self.app = app self.volume = volume @@ -99,6 +105,9 @@ struct AppRowWithLevelPolling: View { self.isEQExpanded = isEQExpanded self.onEQToggle = onEQToggle self.isFocused = isFocused + self.isLoopbackAvailable = isLoopbackAvailable + self.isLoopbackEnabled = isLoopbackEnabled + self.onLoopbackToggle = onLoopbackToggle } var body: some View { @@ -131,7 +140,10 @@ struct AppRowWithLevelPolling: View { onRenameUserPreset: onRenameUserPreset, isEQExpanded: isEQExpanded, onEQToggle: onEQToggle, - isFocused: isFocused + isFocused: isFocused, + isLoopbackAvailable: isLoopbackAvailable, + isLoopbackEnabled: isLoopbackEnabled, + onLoopbackToggle: onLoopbackToggle ) .onAppear { if isPopupVisible { diff --git a/FineTune/Views/Rows/InactiveAppRow.swift b/FineTune/Views/Rows/InactiveAppRow.swift index 3d9fcdf1..7fbb5c7e 100644 --- a/FineTune/Views/Rows/InactiveAppRow.swift +++ b/FineTune/Views/Rows/InactiveAppRow.swift @@ -148,6 +148,8 @@ struct InactiveAppRow: View { defaultDeviceUID: defaultDeviceUID, deviceSelectionMode: deviceSelectionMode, boost: boost, + isLoopbackAvailable: false, + isLoopbackEnabled: false, isEQExpanded: isEQExpanded, onVolumeChange: onVolumeChange, onMuteChange: onMuteChange, @@ -156,6 +158,7 @@ struct InactiveAppRow: View { onDevicesSelected: onDevicesSelected, onDeviceModeChange: onDeviceModeChange, onSelectFollowDefault: onSelectFollowDefault, + onLoopbackToggle: {}, onEQToggle: onEQToggle ) }