Mixer war still: er hat sich selbst abgegriffen

Der Mixer gibt den Ton der bearbeiteten Programme selbst aus — und steht damit
als Tonquelle in seiner eigenen Liste. Eine Zeile wird geregelt, sobald man ihren
Regler anfasst. Ein Klick auf Onyx' eigene Zeile genügte also.

Der Selbst-Tap läuft wie jeder andere mit mutedWhenTapped und schaltet damit
Onyx' gesamte Ausgabe stumm — die Wiedergabe *aller* bearbeiteten Programme.
Völlige Stille, während die Pegelanzeigen weiter ausschlagen, weil die Taps ja
Signal bekommen. Und der Zustand stand in den Einstellungen, überlebte also
jeden Neustart.

Gemessen (Spikes/mixer-path-probe.swift): Onyx' Ausgabepegel klettert von 0,388
auf 2,833, sobald er sich selbst bearbeitet. Der Weg an sich ist in Ordnung —
Tap, Aggregate-Gerät und IOProc liefern einzeln wie zu dritt volle Pegel, das
war der Hauptverdacht und ist widerlegt.

Onyx erscheint jetzt nicht mehr in der eigenen Liste, lässt sich nicht auswählen,
und ein gespeicherter Eintrag wird beim Laden entfernt und zurückgeschrieben.

Dazu ein zweiter Fund aus derselben Ecke: setVolume und setMuted griffen auf
taps[bundleID] zu. Die Taps liegen aber unter Kennung#Prozessnummer, weil ein
Programm mehrere Tonquellen haben kann — der Zugriff ging immer ins Leere. Der
Regler wirkte erst, wenn die Auffrischung drei Sekunden später nachzog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 17:57:15 +02:00
parent ed5bd01482
commit d001e439a6
7 changed files with 636 additions and 7 deletions

View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Baut Spike C2 (Wiedergabeweg des Mixers) und lässt ihn laufen.
#
# Aufruf: Spikes/build-mixer-path-probe.sh [Bundle-ID-Fragment]
# Ohne Argument nimmt der Spike den ersten Prozess, der gerade Ton ausgibt.
set -euo pipefail
cd "$(dirname "$0")/.."
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
OUT=/tmp/mixer-path-probe
IDENTITY="${IDENTITY:-Apple Development: guidoschmit@guidoschmit.com (F586278766)}"
echo "→ kompiliere"
xcrun swiftc -O Spikes/mixer-path-probe.swift -o "$OUT"
echo "→ signiere"
codesign --force --options runtime --sign "$IDENTITY" "$OUT"
echo
"$OUT" "$@"

View File

