diff --git a/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift b/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift index 60917e9..67ec8f4 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift @@ -30,6 +30,10 @@ public struct AudioProcess: Identifiable, Equatable, Sendable { /// deshalb gibt es einen Wachhund gegen die bekannten Null-Puffer. final class ProcessTap { + /// Vorsatz der Gerätekennungen, die Onyx selbst anlegt. + static let aggregateUIDPrefix = "com.scarriffleservices.onyx.tap." + + let process: AudioProcess /// Wird im Echtzeitpfad gelesen. `atomic` genügt hier nicht als Sprachmittel, /// aber ein einzelner `Float` wird auf arm64 atomar geschrieben. @@ -102,7 +106,11 @@ final class ProcessTap { let aggregate: [String: Any] = [ kAudioAggregateDeviceNameKey: "Onyx Mixer – \(process.name)", - kAudioAggregateDeviceUIDKey: UUID().uuidString, + // Kennung mit erkennbarem Vorsatz: die Geräte sind zwar privat, + // das verbirgt sie aber nur vor fremden Prozessen. Onyx selbst + // sieht sie in der Geräteliste und muss sie dort aussortieren + // können — über den Namen ginge das nur unzuverlässig. + kAudioAggregateDeviceUIDKey: Self.aggregateUIDPrefix + UUID().uuidString, // Privat: das Gerät soll nicht in der Tonauswahl des Systems // auftauchen und auch nicht von anderen Programmen gewählt werden. kAudioAggregateDeviceIsPrivateKey: true, diff --git a/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift b/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift index f9d3d17..48d7885 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift @@ -144,6 +144,10 @@ public final class SystemAudio { &address, 0, nil, &size, &ids) == noErr else { return [] } return ids.compactMap { id in + // Die eigenen Aggregate-Geräte gehören nicht in die Auswahl: sie + // sind Werkzeug, kein Ziel. Wer eines davon als Ausgabe wählt, + // hängt seinen Ton in eine Schleife. + guard !isOwnDevice(uid: uid(of: id)) else { return nil } guard let name = name(of: id) else { return nil } let output = channels(id, scope: kAudioDevicePropertyScopeOutput) > 0 let input = channels(id, scope: kAudioDevicePropertyScopeInput) > 0 @@ -154,6 +158,28 @@ public final class SystemAudio { } } + /// Ob Onyx dieses Gerät selbst angelegt hat. + /// + /// `nonisolated`, weil es eine reine Zeichenkettenprüfung ist und keinen + /// Zustand anfasst — so lässt es sich auch prüfen, ohne den MainActor zu + /// bemühen. + nonisolated static func isOwnDevice(uid: String?) -> Bool { + guard let uid, !uid.isEmpty else { return false } + return uid.hasPrefix(ProcessTap.aggregateUIDPrefix) + } + + private static func uid(of device: AudioObjectID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyDeviceUID, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size = UInt32(MemoryLayout.size) + var uid: CFString? + guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &uid) == noErr + else { return nil } + return uid as String? + } + private static func name(of device: AudioObjectID) -> String? { var address = AudioObjectPropertyAddress( mSelector: kAudioObjectPropertyName, diff --git a/Packages/OnyxKit/Tests/AudioProviderTests/OwnDeviceTests.swift b/Packages/OnyxKit/Tests/AudioProviderTests/OwnDeviceTests.swift new file mode 100644 index 0000000..5f433fb --- /dev/null +++ b/Packages/OnyxKit/Tests/AudioProviderTests/OwnDeviceTests.swift @@ -0,0 +1,41 @@ +import Testing +@testable import AudioProvider + +// Für jedes geregelte Programm legt Onyx ein Aggregate-Gerät an. Sie sind als +// privat markiert — das verbirgt sie aber nur vor **fremden** Prozessen, nicht +// vor Onyx selbst. In der Geräteauswahl standen sie deshalb mitten zwischen +// den echten Ausgabegeräten, und wer eines davon wählt, hängt seinen Ton in +// eine Schleife. + +@Suite("Eigene Geräte erkennen") +struct OwnDeviceTests { + + @Test("Ein von Onyx angelegtes Gerät wird erkannt") + func ownDeviceIsDetected() { + let uid = ProcessTap.aggregateUIDPrefix + "1234-ABCD" + #expect(SystemAudio.isOwnDevice(uid: uid)) + } + + @Test("Fremde Geräte bleiben unangetastet") + func foreignDevicesPass() { + for uid in ["BuiltInSpeakerDevice", "AppleUSBAudioEngine:Hue:Sync", + "com.apple.audio.AggregateDevice:1234"] { + #expect(!SystemAudio.isOwnDevice(uid: uid)) + } + } + + @Test("Ohne Kennung gilt ein Gerät nicht als eigenes") + func missingUIDIsForeign() { + // Lieber ein Gerät zu viel in der Liste als ein echtes Ausgabegerät zu + // verschlucken, das der Nutzer braucht. + #expect(!SystemAudio.isOwnDevice(uid: nil)) + #expect(!SystemAudio.isOwnDevice(uid: "")) + } + + @Test("Die Kennung trägt den Programmnamen, nicht nur eine Zufallszahl") + func prefixIsRecognisable() { + // Taucht sie doch einmal irgendwo auf — im Audio-MIDI-Setup etwa —, + // soll man sehen, wer sie angelegt hat. + #expect(ProcessTap.aggregateUIDPrefix.contains("onyx")) + } +}