Ein Tap mit mutedWhenTapped schaltet die ursprüngliche Ausgabe eines Programms stumm und liefert das Signal stattdessen bei Onyx ab; ein privates Aggregate-Device aus Tap und echtem Ausgabegerät gibt es nach der Verstärkung wieder aus. Das ist der einzige Weg, den macOS ohne eigenen Treiber anbietet. Die Kennlinie ist hergeleitet, nicht geraten: halbe empfundene Lautstärke sind rund −10 dB, also Faktor 0,316. Aus 0,5^k = 0,316 folgt k ≈ 1,66. Die Skala ist damit symmetrisch — 50 % sind −10 dB, 200 % sind +10 dB. Mein erster Ansatz mit der vierten Potenz ergab bei halbem Regler 6 % statt 32 %; der Test hat das gefangen, bevor es an einen Lautsprecher kam. Über 100 % greift ein weicher Begrenzer. Ohne ihn schneidet die Hardware die Werte hart ab, und das knackt hörbar. Er arbeitet erst ab 0,7, weil eine Begrenzung, die schon bei Zimmerlautstärke einsetzt, matt klingt. Der Mixer ist standardmäßig aus, und auch eingeschaltet greift er nur bei ausdrücklich gewählten Programmen ein. Alles andere läuft unverändert vorbei. Der Notausschalter steht sichtbar in der Ansicht, nicht in einem Menü: wenn der Ton weg ist, ist das der Knopf, den man sucht. Drei Fehlerfälle sind eingeplant, weil jeder von ihnen ein stummes Programm bedeutet. Der Wachhund baut Taps neu auf, die nur noch Stille liefern — der bekannte Fall aus Spike C. Ein Wechsel des Ausgabegeräts baut alle Taps neu auf, weil sie am alten Gerät hängen. Und ein fehlgeschlagener Aufbau räumt sich selbst weg, statt in einem halben Zustand zu verharren. Beim Beenden von Onyx wird der Mixer abgebaut: sonst bleiben die abgegriffenen Programme stumm zurück. 178 Tests grün, App-Signatur gültig.
104 lines
3.9 KiB
Swift
104 lines
3.9 KiB
Swift
import Foundation
|
||
|
||
/// Rechnet Reglerstellung in Verstärkung um.
|
||
public enum Gain {
|
||
|
||
/// Zweifache Lautstärke ist die Obergrenze.
|
||
///
|
||
/// Mehr wäre technisch möglich und praktisch sinnlos: bei doppelter
|
||
/// Verstärkung arbeitet der Begrenzer bereits deutlich, darüber bleibt vom
|
||
/// Signal nur noch Kompression übrig.
|
||
public static let maximumVolume: Double = 2.0
|
||
|
||
/// Verstärkungsfaktor zu einer Reglerstellung von 0 bis 2.
|
||
///
|
||
/// Bewusst nicht linear. Lautstärke wird ungefähr logarithmisch
|
||
/// wahrgenommen; ein linearer Regler wäre in der unteren Hälfte praktisch
|
||
/// wirkungslos und in der oberen viel zu empfindlich.
|
||
///
|
||
/// Der Exponent ist hergeleitet, nicht geraten: halbe empfundene Lautstärke
|
||
/// entspricht rund −10 dB, also Faktor 0,316. Aus 0,5^k = 0,316 folgt
|
||
/// k ≈ 1,66. Damit klingt der halbe Reglerweg auch halb so laut.
|
||
///
|
||
/// Eine Potenz statt einer Dezibel-Rechnung, weil hier kein Logarithmus je
|
||
/// Abtastwert anfallen soll — der Faktor wird einmal je Puffer bestimmt,
|
||
/// aber die Formel steht in einem Echtzeitpfad.
|
||
public static let taper: Double = 1.66
|
||
|
||
public static func factor(forVolume volume: Double) -> Double {
|
||
let clamped = min(max(volume, 0), maximumVolume)
|
||
guard clamped > 0 else { return 0 }
|
||
return pow(clamped, taper)
|
||
}
|
||
}
|
||
|
||
/// Weiche Begrenzung.
|
||
///
|
||
/// Bei zweifacher Verstärkung entstehen Abtastwerte weit über dem gültigen
|
||
/// Bereich. Ungebremst schneidet die Hardware sie hart ab, und das knackt
|
||
/// hörbar. Der Tangens hyperbolicus biegt sie stattdessen sanft um: leise
|
||
/// Signale bleiben unangetastet, laute werden zunehmend gestaucht.
|
||
public enum SoftClip {
|
||
|
||
/// Ab hier greift die Kennlinie. Darunter bleibt das Signal unverändert —
|
||
/// eine Begrenzung, die schon bei Zimmerlautstärke arbeitet, klingt matt.
|
||
public static let threshold: Float = 0.7
|
||
|
||
public static func apply(_ sample: Float) -> Float {
|
||
let magnitude = abs(sample)
|
||
guard magnitude > threshold else { return sample }
|
||
|
||
let excess = magnitude - threshold
|
||
let headroom = 1 - threshold
|
||
let compressed = threshold + headroom * tanh(excess / headroom)
|
||
return sample < 0 ? -compressed : compressed
|
||
}
|
||
}
|
||
|
||
/// Merkt sich Lautstärke und Stummschaltung je Programm.
|
||
///
|
||
/// Nach der Bundle-Kennung, nicht nach Prozessnummer: die ändert sich bei
|
||
/// jedem Start, und eine Einstellung, die einen Neustart des Programms nicht
|
||
/// überlebt, ist keine.
|
||
public struct VolumeStore: Sendable {
|
||
|
||
private var volumes: [String: Double] = [:]
|
||
private var muted: Set<String> = []
|
||
|
||
public init() {}
|
||
|
||
/// Unbekannte Programme starten auf voller Lautstärke — nicht auf null.
|
||
/// Ein Programm, das nach dem Start stumm ist, wirkt kaputt, und niemand
|
||
/// sucht die Ursache im Mixer.
|
||
public func volume(for bundleID: String) -> Double {
|
||
volumes[bundleID] ?? 1.0
|
||
}
|
||
|
||
public func isMuted(for bundleID: String) -> Bool { muted.contains(bundleID) }
|
||
|
||
public mutating func set(_ volume: Double, for bundleID: String) {
|
||
volumes[bundleID] = min(max(volume, 0), Gain.maximumVolume)
|
||
}
|
||
|
||
public mutating func setMuted(_ isMuted: Bool, for bundleID: String) {
|
||
// Getrennt von der Lautstärke: sonst verliert man beim Stummschalten
|
||
// den eingestellten Wert und muss ihn danach neu suchen.
|
||
if isMuted { muted.insert(bundleID) } else { muted.remove(bundleID) }
|
||
}
|
||
|
||
/// Was der Audiopfad tatsächlich anwenden soll.
|
||
public func effectiveGain(for bundleID: String) -> Double {
|
||
isMuted(for: bundleID) ? 0 : Gain.factor(forVolume: volume(for: bundleID))
|
||
}
|
||
|
||
// MARK: - Sicherung
|
||
|
||
public var snapshot: [String: Double] { volumes }
|
||
public var mutedIdentifiers: [String] { Array(muted) }
|
||
|
||
public init(volumes: [String: Double], muted: [String]) {
|
||
self.volumes = volumes
|
||
self.muted = Set(muted)
|
||
}
|
||
}
|