@@ -0,0 +1,286 @@
// Spike C2 der **Wiedergabeweg** des Mixers
//
// Spike C hat nur zugehört (`muteBehavior = .unmuted`) und nie etwas ausgegeben.
// Genau der ungeprüfte Teil ist der, der jetzt still bleibt: Tap mit
// `.mutedWhenTapped` schaltet das Programm stumm, und das Aggregate-Device soll
// das Signal wieder ausgeben. Hörbar ist nichts.
//
// Dieser Spike baut denselben Weg nach und misst, was in der IOProc ankommt:
// wie viele Puffer, wie viele Kanäle, welche Größe auf beiden Seiten.
//
// Bauen und laufen: Spikes/build-mixer-path-probe.sh [Bundle-ID-Fragment]
import Foundation
import AppKit
import AVFoundation
import CoreAudio
import AudioToolbox
func address(_ selector: AudioObjectPropertySelector,
_ scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal)
-> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(mSelector: selector, mScope: scope,
mElement: kAudioObjectPropertyElementMain)
}
func readArray<T>(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ type: T.Type) -> [T] {
var addr = addr
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(object, &addr, 0, nil, &size) == noErr, size > 0 else { return [] }
let count = Int(size) / MemoryLayout<T>.stride
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
alignment: MemoryLayout<T>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, raw) == noErr else { return [] }
return Array(UnsafeBufferPointer(start: raw.assumingMemoryBound(to: T.self), count: count))
}
func readValue<T>(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ initial: T) -> T? {
var addr = addr
var value = initial
var size = UInt32(MemoryLayout<T>.size)
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr else { return nil }
return value
}
func readString(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress) -> String? {
var addr = addr
var cf: CFString?
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &cf) {
AudioObjectGetPropertyData(object, &addr, 0, nil, &size, $0)
}
guard status == noErr, let cf else { return nil }
return cf as String
}
/// Die Kanalaufteilung eines Geräts in einem Bereich.
func streamLayout(_ device: AudioObjectID, scope: AudioObjectPropertyScope) -> String {
var addr = address(kAudioDevicePropertyStreamConfiguration, scope)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(device, &addr, 0, nil, &size) == noErr, size > 0 else {
return ""
}
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: 16)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(device, &addr, 0, nil, &size, raw) == noErr else { return "" }
let list = UnsafeMutableAudioBufferListPointer(
raw.assumingMemoryBound(to: AudioBufferList.self))
let parts = list.map { "\($0.mNumberChannels) Kan." }
return parts.isEmpty ? "keine" : "\(list.count) Puffer: " + parts.joined(separator: ", ")
}
// MARK: - Zielprozess
let wanted = CommandLine.arguments.dropFirst().first { !$0.hasPrefix("--") }
struct Candidate {
let object: AudioObjectID
let bundleID: String
let pid: pid_t
let playing: Bool
}
let objects = readArray(AudioObjectID(kAudioObjectSystemObject),
address(kAudioHardwarePropertyProcessObjectList), AudioObjectID.self)
let candidates: [Candidate] = objects.compactMap { object in
let pid = readValue(object, address(kAudioProcessPropertyPID), pid_t(0)) ?? -1
// Auch Prozesse ohne Bundle-Kennung: `afplay` ist als Testquelle genau
// deshalb brauchbar, weil es ein nacktes Programm ohne Bundle ist.
let bundle = readString(object, address(kAudioProcessPropertyBundleID)).flatMap {
$0.isEmpty ? nil : $0
} ?? (NSRunningApplication(processIdentifier: pid)?.localizedName ?? "pid \(pid)")
let playing = (readValue(object, address(kAudioProcessPropertyIsRunningOutput), UInt32(0)) ?? 0) != 0
return Candidate(object: object, bundleID: bundle, pid: pid, playing: playing)
}
print("Audio-Prozesse, die gerade ausgeben:")
for candidate in candidates where candidate.playing {
print(" \(candidate.bundleID) pid \(candidate.pid) obj \(candidate.object)")
}
let target: Candidate? = {
if let wanted {
return candidates.first { $0.bundleID.caseInsensitiveCompare(wanted) == .orderedSame }
?? candidates.first { $0.bundleID.localizedCaseInsensitiveContains(wanted) }
}
return candidates.first { $0.playing }
}()
guard let target else {
print("\nKein ausgebender Prozess gefunden. Musik oder ein Video starten und erneut laufen lassen.")
exit(1)
}
print("\n→ Ziel: \(target.bundleID) (obj \(target.object))")
// MARK: - Ausgabegerät
guard let outputDevice = readValue(AudioObjectID(kAudioObjectSystemObject),
address(kAudioHardwarePropertyDefaultOutputDevice),
AudioObjectID(kAudioObjectUnknown)),
let outputUID = readString(outputDevice, address(kAudioDevicePropertyDeviceUID))
else {
print("Kein Standard-Ausgabegerät.")
exit(1)
}
let outputName = readString(outputDevice, address(kAudioObjectPropertyName)) ?? "?"
print("→ Ausgabegerät: \(outputName) [\(outputUID)] obj \(outputDevice)")
print(" Eingänge: \(streamLayout(outputDevice, scope: kAudioObjectPropertyScopeInput))")
print(" Ausgänge: \(streamLayout(outputDevice, scope: kAudioObjectPropertyScopeOutput))")
// MARK: - Messung
final class Stats: @unchecked Sendable {
var callbacks = 0
var inputChannels: [UInt32] = []
var outputChannels: [UInt32] = []
var inputBytes: [UInt32] = []
var outputBytes: [UInt32] = []
var inputPeak: Float = 0
var copiedSamples = 0
var nilInput = 0
var nilOutput = 0
}
/// Baut den Weg einmal auf, misst und baut ihn wieder ab.
///
/// - Parameter muted: der Unterschied, um den es geht. `false` heißt zuhören
/// ohne einzugreifen (so lief Spike C), `true` heißt: das Original wird stumm
/// geschaltet und der Ton soll nur noch aus dieser IOProc kommen.
@discardableResult
func measure(label: String, muted: Bool, global: Bool, seconds: Double,
passthrough: Bool = true) -> Stats? {
let stats = Stats()
let tapUUID = UUID()
let description = global
? CATapDescription(stereoGlobalTapButExcludeProcesses: [])
: CATapDescription(stereoMixdownOfProcesses: [target.object])
description.uuid = tapUUID
description.name = "Onyx Probe"
description.isPrivate = true
description.muteBehavior = muted ? .mutedWhenTapped : .unmuted
var tapID = AudioObjectID(kAudioObjectUnknown)
let tapStatus = AudioHardwareCreateProcessTap(description, &tapID)
guard tapStatus == noErr else {
print("[\(label)] Tap fehlgeschlagen: \(tapStatus)")
return nil
}
defer { AudioHardwareDestroyProcessTap(tapID) }
var tapFormat = AudioStreamBasicDescription()
var formatSize = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
var formatAddress = address(kAudioTapPropertyFormat)
if AudioObjectGetPropertyData(tapID, &formatAddress, 0, nil, &formatSize, &tapFormat) == noErr {
print("[\(label)] Tap-Format: \(Int(tapFormat.mSampleRate)) Hz, "
+ "\(tapFormat.mChannelsPerFrame) Kanäle, Flags \(tapFormat.mFormatFlags)")
}
let aggregate: [String: Any] = [
kAudioAggregateDeviceNameKey: "Onyx Probe",
kAudioAggregateDeviceUIDKey: "com.scarriffleservices.onyx.probe." + UUID().uuidString,
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceIsStackedKey: false,
kAudioAggregateDeviceMainSubDeviceKey: outputUID,
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputUID]],
kAudioAggregateDeviceTapListKey: [[
kAudioSubTapUIDKey: tapUUID.uuidString,
kAudioSubTapDriftCompensationKey: true,
]],
]
var aggregateID = AudioObjectID(kAudioObjectUnknown)
let aggregateStatus = AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &aggregateID)
guard aggregateStatus == noErr else {
print("[\(label)] Aggregate fehlgeschlagen: \(aggregateStatus)")
return nil
}
defer { AudioHardwareDestroyAggregateDevice(aggregateID) }
print("[\(label)] Aggregate \(aggregateID) — Eingänge \(streamLayout(aggregateID, scope: kAudioObjectPropertyScopeInput)), Ausgänge \(streamLayout(aggregateID, scope: kAudioObjectPropertyScopeOutput))")
var procID: AudioDeviceIOProcID?
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&procID, aggregateID, nil) {
_, inputData, _, outputData, _ in
let input = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inputData))
let output = UnsafeMutableAudioBufferListPointer(outputData)
stats.callbacks += 1
if stats.inputChannels.isEmpty {
stats.inputChannels = input.map(\.mNumberChannels)
stats.outputChannels = output.map(\.mNumberChannels)
stats.inputBytes = input.map(\.mDataByteSize)
stats.outputBytes = output.map(\.mDataByteSize)
}
for (index, outputBuffer) in output.enumerated() {
guard index < input.count else { continue }
guard let source = input[index].mData else { stats.nilInput += 1; continue }
guard let destination = outputBuffer.mData else { stats.nilOutput += 1; continue }
let count = Int(min(input[index].mDataByteSize, outputBuffer.mDataByteSize))
/ MemoryLayout<Float>.size
let inputSamples = source.assumingMemoryBound(to: Float.self)
let outputSamples = destination.assumingMemoryBound(to: Float.self)
for sample in 0..<count {
let value = inputSamples[sample]
// Beim reinen Messen wird **nicht** durchgereicht: sonst hört
// der globale Tap die eigene Wiedergabe wieder mit, und der
// Pegel schaukelt sich auf, statt den Ist-Zustand zu zeigen.
outputSamples[sample] = passthrough ? value : 0
let magnitude = abs(value)
if magnitude > stats.inputPeak { stats.inputPeak = magnitude }
}
stats.copiedSamples += count
}
}
guard ioStatus == noErr, let procID else {
print("[\(label)] IOProc fehlgeschlagen: \(ioStatus)")
return nil
}
defer { AudioDeviceDestroyIOProcID(aggregateID, procID) }
let startStatus = AudioDeviceStart(aggregateID, procID)
print("[\(label)] läuft \(Int(seconds)) s (Start \(startStatus))")
Thread.sleep(forTimeInterval: seconds)
AudioDeviceStop(aggregateID, procID)
print("[\(label)] Aufrufe \(stats.callbacks), Samples \(stats.copiedSamples), "
+ "Spitze \(stats.inputPeak), Eingang \(stats.inputChannels)/\(stats.inputBytes), "
+ "Ausgang \(stats.outputChannels)/\(stats.outputBytes), "
+ "leer \(stats.nilInput)/\(stats.nilOutput)")
return stats
}
// Drei Durchgänge, die sich gegenseitig einordnen:
//
// 1. nur zuhören so lief Spike C. Kommt hier nichts an, liegt es nicht am
// Stummschalten, sondern daran, dass der Tap den Prozess nicht erwischt.
// 2. stummschalten und ausgeben das ist der Weg des Mixers.
// 3. alles abgreifen statt nur einen Prozess trennt dieser Prozess" von
// Taps gehen auf dieser Maschine gerade gar nicht".
// Mit `--global-only` bleibt der Zielprozess unangetastet: dann misst der
// Spike nur, was am Ausgabegerät tatsächlich ankommt brauchbar, um Onyx
// selbst zu vermessen, ohne ihm mit einem zweiten Tap dazwischenzufunken.
let globalOnly = CommandLine.arguments.contains("--global-only")
// `--listen-only`: nur zuhören, nichts stummschalten so lässt sich messen,
// was ein **anderes** Programm (etwa Onyx selbst) gerade ausgibt.
let listenOnly = CommandLine.arguments.contains("--listen-only")
// `--muted-only`: nur der Weg, den der Mixer geht stummschalten und selbst
// ausgeben. Damit lässt sich messen, ob dabei überhaupt etwas hörbar bleibt.
let mutedOnly = CommandLine.arguments.contains("--muted-only")
print("")
if mutedOnly {
measure(label: "2 stumm + ausgeben", muted: true, global: false, seconds: 10)
} else if !globalOnly {
measure(label: "1 nur zuhören", muted: false, global: false, seconds: 5, passthrough: false)
print("")
if !listenOnly {
measure(label: "2 stumm + ausgeben", muted: true, global: false, seconds: 5)
print("")
}
}
if !listenOnly {
measure(label: "3 global, nur zuhören", muted: false, global: true, seconds: 5,
passthrough: false)
}
print("\n→ Alles abgebaut, Wiedergabe wieder normal.")

