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 = [] 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) } }