Eigene Aggregate-Geräte gehören nicht in die Geräteauswahl
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 Ausgabeauswahl standen sie deshalb mitten zwischen den echten Geräten, und wer eines davon wählt, hängt seinen Ton in eine Schleife: die Ausgabe eines Taps wird zur Quelle desselben Taps. Erkannt werden sie über einen Vorsatz in der Gerätekennung, nicht über den Namen. Der Name enthält den Programmnamen und ließe sich von einem echten Gerät nicht sicher unterscheiden — die Kennung vergibt Onyx selbst. Der Vorsatz nennt Onyx beim Namen: taucht so ein Gerät doch einmal irgendwo auf, im Audio-MIDI-Setup etwa, sieht man, wer es angelegt hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<CFString?>.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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user