From dfbdd8c052927741daa9bf77181f87bc3f1d2d46 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 01:38:04 -0800 Subject: [PATCH 01/33] feat(host): add SimplyCoreAudio dependency and AppFadersTests target Integrate SimplyCoreAudio 4.1.0 for Core Audio device enumeration and add AppFadersTests test target with placeholder test verifying import. --- Package.swift | 9 ++++++++- Tests/AppFadersTests/AppFadersTests.swift | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 Tests/AppFadersTests/AppFadersTests.swift diff --git a/Package.swift b/Package.swift index d53ccfb..4321680 100644 --- a/Package.swift +++ b/Package.swift @@ -15,11 +15,14 @@ let package = Package( dependencies: [ // Pancake is an Xcode project, not SPM - see docs/pancake-compatibility.md // .package(url: "https://github.com/0bmxa/Pancake.git", branch: "master") + .package(url: "https://github.com/rnine/SimplyCoreAudio.git", from: "4.1.0") ], targets: [ .executableTarget( name: "AppFaders", - dependencies: [] + dependencies: [ + .product(name: "SimplyCoreAudio", package: "SimplyCoreAudio") + ] ), // C interface layer for HAL AudioServerPlugIn .target( @@ -54,6 +57,10 @@ let package = Package( .testTarget( name: "AppFadersDriverTests", dependencies: ["AppFadersDriver"] + ), + .testTarget( + name: "AppFadersTests", + dependencies: ["AppFaders"] ) ] ) diff --git a/Tests/AppFadersTests/AppFadersTests.swift b/Tests/AppFadersTests/AppFadersTests.swift new file mode 100644 index 0000000..ebcfae5 --- /dev/null +++ b/Tests/AppFadersTests/AppFadersTests.swift @@ -0,0 +1,9 @@ +import SimplyCoreAudio +import Testing + +/// Placeholder tests - full implementation in Tasks 13-14 +@Test func simplyCoreAudioImports() async throws { + // Verify SimplyCoreAudio dependency is properly configured + let sca = SimplyCoreAudio() + #expect(sca.allDevices.count >= 0) +} From 6cacc30ac4ca1a37a1d40d69781208ef2a75e545 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 01:41:00 -0800 Subject: [PATCH 02/33] chore: add package.resolved to repo --- Package.resolved | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Package.resolved diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..aac4493 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "e14beb9690ca21c49069b530098070be1a6091eb5519a5bda71aa64f8a55ea5a", + "pins" : [ + { + "identity" : "simplycoreaudio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rnine/SimplyCoreAudio.git", + "state" : { + "revision" : "35cc0e6eac5c2ee5049431f4238b0e333cf79869", + "version" : "4.1.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} From e9a2690e20d4e04991f259773f865c094946c5ac Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 01:41:47 -0800 Subject: [PATCH 03/33] docs(orchestrator): task 1 completed --- .spec-workflow/specs/host-audio-orchestrator/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index f7a0476..1800b6d 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -2,7 +2,7 @@ ## Phase 1: Package Setup -- [ ] 1. Add SimplyCoreAudio dependency and test target to Package.swift +- [x] 1. Add SimplyCoreAudio dependency and test target to Package.swift - File: Package.swift - Add SimplyCoreAudio 4.1.0+ dependency from github.com/rnine/SimplyCoreAudio - Add dependency to AppFaders executable target From 8b7ca47d1fc7a6e5561b695e26a0cb731ed99956 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 01:56:01 -0800 Subject: [PATCH 04/33] chore: cleanup package.swift comments --- Package.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Package.swift b/Package.swift index 4321680..9e865a6 100644 --- a/Package.swift +++ b/Package.swift @@ -13,8 +13,6 @@ let package = Package( .plugin(name: "BundleAssembler", targets: ["BundleAssembler"]) ], dependencies: [ - // Pancake is an Xcode project, not SPM - see docs/pancake-compatibility.md - // .package(url: "https://github.com/0bmxa/Pancake.git", branch: "master") .package(url: "https://github.com/rnine/SimplyCoreAudio.git", from: "4.1.0") ], targets: [ @@ -24,7 +22,6 @@ let package = Package( .product(name: "SimplyCoreAudio", package: "SimplyCoreAudio") ] ), - // C interface layer for HAL AudioServerPlugIn .target( name: "AppFadersDriverBridge", dependencies: [], @@ -43,7 +40,6 @@ let package = Package( linkerSettings: [ .linkedFramework("CoreAudio"), .linkedFramework("AudioToolbox"), - // Build as MH_BUNDLE instead of MH_DYLIB for CFPlugIn compatibility .unsafeFlags(["-Xlinker", "-bundle"]) ], plugins: [ From 1814b595112256b3ca79ce2a252c3e4b269d0255 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:54:21 -0800 Subject: [PATCH 05/33] feat(orchestrator): add custom IPC property selectors --- Sources/AppFadersDriver/AudioTypes.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/AppFadersDriver/AudioTypes.swift b/Sources/AppFadersDriver/AudioTypes.swift index d7d1a7d..03c1d00 100644 --- a/Sources/AppFadersDriver/AudioTypes.swift +++ b/Sources/AppFadersDriver/AudioTypes.swift @@ -114,3 +114,13 @@ extension AudioDeviceConfiguration { } } } + +// MARK: - Custom Properties + +/// custom property selectors for AppFaders IPC +public enum AppFadersProperty { + /// set volume for an application: 'afvc' + public static let setVolume = AudioObjectPropertySelector(0x6166_7663) + /// get volume for an application: 'afvq' + public static let getVolume = AudioObjectPropertySelector(0x6166_7671) +} From 911bf59a025bd19c3220922ac3cdc7e6f6ec0341 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:54:24 -0800 Subject: [PATCH 06/33] feat(orchestrator): add thread-safe VolumeStore for per-app volume --- Sources/AppFadersDriver/VolumeStore.swift | 57 +++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 Sources/AppFadersDriver/VolumeStore.swift diff --git a/Sources/AppFadersDriver/VolumeStore.swift b/Sources/AppFadersDriver/VolumeStore.swift new file mode 100644 index 0000000..8151e3e --- /dev/null +++ b/Sources/AppFadersDriver/VolumeStore.swift @@ -0,0 +1,57 @@ +// VolumeStore.swift +// Thread-safe storage for per-application volume settings +// +// handles storage and retrieval of volume levels for different bundle IDs. +// used by the virtual device to apply gain in real-time. + +import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders.driver", category: "VolumeStore") + +/// thread-safe storage for application-specific volumes +final class VolumeStore: @unchecked Sendable { + static let shared = VolumeStore() + + private let lock = NSLock() + private var volumes: [String: Float] = [:] + + private init() { + os_log(.info, log: log, "VolumeStore initialized") + } + + /// set volume for a specific application + /// - Parameters: + /// - bundleID: application bundle identifier + /// - volume: volume level (0.0 to 1.0) + func setVolume(for bundleID: String, volume: Float) { + // clamp volume to valid range + let clampedVolume = max(0.0, min(1.0, volume)) + + lock.lock() + volumes[bundleID] = clampedVolume + lock.unlock() + + os_log(.info, log: log, "volume updated for %{public}@: %.2f", bundleID, clampedVolume) + } + + /// get volume for a specific application + /// - Parameter bundleID: application bundle identifier + /// - Returns: volume level (defaults to 1.0 if unknown) + func getVolume(for bundleID: String) -> Float { + lock.lock() + let volume = volumes[bundleID] ?? 1.0 + lock.unlock() + return volume + } + + /// remove volume setting for an application + /// - Parameter bundleID: application bundle identifier + func removeVolume(for bundleID: String) { + lock.lock() + volumes.removeValue(forKey: bundleID) + lock.unlock() + + os_log(.info, log: log, "volume removed for %{public}@", bundleID) + } +} From 9e03f58ff1471976e40eb85cfcbc70505157ed58 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:03:19 -0800 Subject: [PATCH 07/33] docs(orchestrator): mark tasks 2 and 3 as completed in spec Updated tasks.md for host-audio-orchestrator to reflect completion of AppFadersProperty enum and VolumeStore implementation. --- .spec-workflow/specs/host-audio-orchestrator/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 1800b6d..85090bd 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -14,7 +14,7 @@ ## Phase 2: Shared Types (Driver-side) -- [ ] 2. Add AppFadersProperty enum to AudioTypes.swift +- [x] 2. Add AppFadersProperty enum to AudioTypes.swift - File: Sources/AppFadersDriver/AudioTypes.swift - Define custom property selectors: setVolume (0x61667663 = 'afvc'), getVolume (0x61667671 = 'afvq') - Use AudioObjectPropertySelector type @@ -23,7 +23,7 @@ - _Requirements: 5.1, 5.2, 6.1_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift/CoreAudio developer | Task: Add AppFadersProperty enum to AudioTypes.swift with static let setVolume and getVolume as AudioObjectPropertySelector values. Use hex values 0x61667663 and 0x61667671. These are four-char codes 'afvc' and 'afvq'. | Restrictions: Do not modify existing types. Add to existing file only. | _Leverage: Sources/AppFadersDriver/AudioTypes.swift, design.md | Success: AppFadersProperty compiles and is accessible from driver code | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 3. Create VolumeStore for per-app volume storage +- [x] 3. Create VolumeStore for per-app volume storage - File: Sources/AppFadersDriver/VolumeStore.swift - Create thread-safe singleton with NSLock - Implement setVolume(bundleID:volume:), getVolume(bundleID:) with default 1.0, removeVolume(bundleID:) From 6e08872c00681fe2682a1ff8cd60fca0e8cffe2f Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:08:41 -0800 Subject: [PATCH 08/33] docs: design note on clamping and validation --- .spec-workflow/specs/host-audio-orchestrator/design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/design.md b/.spec-workflow/specs/host-audio-orchestrator/design.md index 0b112a9..f1fae4f 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/design.md +++ b/.spec-workflow/specs/host-audio-orchestrator/design.md @@ -167,13 +167,14 @@ graph TD final class VolumeStore: @unchecked Sendable { static let shared = VolumeStore() - func setVolume(for bundleID: String, volume: Float) + func setVolume(for bundleID: String, volume: Float) // clamps to 0.0-1.0 func getVolume(for bundleID: String) -> Float // default 1.0 func removeVolume(for bundleID: String) } ``` - **Dependencies**: Foundation (NSLock for thread safety) - **Reuses**: Lock pattern from VirtualDevice.shared +- **Note**: VolumeStore clamps out-of-range values as a defensive measure; primary validation occurs in DriverBridge on the host side ## Data Models From c5f5a0da01d2b50f754ddc4d95808b59695094da Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:14:42 -0800 Subject: [PATCH 09/33] feat(orchestrator): implement custom property handlers for IPC --- Sources/AppFadersDriver/AudioTypes.swift | 37 +++ Sources/AppFadersDriver/VirtualDevice.swift | 213 ++++++++++++++---- .../AppFadersDriverBridge/PlugInInterface.c | 12 + 3 files changed, 212 insertions(+), 50 deletions(-) diff --git a/Sources/AppFadersDriver/AudioTypes.swift b/Sources/AppFadersDriver/AudioTypes.swift index 03c1d00..edf118d 100644 --- a/Sources/AppFadersDriver/AudioTypes.swift +++ b/Sources/AppFadersDriver/AudioTypes.swift @@ -124,3 +124,40 @@ public enum AppFadersProperty { /// get volume for an application: 'afvq' public static let getVolume = AudioObjectPropertySelector(0x6166_7671) } + +// MARK: - CoreAudio HAL Missing Types + +/// information about a custom property +/// matches AudioServerPlugInCustomPropertyInfo in AudioServerPlugIn.h +public struct AudioServerPlugInCustomPropertyInfo: Sendable { + public var mSelector: AudioObjectPropertySelector + public var mPropertyDataType: UInt32 + public var mQualifierDataType: UInt32 + + public init( + mSelector: AudioObjectPropertySelector, + mPropertyDataType: UInt32, + mQualifierDataType: UInt32 + ) { + self.mSelector = mSelector + self.mPropertyDataType = mPropertyDataType + self.mQualifierDataType = mQualifierDataType + } +} + +// MARK: - IPC Models + +/// IPC command to set volume for an application +/// matches the wire format: [length: UInt8] [bundleID: 255 bytes] [volume: Float32] +public struct VolumeCommand: Sendable { + public static let maxBundleIDLength = 255 + public static let totalSize = 1 + maxBundleIDLength + 4 // 260 bytes + + public let bundleID: String + public let volume: Float + + public init(bundleID: String, volume: Float) { + self.bundleID = bundleID + self.volume = volume + } +} diff --git a/Sources/AppFadersDriver/VirtualDevice.swift b/Sources/AppFadersDriver/VirtualDevice.swift index 8116576..7303917 100644 --- a/Sources/AppFadersDriver/VirtualDevice.swift +++ b/Sources/AppFadersDriver/VirtualDevice.swift @@ -137,7 +137,9 @@ final class VirtualDevice: @unchecked Sendable { /// get size in bytes needed for property data func getPropertyDataSize( objectID: AudioObjectID, - address: AudioObjectPropertyAddress + address: AudioObjectPropertyAddress, + qualifierSize: UInt32, + qualifierData: UnsafeRawPointer? ) -> UInt32? { let result: UInt32? = if objectID == ObjectID.plugIn { getPlugInPropertyDataSize(address: address) @@ -169,14 +171,16 @@ final class VirtualDevice: @unchecked Sendable { func getPropertyData( objectID: AudioObjectID, address: AudioObjectPropertyAddress, - maxSize: UInt32 + maxSize: UInt32, + qualifierSize: UInt32, + qualifierData: UnsafeRawPointer? ) -> (Data, UInt32)? { if objectID == ObjectID.plugIn { return getPlugInPropertyData(address: address, maxSize: maxSize) } if objectID == ObjectID.device { - return getDevicePropertyData(address: address, maxSize: maxSize) + return getDevicePropertyData(address: address, maxSize: maxSize, qualifierSize: qualifierSize, qualifierData: qualifierData) } if objectID == ObjectID.outputStream { @@ -213,33 +217,61 @@ final class VirtualDevice: @unchecked Sendable { return has } - private func getPlugInPropertyDataSize(address: AudioObjectPropertyAddress) -> UInt32? { - switch address.mSelector { - case kAudioObjectPropertyClass, - kAudioObjectPropertyBaseClass: - UInt32(MemoryLayout.size) - case kAudioObjectPropertyOwner: - UInt32(MemoryLayout.size) - case kAudioObjectPropertyManufacturer: - UInt32(MemoryLayout.size) - case kAudioObjectPropertyOwnedObjects, - kAudioPlugInPropertyDeviceList: - UInt32(MemoryLayout.size) // one device - case kAudioObjectPropertyCustomPropertyInfoList, - kAudioPlugInPropertyBoxList: - 0 // empty lists - case kAudioPlugInPropertyTranslateUIDToBox: - UInt32(MemoryLayout.size) - case kAudioPlugInPropertyTranslateUIDToDevice: - UInt32(MemoryLayout.size) - case kAudioPlugInPropertyResourceBundle: - UInt32(MemoryLayout.size) - case kAudioClockDevicePropertyClockDomain: - UInt32(MemoryLayout.size) - default: - nil + private func getPlugInPropertyDataSize(address: AudioObjectPropertyAddress) -> UInt32? { + + switch address.mSelector { + + case kAudioObjectPropertyClass, + + kAudioObjectPropertyBaseClass: + + return UInt32(MemoryLayout.size) + + case kAudioObjectPropertyOwner: + + return UInt32(MemoryLayout.size) + + case kAudioObjectPropertyManufacturer: + + return UInt32(MemoryLayout.size) + + case kAudioObjectPropertyOwnedObjects, + + kAudioPlugInPropertyDeviceList: + + return UInt32(MemoryLayout.size) // one device + + case kAudioObjectPropertyCustomPropertyInfoList, + + kAudioPlugInPropertyBoxList: + + return 0 // empty lists + + case kAudioPlugInPropertyTranslateUIDToBox: + + return UInt32(MemoryLayout.size) + + case kAudioPlugInPropertyTranslateUIDToDevice: + + return UInt32(MemoryLayout.size) + + case kAudioPlugInPropertyResourceBundle: + + return UInt32(MemoryLayout.size) + + case kAudioClockDevicePropertyClockDomain: + + return UInt32(MemoryLayout.size) + + default: + + return nil + + } + } - } + + private func getPlugInPropertyData( address: AudioObjectPropertyAddress, @@ -326,7 +358,9 @@ final class VirtualDevice: @unchecked Sendable { kAudioDevicePropertyZeroTimeStampPeriod, kAudioDevicePropertyClockDomain, kAudioDevicePropertyIsHidden, - kAudioDevicePropertyPreferredChannelsForStereo: + kAudioDevicePropertyPreferredChannelsForStereo, + AppFadersProperty.setVolume, + AppFadersProperty.getVolume: true default: false @@ -344,7 +378,7 @@ final class VirtualDevice: @unchecked Sendable { kAudioObjectPropertyBaseClass, kAudioDevicePropertyTransportType, kAudioDevicePropertyClockDomain: - UInt32(MemoryLayout.size) + return UInt32(MemoryLayout.size) case kAudioObjectPropertyOwner, kAudioDevicePropertyDeviceIsRunning, @@ -354,46 +388,56 @@ final class VirtualDevice: @unchecked Sendable { kAudioDevicePropertySafetyOffset, kAudioDevicePropertyZeroTimeStampPeriod, kAudioDevicePropertyIsHidden: - UInt32(MemoryLayout.size) + return UInt32(MemoryLayout.size) case kAudioDevicePropertyPreferredChannelsForStereo: - UInt32(MemoryLayout.size * 2) // left and right channel + return UInt32(MemoryLayout.size * 2) // left and right channel case kAudioObjectPropertyName, kAudioObjectPropertyManufacturer, kAudioDevicePropertyDeviceUID, kAudioDevicePropertyModelUID: - UInt32(MemoryLayout.size) + return UInt32(MemoryLayout.size) case kAudioObjectPropertyOwnedObjects: // one output stream - UInt32(MemoryLayout.size) + return UInt32(MemoryLayout.size) case kAudioDevicePropertyStreams: // only return stream for output scope (we have no input streams) - (address.mScope == kAudioObjectPropertyScopeOutput || + return (address.mScope == kAudioObjectPropertyScopeOutput || address.mScope == kAudioObjectPropertyScopeGlobal) ? UInt32(MemoryLayout.size) : 0 - case kAudioObjectPropertyCustomPropertyInfoList, - kAudioDevicePropertyControlList: - 0 // empty lists - no custom properties or controls + case kAudioObjectPropertyCustomPropertyInfoList: + return UInt32(MemoryLayout.size * 2) + + case kAudioDevicePropertyControlList: + return 0 // empty list case kAudioDevicePropertyNominalSampleRate: - UInt32(MemoryLayout.size) + return UInt32(MemoryLayout.size) + + case AppFadersProperty.setVolume: + return UInt32(VolumeCommand.totalSize) + + case AppFadersProperty.getVolume: + return UInt32(MemoryLayout.size) case kAudioDevicePropertyAvailableNominalSampleRates: // 3 sample rates: 44100, 48000, 96000 - UInt32(MemoryLayout.size * 3) + return UInt32(MemoryLayout.size * 3) default: - nil + return nil } } private func getDevicePropertyData( address: AudioObjectPropertyAddress, - maxSize: UInt32 + maxSize: UInt32, + qualifierSize: UInt32 = 0, + qualifierData: UnsafeRawPointer? = nil ) -> (Data, UInt32)? { switch address.mSelector { case kAudioObjectPropertyClass: @@ -457,9 +501,33 @@ final class VirtualDevice: @unchecked Sendable { } return (Data(), 0) // no input streams - case kAudioObjectPropertyCustomPropertyInfoList, - kAudioDevicePropertyControlList: - // empty lists + case kAudioObjectPropertyCustomPropertyInfoList: + var info = [ + AudioServerPlugInCustomPropertyInfo( + mSelector: AppFadersProperty.setVolume, + mPropertyDataType: 0, + mQualifierDataType: 0 + ), + AudioServerPlugInCustomPropertyInfo( + mSelector: AppFadersProperty.getVolume, + mPropertyDataType: 0, + mQualifierDataType: 0 + ) + ] + let size = MemoryLayout.size * info.count + return (Data(bytes: &info, count: size), UInt32(size)) + + case AppFadersProperty.getVolume: + guard qualifierSize > 0, let qualifierData else { + return nil + } + let bundleID = String(cString: qualifierData.assumingMemoryBound(to: UInt8.self)) + var volume = Float32(VolumeStore.shared.getVolume(for: bundleID)) + return (Data(bytes: &volume, count: MemoryLayout.size), + UInt32(MemoryLayout.size)) + + case kAudioDevicePropertyControlList: + // empty list return (Data(), 0) case kAudioDevicePropertyNominalSampleRate: @@ -535,7 +603,9 @@ final class VirtualDevice: @unchecked Sendable { objectID: AudioObjectID, address: AudioObjectPropertyAddress, data: UnsafeRawPointer, - size: UInt32 + size: UInt32, + qualifierSize: UInt32 = 0, + qualifierData: UnsafeRawPointer? = nil ) -> OSStatus { // device sample rate change if objectID == ObjectID.device, @@ -567,6 +637,34 @@ final class VirtualDevice: @unchecked Sendable { return noErr } + // set application volume + if objectID == ObjectID.device, + address.mSelector == AppFadersProperty.setVolume + { + guard size >= UInt32(VolumeCommand.totalSize) else { + return kAudioHardwareBadPropertySizeError + } + + // parse VolumeCommand from data + // wire format: [bundleIDLength: UInt8] [bundleIDBytes: 255 bytes] [volume: Float32] + let bundleIDLength = data.load(as: UInt8.self) + guard bundleIDLength <= UInt8(VolumeCommand.maxBundleIDLength) else { + return kAudioHardwareIllegalOperationError + } + + let bundleIDStart = data.advanced(by: 1) + let bundleIDData = Data(bytes: bundleIDStart, count: Int(bundleIDLength)) + guard let bundleID = String(data: bundleIDData, encoding: .utf8) else { + return kAudioHardwareIllegalOperationError + } + + let volumeStart = data.advanced(by: 1 + VolumeCommand.maxBundleIDLength) + let volume = volumeStart.load(as: Float32.self) + + VolumeStore.shared.setVolume(for: bundleID, volume: volume) + return noErr + } + // delegate stream properties if objectID == ObjectID.outputStream { return VirtualStream.shared.setPropertyData(address: address, data: data, size: size) @@ -620,6 +718,8 @@ public func driverGetPropertyDataSize( selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope, element: AudioObjectPropertyElement, + qualifierSize: UInt32, + qualifierData: UnsafeRawPointer?, outSize: UnsafeMutablePointer? ) -> OSStatus { let address = AudioObjectPropertyAddress( @@ -628,7 +728,12 @@ public func driverGetPropertyDataSize( mElement: element ) - guard let size = VirtualDevice.shared.getPropertyDataSize(objectID: objectID, address: address) + guard let size = VirtualDevice.shared.getPropertyDataSize( + objectID: objectID, + address: address, + qualifierSize: qualifierSize, + qualifierData: qualifierData + ) else { return kAudioHardwareUnknownPropertyError } @@ -645,6 +750,8 @@ public func driverGetPropertyData( selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope, element: AudioObjectPropertyElement, + qualifierSize: UInt32, + qualifierData: UnsafeRawPointer?, inDataSize: UInt32, outDataSize: UnsafeMutablePointer?, outData: UnsafeMutableRawPointer? @@ -659,7 +766,9 @@ public func driverGetPropertyData( let (data, actualSize) = VirtualDevice.shared.getPropertyData( objectID: objectID, address: address, - maxSize: inDataSize + maxSize: inDataSize, + qualifierSize: qualifierSize, + qualifierData: qualifierData ) else { return kAudioHardwareUnknownPropertyError @@ -685,6 +794,8 @@ public func driverSetPropertyData( selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope, element: AudioObjectPropertyElement, + qualifierSize: UInt32, + qualifierData: UnsafeRawPointer?, dataSize: UInt32, data: UnsafeRawPointer? ) -> OSStatus { @@ -702,6 +813,8 @@ public func driverSetPropertyData( objectID: objectID, address: address, data: data, - size: dataSize + size: dataSize, + qualifierSize: qualifierSize, + qualifierData: qualifierData ) } diff --git a/Sources/AppFadersDriverBridge/PlugInInterface.c b/Sources/AppFadersDriverBridge/PlugInInterface.c index 1eb09f5..411e019 100644 --- a/Sources/AppFadersDriverBridge/PlugInInterface.c +++ b/Sources/AppFadersDriverBridge/PlugInInterface.c @@ -52,6 +52,8 @@ extern OSStatus AppFadersDriver_GetPropertyDataSize( AudioObjectPropertySelector inSelector, AudioObjectPropertyScope inScope, AudioObjectPropertyElement inElement, + UInt32 inQualifierDataSize, + const void *inQualifierData, UInt32 *outDataSize); extern OSStatus AppFadersDriver_GetPropertyData( AudioObjectID inObjectID, @@ -59,6 +61,8 @@ extern OSStatus AppFadersDriver_GetPropertyData( AudioObjectPropertySelector inSelector, AudioObjectPropertyScope inScope, AudioObjectPropertyElement inElement, + UInt32 inQualifierDataSize, + const void *inQualifierData, UInt32 inDataSize, UInt32 *outDataSize, void *outData); @@ -68,6 +72,8 @@ extern OSStatus AppFadersDriver_SetPropertyData( AudioObjectPropertySelector inSelector, AudioObjectPropertyScope inScope, AudioObjectPropertyElement inElement, + UInt32 inQualifierDataSize, + const void *inQualifierData, UInt32 inDataSize, const void *inData); @@ -303,6 +309,8 @@ static OSStatus PlugIn_GetPropertyDataSize( inAddress->mSelector, inAddress->mScope, inAddress->mElement, + inQualifierDataSize, + inQualifierData, outDataSize); } @@ -328,6 +336,8 @@ static OSStatus PlugIn_GetPropertyData( inAddress->mSelector, inAddress->mScope, inAddress->mElement, + inQualifierDataSize, + inQualifierData, inDataSize, outDataSize, outData); @@ -354,6 +364,8 @@ static OSStatus PlugIn_SetPropertyData( inAddress->mSelector, inAddress->mScope, inAddress->mElement, + inQualifierDataSize, + inQualifierData, inDataSize, inData); } From 21f363ac9ef81d6b2d76141a6096fc8d4cb72a60 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:21:52 -0800 Subject: [PATCH 10/33] fix(driver): add isPropertySettable for setVolume and clean up formatting Add missing isPropertySettable handler for AppFadersProperty.setVolume so CoreAudio clients can properly query settability before IPC calls. Also fix extraneous blank lines in getPlugInPropertyDataSize. --- Sources/AppFadersDriver/VirtualDevice.swift | 78 +++++++++------------ 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/Sources/AppFadersDriver/VirtualDevice.swift b/Sources/AppFadersDriver/VirtualDevice.swift index 7303917..a11b880 100644 --- a/Sources/AppFadersDriver/VirtualDevice.swift +++ b/Sources/AppFadersDriver/VirtualDevice.swift @@ -126,6 +126,13 @@ final class VirtualDevice: @unchecked Sendable { return true } + // custom IPC property for setting app volumes + if objectID == ObjectID.device, + address.mSelector == AppFadersProperty.setVolume + { + return true + } + // delegate stream properties to VirtualStream if objectID == ObjectID.outputStream { return VirtualStream.shared.isPropertySettable(address: address) @@ -217,61 +224,44 @@ final class VirtualDevice: @unchecked Sendable { return has } - private func getPlugInPropertyDataSize(address: AudioObjectPropertyAddress) -> UInt32? { - - switch address.mSelector { - - case kAudioObjectPropertyClass, - - kAudioObjectPropertyBaseClass: - - return UInt32(MemoryLayout.size) - - case kAudioObjectPropertyOwner: - - return UInt32(MemoryLayout.size) - - case kAudioObjectPropertyManufacturer: - - return UInt32(MemoryLayout.size) - - case kAudioObjectPropertyOwnedObjects, - - kAudioPlugInPropertyDeviceList: - - return UInt32(MemoryLayout.size) // one device - - case kAudioObjectPropertyCustomPropertyInfoList, - - kAudioPlugInPropertyBoxList: - - return 0 // empty lists - - case kAudioPlugInPropertyTranslateUIDToBox: - - return UInt32(MemoryLayout.size) - - case kAudioPlugInPropertyTranslateUIDToDevice: + private func getPlugInPropertyDataSize(address: AudioObjectPropertyAddress) -> UInt32? { + switch address.mSelector { + case kAudioObjectPropertyClass, + kAudioObjectPropertyBaseClass: + return UInt32(MemoryLayout.size) - return UInt32(MemoryLayout.size) + case kAudioObjectPropertyOwner: + return UInt32(MemoryLayout.size) - case kAudioPlugInPropertyResourceBundle: + case kAudioObjectPropertyManufacturer: + return UInt32(MemoryLayout.size) - return UInt32(MemoryLayout.size) + case kAudioObjectPropertyOwnedObjects, + kAudioPlugInPropertyDeviceList: + return UInt32(MemoryLayout.size) // one device - case kAudioClockDevicePropertyClockDomain: + case kAudioObjectPropertyCustomPropertyInfoList, + kAudioPlugInPropertyBoxList: + return 0 // empty lists - return UInt32(MemoryLayout.size) + case kAudioPlugInPropertyTranslateUIDToBox: + return UInt32(MemoryLayout.size) - default: + case kAudioPlugInPropertyTranslateUIDToDevice: + return UInt32(MemoryLayout.size) - return nil + case kAudioPlugInPropertyResourceBundle: + return UInt32(MemoryLayout.size) - } + case kAudioClockDevicePropertyClockDomain: + return UInt32(MemoryLayout.size) + default: + return nil } + } + - private func getPlugInPropertyData( address: AudioObjectPropertyAddress, From e4af02c87313b7d4017ed7b2c603d5fad48fad7d Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:22:17 -0800 Subject: [PATCH 11/33] docs(orchestrator): mark task 4 complete --- .spec-workflow/specs/host-audio-orchestrator/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 85090bd..4267e12 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -33,7 +33,7 @@ - _Requirements: 5.1, 5.2, 5.3, 6.3_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift concurrency developer | Task: Create VolumeStore.swift with thread-safe singleton. Use private NSLock and Dictionary for storage. setVolume validates 0.0-1.0 range. getVolume returns 1.0 for unknown bundleIDs. Follow lock pattern from VirtualDevice.shared. Add os_log for volume changes. | Restrictions: Must be thread-safe. No async/await - use locks for real-time safety. | _Leverage: Sources/AppFadersDriver/VirtualDevice.swift lock pattern, design.md | Success: VolumeStore compiles, is Sendable, and handles concurrent access safely | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 4. Add custom property handlers to VirtualDevice.swift +- [x] 4. Add custom property handlers to VirtualDevice.swift - File: Sources/AppFadersDriver/VirtualDevice.swift - Update hasDeviceProperty to include AppFadersProperty.setVolume and getVolume - Update getDevicePropertyDataSize for custom properties From 342f7e91309033085351e953d7e07a043e419a4f Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:51:46 -0800 Subject: [PATCH 12/33] feat(orchestrator): implement TrackedApp model Introduces the TrackedApp struct to represent and identify running applications within the host orchestrator. Includes a convenience initializer from NSRunningApplication and implements custom value semantics for Hashable and Equatable to handle NSImage correctly. --- Sources/AppFaders/TrackedApp.swift | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 Sources/AppFaders/TrackedApp.swift diff --git a/Sources/AppFaders/TrackedApp.swift b/Sources/AppFaders/TrackedApp.swift new file mode 100644 index 0000000..6275209 --- /dev/null +++ b/Sources/AppFaders/TrackedApp.swift @@ -0,0 +1,45 @@ +import AppKit +import Foundation + +/// Represents an application tracked by the AppFaders host orchestrator. +struct TrackedApp: Identifiable, Sendable, Hashable { + /// The unique identifier for the app, which is its bundle ID. + var id: String { bundleID } + + /// The bundle identifier of the application. + let bundleID: String + + /// The localized name of the application. + let localizedName: String + + /// The icon of the application. + let icon: NSImage? + + /// The date when the application was launched. + let launchDate: Date + + /// Initializes a `TrackedApp` from an `NSRunningApplication`. + /// - Parameter runningApp: The `NSRunningApplication` to extract data from. + /// - Returns: A `TrackedApp` instance if the `bundleIdentifier` is available, otherwise `nil`. + init?(from runningApp: NSRunningApplication) { + guard let bundleID = runningApp.bundleIdentifier else { + return nil + } + + self.bundleID = bundleID + localizedName = runningApp.localizedName ?? bundleID + icon = runningApp.icon + launchDate = runningApp.launchDate ?? .distantPast + } + + // MARK: - Equatable & Hashable + + static func == (lhs: TrackedApp, rhs: TrackedApp) -> Bool { + lhs.bundleID == rhs.bundleID && lhs.launchDate == rhs.launchDate + } + + func hash(into hasher: inout Hasher) { + hasher.combine(bundleID) + hasher.combine(launchDate) + } +} From 8169fd3bfd4584575560deacac316969deec47bc Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:52:02 -0800 Subject: [PATCH 13/33] docs: mark task 5 complete --- .spec-workflow/specs/host-audio-orchestrator/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 4267e12..df905fd 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -47,7 +47,7 @@ ## Phase 3: Host Models and Utilities -- [ ] 5. Create TrackedApp model +- [x] 5. Create TrackedApp model - File: Sources/AppFaders/TrackedApp.swift - Define struct with bundleID, localizedName, icon (NSImage?), launchDate - Conform to Identifiable (id = bundleID), Sendable, Hashable From 4487d55980b154f0b0ac8dc769e8b9ed055f8769 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:27:21 -0800 Subject: [PATCH 14/33] feat(orchestrator): add DriverError enum for error handling Implements LocalizedError for device discovery, property I/O, and volume validation cases. Completes task 6 of the orchestrator spec. --- .../specs/host-audio-orchestrator/tasks.md | 2 +- Sources/AppFaders/DriverError.swift | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 Sources/AppFaders/DriverError.swift diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index df905fd..28c5885 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -57,7 +57,7 @@ - _Requirements: 3.1, 3.2, 3.4_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift/AppKit developer | Task: Create TrackedApp.swift with struct matching design.md. Use AppKit NSRunningApplication and NSImage. Add init?(from: NSRunningApplication) that extracts bundleIdentifier, localizedName, icon, launchDate. Return nil if bundleIdentifier is nil. Mark NSImage as @unchecked Sendable via extension. | Restrictions: Exclude apps without bundleID per Requirement 3.5. | _Leverage: design.md Data Models, AppKit docs | Success: TrackedApp compiles, can be created from NSRunningApplication | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 6. Create DriverError enum +- [x] 6. Create DriverError enum - File: Sources/AppFaders/DriverError.swift - Define error cases: deviceNotFound, propertyReadFailed(OSStatus), propertyWriteFailed(OSStatus), invalidVolumeRange(Float), bundleIDTooLong(Int) - Conform to Error, LocalizedError with errorDescription diff --git a/Sources/AppFaders/DriverError.swift b/Sources/AppFaders/DriverError.swift new file mode 100644 index 0000000..5469776 --- /dev/null +++ b/Sources/AppFaders/DriverError.swift @@ -0,0 +1,35 @@ +// DriverError.swift +// Error types for the AppFaders host orchestrator +// +// defines errors that can occur during audio device management and IPC bridge operations. + +import Foundation + +/// errors related to driver communication and management +enum DriverError: Error, LocalizedError { + /// the virtual audio device could not be found + case deviceNotFound + /// failed to read a property from the audio object + case propertyReadFailed(OSStatus) + /// failed to write a property to the audio object + case propertyWriteFailed(OSStatus) + /// the provided volume is outside the valid range (0.0 - 1.0) + case invalidVolumeRange(Float) + /// the bundle identifier exceeds the maximum allowed length + case bundleIDTooLong(Int) + + var errorDescription: String? { + switch self { + case .deviceNotFound: + "AppFaders Virtual Device not found. Please ensure the driver is installed." + case let .propertyReadFailed(status): + "Failed to read driver property (OSStatus: \(status))." + case let .propertyWriteFailed(status): + "Failed to write driver property (OSStatus: \(status))." + case let .invalidVolumeRange(volume): + "Invalid volume level: \(volume). Must be between 0.0 and 1.0." + case let .bundleIDTooLong(length): + "Bundle identifier is too long (\(length) bytes). Max is 255." + } + } +} From a2838e8c4f119f769555dcbd0ca0ecd143189f10 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:28:06 -0800 Subject: [PATCH 15/33] fix: format a bunch of files --- Scripts/uninstall-driver.sh | 4 +- Sources/AppFadersDriver/AudioTypes.swift | 12 ++-- .../AppFadersDriver/PassthroughEngine.swift | 15 +++-- Sources/AppFadersDriver/VirtualDevice.swift | 55 ++++++++++--------- 4 files changed, 46 insertions(+), 40 deletions(-) diff --git a/Scripts/uninstall-driver.sh b/Scripts/uninstall-driver.sh index 6623f0d..6ccfa9f 100755 --- a/Scripts/uninstall-driver.sh +++ b/Scripts/uninstall-driver.sh @@ -7,8 +7,8 @@ set -e DRIVER_PATH="/Library/Audio/Plug-Ins/HAL/AppFadersDriver.driver" if [[ ! -d $DRIVER_PATH ]]; then - echo "Driver not installed at $DRIVER_PATH" - exit 0 + echo "Driver not installed at $DRIVER_PATH" + exit 0 fi echo "Removing $DRIVER_PATH..." diff --git a/Sources/AppFadersDriver/AudioTypes.swift b/Sources/AppFadersDriver/AudioTypes.swift index edf118d..75576bf 100644 --- a/Sources/AppFadersDriver/AudioTypes.swift +++ b/Sources/AppFadersDriver/AudioTypes.swift @@ -92,18 +92,18 @@ public struct StreamFormat: Sendable, Equatable { /// create from CoreAudio AudioStreamBasicDescription public init(from asbd: AudioStreamBasicDescription) { - self.sampleRate = asbd.mSampleRate - self.channelCount = asbd.mChannelsPerFrame - self.bitsPerChannel = asbd.mBitsPerChannel - self.formatID = asbd.mFormatID + sampleRate = asbd.mSampleRate + channelCount = asbd.mChannelsPerFrame + bitsPerChannel = asbd.mBitsPerChannel + formatID = asbd.mFormatID } } // MARK: - Supported Formats -extension AudioDeviceConfiguration { +public extension AudioDeviceConfiguration { /// generate all supported StreamFormats for this device - public var supportedFormats: [StreamFormat] { + var supportedFormats: [StreamFormat] { sampleRates.map { rate in StreamFormat( sampleRate: rate, diff --git a/Sources/AppFadersDriver/PassthroughEngine.swift b/Sources/AppFadersDriver/PassthroughEngine.swift index 2c66683..bcb7426 100644 --- a/Sources/AppFadersDriver/PassthroughEngine.swift +++ b/Sources/AppFadersDriver/PassthroughEngine.swift @@ -7,12 +7,15 @@ import AudioToolbox import CoreAudio import Foundation -import Synchronization import os.log +import Synchronization // MARK: - Logging -private let log = OSLog(subsystem: "com.fbreidenbach.appfaders.driver", category: "PassthroughEngine") +private let log = OSLog( + subsystem: "com.fbreidenbach.appfaders.driver", + category: "PassthroughEngine" +) // MARK: - Missing CoreAudio Constants @@ -66,7 +69,7 @@ final class AudioRingBuffer: @unchecked Sendable { let actualFrames = actualSamples / channelCount // copy samples to buffer - for i in 0...size) + UInt32(MemoryLayout.size) case kAudioObjectPropertyOwner: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioObjectPropertyManufacturer: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioObjectPropertyOwnedObjects, kAudioPlugInPropertyDeviceList: - return UInt32(MemoryLayout.size) // one device + UInt32(MemoryLayout.size) // one device case kAudioObjectPropertyCustomPropertyInfoList, kAudioPlugInPropertyBoxList: - return 0 // empty lists + 0 // empty lists case kAudioPlugInPropertyTranslateUIDToBox: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioPlugInPropertyTranslateUIDToDevice: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioPlugInPropertyResourceBundle: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioClockDevicePropertyClockDomain: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) default: - return nil + nil } } - - private func getPlugInPropertyData( address: AudioObjectPropertyAddress, maxSize: UInt32 @@ -368,7 +371,7 @@ final class VirtualDevice: @unchecked Sendable { kAudioObjectPropertyBaseClass, kAudioDevicePropertyTransportType, kAudioDevicePropertyClockDomain: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioObjectPropertyOwner, kAudioDevicePropertyDeviceIsRunning, @@ -378,48 +381,48 @@ final class VirtualDevice: @unchecked Sendable { kAudioDevicePropertySafetyOffset, kAudioDevicePropertyZeroTimeStampPeriod, kAudioDevicePropertyIsHidden: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioDevicePropertyPreferredChannelsForStereo: - return UInt32(MemoryLayout.size * 2) // left and right channel + UInt32(MemoryLayout.size * 2) // left and right channel case kAudioObjectPropertyName, kAudioObjectPropertyManufacturer, kAudioDevicePropertyDeviceUID, kAudioDevicePropertyModelUID: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioObjectPropertyOwnedObjects: // one output stream - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioDevicePropertyStreams: // only return stream for output scope (we have no input streams) - return (address.mScope == kAudioObjectPropertyScopeOutput || + (address.mScope == kAudioObjectPropertyScopeOutput || address.mScope == kAudioObjectPropertyScopeGlobal) ? UInt32(MemoryLayout.size) : 0 case kAudioObjectPropertyCustomPropertyInfoList: - return UInt32(MemoryLayout.size * 2) + UInt32(MemoryLayout.size * 2) case kAudioDevicePropertyControlList: - return 0 // empty list + 0 // empty list case kAudioDevicePropertyNominalSampleRate: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case AppFadersProperty.setVolume: - return UInt32(VolumeCommand.totalSize) + UInt32(VolumeCommand.totalSize) case AppFadersProperty.getVolume: - return UInt32(MemoryLayout.size) + UInt32(MemoryLayout.size) case kAudioDevicePropertyAvailableNominalSampleRates: // 3 sample rates: 44100, 48000, 96000 - return UInt32(MemoryLayout.size * 3) + UInt32(MemoryLayout.size * 3) default: - return nil + nil } } From 584c9d8e5c517ec97ee9fbbc9143b17a28da484d Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 20:26:31 -0800 Subject: [PATCH 16/33] feat(orchestrator): add DeviceManager for audio device discovery Implements a thread-safe wrapper around SimplyCoreAudio to discover the virtual device and observe system-wide device list changes. Includes automatic observer cleanup and identification of the AppFaders driver by UID. --- .../specs/host-audio-orchestrator/tasks.md | 2 +- Sources/AppFaders/DeviceManager.swift | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 Sources/AppFaders/DeviceManager.swift diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 28c5885..849454c 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -68,7 +68,7 @@ ## Phase 4: Host Components -- [ ] 7. Create DeviceManager wrapper for SimplyCoreAudio +- [x] 7. Create DeviceManager wrapper for SimplyCoreAudio - File: Sources/AppFaders/DeviceManager.swift - Import SimplyCoreAudio, create instance - Implement allOutputDevices, appFadersDevice (find by UID) diff --git a/Sources/AppFaders/DeviceManager.swift b/Sources/AppFaders/DeviceManager.swift new file mode 100644 index 0000000..b715203 --- /dev/null +++ b/Sources/AppFaders/DeviceManager.swift @@ -0,0 +1,106 @@ +// DeviceManager.swift +// Wrapper around SimplyCoreAudio for device discovery and notifications +// +// handles enumeration of audio devices and identifies the AppFaders virtual device. +// provides notifications when the system's device list changes. + +import Foundation +import os.log +@preconcurrency import SimplyCoreAudio + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "DeviceManager") + +/// manages audio device discovery and status monitoring +/// note: marked as @unchecked Sendable as it wraps the SimplyCoreAudio hardware interface +final class DeviceManager: @unchecked Sendable { + private let simplyCA = SimplyCoreAudio() + private let notificationObservers = ThreadSafeArray() + private let lock = NSLock() + + private var _onDeviceListChanged: (@Sendable () -> Void)? + /// callback triggered when the system audio device list changes + var onDeviceListChanged: (@Sendable () -> Void)? { + get { + lock.lock() + defer { lock.unlock() } + return _onDeviceListChanged + } + set { + lock.lock() + defer { lock.unlock() } + _onDeviceListChanged = newValue + } + } + + /// returns all available output devices + var allOutputDevices: [AudioDevice] { + simplyCA.allOutputDevices + } + + /// returns the AppFaders Virtual Device if currently available + var appFadersDevice: AudioDevice? { + simplyCA.allOutputDevices.first { $0.uid == "com.fbreidenbach.appfaders.virtualdevice" } + } + + init() { + os_log(.info, log: log, "DeviceManager initialized") + } + + /// starts observing system-wide audio device list changes + func startObserving() { + guard notificationObservers.isEmpty else { return } + + let observer = NotificationCenter.default.addObserver( + forName: .deviceListChanged, + object: nil, + queue: .main + ) { [weak self] _ in + os_log(.debug, log: log, "Device list changed notification received") + self?.onDeviceListChanged?() + } + + notificationObservers.append(observer) + os_log(.info, log: log, "Started observing device list changes") + } + + /// stops observing device list changes + func stopObserving() { + let observers = notificationObservers.removeAll() + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + os_log(.info, log: log, "Stopped observing device list changes") + } + + deinit { + stopObserving() + } +} + +// MARK: - Helper Types + +/// basic thread-safe array for managing observer tokens +private final class ThreadSafeArray: @unchecked Sendable { + private var elements: [T] = [] + private let lock = NSLock() + + var isEmpty: Bool { + lock.lock() + defer { lock.unlock() } + return elements.isEmpty + } + + func append(_ element: T) { + lock.lock() + defer { lock.unlock() } + elements.append(element) + } + + func removeAll() -> [T] { + lock.lock() + defer { lock.unlock() } + let copy = elements + elements.removeAll() + return copy + } +} From 240baf3abff0ac3130d5692b8eff3d28388c71be Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:26:16 -0800 Subject: [PATCH 17/33] docs: update spec to use AsyncStream architecture Refactoring required for DeviceManager --- .../specs/host-audio-orchestrator/design.md | 39 +++++++++++-------- .../host-audio-orchestrator/requirements.md | 8 ++-- .../specs/host-audio-orchestrator/tasks.md | 30 +++++++------- 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/design.md b/.spec-workflow/specs/host-audio-orchestrator/design.md index f1fae4f..6242b4b 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/design.md +++ b/.spec-workflow/specs/host-audio-orchestrator/design.md @@ -16,6 +16,7 @@ This phase delivers: ### Technical Standards (tech.md) - **Swift 6** with strict concurrency and `@Observable` for state management +- **Structured Concurrency**: Use `AsyncStream` and `TaskGroup` instead of delegates or NotificationCenter observers - **SimplyCoreAudio** (v4.1.1) as specified dependency for device management - **AudioObject properties** for IPC — standard HAL pattern, low-latency - **macOS 26+ / arm64 only** per platform requirements @@ -41,8 +42,8 @@ This phase delivers: ### Integration Points -- **SimplyCoreAudio**: New dependency — provides `allOutputDevices`, device UID lookup, NotificationCenter-based change notifications -- **NSWorkspace**: System API for `runningApplications` and launch/terminate notifications +- **SimplyCoreAudio**: New dependency — provides `allOutputDevices`, device UID lookup. NotificationCenter events will be adapted to `AsyncStream`. +- **NSWorkspace**: System API for `runningApplications` and launch/terminate notifications (adapted to `AsyncStream`). - **coreaudiod**: Property get/set calls flow through system daemon to driver ## Architecture @@ -64,8 +65,8 @@ graph TD PE[PassthroughEngine] end - AAM --> AO - DM --> AO + AAM -->|AsyncStream| AO + DM -->|AsyncStream| AO DB --> AO AO --> UI[Phase 3 UI] DB -->|AudioObjectSetPropertyData| coreaudiod @@ -77,7 +78,7 @@ graph TD ### Modular Design Principles - **Single File Responsibility**: Each Swift file handles one concern (monitoring, device mgmt, IPC) -- **Component Isolation**: Components communicate via protocols for testability +- **Component Isolation**: Components expose `AsyncStream` sequences instead of callbacks/delegates - **Service Layer Separation**: DeviceManager handles CoreAudio, AppAudioMonitor handles NSWorkspace - **Utility Modularity**: Shared types in AudioTypes.swift, errors in dedicated file @@ -95,7 +96,7 @@ graph TD private(set) var appVolumes: [String: Float] // bundleID -> volume func setVolume(for bundleID: String, volume: Float) throws - func start() async + func start() async // consumes streams via TaskGroup func stop() } ``` @@ -107,17 +108,17 @@ graph TD - **Purpose**: Track running applications that may produce audio via NSWorkspace - **Interfaces**: ```swift - protocol AppAudioMonitorDelegate: AnyObject { - func monitor(_ monitor: AppAudioMonitor, didLaunch app: TrackedApp) - func monitor(_ monitor: AppAudioMonitor, didTerminate bundleID: String) + enum AppLifecycleEvent: Sendable { + case didLaunch(TrackedApp) + case didTerminate(String) // bundleID } final class AppAudioMonitor { - weak var delegate: AppAudioMonitorDelegate? var runningApps: [TrackedApp] { get } + var events: AsyncStream { get } - func start() - func stop() + func start() // initializes initial state + // stop() is implicit via stream cancellation } ``` - **Dependencies**: NSWorkspace, AppKit (for NSRunningApplication, NSImage) @@ -132,10 +133,7 @@ graph TD var allOutputDevices: [AudioDevice] { get } var appFadersDevice: AudioDevice? { get } - func startObserving() - func stopObserving() - - var onDeviceListChanged: (() -> Void)? + var deviceListUpdates: AsyncStream { get } } ``` - **Dependencies**: SimplyCoreAudio (v4.1.1) @@ -190,6 +188,15 @@ struct TrackedApp: Identifiable, Sendable, Hashable { } ``` +### AppLifecycleEvent + +```swift +enum AppLifecycleEvent: Sendable { + case didLaunch(TrackedApp) + case didTerminate(String) // bundleID +} +``` + ### VolumeCommand ```swift diff --git a/.spec-workflow/specs/host-audio-orchestrator/requirements.md b/.spec-workflow/specs/host-audio-orchestrator/requirements.md index 95ddc6c..64fc763 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/requirements.md +++ b/.spec-workflow/specs/host-audio-orchestrator/requirements.md @@ -23,7 +23,7 @@ SimplyCoreAudio wraps CoreAudio's verbose C APIs with Swift-native patterns: - Device enumeration with type filtering (input/output/aggregate) - Default device get/set operations -- Property change notifications via Combine/NotificationCenter +- **Async-compatible notifications**: We will adapt its NotificationCenter events into `AsyncStream` for modern concurrency - Eliminates hundreds of lines of AudioObject boilerplate The framework is mature (5+ years) and actively maintained. It targets macOS 10.12+ and Swift 4.0+, well within our macOS 26+ / Swift 6 requirements. @@ -59,7 +59,7 @@ Audio session detection (knowing which apps *can* produce audio vs which *are* p 1. WHEN `swift build` is run THEN SimplyCoreAudio SHALL compile without errors alongside existing targets 2. WHEN the host app initializes THEN it SHALL enumerate available audio devices using SimplyCoreAudio -3. WHEN the default output device changes THEN the host SHALL receive a notification via SimplyCoreAudio's observer mechanism +3. WHEN the default output device changes THEN the host SHALL receive a notification via an `AsyncStream` adapter 4. IF SimplyCoreAudio fails to initialize THEN the host SHALL log an error and continue with degraded functionality ### Requirement 2: AppFaders Virtual Device Discovery @@ -80,8 +80,8 @@ Audio session detection (knowing which apps *can* produce audio vs which *are* p #### Acceptance Criteria 1. WHEN the host starts THEN it SHALL enumerate currently running applications -2. WHEN a new application launches THEN AppAudioMonitor SHALL add it to the tracked list within 1 second -3. WHEN an application terminates THEN AppAudioMonitor SHALL remove it from the tracked list within 1 second +2. WHEN a new application launches THEN AppAudioMonitor SHALL emit an event via `AsyncStream` within 1 second +3. WHEN an application terminates THEN AppAudioMonitor SHALL emit an event via `AsyncStream` within 1 second 4. WHEN an application is tracked THEN its bundle ID, localized name, and icon SHALL be available 5. IF an application has no bundle ID (command-line tool) THEN it SHALL be excluded from tracking diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 849454c..cae1fd5 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -68,27 +68,26 @@ ## Phase 4: Host Components -- [x] 7. Create DeviceManager wrapper for SimplyCoreAudio +- [ ] 7. Create DeviceManager wrapper for SimplyCoreAudio - File: Sources/AppFaders/DeviceManager.swift - Import SimplyCoreAudio, create instance - Implement allOutputDevices, appFadersDevice (find by UID) - - Implement startObserving/stopObserving with NotificationCenter for .deviceListChanged - - Add onDeviceListChanged callback - - Purpose: Encapsulate device discovery and notifications + - Expose `deviceListUpdates` as an `AsyncStream` adapting `.deviceListChanged` + - Purpose: Encapsulate device discovery and notifications via AsyncStream - _Leverage: design.md Component 3: DeviceManager, SimplyCoreAudio Integration Details_ - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Create DeviceManager.swift per design.md. Use SimplyCoreAudio() instance. Find appFadersDevice by filtering allOutputDevices where uid == "com.fbreidenbach.appfaders.virtualdevice". Subscribe to NotificationCenter .deviceListChanged in startObserving, store observer token, remove in stopObserving. Call onDeviceListChanged callback when notification fires. | Restrictions: Use SimplyCoreAudio APIs only, no raw CoreAudio. Handle nil device gracefully. | _Leverage: design.md, SimplyCoreAudio README | Success: DeviceManager finds virtual device when installed, receives device change notifications | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Re-implement DeviceManager.swift per updated design.md. Remove ThreadSafeArray usage. Use SimplyCoreAudio() instance. Find appFadersDevice by filtering allOutputDevices where uid == "com.fbreidenbach.appfaders.virtualdevice". Expose `deviceListUpdates` as an `AsyncStream` that adds a NotificationCenter observer for .deviceListChanged and yields on each firing. Use `continuation.onTermination` to remove the observer. | Restrictions: Use SimplyCoreAudio and AsyncStream only. No manual start/stop or delegates. | _Leverage: design.md, SimplyCoreAudio README | Success: DeviceManager finds virtual device and provides an async stream of updates | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - [ ] 8. Create AppAudioMonitor for process tracking - File: Sources/AppFaders/AppAudioMonitor.swift - Use NSWorkspace.shared.runningApplications for initial list - - Subscribe to NSWorkspace.didLaunchApplicationNotification, didTerminateApplicationNotification + - Expose `events` as an `AsyncStream` adapting launch/terminate notifications - Filter to apps with bundleIdentifier (exclude command-line tools) - - Implement delegate protocol for launch/terminate events - - Purpose: Track running applications that may produce audio + - Remove `Sources/AppFaders/Utilities.swift` (cleanup) + - Purpose: Track running applications via AsyncStream - _Leverage: design.md Component 2: AppAudioMonitor_ - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS/AppKit developer | Task: Create AppAudioMonitor.swift per design.md. On start(), snapshot NSWorkspace.shared.runningApplications filtered to non-nil bundleIdentifier, create TrackedApp for each. Subscribe to NSWorkspace notifications for launch/terminate. On launch: create TrackedApp, call delegate.monitor(_:didLaunch:). On terminate: extract bundleID from notification, call delegate.monitor(_:didTerminate:). Store observer tokens, remove in stop(). | Restrictions: Filter out apps without bundleIdentifier. Use main queue for notifications. | _Leverage: design.md, AppKit NSWorkspace docs | Success: AppAudioMonitor tracks app launches/terminates with < 1 second latency | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS/AppKit developer | Task: Re-implement AppAudioMonitor.swift per updated design.md. Expose `events` as an `AsyncStream` that adapts NSWorkspace notifications for launch/terminate. Yield .didLaunch(TrackedApp) and .didTerminate(bundleID) respectively. Use `continuation.onTermination` to remove observers. Ensure initial `runningApps` state is populated. Also delete Sources/AppFaders/Utilities.swift as it is no longer needed. | Restrictions: Filter out apps without bundleIdentifier. Use AsyncStream only. | _Leverage: design.md, AppKit NSWorkspace docs | Success: AppAudioMonitor tracks app launches/terminates via an async stream, and Utilities.swift is removed | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - [ ] 9. Create DriverBridge for IPC communication - File: Sources/AppFaders/DriverBridge.swift @@ -107,13 +106,12 @@ - Mark as @Observable for SwiftUI binding - Compose DeviceManager, AppAudioMonitor, DriverBridge - Expose trackedApps, isDriverConnected, appVolumes state - - Implement start() that initializes all components + - Implement start() that consumes streams from DeviceManager and AppAudioMonitor - Implement setVolume(for:volume:) that updates state and calls DriverBridge - - Conform to AppAudioMonitorDelegate to update trackedApps - Purpose: Central state container for orchestration layer - _Leverage: design.md Component 1: AudioOrchestrator_ - _Requirements: 7.1, 7.2, 7.3_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer with Observation framework expertise | Task: Create AudioOrchestrator.swift per design.md. Use @Observable macro. Create DeviceManager, AppAudioMonitor (set self as delegate), DriverBridge as private properties. In start(): call deviceManager.startObserving(), appAudioMonitor.start(), attempt driverBridge.connect() if device found. setVolume: update appVolumes dict, call driverBridge.setAppVolume, handle errors. Implement AppAudioMonitorDelegate to add/remove from trackedApps. | Restrictions: Handle errors gracefully - don't crash if driver missing. | _Leverage: design.md, Swift Observation framework | Success: AudioOrchestrator compiles, manages state, coordinates components | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer with Observation framework expertise | Task: Create AudioOrchestrator.swift per design.md. Use @Observable macro. Create DeviceManager, AppAudioMonitor, DriverBridge as private properties. In start(): use `withTaskGroup` or multiple `Task`s to iterate over `deviceManager.deviceListUpdates` and `appAudioMonitor.events` concurrently. Update `trackedApps` and `isDriverConnected` state based on events. Implement setVolume to update appVolumes and call DriverBridge. | Restrictions: Use structured concurrency (TaskGroup) for stream consumption. Handle errors gracefully. | _Leverage: design.md, Swift Observation framework | Success: AudioOrchestrator compiles, manages state, coordinates components via streams | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - [ ] 11. Update main.swift to initialize orchestrator - File: Sources/AppFaders/main.swift @@ -124,7 +122,7 @@ - Purpose: Entry point that runs orchestrator as background service - _Leverage: design.md, existing main.swift_ - _Requirements: 7.1, 7.2, 7.3, 7.4_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Update main.swift to create AudioOrchestrator, call start(), print "AppFaders Host v0.2.0", print driver connection status and tracked app count. Use dispatchMain() to keep process running for notifications. Add signal handler for SIGINT to clean shutdown. | Restrictions: No UI - just console output for Phase 2. Keep it minimal. | _Leverage: Sources/AppFaders/main.swift, design.md | Success: `swift run AppFaders` starts orchestrator, prints status, receives app notifications | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Update main.swift to create AudioOrchestrator, call start() (in a Task), print "AppFaders Host v0.2.0". Use dispatchMain() to keep process running. Add signal handler for SIGINT to clean shutdown if needed (though streams handle cleanup). | Restrictions: No UI - just console output for Phase 2. Keep it minimal. | _Leverage: Sources/AppFaders/main.swift, design.md | Success: `swift run AppFaders` starts orchestrator, prints status, receives app notifications | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ ## Phase 5: Testing @@ -142,11 +140,11 @@ - [ ] 13. Create AppAudioMonitor unit tests - File: Tests/AppFadersTests/AppAudioMonitorTests.swift - Test initial app enumeration - - Test filtering (apps without bundleID excluded) + - Test stream events (launch/terminate) - Purpose: Verify app tracking logic - _Leverage: Swift Testing framework, design.md Testing Strategy_ - _Requirements: 3.1, 3.2, 3.5_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create AppAudioMonitorTests.swift using Swift Testing. Test that start() populates runningApps with current apps. Test that apps without bundleIdentifier are excluded. Use a mock delegate to verify callbacks. Note: Can't easily mock NSWorkspace notifications in unit tests, so focus on filtering logic. | Restrictions: Keep tests isolated and fast. Don't test NSWorkspace internals. | _Leverage: Swift Testing docs, design.md | Success: `swift test --filter AppAudioMonitor` passes | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create AppAudioMonitorTests.swift using Swift Testing. Test that initial `runningApps` is populated. Verify that events are yielded to the `events` stream by creating a `Task` that consumes the stream and checking for expected values (using mocks or manual triggering if possible, otherwise rely on system behavior for integration tests). Note: Can't easily mock NSWorkspace in unit tests, so mainly verify the stream mechanics if possible or skip deep integration testing in unit test layer. | Restrictions: Keep tests isolated and fast. | _Leverage: Swift Testing docs, design.md | Success: `swift test --filter AppAudioMonitor` passes | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - [ ] 14. Create DriverBridge unit tests - File: Tests/AppFadersTests/DriverBridgeTests.swift @@ -169,4 +167,4 @@ - Purpose: End-to-end verification of IPC - _Leverage: Scripts/install-driver.sh, Console.app_ - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 7.1_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: QA engineer | Task: Document and run manual integration test: 1) Run Scripts/install-driver.sh to install driver, 2) Run swift run AppFaders and verify "Driver connected" logged, 3) Modify main.swift temporarily to call orchestrator.setVolume(for: "com.apple.Safari", volume: 0.5), 4) Check Console.app for driver log showing volume received, 5) Verify getAppVolume returns 0.5. Document pass/fail results. | Restrictions: Manual testing - document actual results. | _Leverage: Scripts/install-driver.sh, Console.app | Success: Volume command successfully sent from host and received by driver | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion documenting test results, mark complete when done._ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: QA engineer | Task: Document and run manual integration test: 1) Run Scripts/install-driver.sh to install driver, 2) Run swift run AppFaders and verify "Driver connected" logged, 3) Modify main.swift temporarily to call orchestrator.setVolume(for: "com.apple.Safari", volume: 0.5), 4) Check Console.app for driver log showing volume received, 5) Verify getAppVolume returns 0.5. Document pass/fail results. | Restrictions: Manual testing - document actual results. | _Leverage: Scripts/install-driver.sh, Console.app | Success: Volume command successfully sent from host and received by driver | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion documenting test results, mark complete when done._ \ No newline at end of file From 36ed1a996d81614443ed5cf4b023c072c453dc24 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:53:11 -0800 Subject: [PATCH 18/33] feat(orchestrator): use swift build-tools 6.2 for latest features --- .spec-workflow/steering/tech.md | 2 +- Package.swift | 2 +- docs/implementation-roadmap.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.spec-workflow/steering/tech.md b/.spec-workflow/steering/tech.md index 58bd97f..e3382f3 100644 --- a/.spec-workflow/steering/tech.md +++ b/.spec-workflow/steering/tech.md @@ -8,7 +8,7 @@ Native macOS Desktop Application (Menu Bar Extra) utilizing a User-Space Audio D ### Primary Language(s) -- **Swift 6.0**: The primary language for the application UI, business logic, and host-side audio management. +- **Swift 6.2**: The primary language for the application UI, business logic, and host-side audio management. - **C/C++**: Minimal usage reserved for the low-level Audio Server Plug-in (HAL driver) boilerplate, integrated via Swift Package Manager. - **Runtime/Compiler**: Xcode 16+ / LLVM. - **Language-specific tools**: Swift Package Manager (SPM) for all dependency management and build orchestration. diff --git a/Package.swift b/Package.swift index 9e865a6..ad26d75 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 import PackageDescription diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index f7faf13..5c3bb92 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -7,7 +7,7 @@ This document outlines the sequential phases for building the AppFaders macOS ap **Goal**: Establish the monorepo and the virtual audio pipeline. - **Project Setup**: Initialize the SPM Monorepo in the clean project directory. -- **Driver Core**: Integrate the **Pancake** framework (Stable) as a dependency. +- **Driver Core**: Implement custom C/Swift wrapper (`AppFadersDriverBridge`) to replace incompatible Pancake framework. - **HAL Implementation**: Build the minimal Audio Server Plug-in that registers the "AppFaders Virtual Device." - **Verification**: Device appears in System Settings and passes audio successfully. @@ -15,8 +15,8 @@ This document outlines the sequential phases for building the AppFaders macOS ap **Goal**: Build the "Brain" of the application (Host Logic). -- **Device Management**: Integrate **SimplyCoreAudio** for high-level orchestration. -- **Process Monitoring**: Implement `AppAudioMonitor` to track running apps and their audio state via `NSWorkspace`. +- **Device Management**: Integrate **SimplyCoreAudio** for high-level orchestration using `AsyncStream` and structured concurrency. +- **Process Monitoring**: Implement `AppAudioMonitor` to track running apps and their audio state via `NSWorkspace` notifications. - **IPC Bridge**: Create the communication layer using `AudioObject` properties to send commands from the Host to the Driver. - **Verification**: Unit tests proving volume commands reach the driver's logic layer. From 343d278042ca93270215e2e3476d5006f04e0457 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:31:21 -0800 Subject: [PATCH 19/33] feat(orchestrator): implement AsyncStream for DeviceManager notifications Refactors DeviceManager to use a modern AsyncStream for audio device list changes instead of closures and manual lock management. - Replaces onDeviceListChanged callback with deviceListUpdates AsyncStream. - Removes NSLock and ThreadSafeArray as state is now managed via structured concurrency. - Uses NotificationCenter.notifications(named:) for Swift 6.2 compliance. - Implements [weak self] and proper task cancellation. --- .../specs/host-audio-orchestrator/tasks.md | 2 +- Sources/AppFaders/DeviceManager.swift | 89 ++++--------------- 2 files changed, 16 insertions(+), 75 deletions(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index cae1fd5..8194d2d 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -68,7 +68,7 @@ ## Phase 4: Host Components -- [ ] 7. Create DeviceManager wrapper for SimplyCoreAudio +- [x] 7. Create DeviceManager wrapper for SimplyCoreAudio - File: Sources/AppFaders/DeviceManager.swift - Import SimplyCoreAudio, create instance - Implement allOutputDevices, appFadersDevice (find by UID) diff --git a/Sources/AppFaders/DeviceManager.swift b/Sources/AppFaders/DeviceManager.swift index b715203..d66b84a 100644 --- a/Sources/AppFaders/DeviceManager.swift +++ b/Sources/AppFaders/DeviceManager.swift @@ -2,7 +2,7 @@ // Wrapper around SimplyCoreAudio for device discovery and notifications // // handles enumeration of audio devices and identifies the AppFaders virtual device. -// provides notifications when the system's device list changes. +// provides notifications when the system's device list changes via AsyncStream. import Foundation import os.log @@ -14,23 +14,6 @@ private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "Devi /// note: marked as @unchecked Sendable as it wraps the SimplyCoreAudio hardware interface final class DeviceManager: @unchecked Sendable { private let simplyCA = SimplyCoreAudio() - private let notificationObservers = ThreadSafeArray() - private let lock = NSLock() - - private var _onDeviceListChanged: (@Sendable () -> Void)? - /// callback triggered when the system audio device list changes - var onDeviceListChanged: (@Sendable () -> Void)? { - get { - lock.lock() - defer { lock.unlock() } - return _onDeviceListChanged - } - set { - lock.lock() - defer { lock.unlock() } - _onDeviceListChanged = newValue - } - } /// returns all available output devices var allOutputDevices: [AudioDevice] { @@ -42,65 +25,23 @@ final class DeviceManager: @unchecked Sendable { simplyCA.allOutputDevices.first { $0.uid == "com.fbreidenbach.appfaders.virtualdevice" } } - init() { - os_log(.info, log: log, "DeviceManager initialized") - } - - /// starts observing system-wide audio device list changes - func startObserving() { - guard notificationObservers.isEmpty else { return } - - let observer = NotificationCenter.default.addObserver( - forName: .deviceListChanged, - object: nil, - queue: .main - ) { [weak self] _ in - os_log(.debug, log: log, "Device list changed notification received") - self?.onDeviceListChanged?() - } - - notificationObservers.append(observer) - os_log(.info, log: log, "Started observing device list changes") - } + /// an async stream of notifications for device list changes + var deviceListUpdates: AsyncStream { + AsyncStream { continuation in + let task = Task { [weak self] in + guard self != nil else { return } + for await _ in NotificationCenter.default.notifications(named: .deviceListChanged) { + continuation.yield() + } + } - /// stops observing device list changes - func stopObserving() { - let observers = notificationObservers.removeAll() - for observer in observers { - NotificationCenter.default.removeObserver(observer) + continuation.onTermination = { @Sendable _ in + task.cancel() + } } - os_log(.info, log: log, "Stopped observing device list changes") } - deinit { - stopObserving() - } -} - -// MARK: - Helper Types - -/// basic thread-safe array for managing observer tokens -private final class ThreadSafeArray: @unchecked Sendable { - private var elements: [T] = [] - private let lock = NSLock() - - var isEmpty: Bool { - lock.lock() - defer { lock.unlock() } - return elements.isEmpty - } - - func append(_ element: T) { - lock.lock() - defer { lock.unlock() } - elements.append(element) - } - - func removeAll() -> [T] { - lock.lock() - defer { lock.unlock() } - let copy = elements - elements.removeAll() - return copy + init() { + os_log(.info, log: log, "DeviceManager initialized") } } From 9e61f6206698f57f032bc591500b5a8389796668 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:43:09 -0800 Subject: [PATCH 20/33] feat(orchestrator): implement AppAudioMonitor with AsyncStream events Uses NSWorkspace notifications to track application lifecycle and emits events via a modern AsyncStream. - Tracks app launch and termination using structured concurrency. - Manages thread-safe state for currently running applications. - Filters processes by bundle identifier to ensure trackability. - Integrates with the updated orchestrator design using Swift 6.2 patterns. --- .../specs/host-audio-orchestrator/tasks.md | 2 +- Sources/AppFaders/AppAudioMonitor.swift | 117 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 Sources/AppFaders/AppAudioMonitor.swift diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 8194d2d..f2ff9d4 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -78,7 +78,7 @@ - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Re-implement DeviceManager.swift per updated design.md. Remove ThreadSafeArray usage. Use SimplyCoreAudio() instance. Find appFadersDevice by filtering allOutputDevices where uid == "com.fbreidenbach.appfaders.virtualdevice". Expose `deviceListUpdates` as an `AsyncStream` that adds a NotificationCenter observer for .deviceListChanged and yields on each firing. Use `continuation.onTermination` to remove the observer. | Restrictions: Use SimplyCoreAudio and AsyncStream only. No manual start/stop or delegates. | _Leverage: design.md, SimplyCoreAudio README | Success: DeviceManager finds virtual device and provides an async stream of updates | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 8. Create AppAudioMonitor for process tracking +- [x] 8. Create AppAudioMonitor for process tracking - File: Sources/AppFaders/AppAudioMonitor.swift - Use NSWorkspace.shared.runningApplications for initial list - Expose `events` as an `AsyncStream` adapting launch/terminate notifications diff --git a/Sources/AppFaders/AppAudioMonitor.swift b/Sources/AppFaders/AppAudioMonitor.swift new file mode 100644 index 0000000..731f9d3 --- /dev/null +++ b/Sources/AppFaders/AppAudioMonitor.swift @@ -0,0 +1,117 @@ +// AppAudioMonitor.swift +// Monitors running applications for audio control +// +// tracks app launch and termination events via NSWorkspace. +// filters apps to those with valid bundle identifiers. + +import AppKit +import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "AppAudioMonitor") + +/// lifecycle events for tracked applications +enum AppLifecycleEvent: Sendable { + case didLaunch(TrackedApp) + case didTerminate(String) // bundleID +} + +/// monitors running applications using NSWorkspace +final class AppAudioMonitor: @unchecked Sendable { + private let workspace = NSWorkspace.shared + private let lock = NSLock() + private var _runningApps: [TrackedApp] = [] + + /// currently running applications that are tracked + var runningApps: [TrackedApp] { + lock.lock() + defer { lock.unlock() } + return _runningApps + } + + /// async stream of app lifecycle events + var events: AsyncStream { + AsyncStream { continuation in + let task = Task { [weak self] in + guard let self else { return } + + await withTaskGroup(of: Void.self) { group in + // Launch notifications + group.addTask { [weak self] in + for await notification in NotificationCenter.default.notifications( + named: NSWorkspace.didLaunchApplicationNotification + ) { + self?.handleAppLaunch(notification, continuation: continuation) + } + } + + // Termination notifications + group.addTask { [weak self] in + for await notification in NotificationCenter.default.notifications( + named: NSWorkspace.didTerminateApplicationNotification + ) { + self?.handleAppTerminate(notification, continuation: continuation) + } + } + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + init() { + os_log(.info, log: log, "AppAudioMonitor initialized") + } + + /// starts monitoring and populates initial state + func start() { + // initial snapshot + let currentApps = workspace.runningApplications + .compactMap { TrackedApp(from: $0) } + + lock.lock() + _runningApps = currentApps + lock.unlock() + + os_log(.info, log: log, "Started monitoring with %d initial apps", currentApps.count) + } + + private func handleAppLaunch( + _ notification: Notification, + continuation: AsyncStream.Continuation + ) { + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let trackedApp = TrackedApp(from: app) + else { return } + + lock.lock() + _runningApps.append(trackedApp) + lock.unlock() + + os_log(.debug, log: log, "App launched: %{public}@", trackedApp.bundleID) + continuation.yield(.didLaunch(trackedApp)) + } + + private func handleAppTerminate( + _ notification: Notification, + continuation: AsyncStream.Continuation + ) { + guard let app = notification + .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleID = app.bundleIdentifier + else { return } + + lock.lock() + if let index = _runningApps.firstIndex(where: { $0.bundleID == bundleID }) { + _runningApps.remove(at: index) + } + lock.unlock() + + os_log(.debug, log: log, "App terminated: %{public}@", bundleID) + continuation.yield(.didTerminate(bundleID)) + } +} From 74de015d0b44a7927062faff8b82eebde36b14c6 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 01:05:56 -0800 Subject: [PATCH 21/33] docs(orchestrator): replace SimplyCoreAudio with CAAudioHardware in design Updates the technical stack and architectural design to use CAAudioHardware by sbooth as the primary HAL wrapper. - Swaps SimplyCoreAudio dependency for CAAudioHardware in tech.md. - Refactors design.md architecture and component interfaces to use CAAudioHardware types. - Adds migration tasks 9 and 10 to tasks.md for dependency replacement and DeviceManager refactoring. - Renumbers subsequent tasks 11-17 to maintain spec consistency. --- .../specs/host-audio-orchestrator/design.md | 47 ++++++++++--------- .../specs/host-audio-orchestrator/tasks.md | 39 +++++++++++---- .spec-workflow/steering/tech.md | 2 +- 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/design.md b/.spec-workflow/specs/host-audio-orchestrator/design.md index 6242b4b..eb1cc49 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/design.md +++ b/.spec-workflow/specs/host-audio-orchestrator/design.md @@ -17,7 +17,7 @@ This phase delivers: - **Swift 6** with strict concurrency and `@Observable` for state management - **Structured Concurrency**: Use `AsyncStream` and `TaskGroup` instead of delegates or NotificationCenter observers -- **SimplyCoreAudio** (v4.1.1) as specified dependency for device management +- **CAAudioHardware** (sbooth/CAAudioHardware) as specified dependency for device management - **AudioObject properties** for IPC — standard HAL pattern, low-latency - **macOS 26+ / arm64 only** per platform requirements - **os_log** for diagnostics with subsystem `com.fbreidenbach.appfaders` @@ -28,7 +28,7 @@ This phase delivers: - Driver modifications minimal — add to existing `VirtualDevice.swift` and `AudioTypes.swift` - New files follow `PascalCase.swift` naming - Max 400 lines per file, 40 lines per method -- Import order: System frameworks → SimplyCoreAudio → Internal modules +- Import order: System frameworks → CAAudioHardware → Internal modules ## Code Reuse Analysis @@ -42,7 +42,7 @@ This phase delivers: ### Integration Points -- **SimplyCoreAudio**: New dependency — provides `allOutputDevices`, device UID lookup. NotificationCenter events will be adapted to `AsyncStream`. +- **CAAudioHardware**: New dependency — provides `AudioSystem`, `AudioDevice`, device UID lookup. NotificationCenter events will be adapted to `AsyncStream` via `whenSelectorChanges`. - **NSWorkspace**: System API for `runningApplications` and launch/terminate notifications (adapted to `AsyncStream`). - **coreaudiod**: Property get/set calls flow through system daemon to driver @@ -55,7 +55,7 @@ graph TD subgraph Host["AppFaders Host (Swift)"] AO[AudioOrchestrator
@Observable] AAM[AppAudioMonitor
NSWorkspace] - DM[DeviceManager
SimplyCoreAudio] + DM[DeviceManager
CAAudioHardware] DB[DriverBridge
AudioObject props] end @@ -126,7 +126,7 @@ graph TD ### Component 3: DeviceManager -- **Purpose**: Wrapper around SimplyCoreAudio for device discovery and notifications +- **Purpose**: Wrapper around CAAudioHardware for device discovery and notifications - **Interfaces**: ```swift final class DeviceManager { @@ -136,7 +136,7 @@ graph TD var deviceListUpdates: AsyncStream { get } } ``` -- **Dependencies**: SimplyCoreAudio (v4.1.1) +- **Dependencies**: CAAudioHardware - **Reuses**: None (new component) ### Component 4: DriverBridge @@ -279,19 +279,19 @@ enum DriverError: Error, LocalizedError { - Launch various apps (Safari, Music), verify AppAudioMonitor detects them - Set volume for an app, verify driver logs receipt (via Console.app) -## SimplyCoreAudio Integration Details +## CAAudioHardware Integration Details ### Package.swift Changes ```swift dependencies: [ - .package(url: "https://github.com/rnine/SimplyCoreAudio.git", from: "4.1.0") + .package(url: "https://github.com/sbooth/CAAudioHardware", from: "0.7.1") ], targets: [ .executableTarget( name: "AppFaders", dependencies: [ - .product(name: "SimplyCoreAudio", package: "SimplyCoreAudio") + .product(name: "CAAudioHardware", package: "CAAudioHardware") ] ), // ... existing targets unchanged @@ -301,27 +301,28 @@ targets: [ ### Device Discovery Pattern ```swift -import SimplyCoreAudio +import CAAudioHardware final class DeviceManager { - private let simplyCA = SimplyCoreAudio() - private var notificationObservers: [NSObjectProtocol] = [] - var appFadersDevice: AudioDevice? { - simplyCA.allOutputDevices.first { device in - device.uid == "com.fbreidenbach.appfaders.virtualdevice" + guard let id = try? AudioSystem.instance.deviceID(forUID: "com.fbreidenbach.appfaders.virtualdevice") else { + return nil } + return AudioDevice(id) } - func startObserving() { - let observer = NotificationCenter.default.addObserver( - forName: .deviceListChanged, - object: nil, - queue: .main - ) { [weak self] _ in - self?.onDeviceListChanged?() + var deviceListUpdates: AsyncStream { + AsyncStream { continuation in + // Subscribe to kAudioHardwarePropertyDevices + let observer = try? AudioSystem.instance.whenSelectorChanges(.devices, on: .main) { _ in + continuation.yield() + } + + continuation.onTermination = { _ in + // Remove observer (implementation detail: pass nil block) + try? AudioSystem.instance.whenSelectorChanges(.devices, perform: nil) + } } - notificationObservers.append(observer) } } ``` diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index f2ff9d4..3936e15 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -76,20 +76,39 @@ - Purpose: Encapsulate device discovery and notifications via AsyncStream - _Leverage: design.md Component 3: DeviceManager, SimplyCoreAudio Integration Details_ - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Re-implement DeviceManager.swift per updated design.md. Remove ThreadSafeArray usage. Use SimplyCoreAudio() instance. Find appFadersDevice by filtering allOutputDevices where uid == "com.fbreidenbach.appfaders.virtualdevice". Expose `deviceListUpdates` as an `AsyncStream` that adds a NotificationCenter observer for .deviceListChanged and yields on each firing. Use `continuation.onTermination` to remove the observer. | Restrictions: Use SimplyCoreAudio and AsyncStream only. No manual start/stop or delegates. | _Leverage: design.md, SimplyCoreAudio README | Success: DeviceManager finds virtual device and provides an async stream of updates | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Create/Update DeviceManager.swift per design.md. Use SimplyCoreAudio() instance. Find appFadersDevice by filtering allOutputDevices where uid == "com.fbreidenbach.appfaders.virtualdevice". Expose `deviceListUpdates` as an `AsyncStream` that adds a NotificationCenter observer for .deviceListChanged and yields on each firing. Use `continuation.onTermination` to remove the observer. | Restrictions: Use SimplyCoreAudio and AsyncStream only. No manual start/stop or delegates. | _Leverage: design.md, SimplyCoreAudio README | Success: DeviceManager finds virtual device and provides an async stream of updates | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - [x] 8. Create AppAudioMonitor for process tracking - File: Sources/AppFaders/AppAudioMonitor.swift - Use NSWorkspace.shared.runningApplications for initial list - Expose `events` as an `AsyncStream` adapting launch/terminate notifications - Filter to apps with bundleIdentifier (exclude command-line tools) - - Remove `Sources/AppFaders/Utilities.swift` (cleanup) - Purpose: Track running applications via AsyncStream - _Leverage: design.md Component 2: AppAudioMonitor_ - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS/AppKit developer | Task: Re-implement AppAudioMonitor.swift per updated design.md. Expose `events` as an `AsyncStream` that adapts NSWorkspace notifications for launch/terminate. Yield .didLaunch(TrackedApp) and .didTerminate(bundleID) respectively. Use `continuation.onTermination` to remove observers. Ensure initial `runningApps` state is populated. Also delete Sources/AppFaders/Utilities.swift as it is no longer needed. | Restrictions: Filter out apps without bundleIdentifier. Use AsyncStream only. | _Leverage: design.md, AppKit NSWorkspace docs | Success: AppAudioMonitor tracks app launches/terminates via an async stream, and Utilities.swift is removed | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS/AppKit developer | Task: Create/Update AppAudioMonitor.swift per design.md. Expose `events` as an `AsyncStream` that adapts NSWorkspace notifications for launch/terminate. Yield .didLaunch(TrackedApp) and .didTerminate(bundleID) respectively. Use `continuation.onTermination` to remove observers. Ensure initial `runningApps` state is populated. Also delete Sources/AppFaders/Utilities.swift as it is no longer needed. | Restrictions: Filter out apps without bundleIdentifier. Use AsyncStream only. | _Leverage: design.md, AppKit NSWorkspace docs | Success: AppAudioMonitor tracks app launches/terminates via an async stream | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 9. Create DriverBridge for IPC communication +- [ ] 9. Migration: Replace SimplyCoreAudio with CAAudioHardware + - File: Package.swift + - Remove SimplyCoreAudio dependency + - Add CAAudioHardware dependency (from: "0.7.1") + - Update target dependencies + - Purpose: Replace unmaintained library with active one + - _Leverage: design.md Package.swift Changes_ + - _Requirements: 1.1, 1.2, 1.3_ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Update Package.swift to remove SimplyCoreAudio and add CAAudioHardware (url: "https://github.com/sbooth/CAAudioHardware", from: "0.7.1"). Update AppFaders target to depend on CAAudioHardware product. | Restrictions: Do not modify driver targets. | _Leverage: design.md | Success: `swift build` resolves new dependency | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + +- [ ] 10. Migration: Refactor DeviceManager to use CAAudioHardware + - File: Sources/AppFaders/DeviceManager.swift + - Replace SimplyCoreAudio imports and usage with CAAudioHardware + - Use `AudioSystem.instance.deviceID(forUID:)` for discovery + - Adapt `AudioSystem` notifications to `AsyncStream` using `whenSelectorChanges` + - Purpose: Migrate device management logic to new library + - _Leverage: design.md CAAudioHardware Integration Details_ + - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_ + - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Rewrite DeviceManager.swift to use CAAudioHardware. Replace SimplyCoreAudio logic. Implement `appFadersDevice` using `AudioSystem.instance.deviceID(forUID:)`. Implement `deviceListUpdates` using `AudioSystem.instance.whenSelectorChanges(.devices)` inside an AsyncStream. Ensure resource cleanup in `onTermination`. | Restrictions: Use CAAudioHardware APIs only. Maintain AsyncStream interface. | _Leverage: design.md | Success: DeviceManager compiles with CAAudioHardware | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ + +- [ ] 11. Create DriverBridge for IPC communication - File: Sources/AppFaders/DriverBridge.swift - Import CoreAudio for AudioObject functions - Implement connect(deviceID:) storing AudioDeviceID @@ -101,7 +120,7 @@ - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 6.1, 6.2_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: CoreAudio/IPC developer | Task: Create DriverBridge.swift per design.md. Store connected AudioDeviceID. For setAppVolume: validate 0.0-1.0 range, serialize VolumeCommand (UInt8 length + bundleID bytes padded to 255 + Float32 volume), call AudioObjectSetPropertyData with AppFadersProperty.setVolume selector. For getAppVolume: encode bundleID as qualifier, call AudioObjectGetPropertyData with getVolume selector, return Float32. Check OSStatus, throw DriverError on failure. | Restrictions: Bundle ID max 255 chars. Validate all inputs. Use os_log for errors. | _Leverage: design.md, CoreAudio AudioObject.h | Success: DriverBridge can send volume commands to installed driver | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 10. Create AudioOrchestrator as central coordinator +- [ ] 12. Create AudioOrchestrator as central coordinator - File: Sources/AppFaders/AudioOrchestrator.swift - Mark as @Observable for SwiftUI binding - Compose DeviceManager, AppAudioMonitor, DriverBridge @@ -113,7 +132,7 @@ - _Requirements: 7.1, 7.2, 7.3_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer with Observation framework expertise | Task: Create AudioOrchestrator.swift per design.md. Use @Observable macro. Create DeviceManager, AppAudioMonitor, DriverBridge as private properties. In start(): use `withTaskGroup` or multiple `Task`s to iterate over `deviceManager.deviceListUpdates` and `appAudioMonitor.events` concurrently. Update `trackedApps` and `isDriverConnected` state based on events. Implement setVolume to update appVolumes and call DriverBridge. | Restrictions: Use structured concurrency (TaskGroup) for stream consumption. Handle errors gracefully. | _Leverage: design.md, Swift Observation framework | Success: AudioOrchestrator compiles, manages state, coordinates components via streams | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 11. Update main.swift to initialize orchestrator +- [ ] 13. Update main.swift to initialize orchestrator - File: Sources/AppFaders/main.swift - Create AudioOrchestrator instance - Call start() to initialize components @@ -126,7 +145,7 @@ ## Phase 5: Testing -- [ ] 12. Create VolumeStore unit tests +- [ ] 14. Create VolumeStore unit tests - File: Tests/AppFadersDriverTests/VolumeStoreTests.swift - Test setVolume/getVolume round-trip - Test default value (1.0) for unknown bundleID @@ -137,7 +156,7 @@ - _Requirements: 5.1, 5.2_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create VolumeStoreTests.swift using Swift Testing (@Test, #expect). Test: setVolume then getVolume returns same value, getVolume for unknown returns 1.0, removeVolume then getVolume returns 1.0, concurrent access from multiple DispatchQueue.global().async blocks doesn't crash. | Restrictions: Use Swift Testing not XCTest. Keep tests fast. | _Leverage: Swift Testing docs | Success: `swift test --filter VolumeStore` passes | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 13. Create AppAudioMonitor unit tests +- [ ] 15. Create AppAudioMonitor unit tests - File: Tests/AppFadersTests/AppAudioMonitorTests.swift - Test initial app enumeration - Test stream events (launch/terminate) @@ -146,7 +165,7 @@ - _Requirements: 3.1, 3.2, 3.5_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create AppAudioMonitorTests.swift using Swift Testing. Test that initial `runningApps` is populated. Verify that events are yielded to the `events` stream by creating a `Task` that consumes the stream and checking for expected values (using mocks or manual triggering if possible, otherwise rely on system behavior for integration tests). Note: Can't easily mock NSWorkspace in unit tests, so mainly verify the stream mechanics if possible or skip deep integration testing in unit test layer. | Restrictions: Keep tests isolated and fast. | _Leverage: Swift Testing docs, design.md | Success: `swift test --filter AppAudioMonitor` passes | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 14. Create DriverBridge unit tests +- [ ] 16. Create DriverBridge unit tests - File: Tests/AppFadersTests/DriverBridgeTests.swift - Test volume validation (reject out of range) - Test bundleID length validation @@ -156,7 +175,7 @@ - _Requirements: 5.4, 6.1, 6.2_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create DriverBridgeTests.swift using Swift Testing. Test: setAppVolume throws invalidVolumeRange for volume < 0 or > 1, setAppVolume throws bundleIDTooLong for bundleID > 255 chars. Can't test actual CoreAudio calls in unit test, but can test validation logic. Consider extracting validation to testable methods. | Restrictions: Don't require installed driver for unit tests. Test validation only. | _Leverage: Swift Testing docs, design.md | Success: `swift test --filter DriverBridge` passes | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 15. Integration test: volume command round-trip +- [ ] 17. Integration test: volume command round-trip - File: Documentation only (manual test procedure) - Install driver using Scripts/install-driver.sh - Run host app diff --git a/.spec-workflow/steering/tech.md b/.spec-workflow/steering/tech.md index e3382f3..8c05f5d 100644 --- a/.spec-workflow/steering/tech.md +++ b/.spec-workflow/steering/tech.md @@ -16,7 +16,7 @@ Native macOS Desktop Application (Menu Bar Extra) utilizing a User-Space Audio D ### Key Dependencies/Libraries - **SwiftUI**: Modern UI framework using the `Observation` framework for reactive state management. -- **SimplyCoreAudio**: A Swift package for high-level management of CoreAudio devices, simplifying device discovery and volume control. +- **CAAudioHardware**: A Swift package (`sbooth/CAAudioHardware`) for high-level management of CoreAudio devices, providing a robust object-oriented wrapper for the HAL. - **Custom C/Swift HAL Wrapper**: Minimal C interface (`AppFadersDriverBridge`) with Swift implementation (`AppFadersDriver`). Pancake was evaluated but found incompatible with Swift 6 strict concurrency — see `docs/pancake-compatibility.md`. - **CoreAudio / AudioToolbox**: Native system frameworks for low-level audio device interaction. - **ServiceManagement**: For implementing "Launch at Login" using the modern `SMAppService` Swift API. *(Phase 3+)* From e549a3bfff264dfebc33f80ccc04e49631f962e3 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 01:36:26 -0800 Subject: [PATCH 22/33] chore(deps): replace SimplyCoreAudio with CAAudioHardware Updates Package.swift and Package.resolved to use sbooth/CAAudioHardware version 0.7.1. --- .../specs/host-audio-orchestrator/tasks.md | 2 +- Package.resolved | 18 +++++++++--------- Package.swift | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 3936e15..88897e6 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -88,7 +88,7 @@ - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS/AppKit developer | Task: Create/Update AppAudioMonitor.swift per design.md. Expose `events` as an `AsyncStream` that adapts NSWorkspace notifications for launch/terminate. Yield .didLaunch(TrackedApp) and .didTerminate(bundleID) respectively. Use `continuation.onTermination` to remove observers. Ensure initial `runningApps` state is populated. Also delete Sources/AppFaders/Utilities.swift as it is no longer needed. | Restrictions: Filter out apps without bundleIdentifier. Use AsyncStream only. | _Leverage: design.md, AppKit NSWorkspace docs | Success: AppAudioMonitor tracks app launches/terminates via an async stream | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 9. Migration: Replace SimplyCoreAudio with CAAudioHardware +- [x] 9. Migration: Replace SimplyCoreAudio with CAAudioHardware - File: Package.swift - Remove SimplyCoreAudio dependency - Add CAAudioHardware dependency (from: "0.7.1") diff --git a/Package.resolved b/Package.resolved index aac4493..4a55b73 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,22 +1,22 @@ { - "originHash" : "e14beb9690ca21c49069b530098070be1a6091eb5519a5bda71aa64f8a55ea5a", + "originHash" : "2b9429c0e52243e61ea3c816543f92c473c050f02569fb3023aadf5b47702f5d", "pins" : [ { - "identity" : "simplycoreaudio", + "identity" : "caaudiohardware", "kind" : "remoteSourceControl", - "location" : "https://github.com/rnine/SimplyCoreAudio.git", + "location" : "https://github.com/sbooth/CAAudioHardware", "state" : { - "revision" : "35cc0e6eac5c2ee5049431f4238b0e333cf79869", - "version" : "4.1.1" + "revision" : "d927963dcfbb819da6ed6f17b83f17ffbc689280", + "version" : "0.7.1" } }, { - "identity" : "swift-atomics", + "identity" : "coreaudioextensions", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", + "location" : "https://github.com/sbooth/CoreAudioExtensions", "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" + "revision" : "632e715abbb22baa2cb5b939f1fd432045aa1c6a", + "version" : "0.4.0" } } ], diff --git a/Package.swift b/Package.swift index ad26d75..8ce019f 100644 --- a/Package.swift +++ b/Package.swift @@ -13,13 +13,13 @@ let package = Package( .plugin(name: "BundleAssembler", targets: ["BundleAssembler"]) ], dependencies: [ - .package(url: "https://github.com/rnine/SimplyCoreAudio.git", from: "4.1.0") + .package(url: "https://github.com/sbooth/CAAudioHardware", from: "0.7.1") ], targets: [ .executableTarget( name: "AppFaders", dependencies: [ - .product(name: "SimplyCoreAudio", package: "SimplyCoreAudio") + .product(name: "CAAudioHardware", package: "CAAudioHardware") ] ), .target( From 02d153a623ea12555330d94a48c1c7956ab14e27 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 02:17:19 -0800 Subject: [PATCH 23/33] refactor(orchestrator): refactor DeviceManager to use CAAudioHardware Migrates the DeviceManager component from SimplyCoreAudio to CAAudioHardware, which is actually maintained. --- .../specs/host-audio-orchestrator/tasks.md | 2 +- Sources/AppFaders/DeviceManager.swift | 44 ++++++++++++++----- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 88897e6..4bdab19 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -98,7 +98,7 @@ - _Requirements: 1.1, 1.2, 1.3_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Update Package.swift to remove SimplyCoreAudio and add CAAudioHardware (url: "https://github.com/sbooth/CAAudioHardware", from: "0.7.1"). Update AppFaders target to depend on CAAudioHardware product. | Restrictions: Do not modify driver targets. | _Leverage: design.md | Success: `swift build` resolves new dependency | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 10. Migration: Refactor DeviceManager to use CAAudioHardware +- [x] 10. Migration: Refactor DeviceManager to use CAAudioHardware - File: Sources/AppFaders/DeviceManager.swift - Replace SimplyCoreAudio imports and usage with CAAudioHardware - Use `AudioSystem.instance.deviceID(forUID:)` for discovery diff --git a/Sources/AppFaders/DeviceManager.swift b/Sources/AppFaders/DeviceManager.swift index d66b84a..ea00886 100644 --- a/Sources/AppFaders/DeviceManager.swift +++ b/Sources/AppFaders/DeviceManager.swift @@ -1,42 +1,62 @@ // DeviceManager.swift -// Wrapper around SimplyCoreAudio for device discovery and notifications +// Wrapper around CAAudioHardware for device discovery and notifications // // handles enumeration of audio devices and identifies the AppFaders virtual device. // provides notifications when the system's device list changes via AsyncStream. +@preconcurrency import CAAudioHardware import Foundation import os.log -@preconcurrency import SimplyCoreAudio private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "DeviceManager") /// manages audio device discovery and status monitoring -/// note: marked as @unchecked Sendable as it wraps the SimplyCoreAudio hardware interface -final class DeviceManager: @unchecked Sendable { - private let simplyCA = SimplyCoreAudio() - +final class DeviceManager { /// returns all available output devices var allOutputDevices: [AudioDevice] { - simplyCA.allOutputDevices + do { + return try AudioDevice.devices.filter { try $0.supportsOutput } + } catch { + os_log(.error, log: log, "Failed to get all output devices: %@", error as CVarArg) + return [] + } } /// returns the AppFaders Virtual Device if currently available var appFadersDevice: AudioDevice? { - simplyCA.allOutputDevices.first { $0.uid == "com.fbreidenbach.appfaders.virtualdevice" } + do { + guard let deviceID = try AudioSystem.instance + .deviceID(forUID: "com.fbreidenbach.appfaders.virtualdevice") + else { + return nil + } + let audioObject = try AudioObject.make(deviceID) + guard let device = audioObject as? AudioDevice else { + os_log(.error, log: log, "Found object for UID is not an AudioDevice") + return nil + } + return device + } catch { + os_log(.error, log: log, "Failed to find AppFaders device: %@", error as CVarArg) + return nil + } } /// an async stream of notifications for device list changes var deviceListUpdates: AsyncStream { AsyncStream { continuation in - let task = Task { [weak self] in - guard self != nil else { return } - for await _ in NotificationCenter.default.notifications(named: .deviceListChanged) { + do { + try AudioSystem.instance.whenSelectorChanges(.devices) { _ in continuation.yield() } + } catch { + os_log(.error, log: log, "Failed to subscribe to device list changes: %@", error as CVarArg) + continuation.finish() } continuation.onTermination = { @Sendable _ in - task.cancel() + // To stop observing, CAAudioHardware expects passing nil to the block + try? AudioSystem.instance.whenSelectorChanges(.devices, perform: nil) } } } From 972c2f392092625e6a08733fbdb707c3f9dea70f Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 02:23:42 -0800 Subject: [PATCH 24/33] docs: update spec requirements --- .../host-audio-orchestrator/requirements.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.spec-workflow/specs/host-audio-orchestrator/requirements.md b/.spec-workflow/specs/host-audio-orchestrator/requirements.md index 64fc763..b23442b 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/requirements.md +++ b/.spec-workflow/specs/host-audio-orchestrator/requirements.md @@ -17,16 +17,16 @@ Per `product.md`, AppFaders provides per-application volume control. This spec d ## Technical Approach -### Why SimplyCoreAudio +### Why CAAudioHardware -SimplyCoreAudio wraps CoreAudio's verbose C APIs with Swift-native patterns: +CAAudioHardware (sbooth/CAAudioHardware) wraps CoreAudio's verbose C APIs with Swift-native patterns: - Device enumeration with type filtering (input/output/aggregate) - Default device get/set operations - **Async-compatible notifications**: We will adapt its NotificationCenter events into `AsyncStream` for modern concurrency - Eliminates hundreds of lines of AudioObject boilerplate -The framework is mature (5+ years) and actively maintained. It targets macOS 10.12+ and Swift 4.0+, well within our macOS 26+ / Swift 6 requirements. +The framework is mature and actively maintained. It targets macOS 10.15+ and Swift 6.0+, well within our macOS 26+ requirements. ### Why AudioObject Properties for IPC @@ -51,16 +51,16 @@ Audio session detection (knowing which apps *can* produce audio vs which *are* p ## Requirements -### Requirement 1: SimplyCoreAudio Integration +### Requirement 1: CAAudioHardware Integration -**User Story:** As a developer, I want SimplyCoreAudio integrated as an SPM dependency, so that I can manage audio devices with idiomatic Swift code. +**User Story:** As a developer, I want CAAudioHardware integrated as an SPM dependency, so that I can manage audio devices with idiomatic Swift code. #### Acceptance Criteria -1. WHEN `swift build` is run THEN SimplyCoreAudio SHALL compile without errors alongside existing targets -2. WHEN the host app initializes THEN it SHALL enumerate available audio devices using SimplyCoreAudio +1. WHEN `swift build` is run THEN CAAudioHardware SHALL compile without errors alongside existing targets +2. WHEN the host app initializes THEN it SHALL enumerate available audio devices using CAAudioHardware 3. WHEN the default output device changes THEN the host SHALL receive a notification via an `AsyncStream` adapter -4. IF SimplyCoreAudio fails to initialize THEN the host SHALL log an error and continue with degraded functionality +4. IF CAAudioHardware fails to initialize THEN the host SHALL log an error and continue with degraded functionality ### Requirement 2: AppFaders Virtual Device Discovery @@ -125,7 +125,7 @@ Audio session detection (knowing which apps *can* produce audio vs which *are* p #### Acceptance Criteria -1. WHEN the AppFaders app launches THEN it SHALL initialize SimplyCoreAudio, AppAudioMonitor, and IPC bridge +1. WHEN the AppFaders app launches THEN it SHALL initialize CAAudioHardware, AppAudioMonitor, and IPC bridge 2. WHEN initialization completes THEN the app SHALL expose an observable state object for future UI binding 3. WHEN any component fails to initialize THEN the app SHALL continue with available functionality and surface errors 4. WHEN running without UI THEN the orchestrator SHALL function as a background service (no window, just initialization) From 10a1316e913e0f70d650c65cf27dbaf745a4a4b9 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 03:20:25 -0800 Subject: [PATCH 25/33] feat(orchestrator): implement DriverBridge for IPC comms Creates DriverBridge.swift for low-level IPC with the virtual driver. - Handles serialization of VolumeCommand for setAppVolume. - Uses AudioObject properties for setAppVolume and getAppVolume. - Incorporates NSLock for thread safety of deviceID access. - Adds null terminator to bundleID when retrieving volume to ensure C-string compatibility with the driver. --- .../specs/host-audio-orchestrator/tasks.md | 2 +- Sources/AppFaders/DriverBridge.swift | 180 ++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 Sources/AppFaders/DriverBridge.swift diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md index 4bdab19..6f7110a 100644 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ b/.spec-workflow/specs/host-audio-orchestrator/tasks.md @@ -108,7 +108,7 @@ - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_ - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Rewrite DeviceManager.swift to use CAAudioHardware. Replace SimplyCoreAudio logic. Implement `appFadersDevice` using `AudioSystem.instance.deviceID(forUID:)`. Implement `deviceListUpdates` using `AudioSystem.instance.whenSelectorChanges(.devices)` inside an AsyncStream. Ensure resource cleanup in `onTermination`. | Restrictions: Use CAAudioHardware APIs only. Maintain AsyncStream interface. | _Leverage: design.md | Success: DeviceManager compiles with CAAudioHardware | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ -- [ ] 11. Create DriverBridge for IPC communication +- [x] 11. Create DriverBridge for IPC communication - File: Sources/AppFaders/DriverBridge.swift - Import CoreAudio for AudioObject functions - Implement connect(deviceID:) storing AudioDeviceID diff --git a/Sources/AppFaders/DriverBridge.swift b/Sources/AppFaders/DriverBridge.swift new file mode 100644 index 0000000..a3b770c --- /dev/null +++ b/Sources/AppFaders/DriverBridge.swift @@ -0,0 +1,180 @@ +// DriverBridge.swift +// Low-level IPC bridge for communicating with the AppFaders virtual driver +// +// Handles serialization of volume commands and direct AudioObject property access. +// Manages the connection state to the specific AudioDeviceID of the virtual driver. + +import CoreAudio +import Foundation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "DriverBridge") + +// Re-defining constants here since we don't link against the driver target directly +// These must match AppFadersDriver/AudioTypes.swift +private enum AppFadersProperty { + // 'afvc' - Set Volume Command + static let setVolume = AudioObjectPropertySelector(0x6166_7663) + // 'afvq' - Get Volume Query + static let getVolume = AudioObjectPropertySelector(0x6166_7671) +} + +/// handles low-level communication with the AppFaders virtual driver +final class DriverBridge: @unchecked Sendable { + private var deviceID: AudioDeviceID? + private let lock = NSLock() + + /// returns true if currently connected to a valid device ID + var isConnected: Bool { + lock.withLock { deviceID != nil } + } + + /// connects to the specified audio device + /// - Parameter deviceID: The AudioDeviceID of the AppFaders Virtual Device + func connect(deviceID: AudioDeviceID) throws { + lock.withLock { + self.deviceID = deviceID + } + os_log(.info, log: log, "DriverBridge connected to deviceID: %u", deviceID) + } + + /// clears the stored device ID + func disconnect() { + lock.withLock { + deviceID = nil + } + os_log(.info, log: log, "DriverBridge disconnected") + } + + /// sends a volume command to the driver for a specific application + /// - Parameters: + /// - bundleID: The target application's bundle identifier + /// - volume: The desired volume level (0.0 - 1.0) + /// - Throws: DriverError if validation fails or the property write fails + func setAppVolume(bundleID: String, volume: Float) throws { + let currentDeviceID = lock.withLock { deviceID } + guard let deviceID = currentDeviceID else { + throw DriverError.deviceNotFound + } + + // Validation + guard volume >= 0.0, volume <= 1.0 else { + throw DriverError.invalidVolumeRange(volume) + } + + guard let bundleIDData = bundleID.data(using: .utf8) else { + throw DriverError.propertyWriteFailed(kAudioHardwareUnspecifiedError) + } + + guard bundleIDData.count <= 255 else { + throw DriverError.bundleIDTooLong(bundleIDData.count) + } + + // Manual Serialization of VolumeCommand + // Format: [length: UInt8] [bundleID: 255 bytes] [volume: Float32] + // Total: 260 bytes + var data = Data() + + // 1. Length (UInt8) + data.append(UInt8(bundleIDData.count)) + + // 2. Bundle ID (255 bytes, padded) + data.append(bundleIDData) + let padding = 255 - bundleIDData.count + if padding > 0 { + data.append(Data(repeating: 0, count: padding)) + } + + // 3. Volume (Float32) + var vol = volume + withUnsafeBytes(of: &vol) { buffer in + data.append(contentsOf: buffer) + } + + // Prepare Property Address + var address = AudioObjectPropertyAddress( + mSelector: AppFadersProperty.setVolume, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + // Write Property + let status = data.withUnsafeBytes { buffer in + AudioObjectSetPropertyData( + deviceID, + &address, + 0, // inQualifierDataSize + nil, // inQualifierData + UInt32(buffer.count), + buffer.baseAddress! + ) + } + + guard status == noErr else { + os_log( + .error, + log: log, + "Failed to set volume for %{public}@: %d", + bundleID, + status + ) + throw DriverError.propertyWriteFailed(status) + } + } + + /// retrieves the current volume for a specific application from the driver + /// - Parameter bundleID: The target application's bundle identifier + /// - Returns: The current volume level (0.0 - 1.0) + /// - Throws: DriverError if the property read fails + func getAppVolume(bundleID: String) throws -> Float { + let currentDeviceID = lock.withLock { deviceID } + guard let deviceID = currentDeviceID else { + throw DriverError.deviceNotFound + } + + guard var bundleIDData = bundleID.data(using: .utf8) else { + throw DriverError.propertyReadFailed(kAudioHardwareUnspecifiedError) + } + + guard bundleIDData.count <= 255 else { + throw DriverError.bundleIDTooLong(bundleIDData.count) + } + + // Append null terminator for C-string compatibility in the driver + bundleIDData.append(0) + + var address = AudioObjectPropertyAddress( + mSelector: AppFadersProperty.getVolume, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + var volume: Float32 = 0.0 + var dataSize = UInt32(MemoryLayout.size) + + // Use bundleID (null-terminated) as qualifier data + let status = bundleIDData.withUnsafeBytes { qualifierBuffer in + AudioObjectGetPropertyData( + deviceID, + &address, + UInt32(bundleIDData.count), + qualifierBuffer.baseAddress, + &dataSize, + &volume + ) + } + + guard status == noErr else { + os_log( + .error, + log: log, + "Failed to get volume for %{public}@: %d", + bundleID, + status + ) + throw DriverError.propertyReadFailed(status) + } + + return volume + } +} From c52a6019b48a36b4a3023e020d286f9c9310e2a9 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 03:27:34 -0800 Subject: [PATCH 26/33] fix: update placeholder test for new package and formatting --- Tests/AppFadersDriverTests/AudioTypesTests.swift | 14 +++++++------- Tests/AppFadersTests/AppFadersTests.swift | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Tests/AppFadersDriverTests/AudioTypesTests.swift b/Tests/AppFadersDriverTests/AudioTypesTests.swift index 3499ef7..0971508 100644 --- a/Tests/AppFadersDriverTests/AudioTypesTests.swift +++ b/Tests/AppFadersDriverTests/AudioTypesTests.swift @@ -190,7 +190,7 @@ struct AudioRingBufferTests { #expect(read == 10) // verify data matches - for i in 0..<20 { + for i in 0 ..< 20 { #expect(output[i] == 0.5) } } @@ -200,7 +200,7 @@ struct AudioRingBufferTests { let buffer = AudioRingBuffer() // write 5 frames - let input: [Float] = Array(1...10).map { Float($0) } + let input: [Float] = Array(1 ... 10).map { Float($0) } _ = input.withUnsafeBufferPointer { ptr in buffer.write(frames: ptr.baseAddress!, frameCount: 5) } @@ -215,12 +215,12 @@ struct AudioRingBufferTests { #expect(read == 5) // first 10 samples should be the data - for i in 0..<10 { + for i in 0 ..< 10 { #expect(output[i] == Float(i + 1)) } // remaining samples should be silence (0.0) - for i in 10..<20 { + for i in 10 ..< 20 { #expect(output[i] == 0.0) } } @@ -251,7 +251,7 @@ struct AudioRingBufferTests { var readBuffer = [Float](repeating: 0.0, count: 2000 * 2) // do 10 iterations to wrap around the 8192 frame buffer - for iteration in 0..<10 { + for iteration in 0 ..< 10 { let written = chunk.withUnsafeBufferPointer { ptr in buffer.write(frames: ptr.baseAddress!, frameCount: 2000) } @@ -263,7 +263,7 @@ struct AudioRingBufferTests { #expect(read == 2000, "iteration \(iteration) read failed") // verify data integrity after wrap - for i in 0..<(2000 * 2) { + for i in 0 ..< (2000 * 2) { #expect(readBuffer[i] == 0.25, "data corruption at iteration \(iteration), index \(i)") } } @@ -290,7 +290,7 @@ struct AudioRingBufferTests { #expect(read == 0) // read fills remainder with silence (0.0) on underflow - for i in 0..<20 { + for i in 0 ..< 20 { #expect(output[i] == 0.0) } } diff --git a/Tests/AppFadersTests/AppFadersTests.swift b/Tests/AppFadersTests/AppFadersTests.swift index ebcfae5..26aed1e 100644 --- a/Tests/AppFadersTests/AppFadersTests.swift +++ b/Tests/AppFadersTests/AppFadersTests.swift @@ -1,9 +1,9 @@ -import SimplyCoreAudio +import CAAudioHardware import Testing /// Placeholder tests - full implementation in Tasks 13-14 -@Test func simplyCoreAudioImports() async throws { - // Verify SimplyCoreAudio dependency is properly configured - let sca = SimplyCoreAudio() - #expect(sca.allDevices.count >= 0) +@Test func caAudioHardwareImports() async throws { + // Verify CAAudioHardware dependency is properly configured + let devices = try AudioDevice.devices + #expect(devices.count >= 0) } From 1529122c19a8782326cc686ad543b41f66b3ca08 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 03:59:36 -0800 Subject: [PATCH 27/33] feat(orchestrator): implement AudioOrchestrator and update DeviceManager AudioOrchestrator for coordinating app state, device connection, and IPC. Updates DeviceManager to be Sendable and refreshes spec documentation (tasks.md, design.md, requirements.md) for accuracy. --- Sources/AppFaders/AudioOrchestrator.swift | 216 ++++++++++++++++++++++ Sources/AppFaders/DeviceManager.swift | 2 +- 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 Sources/AppFaders/AudioOrchestrator.swift diff --git a/Sources/AppFaders/AudioOrchestrator.swift b/Sources/AppFaders/AudioOrchestrator.swift new file mode 100644 index 0000000..e6fb627 --- /dev/null +++ b/Sources/AppFaders/AudioOrchestrator.swift @@ -0,0 +1,216 @@ +// AudioOrchestrator.swift +// Central coordinator for the AppFaders host application +// +// Manages state for the UI, coordinates device discovery, app monitoring, +// and IPC communication with the virtual driver. + +import CAAudioHardware +import Foundation +import Observation +import os.log + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "AudioOrchestrator") + +/// Orchestrates the interaction between the UI, audio system, and running applications +@MainActor +@Observable +final class AudioOrchestrator { + // MARK: - State + + /// Currently running applications that are tracked + private(set) var trackedApps: [TrackedApp] = [] + + /// Whether the AppFaders Virtual Driver is currently connected + private(set) var isDriverConnected: Bool = false + + /// Current volume levels for applications (Bundle ID -> Volume 0.0-1.0) + private(set) var appVolumes: [String: Float] = [:] + + // MARK: - Components + + private let deviceManager: DeviceManager + private let appAudioMonitor: AppAudioMonitor + private let driverBridge: DriverBridge + + // MARK: - Initialization + + init() { + deviceManager = DeviceManager() + appAudioMonitor = AppAudioMonitor() + driverBridge = DriverBridge() + os_log(.info, log: log, "AudioOrchestrator initialized") + } + + // MARK: - Lifecycle + + /// Starts the orchestration process + /// - Consumes updates from DeviceManager and AppAudioMonitor + /// - Maintains connection to the virtual driver + /// - Note: This method blocks until the task is cancelled. + func start() async { + os_log(.info, log: log, "AudioOrchestrator starting...") + + // 1. Capture streams first to avoid race conditions and actor isolation issues + let deviceUpdates = deviceManager.deviceListUpdates + let appEvents = appAudioMonitor.events + + // 2. Initialize monitoring (populates initial list) + appAudioMonitor.start() + + // 3. Process initial apps (deduplicating if stream caught them already) + for app in appAudioMonitor.runningApps { + trackApp(app) + } + + // 4. Initial check for driver + await checkDriverConnection() + + // 5. Start consuming streams + await withTaskGroup(of: Void.self) { group in + // Device List Updates + group.addTask { [weak self] in + for await _ in deviceUpdates { + await self?.checkDriverConnection() + } + } + + // App Lifecycle Events + group.addTask { [weak self] in + for await event in appEvents { + await self?.handleAppEvent(event) + } + } + } + } + + /// Stops the orchestrator (placeholder for interface compliance) + /// Task cancellation is the primary mechanism to stop start(). + func stop() { + os_log(.info, log: log, "AudioOrchestrator stopping") + driverBridge.disconnect() + isDriverConnected = false + } + + // MARK: - Actions + + /// Sets the volume for a specific application + /// - Parameters: + /// - bundleID: The bundle identifier of the application + /// - volume: The volume level (0.0 - 1.0) + /// - Throws: Error if the driver communication fails + func setVolume(for bundleID: String, volume: Float) throws { + let oldVolume = appVolumes[bundleID] + + // 1. Update local state immediately for UI responsiveness + appVolumes[bundleID] = volume + + // 2. Send command to driver + do { + if driverBridge.isConnected { + try driverBridge.setAppVolume(bundleID: bundleID, volume: volume) + } else { + os_log(.debug, log: log, "Driver not connected, volume cached for %{public}@", bundleID) + } + } catch { + // Revert on error to maintain consistency + if let old = oldVolume { + appVolumes[bundleID] = old + } else { + appVolumes.removeValue(forKey: bundleID) + } + + os_log( + .error, + log: log, + "Failed to set volume for %{public}@: %@", + bundleID, + error as CVarArg + ) + throw error + } + } + + // MARK: - Private Helpers + + /// Checks if the virtual driver is present and updates connection state + private func checkDriverConnection() async { + if let device = deviceManager.appFadersDevice { + if !driverBridge.isConnected { + do { + try driverBridge.connect(deviceID: device.objectID) + isDriverConnected = true + os_log(.info, log: log, "Connected to AppFaders Virtual Driver") + + // Restore volumes to driver + restoreVolumes() + } catch { + os_log(.error, log: log, "Failed to connect to driver: %@", error as CVarArg) + isDriverConnected = false + } + } + } else { + if driverBridge.isConnected { + driverBridge.disconnect() + isDriverConnected = false + os_log(.info, log: log, "Disconnected from AppFaders Virtual Driver") + } + } + } + + private func restoreVolumes() { + for (bundleID, volume) in appVolumes { + do { + try driverBridge.setAppVolume(bundleID: bundleID, volume: volume) + } catch { + os_log( + .error, + log: log, + "Failed to restore volume for %{public}@: %@", + bundleID, + error as CVarArg + ) + } + } + } + + /// Handles app launch and termination events + private func handleAppEvent(_ event: AppLifecycleEvent) { + switch event { + case let .didLaunch(app): + trackApp(app) + + // Sync volume to driver if it exists + if let vol = appVolumes[app.bundleID], driverBridge.isConnected { + do { + try driverBridge.setAppVolume(bundleID: app.bundleID, volume: vol) + } catch { + os_log( + .error, + log: log, + "Failed to sync volume for launched app %{public}@: %@", + app.bundleID, + error as CVarArg + ) + } + } + + case let .didTerminate(bundleID): + if let index = trackedApps.firstIndex(where: { $0.bundleID == bundleID }) { + trackedApps.remove(at: index) + os_log(.debug, log: log, "Tracked app terminated: %{public}@", bundleID) + // We generally keep the volume in appVolumes to remember it for next launch + } + } + } + + private func trackApp(_ app: TrackedApp) { + if !trackedApps.contains(where: { $0.bundleID == app.bundleID }) { + trackedApps.append(app) + // Initialize volume if not present (default 1.0) + if appVolumes[app.bundleID] == nil { + appVolumes[app.bundleID] = 1.0 + } + os_log(.debug, log: log, "Tracked app: %{public}@", app.bundleID) + } + } +} diff --git a/Sources/AppFaders/DeviceManager.swift b/Sources/AppFaders/DeviceManager.swift index ea00886..0590769 100644 --- a/Sources/AppFaders/DeviceManager.swift +++ b/Sources/AppFaders/DeviceManager.swift @@ -11,7 +11,7 @@ import os.log private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "DeviceManager") /// manages audio device discovery and status monitoring -final class DeviceManager { +final class DeviceManager: Sendable { /// returns all available output devices var allOutputDevices: [AudioDevice] { do { From be2bc85293cd1e50ebed6982b73ab1763113f724 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:38:27 -0800 Subject: [PATCH 28/33] docs: remove spec-workflow folder from repo --- .gitignore | 4 +- .../archive/specs/driver-foundation/design.md | 287 -------------- .../specs/driver-foundation/requirements.md | 125 ------- .../archive/specs/driver-foundation/tasks.md | 150 -------- .../specs/host-audio-orchestrator/design.md | 354 ------------------ .../host-audio-orchestrator/requirements.md | 164 -------- .../specs/host-audio-orchestrator/tasks.md | 189 ---------- .spec-workflow/steering/product.md | 49 --- .spec-workflow/steering/structure.md | 102 ----- .spec-workflow/steering/tech.md | 98 ----- .spec-workflow/user-templates/README.md | 64 ---- 11 files changed, 1 insertion(+), 1585 deletions(-) delete mode 100644 .spec-workflow/archive/specs/driver-foundation/design.md delete mode 100644 .spec-workflow/archive/specs/driver-foundation/requirements.md delete mode 100644 .spec-workflow/archive/specs/driver-foundation/tasks.md delete mode 100644 .spec-workflow/specs/host-audio-orchestrator/design.md delete mode 100644 .spec-workflow/specs/host-audio-orchestrator/requirements.md delete mode 100644 .spec-workflow/specs/host-audio-orchestrator/tasks.md delete mode 100644 .spec-workflow/steering/product.md delete mode 100644 .spec-workflow/steering/structure.md delete mode 100644 .spec-workflow/steering/tech.md delete mode 100644 .spec-workflow/user-templates/README.md diff --git a/.gitignore b/.gitignore index 3af97d5..34860f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ ### Mine ### -.spec-workflow/templates/ -.spec-workflow/approvals/ +.spec-workflow/ audit.log -**/Implementation Logs/ ### Gemini ### gha-creds-*.json diff --git a/.spec-workflow/archive/specs/driver-foundation/design.md b/.spec-workflow/archive/specs/driver-foundation/design.md deleted file mode 100644 index 7bee371..0000000 --- a/.spec-workflow/archive/specs/driver-foundation/design.md +++ /dev/null @@ -1,287 +0,0 @@ -# Design Document: driver-foundation - -## Overview - -This design establishes the foundational architecture for AppFaders: an SPM-based monorepo containing a HAL AudioServerPlugIn that creates a virtual audio device. The driver intercepts system audio and passes it through to the default physical output. - -This phase delivers: - -- SPM monorepo structure with app and driver targets -- HAL plug-in that registers "AppFaders Virtual Device" -- Basic audio passthrough (no per-app control yet) - -## Steering Document Alignment - -### Technical Standards (tech.md) - -- **Swift 6** with strict concurrency for all new code -- **SPM-first** build system (with build plugin for bundle assembly) -- **Custom HAL wrapper** - minimal Swift/C implementation (Pancake not SPM-compatible) -- **macOS 26+ / arm64 only** per updated requirements - -### Project Structure (structure.md) - -- Monorepo with separate targets: `AppFaders` (app) and `AppFadersDriver` (driver) -- Driver code isolated in `Sources/AppFadersDriver/` -- Swift files follow `PascalCase.swift` naming -- Max 400 lines per file, 40 lines per method - -## Code Reuse Analysis - -### Existing Components to Leverage - -- **Custom HAL wrapper** (internal): Minimal Swift/C implementation wrapping AudioServerPlugIn C API - see `docs/pancake-compatibility.md` for decision rationale -- **BackgroundMusic** (reference): Open-source HAL driver for patterns and implementation guidance -- **CoreAudio/AudioToolbox** (system): Native frameworks for audio device interaction and format handling - -### Integration Points - -- **coreaudiod**: System audio daemon that loads our HAL plug-in from `/Library/Audio/Plug-Ins/HAL/` -- **System Settings**: Where our virtual device appears after registration - -## Architecture - -The driver operates as a HAL AudioServerPlugIn loaded by `coreaudiod`. It creates a virtual output device that captures audio and forwards it to the real output. - -```sh -┌─────────────────────────────────────────────────────────────────────┐ -│ coreaudiod │ -│ ┌───────────────────────────────────────────────────────────────┐ │ -│ │ AppFadersDriver.driver (HAL Plug-in) │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────┐ │ │ -│ │ │ PlugIn │ │ VirtualDevice│ │ PassthroughEngine │ │ │ -│ │ │ (entry pt) │──│ (AudioObject)│──│ (routes to output) │ │ │ -│ │ └─────────────┘ └─────────────┘ └───────────────────────┘ │ │ -│ └───────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌───────────────────────┐ - │ Physical Output │ - │ (speakers/headphones)│ - └───────────────────────┘ -``` - -### Modular Design Principles - -- **Single File Responsibility**: Each Swift file handles one HAL object type -- **Component Isolation**: PlugIn, Device, Stream, and Passthrough are separate modules -- **Protocol-Driven**: Internal interfaces use Swift protocols for testability - -## Components and Interfaces - -### Component 1: DriverEntry (PlugIn Interface) - -- **Purpose**: Entry point that `coreaudiod` calls to initialize the plug-in -- **Interfaces**: - - `AudioServerPlugInDriverInterface` vtable (C function pointers in PlugInInterface.c) - - Swift exports via @_cdecl: `AppFadersDriver_Initialize()`, `AppFadersDriver_CreateDevice()`, `AppFadersDriver_DestroyDevice()` -- **Dependencies**: AppFadersDriverBridge target, CoreAudio types -- **Files**: `Sources/AppFadersDriver/DriverEntry.swift`, `Sources/AppFadersDriverBridge/PlugInInterface.c` - -### Component 2: VirtualDevice - -- **Purpose**: Represents the "AppFaders Virtual Device" AudioObject with property handlers -- **Interfaces**: - - Property handlers via @_cdecl: `HasProperty()`, `IsPropertySettable()`, `GetPropertyDataSize()`, `GetPropertyData()` - - `ObjectID` enum for stable audio object identifiers (plugIn=1, device=2, outputStream=3) -- **Dependencies**: DriverEntry, CoreAudio types -- **File**: `Sources/AppFadersDriver/VirtualDevice.swift` - -### Component 3: VirtualStream - -- **Purpose**: Handles audio stream configuration (sample rate, format, channels) -- **Interfaces**: - - `configure(format: AudioStreamBasicDescription)` - - `startIO()` / `stopIO()` -- **Dependencies**: VirtualDevice, CoreAudio types -- **File**: `Sources/AppFadersDriver/VirtualStream.swift` - -### Component 4: PassthroughEngine - -- **Purpose**: Routes captured audio to the default physical output device -- **Interfaces**: - - `start(inputDevice: AudioDeviceID)` - - `stop()` - - `processBuffer(_ buffer: AudioBuffer)` (real-time safe) -- **Dependencies**: CoreAudio, AudioToolbox -- **File**: `Sources/AppFadersDriver/PassthroughEngine.swift` - -### Component 5: Build Plugin (BundleAssembler) - -- **Purpose**: SPM build tool plugin that assembles the `.driver` bundle with correct structure and Info.plist -- **Interfaces**: SPM `BuildToolPlugin` protocol -- **Dependencies**: Foundation, PackagePlugin -- **File**: `Plugins/BundleAssembler/BundleAssembler.swift` - -## Data Models - -### AudioDeviceConfiguration - -```swift -struct AudioDeviceConfiguration: Sendable { - let name: String // "AppFaders Virtual Device" - let uid: String // "com.fbreidenbach.appfaders.virtualdevice" - let manufacturer: String // "AppFaders" - let sampleRates: [Double] // [44100, 48000, 96000] - let channelCount: UInt32 // 2 (stereo) -} -``` - -### StreamFormat - -```swift -struct StreamFormat: Sendable { - let sampleRate: Double - let channelCount: UInt32 - let bitsPerChannel: UInt32 - let formatID: AudioFormatID // kAudioFormatLinearPCM -} -``` - -## Bundle Structure - -The driver must be packaged as a valid HAL plug-in bundle: - -```sh -AppFadersDriver.driver/ -├── Contents/ -│ ├── Info.plist # AudioServerPlugIn keys -│ ├── MacOS/ -│ │ └── AppFadersDriver # Compiled binary -│ └── Resources/ -│ └── (empty for now) -``` - -### Required Info.plist Keys - -```xml -CFBundleIdentifier -com.appfaders.driver -AudioServerPlugIn - - DeviceUID - com.appfaders.virtualdevice - -``` - -## Build System Design - -### Package.swift Structure - -```swift -// swift-tools-version: 6.0 -let package = Package( - name: "AppFaders", - platforms: [.macOS(.v26)], - products: [ - .executable(name: "AppFaders", targets: ["AppFaders"]), - .library(name: "AppFadersDriver", type: .dynamic, targets: ["AppFadersDriver"]), - .plugin(name: "BundleAssembler", targets: ["BundleAssembler"]) - ], - dependencies: [ - // No external dependencies - using custom HAL wrapper - ], - targets: [ - .executableTarget(name: "AppFaders", dependencies: []), - // C interface layer - SPM requires separate target for C code - .target( - name: "AppFadersDriverBridge", - publicHeadersPath: "include", - linkerSettings: [ - .linkedFramework("CoreAudio"), - .linkedFramework("CoreFoundation") - ] - ), - // Swift driver implementation - .target( - name: "AppFadersDriver", - dependencies: ["AppFadersDriverBridge"], - linkerSettings: [ - .linkedFramework("CoreAudio"), - .linkedFramework("AudioToolbox") - ] - ), - .plugin( - name: "BundleAssembler", - capability: .buildTool() - ), - .testTarget(name: "AppFadersDriverTests", dependencies: ["AppFadersDriver"]) - ] -) -``` - -### Build & Install Flow - -1. `swift build` compiles the driver library -2. Build plugin assembles `.driver` bundle structure -3. Manual/scripted copy to `/Library/Audio/Plug-Ins/HAL/` -4. `sudo killall coreaudiod` to reload - -## Error Handling - -### Error Scenarios - -1. **C/Swift interop issues in HAL wrapper** - - **Handling**: Ensure @_cdecl exports match C header declarations exactly - - **User Impact**: None (build-time issue) - -2. **Driver fails to load in coreaudiod** - - **Handling**: Log detailed diagnostics via `os_log` to Console.app - - **User Impact**: Virtual device doesn't appear; user checks Console - -3. **Default output device unavailable** - - **Handling**: PassthroughEngine gracefully stops; resumes when device returns - - **User Impact**: Silence until output device reconnects - -4. **Audio format mismatch** - - **Handling**: VirtualStream reports supported formats; rejects unsupported - - **User Impact**: App may need to resample (handled by CoreAudio) - -## Testing Strategy - -### Unit Testing - -- **VirtualDevice**: Test property getters/setters with mock AudioObjects -- **StreamFormat**: Test format validation and conversion -- **AudioDeviceConfiguration**: Test serialization and validation - -### Integration Testing - -- **Driver Loading**: Script that installs driver, restarts coreaudiod, verifies device appears -- **Audio Passthrough**: Play test tone through virtual device, verify output on physical device - -### Manual Testing - -- Install driver, select as output in System Settings -- Play audio from various apps (Music, Safari, etc.) -- Verify audio passes through without artifacts or noticeable latency -- Test hot-plugging headphones while audio plays - -## File Summary - -```sh -AppFaders/ -├── Package.swift -├── Sources/ -│ ├── AppFaders/ -│ │ └── main.swift # Placeholder app entry -│ ├── AppFadersDriverBridge/ # Separate C target (SPM requires this) -│ │ ├── include/ -│ │ │ └── PlugInInterface.h # C header for coreaudiod -│ │ └── PlugInInterface.c # C entry point, COM vtable -│ └── AppFadersDriver/ -│ ├── DriverEntry.swift # HAL plug-in Swift implementation -│ ├── VirtualDevice.swift # AudioObject device implementation -│ ├── VirtualStream.swift # Stream configuration -│ ├── PassthroughEngine.swift # Audio routing to physical output -│ └── AudioTypes.swift # Shared types and extensions -├── Plugins/ -│ └── BundleAssembler/ -│ └── BundleAssembler.swift # Build plugin for .driver bundle -├── Tests/ -│ └── AppFadersDriverTests/ -│ └── VirtualDeviceTests.swift -└── Resources/ - └── Info.plist # Template for driver bundle -``` diff --git a/.spec-workflow/archive/specs/driver-foundation/requirements.md b/.spec-workflow/archive/specs/driver-foundation/requirements.md deleted file mode 100644 index 0afa9df..0000000 --- a/.spec-workflow/archive/specs/driver-foundation/requirements.md +++ /dev/null @@ -1,125 +0,0 @@ -# Requirements Document: driver-foundation - -## Introduction - -This spec establishes the foundational layer for AppFaders: a Swift Package Manager monorepo and a minimal HAL (Hardware Abstraction Layer) audio plug-in that registers a virtual audio device with macOS. This phase focuses purely on infrastructure—getting the project structure right and proving the virtual device can be loaded by `coreaudiod`. - -No UI, no per-app volume control, no IPC—just a passthrough virtual audio device that appears in System Settings and successfully routes audio. - -## Alignment with Product Vision - -Per `product.md`, AppFaders requires a virtual audio device to intercept and modify per-application audio streams. This spec delivers the essential plumbing: - -- **Native Experience**: Using SPM and Swift 6 ensures modern, maintainable code from day one -- **Performance First**: A minimal passthrough driver establishes the foundation for future optimization -- The virtual device is the prerequisite for all future audio manipulation features - -## Technical Approach - -### Why HAL AudioServerPlugIn (not AudioDriverKit) - -Apple's AudioDriverKit framework does **not** support virtual audio devices. Per Apple's guidance, AudioDriverKit is only for hardware-backed drivers. For virtual audio devices (like ours), the HAL AudioServerPlugIn model remains the required approach. - -This means: - -- We must use the traditional `/Library/Audio/Plug-Ins/HAL/` installation path -- Driver installation requires `coreaudiod` restart (no hot-reload) -- We'll use a custom minimal Swift/C wrapper for the HAL API (Pancake is not SPM-compatible) - -### macOS 26+ and Apple Silicon Only - -Targeting only macOS 26 and arm64 enables: - -- **Single architecture build** — no Universal Binary complexity -- **Latest Swift 6 concurrency** — no runtime availability checks needed -- **Latest SwiftUI** — no `@available` guards throughout the codebase -- **Simplified testing** — one architecture to validate -- **Modern dependencies** — can require latest versions without backwards compat concerns - -## Requirements - -### Requirement 1: SPM Monorepo Initialization - -**User Story:** As a developer, I want a properly structured Swift Package Manager monorepo, so that all targets (app and driver) can be built and tested with standard Swift tooling. - -#### Acceptance Criteria - -1. WHEN `swift build` is run in the project root THEN the build system SHALL compile all targets without errors -2. WHEN `swift test` is run THEN the test runner SHALL execute tests for all testable targets -3. IF the project is opened in Xcode THEN Xcode SHALL recognize the SPM structure and provide full IDE support -4. WHEN a new dependency is added to `Package.swift` THEN SPM SHALL resolve and fetch it automatically - -### Requirement 2: HAL Driver Framework Integration - -**User Story:** As a developer, I want a Swift-friendly HAL framework integrated, so that I can write plug-in logic in Swift instead of raw C/C++. - -#### Acceptance Criteria - -1. WHEN `swift build` is run THEN the custom HAL wrapper SHALL compile without errors -2. The HAL wrapper SHALL provide a C interface layer (`AppFadersDriverBridge` target with `PlugInInterface.c`) that implements the `AudioServerPlugInDriverInterface` vtable -3. WHEN the driver target is compiled THEN the AudioServerPlugIn APIs SHALL be accessible from Swift code via @_cdecl exports -4. The C and Swift code SHALL be in separate SPM targets (SPM requires this for mixed-language support) - -### Requirement 3: Virtual Audio Device Registration - -**User Story:** As a user, I want a virtual audio device called "AppFaders Virtual Device" to appear in my system, so that I can select it as an audio output. - -#### Acceptance Criteria - -1. WHEN the HAL plug-in bundle is installed to `/Library/Audio/Plug-Ins/HAL/` AND `coreaudiod` is restarted THEN the virtual device SHALL appear in System Settings → Sound → Output -2. WHEN the virtual device is selected as output THEN audio from any application SHALL play through it without audible artifacts -3. IF the driver encounters an initialization error THEN it SHALL log diagnostic information to the system console -4. WHEN the driver bundle is removed from the HAL directory AND `coreaudiod` is restarted THEN the virtual device SHALL no longer appear - -### Requirement 4: Audio Passthrough - -**User Story:** As a user, I want audio routed through the virtual device to be forwarded to my default physical output, so that I can hear sound while using the virtual device. - -#### Acceptance Criteria - -1. WHEN audio is played to the virtual device THEN the driver SHALL forward it to the default physical output device -2. WHEN passthrough occurs THEN the audio latency SHALL be reasonable (no noticeable delay in normal use) -3. IF the default physical output changes THEN the driver SHALL continue routing to the new default device -4. WHEN multiple applications play audio simultaneously THEN the driver SHALL mix them correctly before passthrough - -### Requirement 5: Driver Bundle Structure - -**User Story:** As a developer, I want the driver to compile into a valid `.driver` bundle, so that macOS recognizes it as a HAL plug-in. - -#### Acceptance Criteria - -1. WHEN `swift build` completes THEN a `AppFadersDriver.driver` bundle SHALL be produced -2. WHEN the bundle's `Info.plist` is inspected THEN it SHALL contain the required `AudioServerPlugIn` keys -3. IF the bundle structure is invalid THEN `coreaudiod` SHALL reject it with a logged error (testable during development) - -## Non-Functional Requirements - -### Code Architecture and Modularity - -- **Single Responsibility**: The driver target contains only HAL plug-in code; no UI or host logic -- **Modular Design**: Driver components (device, stream, controls) are separate Swift files -- **Clear Interfaces**: Public driver API (if any) is defined via Swift protocols -- **SPM Target Isolation**: `AppFaders` (app) and `AppFadersDriver` are separate targets with explicit dependencies - -### Performance - -- Audio passthrough should work without noticeable delay or artifacts -- Specific latency and CPU targets deferred to later optimization phases - -### Security - -- No unnecessary entitlements beyond `com.apple.audio.AudioServerPlugIn` -- Installation requires explicit admin authorization (expected for `/Library/` writes) -- Code-signing and notarization deferred to Phase 4 (system-delivery) - -### Reliability - -- Driver must handle `coreaudiod` restarts gracefully -- No crashes or hangs during audio format changes -- Graceful degradation if physical output device is unavailable - -### Compatibility - -- macOS 26+ only -- Apple Silicon only (arm64) -- Swift 6 with strict concurrency checking enabled diff --git a/.spec-workflow/archive/specs/driver-foundation/tasks.md b/.spec-workflow/archive/specs/driver-foundation/tasks.md deleted file mode 100644 index a6f4d05..0000000 --- a/.spec-workflow/archive/specs/driver-foundation/tasks.md +++ /dev/null @@ -1,150 +0,0 @@ -# Tasks Document: driver-foundation - -## Phase 1: Project Setup - -- [x] 1. Initialize SPM monorepo with Package.swift - - File: Package.swift - - Create Swift 6 package manifest with macOS 26+ platform target - - Define products: AppFaders executable, AppFadersDriver dynamic library, BundleAssembler plugin - - Add Pancake dependency (or placeholder if fork needed) - - Configure CoreAudio/AudioToolbox linker settings for driver target - - Purpose: Establish build system foundation - - _Leverage: SPM documentation, Context7_ - - _Requirements: 1.1, 1.2, 1.3, 1.4_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer specializing in SPM and macOS development | Task: Create Package.swift for monorepo with app target, driver library target, and build plugin target. Use Swift 6, macOS 26+ only, arm64. Add Pancake dependency from github.com/0bmxa/Pancake. Link CoreAudio and AudioToolbox frameworks. Reference design.md for exact structure. | Restrictions: Do not create Xcode project files. Do not add unnecessary dependencies. Keep package manifest clean and minimal. | _Leverage: .spec-workflow/specs/driver-foundation/design.md, Context7 for SPM docs | Success: `swift build` compiles without errors, package structure matches design.md | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 2. Create placeholder app entry point - - File: Sources/AppFaders/main.swift - - Create minimal main.swift that prints version info - - Purpose: Satisfy SPM executable target requirement - - _Leverage: None_ - - _Requirements: 1.1_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Create minimal main.swift for AppFaders executable target that prints "AppFaders v0.1.0 - Driver Foundation" and exits. | Restrictions: Keep it minimal - no UI, no functionality beyond print statement. | _Leverage: None | Success: `swift run AppFaders` prints version and exits cleanly | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 3. Create driver Info.plist template - - File: Resources/Info.plist - - Define CFBundleIdentifier, CFBundleName, CFBundleExecutable - - Add AudioServerPlugIn dictionary with DeviceUID - - Purpose: Required metadata for HAL plug-in bundle - - _Leverage: Apple AudioServerPlugIn documentation_ - - _Requirements: 5.1, 5.2_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS developer with HAL plug-in experience | Task: Create Info.plist for AppFadersDriver.driver bundle. Include CFBundleIdentifier=com.appfaders.driver, CFBundleExecutable=AppFadersDriver, and AudioServerPlugIn dict with DeviceUID=com.appfaders.virtualdevice. | Restrictions: Use exact keys required by coreaudiod. No extra keys. | _Leverage: design.md Bundle Structure section | Success: Info.plist contains all required AudioServerPlugIn keys | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -## Phase 2: HAL Wrapper Setup - -- [x] 4. Verify Pancake Swift 6 compatibility - - File: docs/pancake-compatibility.md - - Tested Pancake import and documented incompatibility - - Decision: Use custom minimal HAL wrapper instead - - Purpose: Validate dependency before building on it - - _Leverage: Pancake repository, Context7_ - - _Requirements: 2.1, 2.2_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer debugging dependency issues | Task: Create PancakeCheck.swift that imports Pancake and attempts to use CreatePancakeDeviceConfig(), PancakeDeviceConfigAddFormat(), and CreatePancakeConfig(). Run swift build and document results. If build fails, document specific errors. | Restrictions: Do not modify Pancake source. Just test and document. | _Leverage: Context7 for Pancake docs if available, github.com/0bmxa/Pancake | Success: Document whether Pancake builds with Swift 6 - either "works" or "fails with [specific errors]" | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts documenting compatibility status, mark complete when done._ - -- [x] 5. Create C interface layer for HAL plug-in - - Files: Sources/AppFadersDriverBridge/PlugInInterface.c, include/PlugInInterface.h - - Implement COM-style factory function `AppFadersDriver_Create()` - - Create AudioServerPlugInDriverInterface vtable with function pointers - - Bridge to Swift implementation via @_cdecl exports - - Purpose: Entry point that coreaudiod loads and calls - - _Leverage: BackgroundMusic BGM_PlugInInterface.cpp, Apple AudioServerPlugIn.h_ - - _Requirements: 2.2, 3.1_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: C/Swift interop developer with CoreAudio experience | Task: Create minimal C interface layer for HAL plug-in. Implement factory function matching CFPlugInFactories UUID. Create vtable implementing AudioServerPlugInDriverInterface. Use @_cdecl to expose Swift functions. Reference BackgroundMusic's BGM_PlugInInterface.cpp for patterns. | Restrictions: Keep C layer minimal - just bridge to Swift. Use Apple's AudioServerPlugIn.h types exactly. | _Leverage: docs/pancake-compatibility.md, BackgroundMusic source | Success: C interface compiles and exports correct symbols for coreaudiod loading | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -## Phase 3: HAL Driver Implementation - -- [x] 6. Implement DriverEntry (HAL plug-in entry point) - - File: Sources/AppFadersDriver/DriverEntry.swift - - Create Swift singleton that manages plug-in lifecycle - - Implement Initialize(), CreateDevice(), DestroyDevice() callbacks via @_cdecl - - Coordinate with C interface layer from Task 5 - - Purpose: Entry point that coreaudiod calls - - _Leverage: BackgroundMusic BGM_PlugIn, design.md Component 1_ - - _Requirements: 3.1, 3.2, 3.3_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio driver developer | Task: Create DriverEntry.swift implementing the HAL plug-in entry point. Create singleton managing plugin state. Expose Initialize(), CreateDevice(), Teardown() via @_cdecl for C interface to call. Use os_log for diagnostics. | Restrictions: Keep real-time safe - no allocations in audio callbacks. | _Leverage: design.md, BackgroundMusic BGM_PlugIn.cpp | Success: Driver compiles and exports correct symbols for coreaudiod | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 7. Implement VirtualDevice - - File: Sources/AppFadersDriver/VirtualDevice.swift - - Create AudioObject representing "AppFaders Virtual Device" - - Implement property getters/setters (name, UID, manufacturer, etc.) - - Configure device as output type - - Purpose: The virtual audio device users see in System Settings - - _Leverage: BackgroundMusic BGM_Device, design.md Component 2_ - - _Requirements: 3.1, 3.2_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: CoreAudio developer | Task: Create VirtualDevice.swift implementing the AudioObject for "AppFaders Virtual Device". Set name="AppFaders Virtual Device", uid="com.fbreidenbach.appfaders.virtualdevice", manufacturer="AppFaders". Implement HasProperty, IsPropertySettable, GetPropertyDataSize, GetPropertyData, SetPropertyData for required properties. | Restrictions: Follow AudioObject property patterns exactly. | _Leverage: design.md, BackgroundMusic BGM_Device.cpp, CoreAudio headers | Success: Device properties are correctly reported when queried | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 8. Implement VirtualStream - - File: Sources/AppFadersDriver/VirtualStream.swift - - Create stream configuration (sample rate, format, channels) - - Support common formats: 44.1kHz, 48kHz, 96kHz stereo - - Implement startIO/stopIO callbacks - - Purpose: Handle audio stream configuration - - _Leverage: BackgroundMusic BGM_Stream, design.md Component 3_ - - _Requirements: 4.1_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Audio systems developer | Task: Create VirtualStream.swift implementing audio stream for VirtualDevice. Support 44100, 48000, 96000 Hz sample rates, stereo (2 channel), 32-bit float PCM. Implement stream property handlers and startIO/stopIO that coordinate with PassthroughEngine. | Restrictions: Support standard formats only for Phase 1. | _Leverage: design.md, BackgroundMusic BGM_Stream.cpp, CoreAudio AudioStreamBasicDescription | Success: Stream reports correct formats and handles IO lifecycle | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 9. Implement PassthroughEngine - - File: Sources/AppFadersDriver/PassthroughEngine.swift - - Route captured audio to default physical output - - Use CoreAudio APIs for output device discovery - - Implement real-time safe audio buffer processing - - Purpose: Actually play the audio through speakers - - _Leverage: CoreAudio/AudioToolbox, design.md Component 4_ - - _Requirements: 4.1, 4.2, 4.3, 4.4_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Real-time audio systems developer | Task: Create PassthroughEngine.swift that routes audio from VirtualDevice to the default output device. Use AudioObjectGetPropertyData to find default output. Set up IOProc for real-time audio routing. Handle device changes gracefully. | Restrictions: MUST be real-time safe - no locks, no allocations in audio callback. Use lock-free patterns. | _Leverage: design.md, BackgroundMusic BGMPlayThrough patterns, CoreAudio | Success: Audio played to virtual device is heard through physical output | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 10. Create shared audio types - - File: Sources/AppFadersDriver/AudioTypes.swift - - Define AudioDeviceConfiguration struct - - Define StreamFormat struct - - Add CoreAudio type extensions if needed - - Purpose: Shared types across driver components - - _Leverage: design.md Data Models_ - - _Requirements: All_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Create AudioTypes.swift with AudioDeviceConfiguration and StreamFormat structs exactly as defined in design.md Data Models section. Make them Sendable for Swift 6 concurrency. Add any useful CoreAudio type aliases or extensions. | Restrictions: Match design.md exactly. Keep minimal. | _Leverage: design.md Data Models section | Success: Types compile and are usable by other driver components | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -## Phase 4: Build System - -- [x] 11. Create BundleAssembler build plugin - - File: Plugins/BundleAssembler/BundleAssembler.swift - - Implement SPM BuildToolPlugin - - Assemble .driver bundle structure (Contents/MacOS, Contents/Info.plist) - - Copy compiled binary and Info.plist to correct locations - - Purpose: Automate driver bundle creation - - _Leverage: SPM plugin docs, Context7, design.md Component 5_ - - _Requirements: 5.1, 5.2, 5.3_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Build systems engineer with SPM expertise | Task: Create BundleAssembler.swift implementing SPM BuildToolPlugin. Use prebuildCommand to create AppFadersDriver.driver/ bundle structure in plugin work directory. Copy Info.plist to Contents/, create Contents/MacOS/ directory. The actual binary linking happens separately. | Restrictions: Follow SPM plugin patterns exactly. Use FileManager for file operations. | _Leverage: Context7 SPM plugin docs, design.md | Success: Running swift build produces correct bundle structure in build output | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 12. Create install script - - File: Scripts/install-driver.sh - - Copy .driver bundle to /Library/Audio/Plug-Ins/HAL/ - - Restart coreaudiod - - Verify device appears - - Purpose: Streamline development iteration - - _Leverage: None_ - - _Requirements: 3.1_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: DevOps/shell scripting | Task: Create install-driver.sh that: 1) Builds with swift build, 2) Copies AppFadersDriver.driver to /Library/Audio/Plug-Ins/HAL/ (requires sudo), 3) Runs sudo killall coreaudiod, 4) Waits 2 seconds, 5) Checks if "AppFaders Virtual Device" appears in system_profiler SPAudioDataType. | Restrictions: Must handle errors gracefully. Require explicit sudo. | _Leverage: None | Success: Script installs driver and verifies it loads | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -## Phase 5: Testing & Verification - -- [x] 13. Create driver unit tests - - File: Tests/AppFadersDriverTests/AudioTypesTests.swift - - Test AudioDeviceConfiguration: creation, defaults, supportedFormats - - Test StreamFormat: creation, defaults, bytesPerFrame, toASBD(), init(from:), Equatable - - Test AudioRingBuffer: write/read operations, wrap-around, underflow/overflow behavior - - Purpose: Verify driver logic without coreaudiod - - _Leverage: Swift Testing framework_ - - _Requirements: All_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create AudioTypesTests.swift using Swift Testing framework (@Test, #expect). Test AudioDeviceConfiguration defaults and supportedFormats. Test StreamFormat bytesPerFrame, toASBD round-trip, Equatable. Test AudioRingBuffer write/read, wrap-around at capacity, and edge cases. | Restrictions: Use Swift Testing, not XCTest. Keep tests fast and isolated. Focus on testable logic, not CoreAudio mocking. | _Leverage: Swift Testing docs via Context7 | Success: `swift test` passes all tests | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 14. Manual integration test - - File: Documentation only (no code file) - - Install driver using install script - - Verify device appears in System Settings → Sound → Output - - Select device and play audio from Music.app or Safari - - Verify audio passes through to speakers - - Document results - - Purpose: End-to-end verification - - _Leverage: Task 12 install script_ - - _Requirements: 3.1, 3.2, 4.1, 4.2_ - - _Prompt: Implement the task for spec driver-foundation, first run spec-workflow-guide to get the workflow guide then implement the task: Role: QA engineer | Task: Run manual integration test: 1) Run Scripts/install-driver.sh, 2) Open System Settings → Sound → Output, 3) Verify "AppFaders Virtual Device" appears, 4) Select it as output, 5) Play audio in Music.app or Safari, 6) Verify audio is heard through speakers. Document pass/fail and any issues. | Restrictions: This is manual testing - document actual results. | _Leverage: install-driver.sh | Success: Audio plays through virtual device without issues | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion documenting test results, mark complete when done._ diff --git a/.spec-workflow/specs/host-audio-orchestrator/design.md b/.spec-workflow/specs/host-audio-orchestrator/design.md deleted file mode 100644 index eb1cc49..0000000 --- a/.spec-workflow/specs/host-audio-orchestrator/design.md +++ /dev/null @@ -1,354 +0,0 @@ -# Design Document: host-audio-orchestrator - -## Overview - -This design establishes the host-side orchestration layer for AppFaders: the Swift code that connects the virtual audio driver to running applications. The orchestrator discovers our virtual device via SimplyCoreAudio, monitors running processes via NSWorkspace, and sends volume commands via custom AudioObject properties. - -This phase delivers: - -- SimplyCoreAudio integration for device discovery and notifications -- AppAudioMonitor for tracking audio-capable applications -- IPC bridge using custom driver properties for volume commands -- Observable state ready for Phase 3 UI binding - -## Steering Document Alignment - -### Technical Standards (tech.md) - -- **Swift 6** with strict concurrency and `@Observable` for state management -- **Structured Concurrency**: Use `AsyncStream` and `TaskGroup` instead of delegates or NotificationCenter observers -- **CAAudioHardware** (sbooth/CAAudioHardware) as specified dependency for device management -- **AudioObject properties** for IPC — standard HAL pattern, low-latency -- **macOS 26+ / arm64 only** per platform requirements -- **os_log** for diagnostics with subsystem `com.fbreidenbach.appfaders` - -### Project Structure (structure.md) - -- Host logic in `Sources/AppFaders/` target per monorepo structure -- Driver modifications minimal — add to existing `VirtualDevice.swift` and `AudioTypes.swift` -- New files follow `PascalCase.swift` naming -- Max 400 lines per file, 40 lines per method -- Import order: System frameworks → CAAudioHardware → Internal modules - -## Code Reuse Analysis - -### Existing Components to Leverage - -- **VirtualDevice.swift**: Already handles property queries via `hasProperty`, `getPropertyData`, `setPropertyData` — extend for custom IPC properties -- **ObjectID enum**: Stable IDs (plugIn=1, device=2, outputStream=3) — add custom property selectors -- **AudioTypes.swift**: Configuration structs — add `AppFadersProperty` enum and `VolumeCommand` struct -- **fourCharCode helper**: Already in VirtualDevice.swift for property selectors -- **os_log infrastructure**: Subsystem `com.fbreidenbach.appfaders.driver` — reuse pattern for host - -### Integration Points - -- **CAAudioHardware**: New dependency — provides `AudioSystem`, `AudioDevice`, device UID lookup. NotificationCenter events will be adapted to `AsyncStream` via `whenSelectorChanges`. -- **NSWorkspace**: System API for `runningApplications` and launch/terminate notifications (adapted to `AsyncStream`). -- **coreaudiod**: Property get/set calls flow through system daemon to driver - -## Architecture - -The host orchestrator sits between the future UI layer and the driver, managing state and communication. - -```mermaid -graph TD - subgraph Host["AppFaders Host (Swift)"] - AO[AudioOrchestrator
@Observable] - AAM[AppAudioMonitor
NSWorkspace] - DM[DeviceManager
CAAudioHardware] - DB[DriverBridge
AudioObject props] - end - - subgraph Driver["AppFadersDriver (HAL)"] - VD[VirtualDevice] - VS[VolumeStore] - PE[PassthroughEngine] - end - - AAM -->|AsyncStream| AO - DM -->|AsyncStream| AO - DB --> AO - AO --> UI[Phase 3 UI] - DB -->|AudioObjectSetPropertyData| coreaudiod - coreaudiod --> VD - VD --> VS - VS --> PE -``` - -### Modular Design Principles - -- **Single File Responsibility**: Each Swift file handles one concern (monitoring, device mgmt, IPC) -- **Component Isolation**: Components expose `AsyncStream` sequences instead of callbacks/delegates -- **Service Layer Separation**: DeviceManager handles CoreAudio, AppAudioMonitor handles NSWorkspace -- **Utility Modularity**: Shared types in AudioTypes.swift, errors in dedicated file - -## Components and Interfaces - -### Component 1: AudioOrchestrator - -- **Purpose**: Central coordinator and state container for the orchestration layer -- **Interfaces**: - ```swift - @Observable - final class AudioOrchestrator { - private(set) var trackedApps: [TrackedApp] - private(set) var isDriverConnected: Bool - private(set) var appVolumes: [String: Float] // bundleID -> volume - - func setVolume(for bundleID: String, volume: Float) throws - func start() async // consumes streams via TaskGroup - func stop() - } - ``` -- **Dependencies**: AppAudioMonitor, DeviceManager, DriverBridge -- **Reuses**: None (new component) - -### Component 2: AppAudioMonitor - -- **Purpose**: Track running applications that may produce audio via NSWorkspace -- **Interfaces**: - ```swift - enum AppLifecycleEvent: Sendable { - case didLaunch(TrackedApp) - case didTerminate(String) // bundleID - } - - final class AppAudioMonitor { - var runningApps: [TrackedApp] { get } - var events: AsyncStream { get } - - func start() // initializes initial state - // stop() is implicit via stream cancellation - } - ``` -- **Dependencies**: NSWorkspace, AppKit (for NSRunningApplication, NSImage) -- **Reuses**: None (new component) - -### Component 3: DeviceManager - -- **Purpose**: Wrapper around CAAudioHardware for device discovery and notifications -- **Interfaces**: - ```swift - final class DeviceManager { - var allOutputDevices: [AudioDevice] { get } - var appFadersDevice: AudioDevice? { get } - - var deviceListUpdates: AsyncStream { get } - } - ``` -- **Dependencies**: CAAudioHardware -- **Reuses**: None (new component) - -### Component 4: DriverBridge - -- **Purpose**: Communicate with the virtual driver via custom AudioObject properties -- **Interfaces**: - ```swift - final class DriverBridge { - var isConnected: Bool { get } - - func connect(deviceID: AudioDeviceID) throws - func disconnect() - - func setAppVolume(bundleID: String, volume: Float) throws - func getAppVolume(bundleID: String) throws -> Float - } - ``` -- **Dependencies**: CoreAudio (AudioObjectSetPropertyData, AudioObjectGetPropertyData) -- **Reuses**: AppFadersProperty selectors from AudioTypes.swift - -### Component 5: VolumeStore (Driver-side) - -- **Purpose**: Store per-app volume settings in the driver for real-time gain application -- **Interfaces**: - ```swift - final class VolumeStore: @unchecked Sendable { - static let shared = VolumeStore() - - func setVolume(for bundleID: String, volume: Float) // clamps to 0.0-1.0 - func getVolume(for bundleID: String) -> Float // default 1.0 - func removeVolume(for bundleID: String) - } - ``` -- **Dependencies**: Foundation (NSLock for thread safety) -- **Reuses**: Lock pattern from VirtualDevice.shared -- **Note**: VolumeStore clamps out-of-range values as a defensive measure; primary validation occurs in DriverBridge on the host side - -## Data Models - -### TrackedApp - -```swift -struct TrackedApp: Identifiable, Sendable, Hashable { - let id: String // bundle ID (also serves as Identifiable id) - let bundleID: String - let localizedName: String - let icon: NSImage? // app icon for future UI - let launchDate: Date -} -``` - -### AppLifecycleEvent - -```swift -enum AppLifecycleEvent: Sendable { - case didLaunch(TrackedApp) - case didTerminate(String) // bundleID -} -``` - -### VolumeCommand - -```swift -// Wire format for IPC property data -struct VolumeCommand { - static let maxBundleIDLength = 255 - - var bundleIDLength: UInt8 // actual length of bundle ID - var bundleIDBytes: (UInt8, ...) // fixed 255 bytes, null-padded - var volume: Float32 // 0.0 to 1.0 - - // Total size: 1 + 255 + 4 = 260 bytes -} -``` - -### AppFadersProperty (Custom Selectors) - -```swift -// Add to AudioTypes.swift -enum AppFadersProperty { - // Four-char codes: 'afXX' (0x6166XXXX) - static let setVolume = AudioObjectPropertySelector(0x61667663) // 'afvc' - static let getVolume = AudioObjectPropertySelector(0x61667671) // 'afvq' -} -``` - -## Error Handling - -### Error Scenarios - -1. **Virtual device not found** - - **Handling**: DeviceManager returns nil for appFadersDevice; DriverBridge.connect() throws DriverError.deviceNotFound - - **User Impact**: App shows "Driver not installed" state; functionality degraded - -2. **Property write fails (OSStatus != noErr)** - - **Handling**: DriverBridge logs error, throws DriverError.propertyWriteFailed(status) - - **User Impact**: Volume change doesn't take effect; error surfaced to orchestrator - -3. **coreaudiod restart during operation** - - **Handling**: SimplyCoreAudio posts `.deviceListChanged` notification; DeviceManager re-discovers devices; DriverBridge reconnects - - **User Impact**: Brief disconnection, automatic recovery - -4. **Invalid volume value** - - **Handling**: DriverBridge validates 0.0-1.0 range before sending; throws DriverError.invalidVolumeRange - - **User Impact**: None (validation prevents bad data) - -### Error Types - -```swift -enum DriverError: Error, LocalizedError { - case deviceNotFound - case propertyReadFailed(OSStatus) - case propertyWriteFailed(OSStatus) - case invalidVolumeRange(Float) - case bundleIDTooLong(Int) - - var errorDescription: String? { ... } -} -``` - -## Testing Strategy - -### Unit Testing - -- **AppAudioMonitor**: Inject mock NotificationCenter; verify app tracking on launch/terminate notifications -- **DriverBridge**: Mock AudioObjectSetPropertyData/GetPropertyData; verify VolumeCommand serialization -- **VolumeStore**: Test concurrent setVolume/getVolume; verify default 1.0 for unknown bundleIDs -- **AudioOrchestrator**: Mock all dependencies; verify state transitions and error propagation - -### Integration Testing - -- **Device discovery**: With installed driver, verify SimplyCoreAudio finds device by UID `com.fbreidenbach.appfaders.virtualdevice` -- **Property round-trip**: Host sets volume, reads back from driver, verifies match -- **Notification flow**: Simulate device removal/addition, verify DeviceManager callbacks - -### End-to-End Testing - -- Install driver via Scripts/install-driver.sh -- Launch host app, verify driver connection -- Launch various apps (Safari, Music), verify AppAudioMonitor detects them -- Set volume for an app, verify driver logs receipt (via Console.app) - -## CAAudioHardware Integration Details - -### Package.swift Changes - -```swift -dependencies: [ - .package(url: "https://github.com/sbooth/CAAudioHardware", from: "0.7.1") -], -targets: [ - .executableTarget( - name: "AppFaders", - dependencies: [ - .product(name: "CAAudioHardware", package: "CAAudioHardware") - ] - ), - // ... existing targets unchanged -] -``` - -### Device Discovery Pattern - -```swift -import CAAudioHardware - -final class DeviceManager { - var appFadersDevice: AudioDevice? { - guard let id = try? AudioSystem.instance.deviceID(forUID: "com.fbreidenbach.appfaders.virtualdevice") else { - return nil - } - return AudioDevice(id) - } - - var deviceListUpdates: AsyncStream { - AsyncStream { continuation in - // Subscribe to kAudioHardwarePropertyDevices - let observer = try? AudioSystem.instance.whenSelectorChanges(.devices, on: .main) { _ in - continuation.yield() - } - - continuation.onTermination = { _ in - // Remove observer (implementation detail: pass nil block) - try? AudioSystem.instance.whenSelectorChanges(.devices, perform: nil) - } - } - } -} -``` - -## File Summary - -``` -AppFaders/ -├── Package.swift # Add SimplyCoreAudio 4.1.0+ dependency -├── Sources/ -│ ├── AppFaders/ -│ │ ├── main.swift # Update: initialize orchestrator -│ │ ├── AudioOrchestrator.swift # NEW: central state coordinator -│ │ ├── AppAudioMonitor.swift # NEW: NSWorkspace app tracking -│ │ ├── DeviceManager.swift # NEW: SimplyCoreAudio wrapper -│ │ ├── DriverBridge.swift # NEW: IPC via AudioObject properties -│ │ ├── DriverError.swift # NEW: error types -│ │ └── TrackedApp.swift # NEW: app model -│ └── AppFadersDriver/ -│ ├── AudioTypes.swift # UPDATE: add AppFadersProperty enum -│ ├── VirtualDevice.swift # UPDATE: custom property handlers -│ └── VolumeStore.swift # NEW: per-app volume storage -└── Tests/ - ├── AppFadersTests/ # NEW: test target - │ ├── AppAudioMonitorTests.swift - │ ├── DriverBridgeTests.swift - │ └── VolumeStoreTests.swift - └── AppFadersDriverTests/ # Existing -``` diff --git a/.spec-workflow/specs/host-audio-orchestrator/requirements.md b/.spec-workflow/specs/host-audio-orchestrator/requirements.md deleted file mode 100644 index b23442b..0000000 --- a/.spec-workflow/specs/host-audio-orchestrator/requirements.md +++ /dev/null @@ -1,164 +0,0 @@ -# Requirements Document: host-audio-orchestrator - -## Introduction - -This spec builds the "brain" of AppFaders: the host-side logic that connects the virtual audio driver to running applications. Phase 2 establishes device management via SimplyCoreAudio, process monitoring to track audio-capable apps, and an IPC bridge using custom AudioObject properties to send volume commands from the host to the driver. - -No UI in this phase—just the orchestration layer that future UI will consume. - -## Alignment with Product Vision - -Per `product.md`, AppFaders provides per-application volume control. This spec delivers the essential host logic: - -- **Per-App Volume Sliders**: Requires knowing which apps are running and can produce audio (AppAudioMonitor) -- **Native Experience**: SimplyCoreAudio provides idiomatic Swift APIs over raw CoreAudio -- **Performance First**: IPC via AudioObject properties is the standard low-latency mechanism for HAL communication -- The orchestrator is the prerequisite for the SwiftUI mixer in Phase 3 - -## Technical Approach - -### Why CAAudioHardware - -CAAudioHardware (sbooth/CAAudioHardware) wraps CoreAudio's verbose C APIs with Swift-native patterns: - -- Device enumeration with type filtering (input/output/aggregate) -- Default device get/set operations -- **Async-compatible notifications**: We will adapt its NotificationCenter events into `AsyncStream` for modern concurrency -- Eliminates hundreds of lines of AudioObject boilerplate - -The framework is mature and actively maintained. It targets macOS 10.15+ and Swift 6.0+, well within our macOS 26+ requirements. - -### Why AudioObject Properties for IPC - -The HAL plug-in model supports custom properties on AudioObjects. This is the established pattern for host ↔ driver communication: - -- **Low latency**: Property reads/writes go directly through coreaudiod -- **No external IPC overhead**: No XPC, no Mach ports, no sockets to manage -- **Atomic operations**: CoreAudio handles synchronization -- **Discoverable**: Standard `kAudioObjectPropertyCustomPropertyInfoList` mechanism - -Phase 1 driver already stubs `kAudioObjectPropertyCustomPropertyInfoList`—we'll extend it to expose volume control properties. - -### Process Monitoring via NSWorkspace - -macOS provides `NSWorkspace.runningApplications` and notifications for app launch/termination. This is the standard approach for tracking running processes without elevated privileges: - -- `NSWorkspaceDidLaunchApplicationNotification` for new apps -- `NSWorkspaceDidTerminateApplicationNotification` for closed apps -- Bundle ID provides stable app identification - -Audio session detection (knowing which apps *can* produce audio vs which *are* producing audio) requires additional heuristics or AudioToolbox queries—this spec focuses on process awareness first. - -## Requirements - -### Requirement 1: CAAudioHardware Integration - -**User Story:** As a developer, I want CAAudioHardware integrated as an SPM dependency, so that I can manage audio devices with idiomatic Swift code. - -#### Acceptance Criteria - -1. WHEN `swift build` is run THEN CAAudioHardware SHALL compile without errors alongside existing targets -2. WHEN the host app initializes THEN it SHALL enumerate available audio devices using CAAudioHardware -3. WHEN the default output device changes THEN the host SHALL receive a notification via an `AsyncStream` adapter -4. IF CAAudioHardware fails to initialize THEN the host SHALL log an error and continue with degraded functionality - -### Requirement 2: AppFaders Virtual Device Discovery - -**User Story:** As a developer, I want the host to locate the AppFaders Virtual Device, so that it can communicate with our driver. - -#### Acceptance Criteria - -1. WHEN the host starts THEN it SHALL search for a device with UID matching our driver's published UID -2. WHEN the virtual device is found THEN the host SHALL store a reference (AudioDeviceID) for IPC operations -3. IF the virtual device is not installed THEN the host SHALL log a warning and indicate driver-not-found state -4. WHEN the virtual device appears or disappears (coreaudiod restart) THEN the host SHALL update its reference accordingly - -### Requirement 3: Process Monitoring (AppAudioMonitor) - -**User Story:** As a user, I want the app to know which applications are running, so that I can see them in the volume mixer. - -#### Acceptance Criteria - -1. WHEN the host starts THEN it SHALL enumerate currently running applications -2. WHEN a new application launches THEN AppAudioMonitor SHALL emit an event via `AsyncStream` within 1 second -3. WHEN an application terminates THEN AppAudioMonitor SHALL emit an event via `AsyncStream` within 1 second -4. WHEN an application is tracked THEN its bundle ID, localized name, and icon SHALL be available -5. IF an application has no bundle ID (command-line tool) THEN it SHALL be excluded from tracking - -### Requirement 4: Audio Capability Filtering - -**User Story:** As a user, I want to see only apps that can produce audio, so that the mixer isn't cluttered with irrelevant processes. - -#### Acceptance Criteria - -1. WHEN enumerating applications THEN AppAudioMonitor SHALL filter to apps with potential audio capability -2. WHEN filtering THEN apps with known audio entitlements or AudioToolbox usage SHALL be prioritized -3. IF an app's audio capability cannot be determined THEN it SHALL be included by default (false negatives are worse than false positives) -4. WHEN the user opens System Settings or other known non-audio apps THEN these MAY be filtered out via a configurable exclusion list - -### Requirement 5: IPC Bridge - Custom Properties - -**User Story:** As a developer, I want to send volume commands to the driver via custom AudioObject properties, so that volume changes take effect in real-time. - -#### Acceptance Criteria - -1. WHEN the host sets a per-app volume THEN it SHALL write the value to a custom property on the virtual device -2. WHEN a custom property is written THEN the driver SHALL receive the data within 10ms -3. WHEN the driver receives a volume command THEN it SHALL apply the gain adjustment to the corresponding audio stream -4. IF the property write fails THEN the host SHALL log the error and retry once -5. WHEN the host reads the current volume THEN the driver SHALL return the last-set value - -### Requirement 6: Volume Command Protocol - -**User Story:** As a developer, I want a well-defined protocol for volume commands, so that host and driver agree on data format. - -#### Acceptance Criteria - -1. WHEN defining the volume command THEN it SHALL include: app bundle ID (String), volume level (Float32, 0.0-1.0) -2. WHEN serializing commands THEN the format SHALL be compact and fixed-size where possible -3. WHEN the driver receives an unknown bundle ID THEN it SHALL create a new volume entry for it -4. WHEN an app terminates THEN the host SHALL optionally send a cleanup command to release driver resources - -### Requirement 7: Host Application Structure - -**User Story:** As a developer, I want the AppFaders executable target to initialize the orchestrator, so that it's ready for UI integration in Phase 3. - -#### Acceptance Criteria - -1. WHEN the AppFaders app launches THEN it SHALL initialize CAAudioHardware, AppAudioMonitor, and IPC bridge -2. WHEN initialization completes THEN the app SHALL expose an observable state object for future UI binding -3. WHEN any component fails to initialize THEN the app SHALL continue with available functionality and surface errors -4. WHEN running without UI THEN the orchestrator SHALL function as a background service (no window, just initialization) - -## Non-Functional Requirements - -### Code Architecture and Modularity - -- **Single Responsibility**: AppAudioMonitor handles process tracking only; IPC bridge handles driver communication only -- **Modular Design**: Each component is a separate Swift file/type with clear public API -- **Clear Interfaces**: Components communicate via protocols, enabling testing with mocks -- **SPM Target Isolation**: Host logic lives in `AppFaders` target; driver code untouched except for custom property additions - -### Performance - -- **Process Monitoring**: CPU usage < 0.5% when idle (no polling, notification-driven only) -- **IPC Latency**: Property writes complete within 10ms under normal load -- **Memory**: Minimal footprint—store only necessary app metadata (bundle ID, name, icon reference) - -### Security - -- No additional entitlements required beyond existing app sandbox -- Bundle ID-based app identification (no process injection or private APIs) -- Volume commands validated before sending (range check 0.0-1.0) - -### Reliability - -- Graceful handling of coreaudiod restarts (re-discover virtual device) -- No crashes if virtual device is absent (degraded mode) -- Notification observers properly removed on deinitialization - -### Testability - -- Unit tests for AppAudioMonitor with mock NSWorkspace -- Unit tests for IPC bridge with mock AudioObject calls -- Integration test proving volume command reaches driver (requires installed driver) diff --git a/.spec-workflow/specs/host-audio-orchestrator/tasks.md b/.spec-workflow/specs/host-audio-orchestrator/tasks.md deleted file mode 100644 index 6f7110a..0000000 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ /dev/null @@ -1,189 +0,0 @@ -# Tasks Document: host-audio-orchestrator - -## Phase 1: Package Setup - -- [x] 1. Add SimplyCoreAudio dependency and test target to Package.swift - - File: Package.swift - - Add SimplyCoreAudio 4.1.0+ dependency from github.com/rnine/SimplyCoreAudio - - Add dependency to AppFaders executable target - - Create AppFadersTests test target with dependency on AppFaders - - Purpose: Enable device management and host-side testing - - _Leverage: design.md SimplyCoreAudio Integration Details section_ - - _Requirements: 1.1, 1.2, 1.3_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer with SPM expertise | Task: Update Package.swift to add SimplyCoreAudio dependency (from: "4.1.0") and add it to AppFaders target dependencies. Create new AppFadersTests test target that depends on AppFaders. Reference design.md for exact structure. | Restrictions: Do not modify driver targets. Keep dependency version at 4.1.0+. | _Leverage: .spec-workflow/specs/host-audio-orchestrator/design.md | Success: `swift build` compiles with SimplyCoreAudio imported, `swift test` runs AppFadersTests | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -## Phase 2: Shared Types (Driver-side) - -- [x] 2. Add AppFadersProperty enum to AudioTypes.swift - - File: Sources/AppFadersDriver/AudioTypes.swift - - Define custom property selectors: setVolume (0x61667663 = 'afvc'), getVolume (0x61667671 = 'afvq') - - Use AudioObjectPropertySelector type - - Purpose: Shared IPC property identifiers between host and driver - - _Leverage: design.md AppFadersProperty section, existing fourCharCode helper in VirtualDevice.swift_ - - _Requirements: 5.1, 5.2, 6.1_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift/CoreAudio developer | Task: Add AppFadersProperty enum to AudioTypes.swift with static let setVolume and getVolume as AudioObjectPropertySelector values. Use hex values 0x61667663 and 0x61667671. These are four-char codes 'afvc' and 'afvq'. | Restrictions: Do not modify existing types. Add to existing file only. | _Leverage: Sources/AppFadersDriver/AudioTypes.swift, design.md | Success: AppFadersProperty compiles and is accessible from driver code | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 3. Create VolumeStore for per-app volume storage - - File: Sources/AppFadersDriver/VolumeStore.swift - - Create thread-safe singleton with NSLock - - Implement setVolume(bundleID:volume:), getVolume(bundleID:) with default 1.0, removeVolume(bundleID:) - - Mark as @unchecked Sendable (uses internal lock) - - Purpose: Store per-app volume settings for real-time gain application - - _Leverage: design.md Component 5: VolumeStore, VirtualDevice.swift lock pattern_ - - _Requirements: 5.1, 5.2, 5.3, 6.3_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift concurrency developer | Task: Create VolumeStore.swift with thread-safe singleton. Use private NSLock and Dictionary for storage. setVolume validates 0.0-1.0 range. getVolume returns 1.0 for unknown bundleIDs. Follow lock pattern from VirtualDevice.shared. Add os_log for volume changes. | Restrictions: Must be thread-safe. No async/await - use locks for real-time safety. | _Leverage: Sources/AppFadersDriver/VirtualDevice.swift lock pattern, design.md | Success: VolumeStore compiles, is Sendable, and handles concurrent access safely | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 4. Add custom property handlers to VirtualDevice.swift - - File: Sources/AppFadersDriver/VirtualDevice.swift - - Update hasDeviceProperty to include AppFadersProperty.setVolume and getVolume - - Update getDevicePropertyDataSize for custom properties - - Update getDevicePropertyData to read from VolumeStore (for getVolume) - - Update setPropertyData to write to VolumeStore (for setVolume) - - Update kAudioObjectPropertyCustomPropertyInfoList to return our custom properties - - Purpose: Enable IPC between host and driver via AudioObject properties - - _Leverage: design.md IPC Protocol Design section, existing property handlers_ - - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: CoreAudio HAL developer | Task: Extend VirtualDevice.swift to handle custom IPC properties. Add AppFadersProperty selectors to hasDeviceProperty, getDevicePropertyDataSize, getDevicePropertyData, setPropertyData. For setVolume: parse VolumeCommand (UInt8 length + 255 bytes bundleID + Float32 volume), call VolumeStore.setVolume. For getVolume: use qualifier data as bundleID, return Float32 from VolumeStore. Update kAudioObjectPropertyCustomPropertyInfoList to return AudioObjectPropertyInfo structs for our properties. | Restrictions: Match existing property handler patterns. Keep real-time safe. | _Leverage: Sources/AppFadersDriver/VirtualDevice.swift, design.md | Success: Custom properties are discoverable and functional via AudioObjectSetPropertyData/GetPropertyData | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -## Phase 3: Host Models and Utilities - -- [x] 5. Create TrackedApp model - - File: Sources/AppFaders/TrackedApp.swift - - Define struct with bundleID, localizedName, icon (NSImage?), launchDate - - Conform to Identifiable (id = bundleID), Sendable, Hashable - - Add convenience init from NSRunningApplication - - Purpose: Represent tracked applications for UI binding - - _Leverage: design.md Data Models TrackedApp section_ - - _Requirements: 3.1, 3.2, 3.4_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift/AppKit developer | Task: Create TrackedApp.swift with struct matching design.md. Use AppKit NSRunningApplication and NSImage. Add init?(from: NSRunningApplication) that extracts bundleIdentifier, localizedName, icon, launchDate. Return nil if bundleIdentifier is nil. Mark NSImage as @unchecked Sendable via extension. | Restrictions: Exclude apps without bundleID per Requirement 3.5. | _Leverage: design.md Data Models, AppKit docs | Success: TrackedApp compiles, can be created from NSRunningApplication | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 6. Create DriverError enum - - File: Sources/AppFaders/DriverError.swift - - Define error cases: deviceNotFound, propertyReadFailed(OSStatus), propertyWriteFailed(OSStatus), invalidVolumeRange(Float), bundleIDTooLong(Int) - - Conform to Error, LocalizedError with errorDescription - - Purpose: Type-safe error handling for driver communication - - _Leverage: design.md Error Types section_ - - _Requirements: 5.4, 6.4_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Create DriverError.swift with enum cases matching design.md Error Types. Implement LocalizedError with descriptive errorDescription for each case. Include OSStatus code in messages for debugging. | Restrictions: Keep error descriptions user-friendly but informative. | _Leverage: design.md Error Handling section | Success: DriverError compiles and provides meaningful error messages | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -## Phase 4: Host Components - -- [x] 7. Create DeviceManager wrapper for SimplyCoreAudio - - File: Sources/AppFaders/DeviceManager.swift - - Import SimplyCoreAudio, create instance - - Implement allOutputDevices, appFadersDevice (find by UID) - - Expose `deviceListUpdates` as an `AsyncStream` adapting `.deviceListChanged` - - Purpose: Encapsulate device discovery and notifications via AsyncStream - - _Leverage: design.md Component 3: DeviceManager, SimplyCoreAudio Integration Details_ - - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Create/Update DeviceManager.swift per design.md. Use SimplyCoreAudio() instance. Find appFadersDevice by filtering allOutputDevices where uid == "com.fbreidenbach.appfaders.virtualdevice". Expose `deviceListUpdates` as an `AsyncStream` that adds a NotificationCenter observer for .deviceListChanged and yields on each firing. Use `continuation.onTermination` to remove the observer. | Restrictions: Use SimplyCoreAudio and AsyncStream only. No manual start/stop or delegates. | _Leverage: design.md, SimplyCoreAudio README | Success: DeviceManager finds virtual device and provides an async stream of updates | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 8. Create AppAudioMonitor for process tracking - - File: Sources/AppFaders/AppAudioMonitor.swift - - Use NSWorkspace.shared.runningApplications for initial list - - Expose `events` as an `AsyncStream` adapting launch/terminate notifications - - Filter to apps with bundleIdentifier (exclude command-line tools) - - Purpose: Track running applications via AsyncStream - - _Leverage: design.md Component 2: AppAudioMonitor_ - - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS/AppKit developer | Task: Create/Update AppAudioMonitor.swift per design.md. Expose `events` as an `AsyncStream` that adapts NSWorkspace notifications for launch/terminate. Yield .didLaunch(TrackedApp) and .didTerminate(bundleID) respectively. Use `continuation.onTermination` to remove observers. Ensure initial `runningApps` state is populated. Also delete Sources/AppFaders/Utilities.swift as it is no longer needed. | Restrictions: Filter out apps without bundleIdentifier. Use AsyncStream only. | _Leverage: design.md, AppKit NSWorkspace docs | Success: AppAudioMonitor tracks app launches/terminates via an async stream | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 9. Migration: Replace SimplyCoreAudio with CAAudioHardware - - File: Package.swift - - Remove SimplyCoreAudio dependency - - Add CAAudioHardware dependency (from: "0.7.1") - - Update target dependencies - - Purpose: Replace unmaintained library with active one - - _Leverage: design.md Package.swift Changes_ - - _Requirements: 1.1, 1.2, 1.3_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Update Package.swift to remove SimplyCoreAudio and add CAAudioHardware (url: "https://github.com/sbooth/CAAudioHardware", from: "0.7.1"). Update AppFaders target to depend on CAAudioHardware product. | Restrictions: Do not modify driver targets. | _Leverage: design.md | Success: `swift build` resolves new dependency | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 10. Migration: Refactor DeviceManager to use CAAudioHardware - - File: Sources/AppFaders/DeviceManager.swift - - Replace SimplyCoreAudio imports and usage with CAAudioHardware - - Use `AudioSystem.instance.deviceID(forUID:)` for discovery - - Adapt `AudioSystem` notifications to `AsyncStream` using `whenSelectorChanges` - - Purpose: Migrate device management logic to new library - - _Leverage: design.md CAAudioHardware Integration Details_ - - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3, 2.4_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: macOS audio developer | Task: Rewrite DeviceManager.swift to use CAAudioHardware. Replace SimplyCoreAudio logic. Implement `appFadersDevice` using `AudioSystem.instance.deviceID(forUID:)`. Implement `deviceListUpdates` using `AudioSystem.instance.whenSelectorChanges(.devices)` inside an AsyncStream. Ensure resource cleanup in `onTermination`. | Restrictions: Use CAAudioHardware APIs only. Maintain AsyncStream interface. | _Leverage: design.md | Success: DeviceManager compiles with CAAudioHardware | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [x] 11. Create DriverBridge for IPC communication - - File: Sources/AppFaders/DriverBridge.swift - - Import CoreAudio for AudioObject functions - - Implement connect(deviceID:) storing AudioDeviceID - - Implement setAppVolume using AudioObjectSetPropertyData with VolumeCommand format - - Implement getAppVolume using AudioObjectGetPropertyData with bundleID as qualifier - - Validate volume range, throw DriverError on failures - - Purpose: Low-level IPC with driver via custom properties - - _Leverage: design.md Component 4: DriverBridge, IPC Protocol Design_ - - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 6.1, 6.2_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: CoreAudio/IPC developer | Task: Create DriverBridge.swift per design.md. Store connected AudioDeviceID. For setAppVolume: validate 0.0-1.0 range, serialize VolumeCommand (UInt8 length + bundleID bytes padded to 255 + Float32 volume), call AudioObjectSetPropertyData with AppFadersProperty.setVolume selector. For getAppVolume: encode bundleID as qualifier, call AudioObjectGetPropertyData with getVolume selector, return Float32. Check OSStatus, throw DriverError on failure. | Restrictions: Bundle ID max 255 chars. Validate all inputs. Use os_log for errors. | _Leverage: design.md, CoreAudio AudioObject.h | Success: DriverBridge can send volume commands to installed driver | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [ ] 12. Create AudioOrchestrator as central coordinator - - File: Sources/AppFaders/AudioOrchestrator.swift - - Mark as @Observable for SwiftUI binding - - Compose DeviceManager, AppAudioMonitor, DriverBridge - - Expose trackedApps, isDriverConnected, appVolumes state - - Implement start() that consumes streams from DeviceManager and AppAudioMonitor - - Implement setVolume(for:volume:) that updates state and calls DriverBridge - - Purpose: Central state container for orchestration layer - - _Leverage: design.md Component 1: AudioOrchestrator_ - - _Requirements: 7.1, 7.2, 7.3_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer with Observation framework expertise | Task: Create AudioOrchestrator.swift per design.md. Use @Observable macro. Create DeviceManager, AppAudioMonitor, DriverBridge as private properties. In start(): use `withTaskGroup` or multiple `Task`s to iterate over `deviceManager.deviceListUpdates` and `appAudioMonitor.events` concurrently. Update `trackedApps` and `isDriverConnected` state based on events. Implement setVolume to update appVolumes and call DriverBridge. | Restrictions: Use structured concurrency (TaskGroup) for stream consumption. Handle errors gracefully. | _Leverage: design.md, Swift Observation framework | Success: AudioOrchestrator compiles, manages state, coordinates components via streams | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [ ] 13. Update main.swift to initialize orchestrator - - File: Sources/AppFaders/main.swift - - Create AudioOrchestrator instance - - Call start() to initialize components - - Print status (driver connected, tracked apps count) - - Keep process alive with RunLoop or dispatchMain() - - Purpose: Entry point that runs orchestrator as background service - - _Leverage: design.md, existing main.swift_ - - _Requirements: 7.1, 7.2, 7.3, 7.4_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift developer | Task: Update main.swift to create AudioOrchestrator, call start() (in a Task), print "AppFaders Host v0.2.0". Use dispatchMain() to keep process running. Add signal handler for SIGINT to clean shutdown if needed (though streams handle cleanup). | Restrictions: No UI - just console output for Phase 2. Keep it minimal. | _Leverage: Sources/AppFaders/main.swift, design.md | Success: `swift run AppFaders` starts orchestrator, prints status, receives app notifications | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -## Phase 5: Testing - -- [ ] 14. Create VolumeStore unit tests - - File: Tests/AppFadersDriverTests/VolumeStoreTests.swift - - Test setVolume/getVolume round-trip - - Test default value (1.0) for unknown bundleID - - Test removeVolume - - Test concurrent access (dispatch multiple operations) - - Purpose: Verify thread-safe volume storage - - _Leverage: Swift Testing framework, design.md Testing Strategy_ - - _Requirements: 5.1, 5.2_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create VolumeStoreTests.swift using Swift Testing (@Test, #expect). Test: setVolume then getVolume returns same value, getVolume for unknown returns 1.0, removeVolume then getVolume returns 1.0, concurrent access from multiple DispatchQueue.global().async blocks doesn't crash. | Restrictions: Use Swift Testing not XCTest. Keep tests fast. | _Leverage: Swift Testing docs | Success: `swift test --filter VolumeStore` passes | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [ ] 15. Create AppAudioMonitor unit tests - - File: Tests/AppFadersTests/AppAudioMonitorTests.swift - - Test initial app enumeration - - Test stream events (launch/terminate) - - Purpose: Verify app tracking logic - - _Leverage: Swift Testing framework, design.md Testing Strategy_ - - _Requirements: 3.1, 3.2, 3.5_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create AppAudioMonitorTests.swift using Swift Testing. Test that initial `runningApps` is populated. Verify that events are yielded to the `events` stream by creating a `Task` that consumes the stream and checking for expected values (using mocks or manual triggering if possible, otherwise rely on system behavior for integration tests). Note: Can't easily mock NSWorkspace in unit tests, so mainly verify the stream mechanics if possible or skip deep integration testing in unit test layer. | Restrictions: Keep tests isolated and fast. | _Leverage: Swift Testing docs, design.md | Success: `swift test --filter AppAudioMonitor` passes | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [ ] 16. Create DriverBridge unit tests - - File: Tests/AppFadersTests/DriverBridgeTests.swift - - Test volume validation (reject out of range) - - Test bundleID length validation - - Test VolumeCommand serialization format - - Purpose: Verify IPC serialization and validation - - _Leverage: Swift Testing framework, design.md Testing Strategy_ - - _Requirements: 5.4, 6.1, 6.2_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: Swift test engineer | Task: Create DriverBridgeTests.swift using Swift Testing. Test: setAppVolume throws invalidVolumeRange for volume < 0 or > 1, setAppVolume throws bundleIDTooLong for bundleID > 255 chars. Can't test actual CoreAudio calls in unit test, but can test validation logic. Consider extracting validation to testable methods. | Restrictions: Don't require installed driver for unit tests. Test validation only. | _Leverage: Swift Testing docs, design.md | Success: `swift test --filter DriverBridge` passes | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion with artifacts, mark complete when done._ - -- [ ] 17. Integration test: volume command round-trip - - File: Documentation only (manual test procedure) - - Install driver using Scripts/install-driver.sh - - Run host app - - Verify device connection logged - - Set volume for a bundleID via test code or debug command - - Read volume back, verify match - - Check driver logs in Console.app for volume receipt - - Purpose: End-to-end verification of IPC - - _Leverage: Scripts/install-driver.sh, Console.app_ - - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 7.1_ - - _Prompt: Implement the task for spec host-audio-orchestrator, first run spec-workflow-guide to get the workflow guide then implement the task: Role: QA engineer | Task: Document and run manual integration test: 1) Run Scripts/install-driver.sh to install driver, 2) Run swift run AppFaders and verify "Driver connected" logged, 3) Modify main.swift temporarily to call orchestrator.setVolume(for: "com.apple.Safari", volume: 0.5), 4) Check Console.app for driver log showing volume received, 5) Verify getAppVolume returns 0.5. Document pass/fail results. | Restrictions: Manual testing - document actual results. | _Leverage: Scripts/install-driver.sh, Console.app | Success: Volume command successfully sent from host and received by driver | Instructions: Mark task in-progress in tasks.md before starting, use log-implementation tool after completion documenting test results, mark complete when done._ \ No newline at end of file diff --git a/.spec-workflow/steering/product.md b/.spec-workflow/steering/product.md deleted file mode 100644 index 469e3c2..0000000 --- a/.spec-workflow/steering/product.md +++ /dev/null @@ -1,49 +0,0 @@ -# Product Overview - -## Product Purpose - -A native macOS application that provides granular control over audio volume on a per-application basis, allowing users to balance sound levels across different software (e.g., turning down browser volume during a Zoom call without affecting the call's audio). - -## Target Users - -- **Office Workers**: Keeping notification sounds low while listening to focus music or in meetings. -- **Casual Users**: Managing multiple audio sources without changing system-wide volume. - -## Key Features - -1. **Per-App Volume Sliders**: Individual volume control for open applications that could produce audio. -2. **Global Mute/Unmute**: Quick toggle for all applications or specific ones. -3. **Menu Bar Integration**: Quick access to volume controls via a sleek macOS menu bar icon. -4. **Global Hotkeys**: Configurable keyboard shortcuts to quickly open the controller or adjust specific volumes. - -## Project Objectives - -- **Quality**: A seamless, stable, and native-feeling utility for macOS. -- **Performance**: Extremely lightweight footprint with minimal impact on system resources. -- **Ease-of-Use**: Intuitive design with zero learning curve. - -## Success Metrics - -- **Performance**: Minimal CPU and memory overhead during active audio management. -- **Responsiveness**: Near-instant UI updates and volume changes. -- **Stability**: Robust integration with the macOS audio engine. - -## Product Principles - -1. **Native Experience**: Look and feel like a first-party macOS utility. -2. **Performance First**: Minimal impact on system resources and audio latency. -3. **Intuitive Design**: Zero-learning curve for basic volume management. Design should be lightweight and include dark, light, and auto (system) modes. - -## Monitoring & Visibility - -- **Dashboard Type**: macOS Menu Bar Extra and a main settings window. -- **Real-time Updates**: Reflecting application volume changes and detecting new audio sources using macOS AudioToolbox/CoreAudio notifications. -- **Key Metrics Displayed**: Current volume levels and active/open applications. - -## Future Vision - -### Potential Enhancements - -- **Profiles**: Saved volume presets for different workflows (e.g., "Work", "Gaming"). -- **Audio Routing**: Ability to select output devices per application. -- **Audio Equalization**: Per-app EQ settings for fine-tuned audio control. diff --git a/.spec-workflow/steering/structure.md b/.spec-workflow/steering/structure.md deleted file mode 100644 index c47f1ff..0000000 --- a/.spec-workflow/steering/structure.md +++ /dev/null @@ -1,102 +0,0 @@ -# Project Structure - -## Directory Organization - -The project is structured as a modern, monorepo-style Swift Package Manager (SPM) project. This ensures all components, including the low-level audio driver, are managed using native Swift tools. - -```sh -AppFaders/ # Project root -├── Package.swift # SPM manifest (targets, dependencies, -bundle flag) -├── CLAUDE.md # Project conventions for AI assistance -├── Sources/ -│ ├── AppFaders/ # Main SwiftUI Application (Phase 2+) -│ │ └── main.swift # Placeholder entry point -│ ├── AppFadersDriver/ # Swift HAL implementation (Phase 1 ✓) -│ │ ├── AppFadersDriver.swift # Module entry, version constant -│ │ ├── AudioTypes.swift # Configuration structs (Sendable) -│ │ ├── DriverEntry.swift # Plugin lifecycle, @_cdecl exports -│ │ ├── PassthroughEngine.swift # Audio routing + AudioRingBuffer -│ │ ├── VirtualDevice.swift # Device property handlers -│ │ └── VirtualStream.swift # Stream config + IO state -│ └── AppFadersDriverBridge/ # C interface layer for HAL -│ ├── PlugInInterface.c # COM-style vtable, factory function -│ └── include/ -│ └── PlugInInterface.h # C function prototypes -├── Tests/ -│ └── AppFadersDriverTests/ # Swift Testing suite -│ ├── AppFadersDriverTests.swift # Placeholder -│ └── AudioTypesTests.swift # Config, format, ring buffer tests -├── Plugins/ -│ └── BundleAssembler/ # SPM BuildToolPlugin -│ └── BundleAssembler.swift # Assembles .driver bundle structure -├── Resources/ -│ └── Info.plist # CFPlugIn configuration for driver -├── Scripts/ -│ ├── install-driver.sh # Build, sign, install, restart coreaudiod -│ └── uninstall-driver.sh # Remove driver from system -└── docs/ - ├── hal-driver-lessons-learned.md # HAL driver gotchas and patterns - └── pancake-compatibility.md # Why we use custom C wrapper -``` - -## Naming Conventions - -### Files - -- **Swift Files**: `PascalCase.swift` (e.g., `VolumeController.swift`). -- **Target Folders**: `PascalCase` (e.g., `AppFadersDriver`). -- **Tests**: `PascalCaseTests.swift`. - -### Code - -- **Types (Structs, Classes, Enums)**: `PascalCase`. -- **Properties & Functions**: `camelCase`. -- **Macros/Property Wrappers**: `@CamelCase` or `@camelCase` depending on framework usage. - -## Import Patterns - -### Import Order - -1. **System Frameworks**: `Foundation`, `SwiftUI`, `CoreAudio`, `AudioToolbox`, `os.log`. -2. **First-party Swift Packages**: `SimplyCoreAudio`. -3. **Internal Modules**: `AppFadersDriver`, `AppFadersDriverBridge`. - -## Code Structure Patterns - -### SwiftUI Component Pattern - -```swift -@Observable -class ComponentViewModel { ... } - -struct ComponentView: View { - @State private var viewModel = ComponentViewModel() - var body: some View { ... } -} -``` - -### Module Organization - -- **Public API**: Clearly marked with `public` or `package` access modifiers. -- **Implementation**: `internal` or `private` by default to enforce strict module boundaries. - -## Code Organization Principles - -1. **Swift-First**: Every component must be implemented in Swift unless strictly prohibited by system constraints. -2. **Strict Concurrency**: Leverage Swift 6 `Sendable` and `Actor` types to manage audio device state safely across threads. -3. **Dependency Injection**: Use protocols and injection to ensure the UI can be tested with mock audio devices. -4. **Package-Driven**: All shared code between the App and the Driver must be factored into local SPM libraries. - -## Module Boundaries - -- **AppFaders (Executable Target)**: Main application. Will depend on `SimplyCoreAudio` for device orchestration. -- **AppFadersDriver (Dynamic Library Target)**: Swift implementation of HAL driver logic. Depends on `AppFadersDriverBridge`. Exports functions via `@_cdecl` for C interop. Built with `-Xlinker -bundle` to produce `MH_BUNDLE` binary. -- **AppFadersDriverBridge (Library Target)**: C interface layer implementing `AudioServerPlugInDriverInterface` vtable. Factory function `AppFadersDriver_Create` serves as CFPlugIn entry point. -- **BundleAssembler (Plugin Target)**: SPM `BuildToolPlugin` that assembles the `.driver` bundle structure from build artifacts. -- **SimplyCoreAudio (External)**: Will be used as primary bridge for high-level CoreAudio interactions in Phase 2. - -## Code Size Guidelines - -- **Source Files**: < 400 lines. Use extensions to separate protocol conformances. -- **Methods**: < 40 lines. Prefer small, composable functions. -- **Complexity**: Favor functional patterns (map, filter) over deep nested loops for processing application lists. diff --git a/.spec-workflow/steering/tech.md b/.spec-workflow/steering/tech.md deleted file mode 100644 index 8c05f5d..0000000 --- a/.spec-workflow/steering/tech.md +++ /dev/null @@ -1,98 +0,0 @@ -# Technology Stack - -## Project Type - -Native macOS Desktop Application (Menu Bar Extra) utilizing a User-Space Audio Driver (HAL Plug-in) for per-application audio management. - -## Core Technologies - -### Primary Language(s) - -- **Swift 6.2**: The primary language for the application UI, business logic, and host-side audio management. -- **C/C++**: Minimal usage reserved for the low-level Audio Server Plug-in (HAL driver) boilerplate, integrated via Swift Package Manager. -- **Runtime/Compiler**: Xcode 16+ / LLVM. -- **Language-specific tools**: Swift Package Manager (SPM) for all dependency management and build orchestration. - -### Key Dependencies/Libraries - -- **SwiftUI**: Modern UI framework using the `Observation` framework for reactive state management. -- **CAAudioHardware**: A Swift package (`sbooth/CAAudioHardware`) for high-level management of CoreAudio devices, providing a robust object-oriented wrapper for the HAL. -- **Custom C/Swift HAL Wrapper**: Minimal C interface (`AppFadersDriverBridge`) with Swift implementation (`AppFadersDriver`). Pancake was evaluated but found incompatible with Swift 6 strict concurrency — see `docs/pancake-compatibility.md`. -- **CoreAudio / AudioToolbox**: Native system frameworks for low-level audio device interaction. -- **ServiceManagement**: For implementing "Launch at Login" using the modern `SMAppService` Swift API. *(Phase 3+)* -- **Swift Concurrency / Synchronization**: Lock-free atomics for real-time audio buffer management. - -### Application Architecture - -- **Host Application (Swift)**: - - **UI Layer**: A sleek, translucent menu bar interface built with SwiftUI. - - **Logic Layer**: Monitors running applications and their audio state. - - **Communication Layer**: Communicates per-app volume settings to the virtual driver using custom properties on the `AudioObject`. -- **Virtual Audio Driver (HAL Plug-in)**: - - A user-space `AudioServerPlugIn` (HAL) component built as two SPM targets: - - **AppFadersDriverBridge** (C): COM-style vtable implementing `AudioServerPlugInDriverInterface`, factory function for CFPlugIn loading. - - **AppFadersDriver** (Swift): Core logic with `@_cdecl` exports called by the C layer. Includes `DriverEntry`, `VirtualDevice`, `VirtualStream`, `PassthroughEngine`. - - **Build requirement**: Must produce `MH_BUNDLE` binary (not `MH_DYLIB`) via `-Xlinker -bundle` flag. - - **Audio flow**: Lock-free ring buffer (`AudioRingBuffer`) routes captured audio to default physical output. - - **Role**: Intercepts system audio and applies process-specific gain adjustments before passing audio to the physical output. -- **Inter-Process Communication (IPC)**: - - Will use `AudioObjectSetPropertyData` and `AudioObjectGetPropertyData` for low-latency communication between the Swift host and the driver. - -### Data Storage - -- **Primary storage**: `UserDefaults` (via `@AppStorage`) for persisting user preferences like hotkeys, default volumes, and login settings. -- **State management**: Swift's `@Observable` macro for real-time UI synchronization with the audio engine state. - -### External Integrations - -- **macOS Audio Server (coreaudiod)**: The application acts as a controller for the system's audio subsystem. - -## Development Environment - -### Build & Development Tools - -- **Build System**: Xcode Build System with integrated Swift Package Manager. -- **Package Management**: 100% Swift Package Manager (SPM). No external package managers (Homebrew/CocoaPods) required for the core build. -- **Development workflow**: Local testing using `Audio Hijack` or `BlackHole` for loopback verification during development. - -### Code Quality Tools - -- **Static Analysis**: SwiftLint (via SPM plugin). -- **Formatting**: SwiftFormat (via SPM plugin). -- **Testing Framework**: Swift Testing (new in Swift 6) for modern, macro-based unit tests. - -## Deployment & Distribution - -- **Target Platform**: macOS 26+ (arm64 only, no backward compatibility, no Universal Binary). -- **Distribution Method**: Notarized App Bundle (DMG/PKG) for direct distribution. -- **Security**: Requires `com.apple.audio.AudioServerPlugIn` sandbox entitlement and `admin` privileges for initial driver installation. - -## Technical Requirements & Constraints - -### Performance Requirements - -- **Audio Latency**: Must maintain < 5ms processing latency to avoid perceptible delay in audio playback. -- **CPU Usage**: The host app must remain < 1% CPU usage when idle; the driver must have negligible overhead. - -### Compatibility Requirements - -- **Hardware**: Apple Silicon only (arm64, no Universal Binary). -- **OS**: macOS 26+ required for latest Swift 6 and SwiftUI features. - -## Technical Decisions & Rationale - -### Decision Log - -1. **Swift 6 and Swift Testing**: Adopted to ensure the project uses the most modern and safe concurrency models from the start. -2. **SimplyCoreAudio for Device Management**: Chosen to replace boilerplate C-based CoreAudio calls with idiomatic Swift code. *(Phase 2)* -3. **Custom HAL Wrapper over Pancake**: Pancake was evaluated in Phase 1 but found incompatible with Swift 6 strict concurrency. Built minimal C interface (`AppFadersDriverBridge`) with Swift implementation instead. Decision documented in `docs/pancake-compatibility.md`. -4. **Two-Target Driver Architecture**: Separating C vtable (`AppFadersDriverBridge`) from Swift logic (`AppFadersDriver`) enables clean @_cdecl bridging and maintains SPM compatibility. -5. **MH_BUNDLE Binary Type**: CFPlugIn requires bundle format, not dylib. Discovered this was the root cause of driver not executing despite loading. Fixed via `-Xlinker -bundle` in Package.swift. -6. **Lock-Free Ring Buffer**: Real-time audio callback requires no allocations or locks. Implemented `AudioRingBuffer` using Swift's `Synchronization.Atomic` for thread-safe operation. -7. **SPM Build Plugin for Bundle Assembly**: Created `BundleAssembler` plugin to construct `.driver` bundle structure from SPM build artifacts. -8. **Modern SMAppService**: Replaces the deprecated `SMLoginItemSetEnabled` for a more robust "Launch at Login" experience. - -## Known Limitations - -- **Driver Installation**: Requires a one-time administrative authorization to install the HAL plug-in into `/Library/Audio/Plug-Ins/HAL`. -- **Sandboxed Apps**: Intercepting audio from certain highly sandboxed System Apps may require specific TCC permissions from the user. diff --git a/.spec-workflow/user-templates/README.md b/.spec-workflow/user-templates/README.md deleted file mode 100644 index ad36a48..0000000 --- a/.spec-workflow/user-templates/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# User Templates - -This directory allows you to create custom templates that override the default Spec Workflow templates. - -## How to Use Custom Templates - -1. **Create your custom template file** in this directory with the exact same name as the default template you want to override: - - `requirements-template.md` - Override requirements document template - - `design-template.md` - Override design document template - - `tasks-template.md` - Override tasks document template - - `product-template.md` - Override product steering template - - `tech-template.md` - Override tech steering template - - `structure-template.md` - Override structure steering template - -2. **Template Loading Priority**: - - The system first checks this `user-templates/` directory - - If a matching template is found here, it will be used - - Otherwise, the default template from `templates/` will be used - -## Example Custom Template - -To create a custom requirements template: - -1. Create a file named `requirements-template.md` in this directory -2. Add your custom structure, for example: - -```markdown -# Requirements Document - -## Executive Summary -[Your custom section] - -## Business Requirements -[Your custom structure] - -## Technical Requirements -[Your custom fields] - -## Custom Sections -[Add any sections specific to your workflow] -``` - -## Template Variables - -Templates can include placeholders that will be replaced when documents are created: -- `{{projectName}}` - The name of your project -- `{{featureName}}` - The name of the feature being specified -- `{{date}}` - The current date -- `{{author}}` - The document author - -## Best Practices - -1. **Start from defaults**: Copy a default template from `../templates/` as a starting point -2. **Keep structure consistent**: Maintain similar section headers for tool compatibility -3. **Document changes**: Add comments explaining why sections were added/modified -4. **Version control**: Track your custom templates in version control -5. **Test thoroughly**: Ensure custom templates work with the spec workflow tools - -## Notes - -- Custom templates are project-specific and not included in the package distribution -- The `templates/` directory contains the default templates which are updated with each version -- Your custom templates in this directory are preserved during updates -- If a custom template has errors, the system will fall back to the default template From 87783adf2da8b821806529bc97c49fb768281ffa Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:48:05 -1000 Subject: [PATCH 29/33] feat(orchestrator): initialize orchestrator entry point Sets up AudioOrchestrator startup, SIGINT handling, and the main execution loop via dispatchMain. --- Sources/AppFaders/main.swift | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Sources/AppFaders/main.swift b/Sources/AppFaders/main.swift index 3f9ca22..f448b1b 100644 --- a/Sources/AppFaders/main.swift +++ b/Sources/AppFaders/main.swift @@ -1,2 +1,26 @@ -// Placeholder - see Task 2 for full implementation -print("AppFaders v0.1.0 - Driver Foundation") +import Dispatch +import Foundation + +// AudioOrchestrator is @MainActor, so we use a Task running on the main actor +Task { @MainActor in + print("AppFaders Host v0.2.0") + + // Create the orchestrator (this initializes components) + let orchestrator = AudioOrchestrator() + print("Orchestrator initialized. Starting...") + + // Handle SIGINT for clean shutdown + let source = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) + source.setEventHandler { + print("\nReceived SIGINT. Shutting down...") + orchestrator.stop() + exit(0) + } + source.resume() + + // start loop (blocks until cancelled) + await orchestrator.start() +} + +// keep the main thread alive - allows the Task to run +dispatchMain() From 45a4165967179c8bd67384e8ff8083a2c58d9436 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:04:01 -1000 Subject: [PATCH 30/33] test(driver): add VolumeStore unit tests Implement unit tests for VolumeStore covering volume operations, clamping, and thread-safety using the Swift Testing framework. --- .../VolumeStoreTests.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 Tests/AppFadersDriverTests/VolumeStoreTests.swift diff --git a/Tests/AppFadersDriverTests/VolumeStoreTests.swift b/Tests/AppFadersDriverTests/VolumeStoreTests.swift new file mode 100644 index 0000000..1a60a3b --- /dev/null +++ b/Tests/AppFadersDriverTests/VolumeStoreTests.swift @@ -0,0 +1,90 @@ +// VolumeStoreTests.swift +// unit tests for VolumeStore +// +// trying to keep it simple for now + +@testable import AppFadersDriver +import Foundation +import Testing + +@Suite("VolumeStore") +struct VolumeStoreTests { + // Helper to generate unique bundle IDs to avoid state collision in shared singleton + func makeBundleID(function: String = #function) -> String { + "com.test.app.\(function)-\(UUID().uuidString)" + } + + @Test("getVolume returns default 1.0 for unknown bundleID") + func defaultVolume() { + let store = VolumeStore.shared + let bundleID = makeBundleID() + + #expect(store.getVolume(for: bundleID) == 1.0) + } + + @Test("setVolume updates volume correctly") + func setVolume() { + let store = VolumeStore.shared + let bundleID = makeBundleID() + + store.setVolume(for: bundleID, volume: 0.5) + #expect(store.getVolume(for: bundleID) == 0.5) + + store.setVolume(for: bundleID, volume: 0.0) + #expect(store.getVolume(for: bundleID) == 0.0) + + store.setVolume(for: bundleID, volume: 1.0) + #expect(store.getVolume(for: bundleID) == 1.0) + } + + @Test("setVolume clamps values to 0.0-1.0 range") + func volumeClamping() { + let store = VolumeStore.shared + let bundleID = makeBundleID() + + // Test upper bound + store.setVolume(for: bundleID, volume: 1.5) + #expect(store.getVolume(for: bundleID) == 1.0) + + // Test lower bound + store.setVolume(for: bundleID, volume: -0.5) + #expect(store.getVolume(for: bundleID) == 0.0) + } + + @Test("removeVolume resets to default") + func removeVolume() { + let store = VolumeStore.shared + let bundleID = makeBundleID() + + store.setVolume(for: bundleID, volume: 0.3) + #expect(store.getVolume(for: bundleID) == 0.3) + + store.removeVolume(for: bundleID) + #expect(store.getVolume(for: bundleID) == 1.0) + } + + @Test("concurrent access is thread-safe") + func concurrentAccess() async { + let store = VolumeStore.shared + let bundleID = makeBundleID() + let iterations = 1000 + + // Use dispatch queue concurrent perform to stress the lock + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + DispatchQueue.concurrentPerform(iterations: iterations) { i in + if i % 2 == 0 { + store.setVolume(for: bundleID, volume: Float(i) / Float(iterations)) + } else { + _ = store.getVolume(for: bundleID) + } + } + continuation.resume() + } + } + + // Verify it didn't crash and returns a valid value + let finalVol = store.getVolume(for: bundleID) + #expect(finalVol >= 0.0 && finalVol <= 1.0) + } +} From 7d0422fcf3c26ba967f88aa9b6bd7f85d2428ea9 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:16:40 -1000 Subject: [PATCH 31/33] test(orchestrator): add AppAudioMonitor tests and fix duplicate tracking Add unit tests for AppAudioMonitor and TrackedApp. Refactor AppAudioMonitor to prevent duplicate launch events and add an internal initializer to TrackedApp for testing. --- Sources/AppFaders/AppAudioMonitor.swift | 13 +- Sources/AppFaders/TrackedApp.swift | 7 ++ .../VolumeStoreTests.swift | 4 +- .../AppFadersTests/AppAudioMonitorTests.swift | 114 ++++++++++++++++++ 4 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 Tests/AppFadersTests/AppAudioMonitorTests.swift diff --git a/Sources/AppFaders/AppAudioMonitor.swift b/Sources/AppFaders/AppAudioMonitor.swift index 731f9d3..a02d77b 100644 --- a/Sources/AppFaders/AppAudioMonitor.swift +++ b/Sources/AppFaders/AppAudioMonitor.swift @@ -22,7 +22,7 @@ final class AppAudioMonitor: @unchecked Sendable { private let lock = NSLock() private var _runningApps: [TrackedApp] = [] - /// currently running applications that are tracked + /// currently running tracked applications var runningApps: [TrackedApp] { lock.lock() defer { lock.unlock() } @@ -89,11 +89,14 @@ final class AppAudioMonitor: @unchecked Sendable { else { return } lock.lock() - _runningApps.append(trackedApp) + if !_runningApps.contains(where: { $0.bundleID == trackedApp.bundleID }) { + _runningApps.append(trackedApp) + os_log(.debug, log: log, "App launched: %{public}@", trackedApp.bundleID) + continuation.yield(.didLaunch(trackedApp)) + } else { + os_log(.debug, log: log, "App launched (already tracked): %{public}@", trackedApp.bundleID) + } lock.unlock() - - os_log(.debug, log: log, "App launched: %{public}@", trackedApp.bundleID) - continuation.yield(.didLaunch(trackedApp)) } private func handleAppTerminate( diff --git a/Sources/AppFaders/TrackedApp.swift b/Sources/AppFaders/TrackedApp.swift index 6275209..1019797 100644 --- a/Sources/AppFaders/TrackedApp.swift +++ b/Sources/AppFaders/TrackedApp.swift @@ -32,6 +32,13 @@ struct TrackedApp: Identifiable, Sendable, Hashable { launchDate = runningApp.launchDate ?? .distantPast } + init(bundleID: String, localizedName: String, icon: NSImage?, launchDate: Date) { + self.bundleID = bundleID + self.localizedName = localizedName + self.icon = icon + self.launchDate = launchDate + } + // MARK: - Equatable & Hashable static func == (lhs: TrackedApp, rhs: TrackedApp) -> Bool { diff --git a/Tests/AppFadersDriverTests/VolumeStoreTests.swift b/Tests/AppFadersDriverTests/VolumeStoreTests.swift index 1a60a3b..1606d7b 100644 --- a/Tests/AppFadersDriverTests/VolumeStoreTests.swift +++ b/Tests/AppFadersDriverTests/VolumeStoreTests.swift @@ -42,11 +42,9 @@ struct VolumeStoreTests { let store = VolumeStore.shared let bundleID = makeBundleID() - // Test upper bound store.setVolume(for: bundleID, volume: 1.5) #expect(store.getVolume(for: bundleID) == 1.0) - // Test lower bound store.setVolume(for: bundleID, volume: -0.5) #expect(store.getVolume(for: bundleID) == 0.0) } @@ -69,7 +67,7 @@ struct VolumeStoreTests { let bundleID = makeBundleID() let iterations = 1000 - // Use dispatch queue concurrent perform to stress the lock + // use dispatch queue concurrent perform to stress the lock await withCheckedContinuation { continuation in DispatchQueue.global().async { DispatchQueue.concurrentPerform(iterations: iterations) { i in diff --git a/Tests/AppFadersTests/AppAudioMonitorTests.swift b/Tests/AppFadersTests/AppAudioMonitorTests.swift new file mode 100644 index 0000000..a9ca37e --- /dev/null +++ b/Tests/AppFadersTests/AppAudioMonitorTests.swift @@ -0,0 +1,114 @@ +@testable import AppFaders +import AppKit +import Foundation +import Testing + +@Suite("TrackedApp") +struct TrackedAppTests { + @Test("Equality check works correctly") + func equality() { + let date = Date() + let app1 = TrackedApp( + bundleID: "com.test.app", + localizedName: "Test App", + icon: nil, + launchDate: date + ) + + let app2 = TrackedApp( + bundleID: "com.test.app", + localizedName: "Different Name", + icon: NSImage(), + launchDate: date + ) + + #expect(app1 == app2) + + let app3 = TrackedApp( + bundleID: "com.test.other", + localizedName: "Test App", + icon: nil, + launchDate: date + ) + + #expect(app1 != app3) + } + + @Test("Hashable implementation is consistent") + func hashing() { + let date = Date() + let app1 = TrackedApp( + bundleID: "com.test.app", + localizedName: "Test App", + icon: nil, + launchDate: date + ) + + let app2 = TrackedApp( + bundleID: "com.test.app", + localizedName: "Test App", + icon: nil, + launchDate: date + ) + + var hasher1 = Hasher() + app1.hash(into: &hasher1) + + var hasher2 = Hasher() + app2.hash(into: &hasher2) + + #expect(hasher1.finalize() == hasher2.finalize()) + } +} + +@Suite("AppAudioMonitor") +struct AppAudioMonitorTests { + @Test("Initial app enumeration populates runningApps") + func initialEnumeration() { + let monitor = AppAudioMonitor() + + #expect(monitor.runningApps.isEmpty) + + monitor.start() + + let apps = monitor.runningApps + #expect(!apps.isEmpty) + + if !apps.isEmpty { + let firstApp = apps[0] + #expect(!firstApp.bundleID.isEmpty) + } + } + + @Test("Stream can be created and cancelled") + func streamMechanics() async { + let monitor = AppAudioMonitor() + let stream = monitor.events + + let task = Task { + for await _ in stream {} + } + + try? await Task.sleep(nanoseconds: 10_000_000) + task.cancel() + + #expect(Bool(true)) + } + + @Test("runningApps is thread-safe") + func concurrency() async { + let monitor = AppAudioMonitor() + monitor.start() + + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + DispatchQueue.concurrentPerform(iterations: 100) { _ in + _ = monitor.runningApps + } + continuation.resume() + } + } + + #expect(Bool(true)) + } +} From 028d9970c5383dae27c03d55ccdd636212923410 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:43:08 -1000 Subject: [PATCH 32/33] test(orchestrator): add DriverBridge validation tests Added unit tests for DriverBridge and made DriverError Equatable. --- Sources/AppFaders/DriverError.swift | 2 +- Tests/AppFadersTests/DriverBridgeTests.swift | 155 +++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 Tests/AppFadersTests/DriverBridgeTests.swift diff --git a/Sources/AppFaders/DriverError.swift b/Sources/AppFaders/DriverError.swift index 5469776..1ded569 100644 --- a/Sources/AppFaders/DriverError.swift +++ b/Sources/AppFaders/DriverError.swift @@ -6,7 +6,7 @@ import Foundation /// errors related to driver communication and management -enum DriverError: Error, LocalizedError { +enum DriverError: Error, LocalizedError, Equatable { /// the virtual audio device could not be found case deviceNotFound /// failed to read a property from the audio object diff --git a/Tests/AppFadersTests/DriverBridgeTests.swift b/Tests/AppFadersTests/DriverBridgeTests.swift new file mode 100644 index 0000000..8fa09dc --- /dev/null +++ b/Tests/AppFadersTests/DriverBridgeTests.swift @@ -0,0 +1,155 @@ +// DriverBridgeTests.swift +// Unit tests for DriverBridge validation logic +// +// bit of nasty code never hurt a + +@testable import AppFaders +import CoreAudio +import Foundation +import Testing + +@Suite("DriverBridge") +struct DriverBridgeTests { + // MARK: - Validation Tests + + @Test("setAppVolume throws invalidVolumeRange for negative values") + func validateNegativeVolume() { + let bridge = DriverBridge() + try? bridge.connect(deviceID: 123) + + #expect(throws: DriverError.invalidVolumeRange(-0.1)) { + try bridge.setAppVolume(bundleID: "com.test.app", volume: -0.1) + } + } + + @Test("setAppVolume throws invalidVolumeRange for values > 1.0") + func validateExcessiveVolume() { + let bridge = DriverBridge() + try? bridge.connect(deviceID: 123) + + #expect(throws: DriverError.invalidVolumeRange(1.1)) { + try bridge.setAppVolume(bundleID: "com.test.app", volume: 1.1) + } + } + + @Test("setAppVolume accepts valid volume range (0.0 - 1.0)") + func validateValidVolume() { + let bridge = DriverBridge() + try? bridge.connect(deviceID: 123) + + // Helper to check validation + func check(_ volume: Float) { + do { + try bridge.setAppVolume(bundleID: "com.test.app", volume: volume) + } catch let error as DriverError { + // We expect it to PASS validation and fail at the CoreAudio call + if case .invalidVolumeRange = error { + Issue.record("Should not throw invalidVolumeRange for \(volume)") + } + } catch { + // Other errors are expected + } + } + + check(0.0) + check(0.5) + check(1.0) + } + + @Test("setAppVolume throws bundleIDTooLong for huge bundle IDs") + func validateBundleIDLength() { + let bridge = DriverBridge() + try? bridge.connect(deviceID: 123) + + let hugeID = String(repeating: "a", count: 256) + + #expect(throws: DriverError.bundleIDTooLong(256)) { + try bridge.setAppVolume(bundleID: hugeID, volume: 0.5) + } + } + + @Test("setAppVolume accepts max length bundle IDs (255 bytes)") + func validateMaxBundleIDLength() { + let bridge = DriverBridge() + try? bridge.connect(deviceID: 123) + + // 255 'a' characters = 255 bytes + let maxID = String(repeating: "a", count: 255) + + do { + try bridge.setAppVolume(bundleID: maxID, volume: 0.5) + } catch let error as DriverError { + if case .bundleIDTooLong = error { + Issue.record("Should accept 255-byte bundle ID") + } + } catch { + // Ignore write failure + } + } + + @Test("setAppVolume correctly handles multi-byte UTF-8 length") + func validateMultiByteBundleID() { + let bridge = DriverBridge() + try? bridge.connect(deviceID: 123) + + // 🚀 is 4 bytes. 256 / 4 = 64 rockets = 256 bytes (too long) + let hugeEmojiID = String(repeating: "🚀", count: 64) + + #expect(throws: DriverError.bundleIDTooLong(256)) { + try bridge.setAppVolume(bundleID: hugeEmojiID, volume: 0.5) + } + + // 63 rockets = 252 bytes (valid) + let validEmojiID = String(repeating: "🚀", count: 63) + do { + try bridge.setAppVolume(bundleID: validEmojiID, volume: 0.5) + } catch let error as DriverError { + if case .bundleIDTooLong = error { + Issue.record("Should accept 252-byte bundle ID") + } + } catch { + // Ignore write failure + } + } + + @Test("getAppVolume throws bundleIDTooLong for huge bundle IDs") + func validateGetVolumeBundleIDLength() { + let bridge = DriverBridge() + try? bridge.connect(deviceID: 123) + + let hugeID = String(repeating: "a", count: 256) + + #expect(throws: DriverError.bundleIDTooLong(256)) { + _ = try bridge.getAppVolume(bundleID: hugeID) + } + } + + // MARK: - Connection State Tests + + @Test("Methods throw deviceNotFound when disconnected") + func deviceNotFound() { + let bridge = DriverBridge() + // Ensure disconnected + bridge.disconnect() + + #expect(throws: DriverError.deviceNotFound) { + try bridge.setAppVolume(bundleID: "com.test.app", volume: 0.5) + } + + #expect(throws: DriverError.deviceNotFound) { + _ = try bridge.getAppVolume(bundleID: "com.test.app") + } + } + + @Test("Connection state is managed correctly") + func connectionState() { + let bridge = DriverBridge() + #expect(!bridge.isConnected) + + try? bridge.connect(deviceID: 123) + #expect(bridge.isConnected) + + bridge.disconnect() + #expect(!bridge.isConnected) + } +} From d5ce88e56270d550768137ec9895d698b7bf5767 Mon Sep 17 00:00:00 2001 From: cheefbird <8886566+cheefbird@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:40:21 -1000 Subject: [PATCH 33/33] feat(orchestrator): add getVolume method to AudioOrchestrator Exposes driver's getAppVolume via public method for reading current volume levels back from the driver, complementing existing setVolume. --- Sources/AppFaders/AudioOrchestrator.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/AppFaders/AudioOrchestrator.swift b/Sources/AppFaders/AudioOrchestrator.swift index e6fb627..eadb6a1 100644 --- a/Sources/AppFaders/AudioOrchestrator.swift +++ b/Sources/AppFaders/AudioOrchestrator.swift @@ -93,6 +93,17 @@ final class AudioOrchestrator { // MARK: - Actions + /// Gets the current volume for an application from the driver + /// - Parameter bundleID: The bundle identifier of the application + /// - Returns: The volume level (0.0 - 1.0) + /// - Throws: Error if the driver communication fails + func getVolume(for bundleID: String) throws -> Float { + guard driverBridge.isConnected else { + throw DriverError.deviceNotFound + } + return try driverBridge.getAppVolume(bundleID: bundleID) + } + /// Sets the volume for a specific application /// - Parameters: /// - bundleID: The bundle identifier of the application