diff --git a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift index 2e33d50..7da8b55 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift @@ -56,6 +56,7 @@ public final class AudioMixer { private enum Keys { static let volumes = "onyx.audio.volumes" static let muted = "onyx.audio.muted" + static let overdrive = "onyx.audio.overdrive" static let managed = "onyx.audio.managed" static let autostart = "onyx.audio.autostart" } @@ -64,7 +65,8 @@ public final class AudioMixer { self.defaults = defaults let volumes = defaults.dictionary(forKey: Keys.volumes) as? [String: Double] ?? [:] let muted = defaults.stringArray(forKey: Keys.muted) ?? [] - store = VolumeStore(volumes: volumes, muted: muted) + let overdrive = defaults.stringArray(forKey: Keys.overdrive) ?? [] + store = VolumeStore(volumes: volumes, muted: muted, overdrive: overdrive) // Beim Laden aussieben statt nur beim Anzeigen: hat sich der Mixer // einmal selbst in die Auswahl geschrieben, säße die Rückkopplung sonst // dauerhaft in den Einstellungen und käme bei jedem Start zurück. @@ -295,6 +297,18 @@ public final class AudioMixer { public func volume(for bundleID: String) -> Double { store.volume(for: bundleID) } public func isMuted(_ bundleID: String) -> Bool { store.isMuted(for: bundleID) } + public func isOverdriven(_ bundleID: String) -> Bool { store.isOverdriven(bundleID) } + + /// Gibt den Bereich über 100 % frei — je Programm, nicht für alle. + /// + /// Ein Browser braucht ihn für eine leise Konferenz, die Musik daneben + /// nicht. Und ein Regler, der überall bis 400 % geht, führt genau dorthin, + /// wo man ihn nicht haben will. + public func setOverdrive(_ isOn: Bool, for bundleID: String) { + store.setOverdrive(isOn, for: bundleID) + applyGain(for: bundleID) + persist() + } public func setVolume(_ volume: Double, for bundleID: String) { store.set(volume, for: bundleID) @@ -325,6 +339,7 @@ public final class AudioMixer { private func persist() { defaults.set(store.snapshot, forKey: Keys.volumes) defaults.set(store.mutedIdentifiers, forKey: Keys.muted) + defaults.set(store.overdrivenIdentifiers, forKey: Keys.overdrive) defaults.set(Array(managed), forKey: Keys.managed) } diff --git a/Packages/OnyxKit/Sources/AudioProvider/Gain.swift b/Packages/OnyxKit/Sources/AudioProvider/Gain.swift index 2ab8727..28844ac 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/Gain.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/Gain.swift @@ -3,12 +3,22 @@ import Foundation /// Rechnet Reglerstellung in Verstärkung um. public enum Gain { - /// Zweifache Lautstärke ist die Obergrenze. + /// Ohne Overdrive ist bei 100 % Schluss. /// - /// 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 + /// Das ist die Stellung, in der das Programm klingt, wie es gemeint war. + /// Alles darüber ist ein Eingriff und soll auch einer sein. + public static let maximumVolume: Double = 1.0 + + /// Mit Overdrive: das Vierfache. + /// + /// Für leise Videokonferenzen und schlecht ausgesteuerte Videos. Darüber + /// hinaus bringt es nichts mehr — der Begrenzer drückt dann alles zusammen, + /// und was lauter wird, ist vor allem das Grundrauschen der Quelle. + public static let overdriveMaximum: Double = 4.0 + + public static func maximum(overdrive: Bool) -> Double { + overdrive ? overdriveMaximum : maximumVolume + } /// Verstärkungsfaktor zu einer Reglerstellung von 0 bis 2. /// @@ -25,10 +35,17 @@ public enum Gain { /// aber die Formel steht in einem Echtzeitpfad. public static let taper: Double = 1.66 + /// Oberhalb von 100 % gilt die Kennlinie **nicht**. + /// + /// Dort steht die Zahl für den Faktor selbst: 400 % sind das Vierfache der + /// Amplitude. Mit der Kennlinie wären daraus 4^1,66 ≈ 10,4 geworden — eine + /// Beschriftung, die etwas anderes verspricht, als der Regler tut. Bei + /// genau 100 % stimmen beide Hälften überein, die Kurve hat also keinen + /// Sprung, nur einen Knick. public static func factor(forVolume volume: Double) -> Double { - let clamped = min(max(volume, 0), maximumVolume) + let clamped = min(max(volume, 0), overdriveMaximum) guard clamped > 0 else { return 0 } - return pow(clamped, taper) + return clamped <= 1 ? pow(clamped, taper) : clamped } } @@ -64,6 +81,7 @@ public struct VolumeStore: Sendable { private var volumes: [String: Double] = [:] private var muted: Set = [] + private var overdrive: Set = [] public init() {} @@ -77,7 +95,26 @@ public struct VolumeStore: Sendable { 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) + volumes[bundleID] = min(max(volume, 0), Gain.maximum(overdrive: isOverdriven(bundleID))) + } + + public func isOverdriven(_ bundleID: String) -> Bool { overdrive.contains(bundleID) } + + /// Schaltet den erweiterten Bereich frei. + /// + /// Beim Abschalten wird die Lautstärke zurückgeholt: ein Wert von 340 % + /// bliebe sonst wirksam, obwohl der Regler ihn gar nicht mehr darstellen + /// kann — der Knopf säße am rechten Anschlag und das Programm wäre trotzdem + /// dreifach verstärkt. + public mutating func setOverdrive(_ isOn: Bool, for bundleID: String) { + if isOn { + overdrive.insert(bundleID) + } else { + overdrive.remove(bundleID) + if let volume = volumes[bundleID], volume > Gain.maximumVolume { + volumes[bundleID] = Gain.maximumVolume + } + } } public mutating func setMuted(_ isMuted: Bool, for bundleID: String) { @@ -95,9 +132,17 @@ public struct VolumeStore: Sendable { public var snapshot: [String: Double] { volumes } public var mutedIdentifiers: [String] { Array(muted) } + public var overdrivenIdentifiers: [String] { Array(overdrive) } - public init(volumes: [String: Double], muted: [String]) { - self.volumes = volumes + public init(volumes: [String: Double], muted: [String], overdrive: [String] = []) { + // Der Overdrive zuerst: sonst schneidet das Einsetzen der Lautstärken + // alles über 100 % ab, was gerade wiederhergestellt werden soll. + self.overdrive = Set(overdrive) + let allowed = Set(overdrive) + self.volumes = volumes.reduce(into: [:]) { result, entry in + let maximum = Gain.maximum(overdrive: allowed.contains(entry.key)) + result[entry.key] = min(max(entry.value, 0), maximum) + } self.muted = Set(muted) } } diff --git a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings index 30cb733..f0589d6 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings @@ -180,6 +180,22 @@ } } }, + "mixer.overdrive": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Overdrive — bis 400 %, kann hörbar verzerren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Overdrive — up to 400 %, may distort audibly" + } + } + } + }, "mixer.release": { "localizations": { "de": { diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index 65f0618..12476ac 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -152,6 +152,7 @@ private struct ProcessRow: View { private var managed: Bool { mixer.isManaged(group.bundleID) } private var muted: Bool { mixer.isMuted(group.bundleID) } private var volume: Double { mixer.volume(for: group.bundleID) } + private var overdriven: Bool { mixer.isOverdriven(group.bundleID) } var body: some View { // Eine Zeile statt zwei: Symbol, Stummschalter, Regler, Wert. Der Name @@ -176,6 +177,7 @@ private struct ProcessRow: View { VolumeBar( volume: volume, + maximum: Gain.maximum(overdrive: overdriven), level: mixer.level(for: group.bundleID), isActive: managed && !muted, onChange: { newValue in @@ -195,6 +197,7 @@ private struct ProcessRow: View { // Feste Breite, sonst wandert der Regler beim Ziehen. .frame(width: 36, alignment: .trailing) + overdriveButton resetButton } .contentShape(.rect) @@ -208,6 +211,26 @@ private struct ProcessRow: View { .animation(Onyx.Motion.value, value: managed) } + /// Der Overdrive-Schalter. + /// + /// Er steht bei jedem Programm, auch bei einem ungeregelten — sonst müsste + /// man erst den Regler anfassen, um an den Schalter zu kommen, und hätte + /// damit schon geregelt. Eingeschaltet reicht der Regler bis 400 %. + @ViewBuilder + private var overdriveButton: some View { + Button { + if !managed { mixer.setManaged(true, for: group.bundleID) } + mixer.setOverdrive(!overdriven, for: group.bundleID) + } label: { + Image(systemName: "bolt.fill") + .font(.system(size: 9, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55)) + .help(Text("mixer.overdrive", bundle: .module)) + .frame(width: 14, height: 14) + } + /// Zurück auf 100 %. /// /// Der Platz dafür ist **immer** reserviert, auch wenn der Knopf nicht zu @@ -290,6 +313,8 @@ private struct ProcessRow: View { /// die Grenze bei 100 %. private struct VolumeBar: View { let volume: Double + /// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive. + let maximum: Double let level: Double let isActive: Bool let onChange: (Double) -> Void @@ -301,7 +326,7 @@ private struct VolumeBar: View { var body: some View { GeometryReader { geometry in let width = geometry.size.width - let fraction = min(max(volume / Gain.maximumVolume, 0), 1) + let fraction = min(max(volume / maximum, 0), 1) let knob: CGFloat = isDragging ? 13 : 11 // Der Knopf soll an den Enden nicht überstehen. let travel = max(width - knob, 1) @@ -313,11 +338,14 @@ private struct VolumeBar: View { .frame(height: trackHeight) // Markierung bei 100 %: darüber wird verstärkt, und man soll - // sehen, wann man diese Grenze überschreitet. - Rectangle() - .fill(Onyx.Color.hairline) - .frame(width: 1, height: trackHeight + 4) - .offset(x: knob / 2 + travel / Gain.maximumVolume) + // sehen, wann man diese Grenze überschreitet. Ohne Overdrive + // liegt sie am rechten Anschlag und sagt dort nichts mehr. + if maximum > 1 { + Rectangle() + .fill(Onyx.Color.hairline) + .frame(width: 1, height: trackHeight + 4) + .offset(x: knob / 2 + travel / maximum) + } Capsule() .fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent) @@ -349,7 +377,7 @@ private struct VolumeBar: View { .onChanged { value in isDragging = true let position = min(max((value.location.x - knob / 2) / travel, 0), 1) - onChange(position * Gain.maximumVolume) + onChange(position * maximum) } .onEnded { _ in isDragging = false }) } diff --git a/Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift b/Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift index 8302345..70fd7d7 100644 --- a/Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift +++ b/Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift @@ -47,7 +47,10 @@ struct GainTests { @Test("Der Regelbereich ist begrenzt — auch bei absurder Eingabe") func rangeIsClamped() { #expect(Gain.factor(forVolume: -5) == 0) - #expect(Gain.factor(forVolume: 99) == Gain.factor(forVolume: Gain.maximumVolume)) + // Die Kappung sitzt beim höchsten überhaupt erreichbaren Wert. Ob ein + // Programm so weit aufgedreht werden **darf**, entscheidet der Overdrive + // im VolumeStore, nicht die Umrechnung hier. + #expect(Gain.factor(forVolume: 99) == Gain.factor(forVolume: Gain.overdriveMaximum)) } } diff --git a/Packages/OnyxKit/Tests/AudioProviderTests/OverdriveTests.swift b/Packages/OnyxKit/Tests/AudioProviderTests/OverdriveTests.swift new file mode 100644 index 0000000..46bcd93 --- /dev/null +++ b/Packages/OnyxKit/Tests/AudioProviderTests/OverdriveTests.swift @@ -0,0 +1,102 @@ +import Testing +@testable import AudioProvider + +@Suite("Overdrive") +struct OverdriveTests { + + @Test("Ohne Overdrive ist bei 100 % Schluss") + func normalMaximumIsOne() { + #expect(Gain.maximum(overdrive: false) == 1) + var store = VolumeStore() + store.set(3.5, for: "com.google.Chrome") + #expect(store.volume(for: "com.google.Chrome") == 1) + } + + @Test("Mit Overdrive geht der Regler bis 400 %") + func overdriveMaximumIsFour() { + #expect(Gain.maximum(overdrive: true) == 4) + var store = VolumeStore() + store.setOverdrive(true, for: "com.google.Chrome") + store.set(3.4, for: "com.google.Chrome") + #expect(store.volume(for: "com.google.Chrome") == 3.4) + } + + @Test("Auch mit Overdrive ist bei 400 % Schluss") + func overdriveStillHasACeiling() { + var store = VolumeStore() + store.setOverdrive(true, for: "app") + store.set(99, for: "app") + #expect(store.volume(for: "app") == 4) + } + + @Test("Über 100 % bedeutet die Zahl den Faktor selbst") + func aboveOneTheNumberIsTheFactor() { + // Mit der Kennlinie wären aus 400 % das 10,4-Fache geworden. Eine + // Beschriftung, die etwas anderes verspricht als der Regler tut, ist + // schlimmer als eine grobe Kennlinie. + #expect(Gain.factor(forVolume: 4) == 4) + #expect(Gain.factor(forVolume: 2) == 2) + } + + @Test("Bei genau 100 % passen beide Hälften zusammen") + func curveIsContinuousAtOne() { + #expect(abs(Gain.factor(forVolume: 1) - 1) < 0.0001) + #expect(abs(Gain.factor(forVolume: 0.999) - 1) < 0.01) + } + + @Test("Unterhalb von 100 % bleibt die Kennlinie") + func belowOneTheTaperStays() { + // Halber Reglerweg soll halb so laut klingen — rund −10 dB. + #expect(abs(Gain.factor(forVolume: 0.5) - 0.316) < 0.01) + } + + @Test("Abschalten holt die Lautstärke zurück") + func turningOffPullsVolumeBack() { + // Sonst bliebe ein Programm dreifach verstärkt, während sein Regler am + // rechten Anschlag steht und 100 % anzeigt. + var store = VolumeStore() + store.setOverdrive(true, for: "app") + store.set(3.0, for: "app") + store.setOverdrive(false, for: "app") + #expect(store.volume(for: "app") == 1) + #expect(store.effectiveGain(for: "app") == 1) + } + + @Test("Abschalten lässt eine leise Einstellung in Ruhe") + func turningOffKeepsQuietSettings() { + var store = VolumeStore() + store.setOverdrive(true, for: "app") + store.set(0.4, for: "app") + store.setOverdrive(false, for: "app") + #expect(store.volume(for: "app") == 0.4) + } + + @Test("Der Overdrive übersteht einen Neustart") + func overdriveSurvivesRestart() { + var store = VolumeStore() + store.setOverdrive(true, for: "app") + store.set(2.5, for: "app") + + let restored = VolumeStore(volumes: store.snapshot, + muted: store.mutedIdentifiers, + overdrive: store.overdrivenIdentifiers) + #expect(restored.isOverdriven("app")) + #expect(restored.volume(for: "app") == 2.5) + } + + @Test("Ein gespeicherter Wert ohne Overdrive wird zurückgeholt") + func storedValueWithoutOverdriveIsClamped() { + // Aus der Zeit, als der Regler noch ohne Freigabe bis 200 % ging. + let restored = VolumeStore(volumes: ["app": 1.8], muted: [], overdrive: []) + #expect(restored.volume(for: "app") == 1) + } + + @Test("Stumm schlägt Overdrive") + func muteWins() { + var store = VolumeStore() + store.setOverdrive(true, for: "app") + store.set(4, for: "app") + store.setMuted(true, for: "app") + #expect(store.effectiveGain(for: "app") == 0) + } +}