Skip to content

Commit 9f9b282

Browse files
iscleclaude
andcommitted
feat: add Audio Unit plugin support for per-app and per-device effects
Enable third-party and system AU effect plugins to be loaded and applied to individual applications or output devices, similar to SoundSource. Audio pipeline: - Non-interleaved stereo rendering with pre-allocated deinterleave buffers - RT-safe AU hosting via AudioUnitRender with monotonic sample time tracking - Immutable AUEffectChain with atomic swap + deferred destruction (500ms) - Signal flow: EQ → AutoEQ → [Per-App AU] → [Per-Device AU] → Loudness → Limiter - Tail time tracking for reverb/delay effects (continues rendering after silence) - Crossfade support with independent AU instances per tap - Device AU chains reload correctly on device switch Plugin management: - AUPluginScanner discovers kAudioUnitType_Effect and kAudioUnitType_MusicEffect - Live detection via kAudioComponentRegistrationsChangedNotification - Factory preset enumeration and selection per effect - AU parameter state persisted on window close and app quit (ClassInfo plist) - Crash guard integration: FNV-1a hash tracking, crash file write via POSIX, auto-disable offending plugins on next launch - Full persistence: per-app chains, per-device chains, bypass state, favorites, crash history UI: - Hierarchical plugin picker with search, favorites (with namespaced ForEach IDs), and crash warnings - Effect chain view with enable/disable, bypass, factory presets, remove - Failed plugin instantiation shown with warning icon - AUGenericView floating windows for parameter editing - Device FX button with ExpandableGlassRow integration Architecture: - AUChainState model consolidates entries, bypass, and failedEntryIDs - AudioEngine owns observable AU state (appAU/deviceAU dictionaries); SettingsManager handles persistence only - Favorites and crash history use @Observable-tracked closures for views, with @State in popover content (NSPanel observation boundary) Includes 45 unit tests covering descriptor codable, scanner discovery, AU instantiation, RT rendering (lowpass attenuation, reverb tail), bypass passthrough, settings persistence, and crash history. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 50a846e commit 9f9b282

20 files changed

Lines changed: 2933 additions & 19 deletions
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// FineTune/Audio/AUPlugins/AUEffectChain.swift
2+
import Foundation
3+
import os
4+
5+
/// Immutable ordered chain of AU effect hosts.
6+
///
7+
/// When the chain changes (add/remove/reorder), build a new `AUEffectChain` and
8+
/// atomically swap the pointer in `ProcessTapController`, then defer-destroy the
9+
/// old chain after 500ms. Same pattern as `LoudnessEqualizer`.
10+
///
11+
/// ## RT-Safety
12+
/// `process()` runs on CoreAudio's HAL I/O thread. Reads `_hosts`/`_hostCount`/`_isBypassed`
13+
/// which are set once at init (except bypass, toggled from main thread).
14+
final class AUEffectChain: @unchecked Sendable {
15+
16+
let entries: [AUEffectChainEntry]
17+
let failedEntryIDs: Set<UUID>
18+
19+
private let _hosts: [AUEffectHost]
20+
private let _hostCount: Int
21+
private nonisolated(unsafe) var _isBypassed: Bool = false
22+
23+
private let logger = Logger(subsystem: "com.finetuneapp.FineTune", category: "AUEffectChain")
24+
25+
var isBypassed: Bool { _isBypassed }
26+
27+
var maxTailTime: Double {
28+
var maxTail: Double = 0
29+
for host in _hosts where host.isEnabled {
30+
if host.tailTimeSeconds > maxTail {
31+
maxTail = host.tailTimeSeconds
32+
}
33+
}
34+
return maxTail
35+
}
36+
37+
var hosts: [AUEffectHost] { _hosts }
38+
39+
func host(for entryID: UUID) -> AUEffectHost? {
40+
_hosts.first { $0.entryID == entryID }
41+
}
42+
43+
init(entries: [AUEffectChainEntry], sampleRate: Double, maxFrames: UInt32 = 4096) {
44+
self.entries = entries
45+
var hosts: [AUEffectHost] = []
46+
var failed = Set<UUID>()
47+
for entry in entries {
48+
let host = AUEffectHost(
49+
descriptor: entry.pluginDescriptor,
50+
entryID: entry.id,
51+
sampleRate: sampleRate,
52+
maxFrames: maxFrames,
53+
enabled: entry.isEnabled
54+
)
55+
if host.instantiate() {
56+
if let presetData = entry.presetData {
57+
_ = host.loadPreset(presetData)
58+
} else if let presetIndex = entry.selectedFactoryPresetIndex {
59+
_ = host.selectFactoryPreset(index: presetIndex)
60+
}
61+
hosts.append(host)
62+
} else {
63+
failed.insert(entry.id)
64+
logger.error("Failed to instantiate \(entry.pluginDescriptor.name), skipping")
65+
}
66+
}
67+
self.failedEntryIDs = failed
68+
self._hosts = hosts
69+
self._hostCount = hosts.count
70+
71+
for host in hosts {
72+
CrashGuard.trackPlugin(host.descriptor.id)
73+
}
74+
75+
logger.info("Created AU effect chain with \(hosts.count)/\(entries.count) plugins at \(sampleRate)Hz")
76+
}
77+
78+
// MARK: - Bypass
79+
80+
func setBypassed(_ bypassed: Bool) {
81+
_isBypassed = bypassed
82+
}
83+
84+
// MARK: - RT-Safe Processing
85+
86+
/// Process interleaved stereo samples through the entire AU chain in-place.
87+
@inline(__always)
88+
func processInterleaved(samples: UnsafeMutablePointer<Float>, frameCount: Int) {
89+
guard !_isBypassed else { return }
90+
let count = _hostCount
91+
for i in 0..<count {
92+
_hosts[i].renderInterleaved(samples: samples, frameCount: frameCount)
93+
}
94+
}
95+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// FineTune/Audio/AUPlugins/AUEffectChainEntry.swift
2+
import Foundation
3+
4+
struct AUEffectChainEntry: Codable, Identifiable, Equatable {
5+
let id: UUID
6+
let pluginDescriptor: AUPluginDescriptor
7+
var isEnabled: Bool
8+
var presetData: Data?
9+
var selectedFactoryPresetIndex: Int?
10+
11+
init(plugin: AUPluginDescriptor, isEnabled: Bool = true) {
12+
self.id = UUID()
13+
self.pluginDescriptor = plugin
14+
self.isEnabled = isEnabled
15+
self.presetData = nil
16+
self.selectedFactoryPresetIndex = nil
17+
}
18+
}
19+
20+
/// Observable UI state for a single AU effect chain (per-app or per-device).
21+
/// AudioEngine owns these; SettingsManager persists entries + bypass to disk.
22+
struct AUChainState {
23+
var entries: [AUEffectChainEntry] = []
24+
var isBypassed: Bool = false
25+
var failedEntryIDs: Set<UUID> = []
26+
}

0 commit comments

Comments
 (0)