View File

@@ -0,0 +1,180 @@
// Spike C3 mehrere Aggregate-Geräte gleichzeitig auf **einem** Ausgabegerät
//
// Der Mixer legt je Prozess einen Tap in einem eigenen Aggregate-Gerät an. Ein
// Programm wie Chrome bringt aber mehrere Prozesse mit also mehrere
// Aggregate-Geräte, die sich alle denselben Lautsprecher als Untergerät teilen.
// Mit einem einzigen Prozess funktioniert der Weg nachweislich (Spike C2).
//
// Dieser Spike misst, was bei zwei und drei gleichzeitigen Geräten passiert:
// laufen alle IOProcs, oder verhungert eines davon?
//
// Bauen und laufen: Spikes/build-multi-tap-probe.sh [Bundle-ID-Fragment]
import Foundation
import AppKit
import AVFoundation
import CoreAudio
import AudioToolbox
func address(_ selector: AudioObjectPropertySelector,
_ scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal)
-> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(mSelector: selector, mScope: scope,
mElement: kAudioObjectPropertyElementMain)
}
func readArray<T>(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ type: T.Type) -> [T] {
var addr = addr
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(object, &addr, 0, nil, &size) == noErr, size > 0 else { return [] }
let count = Int(size) / MemoryLayout<T>.stride
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
alignment: MemoryLayout<T>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, raw) == noErr else { return [] }
return Array(UnsafeBufferPointer(start: raw.assumingMemoryBound(to: T.self), count: count))
}
func readValue<T>(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ initial: T) -> T? {
var addr = addr
var value = initial
var size = UInt32(MemoryLayout<T>.size)
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr else { return nil }
return value
}
func readString(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress) -> String? {
var addr = addr
var cf: CFString?
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &cf) {
AudioObjectGetPropertyData(object, &addr, 0, nil, &size, $0)
}
guard status == noErr, let cf else { return nil }
return cf as String
}
let wanted = CommandLine.arguments.dropFirst().first { !$0.hasPrefix("--") }
let objects = readArray(AudioObjectID(kAudioObjectSystemObject),
address(kAudioHardwarePropertyProcessObjectList), AudioObjectID.self)
struct Candidate { let object: AudioObjectID; let bundleID: String; let playing: Bool }
let candidates: [Candidate] = objects.compactMap { object in
let pid = readValue(object, address(kAudioProcessPropertyPID), pid_t(0)) ?? -1
let bundle = readString(object, address(kAudioProcessPropertyBundleID)).flatMap {
$0.isEmpty ? nil : $0
} ?? "pid \(pid)"
let playing = (readValue(object, address(kAudioProcessPropertyIsRunningOutput), UInt32(0)) ?? 0) != 0
return Candidate(object: object, bundleID: bundle, playing: playing)
}
let target: Candidate? = {
if let wanted { return candidates.first { $0.bundleID.localizedCaseInsensitiveContains(wanted) } }
return candidates.first { $0.playing }
}()
guard let target else { print("Kein ausgebender Prozess."); exit(1) }
guard let outputDevice = readValue(AudioObjectID(kAudioObjectSystemObject),
address(kAudioHardwarePropertyDefaultOutputDevice),
AudioObjectID(kAudioObjectUnknown)),
let outputUID = readString(outputDevice, address(kAudioDevicePropertyDeviceUID))
else { print("Kein Ausgabegerät."); exit(1) }
print("Ziel: \(target.bundleID), Ausgabe: \(outputUID)\n")
/// Ein aufgebauter Weg Tap, Gerät, IOProc.
final class Path: @unchecked Sendable {
let index: Int
let targetObject: AudioObjectID
let outputUID: String
var tapID = AudioObjectID(kAudioObjectUnknown)
var aggregateID = AudioObjectID(kAudioObjectUnknown)
var procID: AudioDeviceIOProcID?
var callbacks = 0
var peak: Float = 0
var startStatus: OSStatus = 0
init(index: Int, targetObject: AudioObjectID, outputUID: String) {
self.index = index
self.targetObject = targetObject
self.outputUID = outputUID
}
func build(muted: Bool) -> Bool {
let tapUUID = UUID()
let description = CATapDescription(stereoMixdownOfProcesses: [targetObject])
description.uuid = tapUUID
description.name = "Onyx Probe \(index)"
description.isPrivate = true
description.muteBehavior = muted ? .mutedWhenTapped : .unmuted
let tapStatus = AudioHardwareCreateProcessTap(description, &tapID)
guard tapStatus == noErr else { print(" [\(index)] Tap: \(tapStatus)"); return false }
let aggregate: [String: Any] = [
kAudioAggregateDeviceNameKey: "Onyx Probe \(index)",
kAudioAggregateDeviceUIDKey: "com.scarriffleservices.onyx.probe.\(index)." + UUID().uuidString,
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceIsStackedKey: false,
kAudioAggregateDeviceMainSubDeviceKey: outputUID,
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputUID]],
kAudioAggregateDeviceTapListKey: [[
kAudioSubTapUIDKey: tapUUID.uuidString,
kAudioSubTapDriftCompensationKey: true,
]],
]
let aggregateStatus = AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &aggregateID)
guard aggregateStatus == noErr else { print(" [\(index)] Gerät: \(aggregateStatus)"); return false }
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&procID, aggregateID, nil) {
[self] _, inputData, _, outputData, _ in
let input = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inputData))
let output = UnsafeMutableAudioBufferListPointer(outputData)
callbacks += 1
for (i, outputBuffer) in output.enumerated() {
guard i < input.count, let source = input[i].mData,
let destination = outputBuffer.mData else { continue }
let count = Int(min(input[i].mDataByteSize, outputBuffer.mDataByteSize))
/ MemoryLayout<Float>.size
let inputSamples = source.assumingMemoryBound(to: Float.self)
let outputSamples = destination.assumingMemoryBound(to: Float.self)
for s in 0..<count {
let value = inputSamples[s]
outputSamples[s] = value
if abs(value) > peak { peak = abs(value) }
}
}
}
guard ioStatus == noErr, let procID else { print(" [\(index)] IOProc: \(ioStatus)"); return false }
startStatus = AudioDeviceStart(aggregateID, procID)
return true
}
func tearDown() {
if let procID {
AudioDeviceStop(aggregateID, procID)
AudioDeviceDestroyIOProcID(aggregateID, procID)
}
if aggregateID != kAudioObjectUnknown { AudioHardwareDestroyAggregateDevice(aggregateID) }
if tapID != kAudioObjectUnknown { AudioHardwareDestroyProcessTap(tapID) }
}
}
func run(count: Int, muted: Bool, seconds: Double) {
print("\(count) Gerät(e) gleichzeitig, \(muted ? "stumm" : "offen"):")
let paths = (1...count).map { Path(index: $0, targetObject: target.object, outputUID: outputUID) }
for path in paths where !path.build(muted: muted) { }
Thread.sleep(forTimeInterval: seconds)
for path in paths {
print(" [\(path.index)] Start \(path.startStatus), Aufrufe \(path.callbacks), Spitze \(path.peak)")
}
for path in paths { path.tearDown() }
print("")
}
run(count: 1, muted: true, seconds: 4)
run(count: 2, muted: true, seconds: 4)
run(count: 3, muted: true, seconds: 4)
print("→ Alles abgebaut.")