Phase 7: Per-Programm-Audiomixer über Core-Audio-Taps
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.
This commit is contained in:
149
Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift
Normal file
149
Packages/OnyxKit/Tests/AudioProviderTests/GainTests.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import AudioProvider
|
||||
|
||||
// Hier wird in den Ton eingegriffen, der aus den Lautsprechern kommt. Ein
|
||||
// Rechenfehler klingt nicht falsch — er knackt, verzerrt oder ist schmerzhaft
|
||||
// laut. Deshalb liegt die Rechnung als reine Funktion vor und wird vollständig
|
||||
// durchgeprüft, bevor sie an einen Audiopuffer kommt.
|
||||
|
||||
@Suite("Verstärkung")
|
||||
struct GainTests {
|
||||
|
||||
@Test("Volle Lautstärke lässt das Signal unverändert")
|
||||
func unityIsTransparent() {
|
||||
#expect(Gain.factor(forVolume: 1.0) == 1.0)
|
||||
}
|
||||
|
||||
@Test("Stumm ist wirklich still")
|
||||
func zeroIsSilent() {
|
||||
#expect(Gain.factor(forVolume: 0) == 0)
|
||||
}
|
||||
|
||||
@Test("Die Skala ist wahrnehmungsgerecht, nicht linear")
|
||||
func scaleIsPerceptual() {
|
||||
// Halber Reglerweg soll halb so laut klingen, nicht halbe Amplitude
|
||||
// haben — das sind rund −10 dB, also etwa Faktor 0,3. Linear wäre der
|
||||
// Regler in der unteren Hälfte praktisch wirkungslos.
|
||||
let half = Gain.factor(forVolume: 0.5)
|
||||
#expect(half > 0.2 && half < 0.4)
|
||||
}
|
||||
|
||||
@Test("Die Skala steigt durchgehend")
|
||||
func scaleIsMonotonic() {
|
||||
var previous = -1.0
|
||||
for step in 0...100 {
|
||||
let value = Gain.factor(forVolume: Double(step) / 100)
|
||||
#expect(value >= previous)
|
||||
previous = value
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Über 100 Prozent wird tatsächlich verstärkt")
|
||||
func boostAmplifies() {
|
||||
#expect(Gain.factor(forVolume: 2.0) > 1.0)
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Begrenzer")
|
||||
struct LimiterTests {
|
||||
|
||||
@Test("Leise Signale gehen unverändert durch")
|
||||
func quietPassesThrough() {
|
||||
#expect(abs(SoftClip.apply(0.3) - 0.3) < 0.001)
|
||||
#expect(abs(SoftClip.apply(-0.3) + 0.3) < 0.001)
|
||||
}
|
||||
|
||||
@Test("Nichts verlässt je den gültigen Bereich")
|
||||
func neverExceedsFullScale() {
|
||||
// Der eigentliche Zweck: bei 200 Prozent entstehen Werte weit über 1,0.
|
||||
// Ungebremst werden die von der Hardware hart abgeschnitten, und das
|
||||
// knackt hörbar.
|
||||
for value in stride(from: -8.0, through: 8.0, by: 0.05) {
|
||||
let result = SoftClip.apply(Float(value))
|
||||
#expect(result <= 1.0 && result >= -1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Die Kennlinie ist punktsymmetrisch")
|
||||
func curveIsSymmetric() {
|
||||
for value in stride(from: 0.0, through: 4.0, by: 0.1) {
|
||||
let positive = SoftClip.apply(Float(value))
|
||||
let negative = SoftClip.apply(Float(-value))
|
||||
#expect(abs(positive + negative) < 0.0001)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Die Kennlinie steigt durchgehend — sonst klingt sie verzerrt")
|
||||
func curveIsMonotonic() {
|
||||
var previous = Float(-2)
|
||||
for value in stride(from: -6.0, through: 6.0, by: 0.01) {
|
||||
let result = SoftClip.apply(Float(value))
|
||||
#expect(result >= previous - 0.0001)
|
||||
previous = result
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Null bleibt null")
|
||||
func zeroStaysZero() {
|
||||
#expect(SoftClip.apply(0) == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Lautstärke-Gedächtnis")
|
||||
struct VolumeStoreTests {
|
||||
|
||||
@Test("Ein gemerkter Wert kommt zurück")
|
||||
func roundTrip() {
|
||||
var store = VolumeStore()
|
||||
store.set(0.4, for: "com.spotify.client")
|
||||
#expect(store.volume(for: "com.spotify.client") == 0.4)
|
||||
}
|
||||
|
||||
@Test("Ein unbekanntes Programm startet auf voller Lautstärke")
|
||||
func unknownDefaultsToFull() {
|
||||
// Nicht auf null: ein Programm, das nach dem Start stumm ist, wirkt
|
||||
// kaputt, und niemand sucht die Ursache im Mixer.
|
||||
#expect(VolumeStore().volume(for: "com.example.neu") == 1.0)
|
||||
}
|
||||
|
||||
@Test("Stummschaltung ist getrennt von der Lautstärke")
|
||||
func muteIsSeparate() {
|
||||
// Sonst verliert man beim Stummschalten den eingestellten Wert und
|
||||
// muss ihn danach neu suchen.
|
||||
var store = VolumeStore()
|
||||
store.set(0.6, for: "app")
|
||||
store.setMuted(true, for: "app")
|
||||
|
||||
#expect(store.isMuted(for: "app"))
|
||||
#expect(store.volume(for: "app") == 0.6)
|
||||
|
||||
store.setMuted(false, for: "app")
|
||||
#expect(store.volume(for: "app") == 0.6)
|
||||
}
|
||||
|
||||
@Test("Die wirksame Verstärkung ist null, solange stumm geschaltet ist")
|
||||
func mutedMeansSilent() {
|
||||
var store = VolumeStore()
|
||||
store.set(0.8, for: "app")
|
||||
store.setMuted(true, for: "app")
|
||||
|
||||
#expect(store.effectiveGain(for: "app") == 0)
|
||||
}
|
||||
|
||||
@Test("Werte außerhalb des Bereichs werden eingefangen")
|
||||
func valuesAreClamped() {
|
||||
var store = VolumeStore()
|
||||
store.set(-1, for: "app")
|
||||
#expect(store.volume(for: "app") == 0)
|
||||
|
||||
store.set(999, for: "app")
|
||||
#expect(store.volume(for: "app") == Gain.maximumVolume)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user