Overdrive je Programm: bis 400 %, aber nur auf Ansage

Der Regler ging bisher immer bis 200 %, ohne dass man das wollen musste. Jetzt
endet er bei 100 % — der Stellung, in der ein Programm klingt, wie es gemeint
war —, und ein Blitz in der Zeile gibt den Bereich darüber frei. Je Programm:
ein Browser braucht ihn für eine leise Konferenz, die Musik daneben nicht.

Oberhalb von 100 % gilt die Kennlinie nicht mehr. 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. Unterhalb bleibt alles wie es war.

Beim Abschalten wird die Lautstärke zurückgeholt. Sonst bliebe ein Programm
dreifach verstärkt, während sein Regler am rechten Anschlag steht und 100 %
anzeigt. Dasselbe beim Laden alter Einstellungen: ein gespeichertes 180 % aus
der Zeit ohne Freigabe fällt auf 100 %.

Die 100 %-Markierung im Regler erscheint nur noch mit Overdrive — ohne ihn läge
sie am rechten Anschlag und sagte dort nichts mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 18:02:43 +02:00
parent d001e439a6
commit ed8b095377
6 changed files with 228 additions and 19 deletions

View File

@@ -56,6 +56,7 @@ public final class AudioMixer {
private enum Keys { private enum Keys {
static let volumes = "onyx.audio.volumes" static let volumes = "onyx.audio.volumes"
static let muted = "onyx.audio.muted" static let muted = "onyx.audio.muted"
static let overdrive = "onyx.audio.overdrive"
static let managed = "onyx.audio.managed" static let managed = "onyx.audio.managed"
static let autostart = "onyx.audio.autostart" static let autostart = "onyx.audio.autostart"
} }
@@ -64,7 +65,8 @@ public final class AudioMixer {
self.defaults = defaults self.defaults = defaults
let volumes = defaults.dictionary(forKey: Keys.volumes) as? [String: Double] ?? [:] let volumes = defaults.dictionary(forKey: Keys.volumes) as? [String: Double] ?? [:]
let muted = defaults.stringArray(forKey: Keys.muted) ?? [] 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 // Beim Laden aussieben statt nur beim Anzeigen: hat sich der Mixer
// einmal selbst in die Auswahl geschrieben, säße die Rückkopplung sonst // einmal selbst in die Auswahl geschrieben, säße die Rückkopplung sonst
// dauerhaft in den Einstellungen und käme bei jedem Start zurück. // 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 volume(for bundleID: String) -> Double { store.volume(for: bundleID) }
public func isMuted(_ bundleID: String) -> Bool { store.isMuted(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) { public func setVolume(_ volume: Double, for bundleID: String) {
store.set(volume, for: bundleID) store.set(volume, for: bundleID)
@@ -325,6 +339,7 @@ public final class AudioMixer {
private func persist() { private func persist() {
defaults.set(store.snapshot, forKey: Keys.volumes) defaults.set(store.snapshot, forKey: Keys.volumes)
defaults.set(store.mutedIdentifiers, forKey: Keys.muted) defaults.set(store.mutedIdentifiers, forKey: Keys.muted)
defaults.set(store.overdrivenIdentifiers, forKey: Keys.overdrive)
defaults.set(Array(managed), forKey: Keys.managed) defaults.set(Array(managed), forKey: Keys.managed)
} }

View File

@@ -3,12 +3,22 @@ import Foundation
/// Rechnet Reglerstellung in Verstärkung um. /// Rechnet Reglerstellung in Verstärkung um.
public enum Gain { 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 /// Das ist die Stellung, in der das Programm klingt, wie es gemeint war.
/// Verstärkung arbeitet der Begrenzer bereits deutlich, darüber bleibt vom /// Alles darüber ist ein Eingriff und soll auch einer sein.
/// Signal nur noch Kompression übrig. public static let maximumVolume: Double = 1.0
public static let maximumVolume: Double = 2.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. /// Verstärkungsfaktor zu einer Reglerstellung von 0 bis 2.
/// ///
@@ -25,10 +35,17 @@ public enum Gain {
/// aber die Formel steht in einem Echtzeitpfad. /// aber die Formel steht in einem Echtzeitpfad.
public static let taper: Double = 1.66 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 { 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 } 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 volumes: [String: Double] = [:]
private var muted: Set<String> = [] private var muted: Set<String> = []
private var overdrive: Set<String> = []
public init() {} public init() {}
@@ -77,7 +95,26 @@ public struct VolumeStore: Sendable {
public func isMuted(for bundleID: String) -> Bool { muted.contains(bundleID) } public func isMuted(for bundleID: String) -> Bool { muted.contains(bundleID) }
public mutating func set(_ volume: Double, for bundleID: String) { 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) { 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 snapshot: [String: Double] { volumes }
public var mutedIdentifiers: [String] { Array(muted) } public var mutedIdentifiers: [String] { Array(muted) }
public var overdrivenIdentifiers: [String] { Array(overdrive) }
public init(volumes: [String: Double], muted: [String]) { public init(volumes: [String: Double], muted: [String], overdrive: [String] = []) {
self.volumes = volumes // 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) self.muted = Set(muted)
} }
} }

View File

@@ -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": { "mixer.release": {
"localizations": { "localizations": {
"de": { "de": {

View File

@@ -152,6 +152,7 @@ private struct ProcessRow: View {
private var managed: Bool { mixer.isManaged(group.bundleID) } private var managed: Bool { mixer.isManaged(group.bundleID) }
private var muted: Bool { mixer.isMuted(group.bundleID) } private var muted: Bool { mixer.isMuted(group.bundleID) }
private var volume: Double { mixer.volume(for: group.bundleID) } private var volume: Double { mixer.volume(for: group.bundleID) }
private var overdriven: Bool { mixer.isOverdriven(group.bundleID) }
var body: some View { var body: some View {
// Eine Zeile statt zwei: Symbol, Stummschalter, Regler, Wert. Der Name // Eine Zeile statt zwei: Symbol, Stummschalter, Regler, Wert. Der Name
@@ -176,6 +177,7 @@ private struct ProcessRow: View {
VolumeBar( VolumeBar(
volume: volume, volume: volume,
maximum: Gain.maximum(overdrive: overdriven),
level: mixer.level(for: group.bundleID), level: mixer.level(for: group.bundleID),
isActive: managed && !muted, isActive: managed && !muted,
onChange: { newValue in onChange: { newValue in
@@ -195,6 +197,7 @@ private struct ProcessRow: View {
// Feste Breite, sonst wandert der Regler beim Ziehen. // Feste Breite, sonst wandert der Regler beim Ziehen.
.frame(width: 36, alignment: .trailing) .frame(width: 36, alignment: .trailing)
overdriveButton
resetButton resetButton
} }
.contentShape(.rect) .contentShape(.rect)
@@ -208,6 +211,26 @@ private struct ProcessRow: View {
.animation(Onyx.Motion.value, value: managed) .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 %. /// Zurück auf 100 %.
/// ///
/// Der Platz dafür ist **immer** reserviert, auch wenn der Knopf nicht zu /// 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 %. /// die Grenze bei 100 %.
private struct VolumeBar: View { private struct VolumeBar: View {
let volume: Double let volume: Double
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
let maximum: Double
let level: Double let level: Double
let isActive: Bool let isActive: Bool
let onChange: (Double) -> Void let onChange: (Double) -> Void
@@ -301,7 +326,7 @@ private struct VolumeBar: View {
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
let width = geometry.size.width 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 let knob: CGFloat = isDragging ? 13 : 11
// Der Knopf soll an den Enden nicht überstehen. // Der Knopf soll an den Enden nicht überstehen.
let travel = max(width - knob, 1) let travel = max(width - knob, 1)
@@ -313,11 +338,14 @@ private struct VolumeBar: View {
.frame(height: trackHeight) .frame(height: trackHeight)
// Markierung bei 100 %: darüber wird verstärkt, und man soll // Markierung bei 100 %: darüber wird verstärkt, und man soll
// sehen, wann man diese Grenze überschreitet. // sehen, wann man diese Grenze überschreitet. Ohne Overdrive
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
if maximum > 1 {
Rectangle() Rectangle()
.fill(Onyx.Color.hairline) .fill(Onyx.Color.hairline)
.frame(width: 1, height: trackHeight + 4) .frame(width: 1, height: trackHeight + 4)
.offset(x: knob / 2 + travel / Gain.maximumVolume) .offset(x: knob / 2 + travel / maximum)
}
Capsule() Capsule()
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent) .fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
@@ -349,7 +377,7 @@ private struct VolumeBar: View {
.onChanged { value in .onChanged { value in
isDragging = true isDragging = true
let position = min(max((value.location.x - knob / 2) / travel, 0), 1) let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
onChange(position * Gain.maximumVolume) onChange(position * maximum)
} }
.onEnded { _ in isDragging = false }) .onEnded { _ in isDragging = false })
} }

View File

@@ -47,7 +47,10 @@ struct GainTests {
@Test("Der Regelbereich ist begrenzt — auch bei absurder Eingabe") @Test("Der Regelbereich ist begrenzt — auch bei absurder Eingabe")
func rangeIsClamped() { func rangeIsClamped() {
#expect(Gain.factor(forVolume: -5) == 0) #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))
} }
} }

View File

@@ -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)
}
}