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 0b112a9..0000000 --- a/.spec-workflow/specs/host-audio-orchestrator/design.md +++ /dev/null @@ -1,345 +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 -- **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 -- **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 → SimplyCoreAudio → 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 - -- **SimplyCoreAudio**: New dependency — provides `allOutputDevices`, device UID lookup, NotificationCenter-based change notifications -- **NSWorkspace**: System API for `runningApplications` and launch/terminate notifications -- **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
SimplyCoreAudio] - DB[DriverBridge
AudioObject props] - end - - subgraph Driver["AppFadersDriver (HAL)"] - VD[VirtualDevice] - VS[VolumeStore] - PE[PassthroughEngine] - end - - AAM --> AO - DM --> 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 communicate via protocols for testability -- **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 - 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 - protocol AppAudioMonitorDelegate: AnyObject { - func monitor(_ monitor: AppAudioMonitor, didLaunch app: TrackedApp) - func monitor(_ monitor: AppAudioMonitor, didTerminate bundleID: String) - } - - final class AppAudioMonitor { - weak var delegate: AppAudioMonitorDelegate? - var runningApps: [TrackedApp] { get } - - func start() - func stop() - } - ``` -- **Dependencies**: NSWorkspace, AppKit (for NSRunningApplication, NSImage) -- **Reuses**: None (new component) - -### Component 3: DeviceManager - -- **Purpose**: Wrapper around SimplyCoreAudio for device discovery and notifications -- **Interfaces**: - ```swift - final class DeviceManager { - var allOutputDevices: [AudioDevice] { get } - var appFadersDevice: AudioDevice? { get } - - func startObserving() - func stopObserving() - - var onDeviceListChanged: (() -> Void)? - } - ``` -- **Dependencies**: SimplyCoreAudio (v4.1.1) -- **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) - 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 - -## 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 -} -``` - -### 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) - -## SimplyCoreAudio Integration Details - -### Package.swift Changes - -```swift -dependencies: [ - .package(url: "https://github.com/rnine/SimplyCoreAudio.git", from: "4.1.0") -], -targets: [ - .executableTarget( - name: "AppFaders", - dependencies: [ - .product(name: "SimplyCoreAudio", package: "SimplyCoreAudio") - ] - ), - // ... existing targets unchanged -] -``` - -### Device Discovery Pattern - -```swift -import SimplyCoreAudio - -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" - } - } - - func startObserving() { - let observer = NotificationCenter.default.addObserver( - forName: .deviceListChanged, - object: nil, - queue: .main - ) { [weak self] _ in - self?.onDeviceListChanged?() - } - notificationObservers.append(observer) - } -} -``` - -## 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 95ddc6c..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 SimplyCoreAudio - -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 -- 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. - -### 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: SimplyCoreAudio 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. - -#### 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 -3. WHEN the default output device changes THEN the host SHALL receive a notification via SimplyCoreAudio's observer mechanism -4. IF SimplyCoreAudio 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 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 -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 SimplyCoreAudio, 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 f7a0476..0000000 --- a/.spec-workflow/specs/host-audio-orchestrator/tasks.md +++ /dev/null @@ -1,172 +0,0 @@ -# Tasks Document: host-audio-orchestrator - -## Phase 1: Package Setup - -- [ ] 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) - -- [ ] 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._ - -- [ ] 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._ - -- [ ] 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 - -- [ ] 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._ - -- [ ] 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 - -- [ ] 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 - - _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._ - -- [ ] 8. Create AppAudioMonitor for process tracking - - File: Sources/AppFaders/AppAudioMonitor.swift - - Use NSWorkspace.shared.runningApplications for initial list - - Subscribe to NSWorkspace.didLaunchApplicationNotification, didTerminateApplicationNotification - - Filter to apps with bundleIdentifier (exclude command-line tools) - - Implement delegate protocol for launch/terminate events - - Purpose: Track running applications that may produce audio - - _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._ - -- [ ] 9. 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._ - -- [ ] 10. 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 initializes all components - - 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._ - -- [ ] 11. 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(), 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._ - -## Phase 5: Testing - -- [ ] 12. 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._ - -- [ ] 13. Create AppAudioMonitor unit tests - - File: Tests/AppFadersTests/AppAudioMonitorTests.swift - - Test initial app enumeration - - Test filtering (apps without bundleID excluded) - - 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._ - -- [ ] 14. 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._ - -- [ ] 15. 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._ 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 58bd97f..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.0**: 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. -- **SimplyCoreAudio**: A Swift package for high-level management of CoreAudio devices, simplifying device discovery and volume control. -- **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 diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..4a55b73 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "2b9429c0e52243e61ea3c816543f92c473c050f02569fb3023aadf5b47702f5d", + "pins" : [ + { + "identity" : "caaudiohardware", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sbooth/CAAudioHardware", + "state" : { + "revision" : "d927963dcfbb819da6ed6f17b83f17ffbc689280", + "version" : "0.7.1" + } + }, + { + "identity" : "coreaudioextensions", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sbooth/CoreAudioExtensions", + "state" : { + "revision" : "632e715abbb22baa2cb5b939f1fd432045aa1c6a", + "version" : "0.4.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index d53ccfb..8ce019f 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 import PackageDescription @@ -13,15 +13,15 @@ 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/sbooth/CAAudioHardware", from: "0.7.1") ], targets: [ .executableTarget( name: "AppFaders", - dependencies: [] + dependencies: [ + .product(name: "CAAudioHardware", package: "CAAudioHardware") + ] ), - // C interface layer for HAL AudioServerPlugIn .target( name: "AppFadersDriverBridge", dependencies: [], @@ -40,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: [ @@ -54,6 +53,10 @@ let package = Package( .testTarget( name: "AppFadersDriverTests", dependencies: ["AppFadersDriver"] + ), + .testTarget( + name: "AppFadersTests", + dependencies: ["AppFaders"] ) ] ) 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/AppFaders/AppAudioMonitor.swift b/Sources/AppFaders/AppAudioMonitor.swift new file mode 100644 index 0000000..a02d77b --- /dev/null +++ b/Sources/AppFaders/AppAudioMonitor.swift @@ -0,0 +1,120 @@ +// 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 tracked applications + 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() + 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() + } + + 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)) + } +} diff --git a/Sources/AppFaders/AudioOrchestrator.swift b/Sources/AppFaders/AudioOrchestrator.swift new file mode 100644 index 0000000..eadb6a1 --- /dev/null +++ b/Sources/AppFaders/AudioOrchestrator.swift @@ -0,0 +1,227 @@ +// 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 + + /// 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 + /// - 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 new file mode 100644 index 0000000..0590769 --- /dev/null +++ b/Sources/AppFaders/DeviceManager.swift @@ -0,0 +1,67 @@ +// DeviceManager.swift +// 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 + +private let log = OSLog(subsystem: "com.fbreidenbach.appfaders", category: "DeviceManager") + +/// manages audio device discovery and status monitoring +final class DeviceManager: Sendable { + /// returns all available output devices + var allOutputDevices: [AudioDevice] { + 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? { + 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 + 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 + // To stop observing, CAAudioHardware expects passing nil to the block + try? AudioSystem.instance.whenSelectorChanges(.devices, perform: nil) + } + } + } + + init() { + os_log(.info, log: log, "DeviceManager initialized") + } +} 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 + } +} diff --git a/Sources/AppFaders/DriverError.swift b/Sources/AppFaders/DriverError.swift new file mode 100644 index 0000000..1ded569 --- /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, Equatable { + /// 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." + } + } +} diff --git a/Sources/AppFaders/TrackedApp.swift b/Sources/AppFaders/TrackedApp.swift new file mode 100644 index 0000000..1019797 --- /dev/null +++ b/Sources/AppFaders/TrackedApp.swift @@ -0,0 +1,52 @@ +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 + } + + 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 { + lhs.bundleID == rhs.bundleID && lhs.launchDate == rhs.launchDate + } + + func hash(into hasher: inout Hasher) { + hasher.combine(bundleID) + hasher.combine(launchDate) + } +} 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() diff --git a/Sources/AppFadersDriver/AudioTypes.swift b/Sources/AppFadersDriver/AudioTypes.swift index d7d1a7d..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, @@ -114,3 +114,50 @@ 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) +} + +// 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/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.. UInt32? { let result: UInt32? = if objectID == ObjectID.plugIn { getPlugInPropertyDataSize(address: address) @@ -169,14 +178,21 @@ 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 { @@ -218,24 +234,33 @@ final class VirtualDevice: @unchecked Sendable { 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 } @@ -326,7 +351,9 @@ final class VirtualDevice: @unchecked Sendable { kAudioDevicePropertyZeroTimeStampPeriod, kAudioDevicePropertyClockDomain, kAudioDevicePropertyIsHidden, - kAudioDevicePropertyPreferredChannelsForStereo: + kAudioDevicePropertyPreferredChannelsForStereo, + AppFadersProperty.setVolume, + AppFadersProperty.getVolume: true default: false @@ -375,13 +402,21 @@ final class VirtualDevice: @unchecked Sendable { address.mScope == kAudioObjectPropertyScopeGlobal) ? UInt32(MemoryLayout.size) : 0 - case kAudioObjectPropertyCustomPropertyInfoList, - kAudioDevicePropertyControlList: - 0 // empty lists - no custom properties or controls + case kAudioObjectPropertyCustomPropertyInfoList: + UInt32(MemoryLayout.size * 2) + + case kAudioDevicePropertyControlList: + 0 // empty list case kAudioDevicePropertyNominalSampleRate: UInt32(MemoryLayout.size) + case AppFadersProperty.setVolume: + UInt32(VolumeCommand.totalSize) + + case AppFadersProperty.getVolume: + UInt32(MemoryLayout.size) + case kAudioDevicePropertyAvailableNominalSampleRates: // 3 sample rates: 44100, 48000, 96000 UInt32(MemoryLayout.size * 3) @@ -393,7 +428,9 @@ final class VirtualDevice: @unchecked Sendable { private func getDevicePropertyData( address: AudioObjectPropertyAddress, - maxSize: UInt32 + maxSize: UInt32, + qualifierSize: UInt32 = 0, + qualifierData: UnsafeRawPointer? = nil ) -> (Data, UInt32)? { switch address.mSelector { case kAudioObjectPropertyClass: @@ -457,9 +494,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 +596,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 +630,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 +711,8 @@ public func driverGetPropertyDataSize( selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope, element: AudioObjectPropertyElement, + qualifierSize: UInt32, + qualifierData: UnsafeRawPointer?, outSize: UnsafeMutablePointer? ) -> OSStatus { let address = AudioObjectPropertyAddress( @@ -628,7 +721,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 +743,8 @@ public func driverGetPropertyData( selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope, element: AudioObjectPropertyElement, + qualifierSize: UInt32, + qualifierData: UnsafeRawPointer?, inDataSize: UInt32, outDataSize: UnsafeMutablePointer?, outData: UnsafeMutableRawPointer? @@ -659,7 +759,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 +787,8 @@ public func driverSetPropertyData( selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope, element: AudioObjectPropertyElement, + qualifierSize: UInt32, + qualifierData: UnsafeRawPointer?, dataSize: UInt32, data: UnsafeRawPointer? ) -> OSStatus { @@ -702,6 +806,8 @@ public func driverSetPropertyData( objectID: objectID, address: address, data: data, - size: dataSize + size: dataSize, + qualifierSize: qualifierSize, + qualifierData: qualifierData ) } 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) + } +} 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); } 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/AppFadersDriverTests/VolumeStoreTests.swift b/Tests/AppFadersDriverTests/VolumeStoreTests.swift new file mode 100644 index 0000000..1606d7b --- /dev/null +++ b/Tests/AppFadersDriverTests/VolumeStoreTests.swift @@ -0,0 +1,88 @@ +// 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() + + store.setVolume(for: bundleID, volume: 1.5) + #expect(store.getVolume(for: bundleID) == 1.0) + + 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) + } +} 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)) + } +} diff --git a/Tests/AppFadersTests/AppFadersTests.swift b/Tests/AppFadersTests/AppFadersTests.swift new file mode 100644 index 0000000..26aed1e --- /dev/null +++ b/Tests/AppFadersTests/AppFadersTests.swift @@ -0,0 +1,9 @@ +import CAAudioHardware +import Testing + +/// Placeholder tests - full implementation in Tasks 13-14 +@Test func caAudioHardwareImports() async throws { + // Verify CAAudioHardware dependency is properly configured + let devices = try AudioDevice.devices + #expect(devices.count >= 0) +} 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) + } +} 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.