Last active
September 17, 2026 13:21
-
-
Save huynhducduy/ccd17ac154fc3e19dfd0abda1332745c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/zsh | |
| # measure-duck.sh — measure the macOS FaceTime audio-duck depth (digital loopback). | |
| # | |
| # Strategy: route system audio to External Headphones + BlackHole 16ch at once | |
| # (stacked multi-output), play a fixed 1 kHz tone, record BlackHole with and | |
| # without a FaceTime call, then compare RMS levels: | |
| # duck (dB) = RMS(no-call) - RMS(in-call) → that number is the unduck gain. | |
| # | |
| # Start here: ./measure-duck.sh guide (prints every step, human + machine) | |
| set -u | |
| TONE=/tmp/duck-tone.wav | |
| DUR=10 | |
| RATE=48000 | |
| MK=/tmp/mk-multi # built from /tmp/mk-multi.swift (headphones+BlackHole stacked agg) | |
| die() { | |
| echo "ERROR: $*" >&2 | |
| exit 1 | |
| } | |
| cmd_guide() { | |
| cat <<'EOF' | |
| HOW TO MEASURE THE DUCK (you + this script together) | |
| ================================================================ | |
| Prepare (once): | |
| 1. You: keep VOLUME FIXED from now on. Do not touch system/app volume | |
| until 'restore' — every take must be at identical volume. | |
| 2. Run: ./measure-duck.sh check (preflight: tools, devices, daemon OFF) | |
| 3. Run: ./measure-duck.sh setup (pauses Spotify, makes the tone, | |
| creates the DuckMeasure multi-output, switches system output to it. | |
| You still hear everything via headphones.) | |
| No-call baseline: | |
| 4. You: silence all audio (Spotify is paused by setup; pause anything else). | |
| NO active call. | |
| 5. Run: ./measure-duck.sh take baseline | |
| → plays the tone ~13 s, records 10 s, prints RMS/peak. | |
| Crest (peak-RMS) should be ≈3 dB (pure sine). If much higher, | |
| something else played along — stop it and re-take. | |
| In-call takes (the other end must be SILENT during each tone): | |
| 6. You: start a FaceTime audio call (e.g. Mac → iPhone). The duck is active. | |
| 7. Run: ./measure-duck.sh take incall1 | |
| 8. Wait a minute, run: ./measure-duck.sh take incall2 | |
| → incall1 ≈ incall2 (within ~0.1 dB) means a static, repeatable duck. | |
| (A true static-vs-dynamic verdict needs a talking take too; the repo | |
| measured STATIC on macOS 26.5.) | |
| Result + cleanup: | |
| 9. You: end the call whenever you like (takes are done). | |
| 10. Run: ./measure-duck.sh results /tmp/duck-take-baseline.wav /tmp/duck-take-incall1.wav | |
| → prints the duck depth in dB = your unduck gain. Apply it with: | |
| /tmp/unduck gain <N> (live, daemon running) or | |
| /tmp/unduck on --manual --gain <N> | |
| 11. Run: ./measure-duck.sh restore | |
| (system output back to headphones, DuckMeasure destroyed. | |
| Spotify stays paused — press play yourself.) | |
| EOF | |
| } | |
| cmd_check() { | |
| echo "== preflight ==" | |
| command -v ffmpeg >/dev/null || die "ffmpeg missing (brew install ffmpeg)" | |
| command -v afplay >/dev/null || die "afplay missing" | |
| [[ -x $MK ]] || die "$MK missing — rebuild: swiftc -O -framework CoreAudio -framework Foundation /tmp/mk-multi.swift -o /tmp/mk-multi" | |
| if [[ -f /tmp/unduck.pid ]] && kill -0 "$(cat /tmp/unduck.pid 2>/dev/null)" 2>/dev/null; then | |
| die "unduck daemon is ON — run '/tmp/unduck off' first (it would corrupt the measurement)" | |
| fi | |
| echo "OK: ffmpeg, afplay, mk-multi present; unduck daemon OFF" | |
| ffmpeg -hide_banner -f avfoundation -list_devices true -i "" 2>&1 | grep -i "blackhole" || | |
| die "BlackHole device not visible to ffmpeg" | |
| echo "OK: BlackHole visible. Keep VOLUME FIXED for the whole session." | |
| } | |
| cmd_setup() { | |
| if [[ -f /tmp/duck-take-baseline.wav || -f /tmp/duck-take-incall1.wav ]]; then | |
| echo "NOTE: old take files exist — removing for a fresh session" | |
| rm -f /tmp/duck-take-*.wav /tmp/duck-take-*.txt | |
| fi | |
| osascript -e 'tell application "Spotify" to pause' 2>/dev/null && echo "Spotify paused" | |
| if [[ ! -f $TONE ]]; then | |
| echo "generating 5-min 1 kHz tone..." | |
| ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=1000:duration=300" \ | |
| -ar $RATE -ac 2 "$TONE" -y || die "tone generation failed" | |
| fi | |
| echo "tone: $TONE" | |
| $MK create || die "multi-output setup failed" | |
| echo "SETUP DONE. System output = DuckMeasure (headphones + BlackHole)." | |
| } | |
| # $1 = wav → echoes "rms peak" in dB (overall = last matching line) | |
| rms_of() { | |
| local out rms peak | |
| out=$(ffmpeg -hide_banner -i "$1" -filter:a astats=metadata=1 -f null - 2>&1) | |
| rms=$(echo "$out" | grep "RMS level dB" | tail -1 | awk '{print $NF}') | |
| peak=$(echo "$out" | grep "Peak level dB" | tail -1 | awk '{print $NF}') | |
| echo "$rms $peak" | |
| } | |
| cmd_take() { | |
| local name="${1:-}" | |
| [[ -n $name ]] || die "usage: $0 take <name> (e.g. take baseline)" | |
| local wav=/tmp/duck-take-$name.wav | |
| echo "playing tone, recording 10 s → $wav (tone audible ~13 s)" | |
| afplay "$TONE" & | |
| local pid=$! | |
| sleep 3 | |
| ffmpeg -hide_banner -loglevel error -f avfoundation -i ":BlackHole 16ch" \ | |
| -t $DUR -ac 2 -ar $RATE "$wav" -y || { | |
| kill $pid 2>/dev/null | |
| die "record failed" | |
| } | |
| kill $pid 2>/dev/null | |
| local parts=($(rms_of "$wav")) # zsh arrays are 1-based: parts[1]=rms parts[2]=peak | |
| local crest=$(python3 -c "print(round(float('${parts[2]}') - float('${parts[1]}'), 2))") | |
| echo "take $name: RMS ${parts[1]} dB, peak ${parts[2]} dB, crest ${crest} dB" | |
| echo "RMS ${parts[1]} peak ${parts[2]} crest ${crest}" >/tmp/duck-take-$name.txt | |
| echo "(crest ≈3 dB = pure tone. Much higher = something else played along — re-take.)" | |
| } | |
| cmd_results() { | |
| local base="${1:-}" call="${2:-}" | |
| [[ -n $base && -n $call ]] || die "usage: $0 results <base.wav> <call.wav>" | |
| [[ -f $base && -f $call ]] || die "file(s) missing" | |
| local b=($(rms_of "$base")) c=($(rms_of "$call")) | |
| local duck=$(python3 -c "print(round(float('${b[1]}') - float('${c[1]}'), 2))") | |
| echo "baseline : $base RMS ${b[1]} dB (peak ${b[2]})" | |
| echo "in-call : $call RMS ${c[1]} dB (peak ${c[2]})" | |
| echo "DUCK DEPTH = ${duck} dB → unduck gain: /tmp/unduck gain ${duck}" | |
| if [[ -f /tmp/duck-take-incall1.wav && -f /tmp/duck-take-incall2.wav ]]; then | |
| local t1=($(rms_of /tmp/duck-take-incall1.wav)) t2=($(rms_of /tmp/duck-take-incall2.wav)) | |
| local drift=$(python3 -c "print(round(abs(float('${t1[1]}') - float('${t2[1]}')), 3))") | |
| echo "stability (incall1 vs incall2): ${drift} dB apart (≈0 = static, repeatable duck)" | |
| fi | |
| } | |
| cmd_restore() { | |
| $MK restore || die "restore failed" | |
| echo "RESTORED. (Spotify still paused — press play yourself.)" | |
| } | |
| case ${1:-guide} in | |
| guide) cmd_guide ;; | |
| check) cmd_check ;; | |
| setup) cmd_setup ;; | |
| take) cmd_take "${2:-}" ;; | |
| results) cmd_results "${2:-}" "${3:-}" ;; | |
| restore) cmd_restore ;; | |
| *) | |
| echo "usage: $0 guide|check|setup|take <name>|results <base.wav> <call.wav>|restore" | |
| exit 1 | |
| ;; | |
| esac |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env swift | |
| // unduck.swift — single-file port of https://github.com/SidPad03/unduck | |
| // | |
| // Strategy (from repo README + phase0/spike-results.md): | |
| // FaceTime's VoiceProcessingIO applies a STATIC duck to all media | |
| // (~16 dB measured here on macOS 27; repo measured ~25 dB on 26.5 — | |
| // per-device/per-OS, see kDuckDB). | |
| // While ON and a call is active: tap every app EXCEPT the call + self, | |
| // mute the originals, re-inject media boosted by the calibrated amount so | |
| // it lands back at normal level after the system attenuates it. | |
| // Call voice stays untouched. | |
| // Gain is unity outside calls, so leaving it ON is transparent. | |
| // | |
| // Maps to repo files: | |
| // AudioRouter.swift -> Engine.build()/teardown() (tap + private aggregate + IOProc, fail-open) | |
| // BufferGeometry.swift -> locate()/silence() | |
| // CoreAudio.swift -> getString()/getScalar()/defaultOutput()/etc. | |
| // CallDetector.swift -> callActive() poll (avconferenced mic running) | |
| // CUnduckRender -> inline gain ramp + duck-aware limiter ceiling | |
| // | |
| // Usage: | |
| // swiftc -O -framework CoreAudio -framework Foundation unduck.swift -o /tmp/unduck | |
| // /tmp/unduck on [--gain 25] [--manual] # --manual: stay at unity until `boost` | |
| // /tmp/unduck boost | unboost | auto # manual latch (use when FaceTime is always open) | |
| // /tmp/unduck off | toggle | status | |
| // | |
| // Requires macOS 14.2+ for process taps; repo gates real routing at 26.1. | |
| // First run: allow "System Audio Recording" when prompted. | |
| import CoreAudio | |
| import CoreGraphics | |
| import Dispatch | |
| import Foundation | |
| import Synchronization | |
| import os | |
| let kPidFile = "/tmp/unduck.pid" | |
| let kCmdFile = "/tmp/unduck.cmd" // manual override: "boost" | "unity" | "auto" | |
| let kGainFile = "/tmp/unduck.gain" // live boost in dB, source of truth once daemon runs | |
| let kStateFile = "/tmp/unduck.state" | |
| let kLogFile = "/tmp/unduck.log" // daemon log: spawnDaemon redirects stdio to null, so print() is invisible | |
| let kDuckDB: Float = 16.0 // measured on macOS 27 via External Headphones (2026-09-17); repo measured 25 dB on 26.5 | |
| // Safety: world-writable /tmp/unduck.gain lets ANY local process request up to | |
| // 40 dB of full-scale square-wave into ears/speakers. Clamp live values here. | |
| let kMaxBoostDB: Float = 20.0 | |
| let kEpsilon: Float = 1e-4 | |
| // OS-DEPENDENT ASSUMPTIONS (baked in, not just comments — see call sites): | |
| // 1. Duck size varies by OS version AND output device (16 dB here, 25 dB on | |
| // 26.5). Never trust kDuckDB blindly; `probe` + per-UID calibration is the | |
| // fix (TODO: gains.plist). Every applied gain is clamped to kMaxBoostDB. | |
| // 2. A global tap's exclusion list freezes at creation. A call app launched | |
| // AFTER build() is tapped/muted/boosted until we rebuild. We poll the | |
| // process set and rebuild on change; on macOS 26+ prefer | |
| // CATapDescription.bundleIDs + isProcessRestoreEnabled so the tap survives | |
| // app restarts (see build()). | |
| // 3. mic-open != call. Muted-but-open, ringing, previews, browser tabs | |
| // (Meet exposes the BROWSER bundle), Shazam, Music recognition, etc. all | |
| // look identical via IsRunningInput. Tier-2 detection is heuristic; | |
| // `reason=` is debug. | |
| // 4. Tap/aggregate stream format is assumed Float32 (true on all taps seen so | |
| // far). build() now VERIFIES via kAudioTapPropertyFormat and fail-opens | |
| // instead of corrupting memory if the OS ever hands us Int16/Float64. | |
| let kCommIDs: Set<String> = ["com.apple.avconferenced", "com.apple.FaceTime"] | |
| // Mic holders that should NEVER trigger auto-boost (assistant/dictation/memo: | |
| // mic open but no call duck to compensate). | |
| let kMicDenyIDs: Set<String> = [ | |
| "com.apple.Siri", "com.apple.assistant", "com.apple.Dictation", | |
| "com.apple.VoiceMemos", "com.apple.VoiceControl", | |
| ] | |
| // MARK: HAL helpers (CoreAudio.swift) | |
| func addr(_ sel: AudioObjectPropertySelector, _ scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal) -> AudioObjectPropertyAddress { | |
| AudioObjectPropertyAddress(mSelector: sel, mScope: scope, mElement: kAudioObjectPropertyElementMain) | |
| } | |
| func getString(_ obj: AudioObjectID, _ sel: AudioObjectPropertySelector) -> String? { | |
| var a = addr(sel); var size = UInt32(MemoryLayout<CFString?>.size) | |
| var v: Unmanaged<CFString>? | |
| guard AudioObjectGetPropertyData(obj, &a, 0, nil, &size, &v) == noErr, let v else { return nil } | |
| return v.takeRetainedValue() as String | |
| } | |
| func getScalar<T>(_ obj: AudioObjectID, _ sel: AudioObjectPropertySelector, _ init: T, scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal) -> T? { | |
| var a = addr(sel, scope); var v = `init`; var z = UInt32(MemoryLayout<T>.size) | |
| let s: OSStatus = withUnsafeMutablePointer(to: &v) { AudioObjectGetPropertyData(obj, &a, 0, nil, &z, $0) } | |
| return s == noErr ? v : nil | |
| } | |
| func defaultOutput() -> AudioObjectID { | |
| getScalar(AudioObjectID(kAudioObjectSystemObject), kAudioHardwarePropertyDefaultOutputDevice, AudioObjectID(0)) ?? kAudioObjectUnknown | |
| } | |
| func deviceUID(_ d: AudioObjectID) -> String? { getString(d, kAudioDevicePropertyDeviceUID) } | |
| func processObjects() -> [AudioObjectID] { | |
| var a = addr(kAudioHardwarePropertyProcessObjectList); var size: UInt32 = 0 | |
| guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &a, 0, nil, &size) == noErr, size > 0 else { return [] } | |
| let n = Int(size) / MemoryLayout<AudioObjectID>.size | |
| var ids = [AudioObjectID](repeating: kAudioObjectUnknown, count: n) | |
| guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &a, 0, nil, &size, &ids) == noErr else { return [] } | |
| return ids | |
| } | |
| func objectForPID(_ pid: pid_t) -> AudioObjectID { | |
| var a = addr(kAudioHardwarePropertyTranslatePIDToProcessObject) | |
| var p = pid; var oid = kAudioObjectUnknown; var z = UInt32(MemoryLayout<AudioObjectID>.size) | |
| let s = withUnsafePointer(to: &p) { | |
| AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &a, UInt32(MemoryLayout<pid_t>.size), $0, &z, &oid) | |
| } | |
| return s == noErr ? oid : kAudioObjectUnknown | |
| } | |
| // CallDetector.swift: call = mic running. | |
| // Tier 1: FaceTime/avconferenced (original behavior). | |
| // Tier 2: ANY other process holding input (Zoom/Meet/Teams/Discord/…) unless | |
| // denylisted (Siri/dictation/memos). Returns the triggering bundle for logging. | |
| // | |
| // VERIFICATION STATUS (unverified at time of writing — typechecks only): | |
| // - Assumes mic-open == system duck active. Muted-but-open calls, ringing, | |
| // previews, and browser-based Meet (browser bundle, not Meet) are | |
| // indistinguishable here and will (mis)trigger. Treat `reason=` in state as debug. | |
| // - Assumes third-party VoIP exposes IsRunningInput the same way FaceTime does; | |
| // not measured live against Zoom/Teams/Discord on macOS 27 yet. | |
| // - Assumes denylist is sufficient; new assistants/dictation agents may need adds. | |
| // - Faster engage (1 streak ≈0.5s) trades earlier compensation for more blip risk; | |
| // widen back to 2 if false boosts appear. Validate with: `probe` + live call, | |
| // watch `status` raw/reason/call. | |
| // CALL `status` TO CONFIRM BEFORE TRUSTING AUTO ON A NEW APP/OS. | |
| func callReason() -> String? { | |
| var fallback: String? | |
| for oid in processObjects() { | |
| guard let b = getString(oid, kAudioProcessPropertyBundleID) else { continue } | |
| guard (getScalar(oid, kAudioProcessPropertyIsRunningInput, UInt32(0)) ?? 0) != 0 else { continue } | |
| if kMicDenyIDs.contains(b) { continue } | |
| if kCommIDs.contains(b) { return b } // Apple call path wins immediately | |
| if fallback == nil { fallback = b } | |
| } | |
| return fallback | |
| } | |
| func callActive() -> Bool { callReason() != nil } | |
| func commProcessObjects() -> [AudioObjectID] { | |
| processObjects().filter { getString($0, kAudioProcessPropertyBundleID).map(kCommIDs.contains) ?? false } | |
| } | |
| // Tap-exclusion parity with detection: every mic holder minus denylist must be | |
| // excluded from the tap, or a Zoom/etc. voice would be muted+boosted instead of | |
| // passing through untouched. Union with comm objects so a call app is excluded | |
| // even before its mic runs. | |
| // | |
| // CAVEAT (same verification gap as callReason): browser-based calls expose the | |
| // BROWSER bundle (e.g. com.google.Chrome), not "Meet". Exclusion still works as | |
| // long as that browser process is the mic holder — confirm via `status reason=` | |
| // on a live call — but two tabs holding input (Meet + dictation) are | |
| // indistinguishable. Rebuilds on mic-holder change drop audio briefly by design. | |
| func callProcessObjects() -> [AudioObjectID] { | |
| processObjects().filter { | |
| guard let b = getString($0, kAudioProcessPropertyBundleID) else { return false } | |
| if kMicDenyIDs.contains(b) { return false } | |
| return (getScalar($0, kAudioProcessPropertyIsRunningInput, UInt32(0)) ?? 0) != 0 | |
| } | |
| } | |
| func tapExcludeObjects() -> [AudioObjectID] { | |
| Array(Set(commProcessObjects() + callProcessObjects())) | |
| } | |
| func stereoChannels(_ dev: AudioObjectID) -> (Int, Int) { | |
| var a = addr(kAudioDevicePropertyPreferredChannelsForStereo, kAudioObjectPropertyScopeOutput) | |
| var size: UInt32 = 0 | |
| guard AudioObjectGetPropertyDataSize(dev, &a, 0, nil, &size) == noErr, size >= 8 else { return (0, 1) } | |
| var pair = [UInt32](repeating: 0, count: 2) | |
| guard AudioObjectGetPropertyData(dev, &a, 0, nil, &size, &pair) == noErr, pair[0] >= 1, pair[1] >= 1 else { return (0, 1) } | |
| return (Int(pair[0]) - 1, Int(pair[1]) - 1) | |
| } | |
| func outputChannelCount(_ dev: AudioObjectID) -> Int { | |
| var a = addr(kAudioDevicePropertyStreamConfiguration, kAudioObjectPropertyScopeOutput) | |
| var size: UInt32 = 0 | |
| guard AudioObjectGetPropertyDataSize(dev, &a, 0, nil, &size) == noErr, size > 0 else { return 0 } | |
| let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment) | |
| defer { raw.deallocate() } | |
| guard AudioObjectGetPropertyData(dev, &a, 0, nil, &size, raw) == noErr else { return 0 } | |
| return UnsafeMutableAudioBufferListPointer(raw.assumingMemoryBound(to: AudioBufferList.self)).reduce(0) { $0 + Int($1.mNumberChannels) } | |
| } | |
| // MARK: daemon observability (fixes "logs go nowhere") | |
| private let unduckLog = Logger(subsystem: "com.unduck", category: "daemon") | |
| /// print() + os_log + append to kLogFile. Daemon stdio is /dev/null, so this | |
| /// is the only channel that survives daemonization. Must NEVER be called on | |
| /// the audio render thread (file I/O + locking). | |
| func dlog(_ msg: String) { | |
| print(msg) | |
| unduckLog.notice("\(msg, privacy: .public)") | |
| let line = "\(ISO8601DateFormatter().string(from: Date())) \(msg)\n" | |
| if let data = line.data(using: .utf8) { | |
| // Rotate at ~1MB to avoid unbounded growth. | |
| if let attrs = try? FileManager.default.attributesOfItem(atPath: kLogFile), | |
| (attrs[.size] as? Int ?? 0) > 1_000_000 { | |
| try? FileManager.default.removeItem(atPath: kLogFile) | |
| } | |
| if !FileManager.default.fileExists(atPath: kLogFile) { | |
| FileManager.default.createFile(atPath: kLogFile, contents: nil) | |
| } | |
| if let fh = try? FileHandle(forWritingTo: URL(fileURLWithPath: kLogFile)) { | |
| _ = try? fh.seekToEnd() | |
| try? fh.write(contentsOf: data) | |
| try? fh.close() | |
| } | |
| } | |
| } | |
| /// Clamp a requested boost to the safety ceiling, logging when clamped. | |
| func clampedBoostDB(_ db: Float) -> Float { | |
| if db > kMaxBoostDB { | |
| dlog("gain \(db)dB exceeds safety cap \(kMaxBoostDB)dB — clamped") | |
| return kMaxBoostDB | |
| } | |
| return max(0, db) | |
| } | |
| /// Verify the tap speaks Float32 before we `assumingMemoryBound(to: Float.self)` | |
| /// anywhere. OS-dependent note #4: true on every tap observed, but corrupt- | |
| /// memory-if-wrong is not a bet worth taking — fail open instead. | |
| func tapIsFloat32(_ tap: AudioObjectID) -> Bool { | |
| var a = addr(kAudioTapPropertyFormat) | |
| var fmt = AudioStreamBasicDescription() | |
| var z = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) | |
| guard AudioObjectGetPropertyData(tap, &a, 0, nil, &z, &fmt) == noErr else { | |
| return false // unknown format: refuse, don't guess | |
| } | |
| return fmt.mFormatID == kAudioFormatLinearPCM | |
| && (fmt.mFormatFlags & kAudioFormatFlagIsFloat) != 0 | |
| && fmt.mBitsPerChannel == 32 | |
| } | |
| /// Soft-knee limiter: transparent below ceiling, continuous value AND slope at | |
| /// the knee, asymptotes to 2*ceiling. Replaces the old hard clipper | |
| /// `max(-c,min(c,x))` which turned hot music into a full-scale square wave. | |
| @inline(__always) func softLimit(_ x: Float, _ ceil: Float) -> Float { | |
| let a = abs(x) | |
| if a <= ceil { return x } | |
| let over = a - ceil | |
| let limited = ceil + over / (1 + over / ceil) | |
| return x > 0 ? limited : -limited | |
| } | |
| // MARK: BufferGeometry.swift | |
| struct Chan { var base: UnsafeMutablePointer<Float>?; var stride = 0; var frames = 0 } | |
| func locate(_ list: UnsafeMutableAudioBufferListPointer, _ ch: Int) -> Chan { | |
| guard ch >= 0 else { return Chan() } | |
| var rem = ch | |
| for i in 0..<list.count { | |
| let b = list[i]; let nch = Int(b.mNumberChannels) | |
| guard nch > 0 else { continue } | |
| if rem < nch { | |
| guard let base = b.mData?.assumingMemoryBound(to: Float.self) else { return Chan() } | |
| return Chan(base: base + rem, stride: nch, frames: Int(b.mDataByteSize) / (nch * 4)) | |
| } | |
| rem -= nch | |
| } | |
| return Chan() | |
| } | |
| func silence(_ list: UnsafeMutableAudioBufferListPointer) { | |
| for i in 0..<list.count { if let d = list[i].mData { memset(d, 0, Int(list[i].mDataByteSize)) } } | |
| } | |
| // MARK: AudioRouter.swift — tap + aggregate + IOProc | |
| /// Reference-type holder so the IOProc can capture render state without | |
| /// touching `self` per callback. Atomic<T> is non-copyable (~Copyable), so it | |
| /// cannot be assigned into closure captures directly — this box is. | |
| final class RenderState: @unchecked Sendable { | |
| let targetGain = Atomic<Float>(1) | |
| let gainCarry = Atomic<Float>(1) // smoothed gain carried across callbacks | |
| let ceiling = Atomic<Float>(pow(10, (min(kDuckDB, kMaxBoostDB) - 0.5) / 20)) | |
| let rampCoeff = Atomic<Float>(1 - exp(-1 / (48000 * 0.030))) // ~30ms smoothing | |
| let callbackCount = Atomic<UInt64>(0) | |
| let peakMeter = Atomic<Float>(0) // max |input| seen (decayed) | |
| } | |
| final class Engine: @unchecked Sendable { | |
| // Render-hot state: lock-free Atomics. The IOProc does exactly ONE relaxed | |
| // load per field at callback entry and ONE store at exit — it NEVER locks, | |
| // allocates, or touches the filesystem (realtime requirement). | |
| let rs = RenderState() | |
| // Control-thread state: only touched on poll/main queues, never on the | |
| // render thread. graphLock serializes build/teardown/rebuild vs quit. | |
| let graphLock = NSLock() | |
| var boostGain: Float = pow(10, min(kDuckDB, kMaxBoostDB) / 20) | |
| var inCall = false // poll-queue owned; render thread reads targetGain instead | |
| var meterOnly = false // set BEFORE build(), never mutated while IOProc runs | |
| var tapID = kAudioObjectUnknown | |
| var aggID = kAudioObjectUnknown | |
| var proc: AudioDeviceIOProcID? | |
| var desc: CATapDescription? | |
| /// Keep limiter ceiling in sync with boost: -0.5 dBFS headroom above nominal. | |
| /// Control thread only; publishes via Atomics for the render thread. | |
| func setBoostGain(_ g: Float) { | |
| boostGain = g | |
| rs.ceiling.store(g * pow(10, -0.5 / 20), ordering: .relaxed) | |
| } | |
| func setBoostDB(_ db: Float) { setBoostGain(pow(10, clampedBoostDB(db) / 20)) } | |
| func build(muted: Bool = true) throws { | |
| // Make-before-break: stash the live graph and only destroy it AFTER | |
| // the replacement is running. Old code called teardown() first, so a | |
| // failed rebuild left the user with NO graph (fail-closed). | |
| // Serialize against teardown() (quit path on main vs rebuild on poll | |
| // queue). Held for the whole build; quit waits ~100ms worst case. | |
| // Safe: build() never calls teardown(), so no self-deadlock. | |
| graphLock.lock(); defer { graphLock.unlock() } | |
| let oldTap = tapID, oldAgg = aggID, oldProc = proc, oldDesc = desc | |
| tapID = kAudioObjectUnknown; aggID = kAudioObjectUnknown; proc = nil; desc = nil | |
| func destroyOld() { | |
| if let p = oldProc, oldAgg != kAudioObjectUnknown { AudioDeviceStop(oldAgg, p); AudioDeviceDestroyIOProcID(oldAgg, p) } | |
| if oldAgg != kAudioObjectUnknown { AudioHardwareDestroyAggregateDevice(oldAgg) } | |
| if oldTap != kAudioObjectUnknown { AudioHardwareDestroyProcessTap(oldTap) } | |
| } | |
| func restoreOldOnFailure() { | |
| tapID = oldTap; aggID = oldAgg; proc = oldProc; desc = oldDesc | |
| } | |
| guard #available(macOS 14.2, *) else { restoreOldOnFailure(); throw NSError(domain: "unduck", code: 1, userInfo: [NSLocalizedDescriptionKey: "needs macOS 14.2+"]) } | |
| let out = defaultOutput() | |
| guard out != kAudioObjectUnknown, let uid = deviceUID(out) else { restoreOldOnFailure(); throw NSError(domain: "unduck", code: 2, userInfo: [NSLocalizedDescriptionKey: "no default output"]) } | |
| var exclude = tapExcludeObjects() | |
| let me = objectForPID(getpid()) | |
| if me != kAudioObjectUnknown { exclude.append(me) } | |
| // 1) tap everything EXCEPT call + self. Do NOT touch isExclusive | |
| // afterward or the set inverts (silent failure). | |
| // muted=false leaves originals playing (probe/meter mode). | |
| // OS-dependent #2: exclusion snapshots freeze at creation — a call | |
| // app launched later needs a rebuild (poll loop). On 26+ also set | |
| // bundleIDs + process-restore so the tap survives app restarts. | |
| let d = CATapDescription(stereoGlobalTapButExcludeProcesses: exclude) | |
| d.uuid = UUID(); d.name = "Unduck"; d.isPrivate = true | |
| d.muteBehavior = muted ? .mutedWhenTapped : .unmuted | |
| if #available(macOS 26, *) { | |
| d.bundleIDs = Array(Set( | |
| (commProcessObjects() + callProcessObjects()).compactMap { | |
| getString($0, kAudioProcessPropertyBundleID) | |
| } + kCommIDs | |
| )) | |
| d.isProcessRestoreEnabled = true | |
| } | |
| desc = d | |
| var tap = kAudioObjectUnknown | |
| var s = AudioHardwareCreateProcessTap(d, &tap) | |
| guard s == noErr, tap != kAudioObjectUnknown else { restoreOldOnFailure(); throw NSError(domain: "unduck", code: 3, userInfo: [NSLocalizedDescriptionKey: "CreateProcessTap failed (\(s))"]) } | |
| // OS-dependent #4: VERIFY Float32 — locate() uses | |
| // assumingMemoryBound(to: Float.self) and would corrupt on Int16/F64. | |
| guard tapIsFloat32(tap) else { | |
| AudioHardwareDestroyProcessTap(tap) | |
| restoreOldOnFailure() | |
| throw NSError(domain: "unduck", code: 3, userInfo: [NSLocalizedDescriptionKey: "tap is not Float32 — refusing (fail-open)"]) | |
| } | |
| tapID = tap | |
| // The aggregate must reference the tap's UID *property*, not just the | |
| // UUID we assigned — otherwise it silently captures nothing. | |
| let tapUID = getString(tap, kAudioTapPropertyUID) ?? d.uuid.uuidString | |
| // 2) private aggregate: real output as main sub-device + tap, same clock. | |
| let aggDesc: [String: Any] = [ | |
| kAudioAggregateDeviceNameKey as String: "Unduck", | |
| kAudioAggregateDeviceUIDKey as String: "com.unduck.repo-toggle.\(UUID().uuidString)", | |
| kAudioAggregateDeviceIsPrivateKey as String: true, | |
| kAudioAggregateDeviceIsStackedKey as String: false, | |
| kAudioAggregateDeviceMainSubDeviceKey as String: uid, | |
| kAudioAggregateDeviceTapAutoStartKey as String: true, | |
| kAudioAggregateDeviceSubDeviceListKey as String: [[kAudioSubDeviceUIDKey as String: uid]], | |
| kAudioAggregateDeviceTapListKey as String: [[ | |
| kAudioSubTapDriftCompensationKey as String: true, | |
| kAudioSubTapUIDKey as String: tapUID, | |
| ]], | |
| ] | |
| var agg = kAudioObjectUnknown | |
| s = AudioHardwareCreateAggregateDevice(aggDesc as CFDictionary, &agg) | |
| guard s == noErr, agg != kAudioObjectUnknown else { | |
| AudioHardwareDestroyProcessTap(tap); restoreOldOnFailure() | |
| throw NSError(domain: "unduck", code: 4, userInfo: [NSLocalizedDescriptionKey: "CreateAggregate failed (\(s))"]) | |
| } | |
| aggID = agg | |
| if let rate = getScalar(agg, kAudioDevicePropertyNominalSampleRate, Double(0)), rate > 0 { | |
| rs.rampCoeff.store(1 - exp(-1 / (Float(rate) * 0.030)), ordering: .relaxed) | |
| } | |
| let nch = outputChannelCount(agg) | |
| guard nch > 0 else { | |
| AudioHardwareDestroyAggregateDevice(agg); aggID = kAudioObjectUnknown | |
| AudioHardwareDestroyProcessTap(tap); tapID = kAudioObjectUnknown | |
| restoreOldOnFailure() | |
| throw NSError(domain: "unduck", code: 4, userInfo: [NSLocalizedDescriptionKey: "aggregate has 0 output channels"]) | |
| } | |
| var (L, R) = stereoChannels(agg) | |
| if L >= nch || R >= nch { L = 0; R = min(1, nch - 1) } | |
| // 3) IOProc: tap in -> boosted out. REALTIME RULES: no locks, no | |
| // allocation, no I/O, no Swift ARC traffic. Exactly one relaxed | |
| // Atomic load per field at entry, one store at exit. `meterOnly` is | |
| // captured by value (set before build, never mutated while running). | |
| let meterSnapshot = meterOnly | |
| var procID: AudioDeviceIOProcID? | |
| // Capture the RenderState box (reference type), not self: the closure | |
| // then borrows box.targetGain etc. per callback without moving the | |
| // non-copyable Atomics and without per-callback ARC traffic on self. | |
| let box = rs | |
| // NOTE: a silent tap (no input buffers) with a MUTED tap leaves outs | |
| // silent by design here; the control-thread watchdog (runDaemon) tells | |
| // silence-vs-fault apart via peakMeter and warns instead of mutating. | |
| s = AudioDeviceCreateIOProcIDWithBlock(&procID, agg, nil) { _, inInputData, _, outOutputData, _ in | |
| let outs = UnsafeMutableAudioBufferListPointer(outOutputData) | |
| silence(outs) | |
| let oL = locate(outs, L) | |
| guard let oLb = oL.base, oL.frames > 0 else { | |
| // Still advance counters so probe/watchdog see liveness. | |
| box.callbackCount.wrappingAdd(1, ordering: .relaxed) | |
| return | |
| } | |
| let oR = L == R ? Chan() : locate(outs, R) // mono sink -> downmix below | |
| let nOut = oR.base == nil ? oL.frames : min(oL.frames, oR.frames) | |
| var iL = Chan(), iR = Chan(), nIn = nOut | |
| do { | |
| // NOTE: mutating cast of const input, but treated READ-ONLY | |
| // (the SDK exposes no const AudioBufferList view; we never write). | |
| let ins = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inInputData)) | |
| if !ins.isEmpty { | |
| iL = locate(ins, 0); iR = locate(ins, 1) | |
| nIn = iL.base == nil ? nOut : (iR.base == nil ? iL.frames : min(iL.frames, iR.frames)) | |
| } | |
| } | |
| let tgt = box.targetGain.load(ordering: .relaxed) | |
| let ceil = box.ceiling.load(ordering: .relaxed) | |
| let rate = box.rampCoeff.load(ordering: .relaxed) | |
| var g = box.gainCarry.load(ordering: .relaxed) | |
| let frames = min(nIn, nOut) | |
| guard frames > 0 else { | |
| box.gainCarry.store(g, ordering: .relaxed) | |
| box.callbackCount.wrappingAdd(1, ordering: .relaxed) | |
| return | |
| } | |
| // Hoist raw pointers/strides out of the per-sample loop (no optionals inside). | |
| let oLs = oL.stride | |
| let oRb = oR.base | |
| let oRs = oR.stride | |
| let iLb = iL.base | |
| let iRb = iR.base | |
| let iLs = iL.stride | |
| let iRs = iR.stride | |
| var peak: Float = 0 | |
| if meterSnapshot { | |
| // Probe: originals still play (unmuted tap); leave the aggregate | |
| // outputs silent and just report what the tap hears. | |
| if let iLb { | |
| for f in 0..<frames { | |
| let a = abs((iLb + f * iLs).pointee) | |
| if a > peak { peak = a } | |
| if let iRb { | |
| let b = abs((iRb + f * iRs).pointee) | |
| if b > peak { peak = b } | |
| } | |
| } | |
| } | |
| let old = box.peakMeter.load(ordering: .relaxed) | |
| box.peakMeter.store(max(old * 0.999, peak), ordering: .relaxed) | |
| box.callbackCount.wrappingAdd(1, ordering: .relaxed) | |
| return | |
| } | |
| if let oRb { | |
| // Stereo sink: fused meter + render in one pass. Branch on | |
| // input topology OUTSIDE the loop (no per-sample optionals). | |
| if let iLb, let iRb { | |
| for f in 0..<frames { | |
| g += (tgt - g) * rate | |
| var l = (iLb + f * iLs).pointee; var r = (iRb + f * iRs).pointee | |
| if !l.isFinite { l = 0 }; if !r.isFinite { r = 0 } | |
| let a = abs(l); if a > peak { peak = a } | |
| let b = abs(r); if b > peak { peak = b } | |
| (oLb + f * oLs).pointee = softLimit(l * g, ceil) | |
| (oRb + f * oRs).pointee = softLimit(r * g, ceil) | |
| } | |
| } else if let iLb { | |
| // Mono tap -> stereo out: duplicate L. | |
| for f in 0..<frames { | |
| g += (tgt - g) * rate | |
| var l = (iLb + f * iLs).pointee | |
| if !l.isFinite { l = 0 } | |
| let a = abs(l); if a > peak { peak = a } | |
| (oLb + f * oLs).pointee = softLimit(l * g, ceil) | |
| (oRb + f * oRs).pointee = softLimit(l * g, ceil) | |
| } | |
| } else { | |
| for _ in 0..<frames { g += (tgt - g) * rate } | |
| } | |
| } else if let iLb { | |
| // Mono sink downmix. | |
| if let iRb { | |
| for f in 0..<frames { | |
| g += (tgt - g) * rate | |
| var l = (iLb + f * iLs).pointee; var r = (iRb + f * iRs).pointee | |
| if !l.isFinite { l = 0 }; if !r.isFinite { r = 0 } | |
| let a = abs(l); if a > peak { peak = a } | |
| let b = abs(r); if b > peak { peak = b } | |
| (oLb + f * oLs).pointee = softLimit((l * 0.5 + r * 0.5) * g, ceil) | |
| } | |
| } else { | |
| for f in 0..<frames { | |
| g += (tgt - g) * rate | |
| var l = (iLb + f * iLs).pointee | |
| if !l.isFinite { l = 0 } | |
| let a = abs(l); if a > peak { peak = a } | |
| (oLb + f * oLs).pointee = softLimit(l * g, ceil) | |
| } | |
| } | |
| } else { | |
| // No tap input: advance the ramp so re-engage doesn't zipper. | |
| for _ in 0..<frames { g += (tgt - g) * rate } | |
| } | |
| box.gainCarry.store(g, ordering: .relaxed) | |
| box.callbackCount.wrappingAdd(1, ordering: .relaxed) | |
| let old = box.peakMeter.load(ordering: .relaxed) | |
| box.peakMeter.store(max(old * 0.999, peak), ordering: .relaxed) | |
| } | |
| guard s == noErr, let p = procID else { | |
| AudioHardwareDestroyAggregateDevice(agg); aggID = kAudioObjectUnknown | |
| AudioHardwareDestroyProcessTap(tap); tapID = kAudioObjectUnknown | |
| restoreOldOnFailure() | |
| throw NSError(domain: "unduck", code: 5, userInfo: [NSLocalizedDescriptionKey: "CreateIOProc failed (\(s))"]) | |
| } | |
| proc = p | |
| s = AudioDeviceStart(agg, p) | |
| guard s == noErr else { | |
| AudioDeviceDestroyIOProcID(agg, p); proc = nil | |
| AudioHardwareDestroyAggregateDevice(agg); aggID = kAudioObjectUnknown | |
| AudioHardwareDestroyProcessTap(tap); tapID = kAudioObjectUnknown | |
| restoreOldOnFailure() | |
| throw NSError(domain: "unduck", code: 6, userInfo: [NSLocalizedDescriptionKey: "AudioDeviceStart failed (\(s)) — allow System Audio Recording, then retry"]) | |
| } | |
| // New graph is RUNNING: now safe to destroy the old one. | |
| destroyOld() | |
| dlog("graph up (boost=\(String(format: "%.1f", 20 * log10(boostGain)))dB, stereo \(L)/\(R), ch=\(nch))") | |
| } | |
| func teardown() { | |
| graphLock.withLock { | |
| if let p = proc, aggID != kAudioObjectUnknown { AudioDeviceStop(aggID, p); AudioDeviceDestroyIOProcID(aggID, p) } | |
| proc = nil | |
| // Destroy the aggregate before the tap it references (destruction is | |
| // async per docs); either order fail-opens because the tap un-mutes. | |
| if aggID != kAudioObjectUnknown { AudioHardwareDestroyAggregateDevice(aggID); aggID = kAudioObjectUnknown } | |
| if tapID != kAudioObjectUnknown { AudioHardwareDestroyProcessTap(tapID); tapID = kAudioObjectUnknown } | |
| desc = nil | |
| } | |
| } | |
| } | |
| // MARK: daemon / toggle CLI | |
| func readPid() -> pid_t? { | |
| guard let s = try? String(contentsOfFile: kPidFile, encoding: .utf8), | |
| let p = Int32(s.trimmingCharacters(in: .whitespacesAndNewlines)), p > 0, kill(p, 0) == 0 else { return nil } | |
| return p | |
| } | |
| func spawnDaemon(extra: [String]) { | |
| let me = CommandLine.arguments[0] | |
| let p = Process() | |
| if me.hasSuffix(".swift") { | |
| p.executableURL = URL(fileURLWithPath: "/usr/bin/env") | |
| p.arguments = ["swift", me, "--daemon"] + extra | |
| } else { | |
| p.executableURL = URL(fileURLWithPath: me) | |
| p.arguments = ["--daemon"] + extra | |
| } | |
| p.standardOutput = FileHandle.nullDevice; p.standardError = FileHandle.nullDevice | |
| try? p.run() | |
| // Poll for the pidfile instead of a fixed sleep: fast machines waste a | |
| // second, slow machines race. | |
| for _ in 0..<30 { | |
| usleep(100_000) | |
| if readPid() != nil { break } | |
| } | |
| } | |
| func storedGainDB() -> Float { | |
| if let gs = try? String(contentsOfFile: kGainFile, encoding: .utf8), | |
| let gdb = Float(gs.trimmingCharacters(in: .whitespacesAndNewlines)), gdb >= 0, gdb <= 40 { | |
| return min(gdb, kMaxBoostDB) // safety cap: file is world-writable | |
| } | |
| return min(kDuckDB, kMaxBoostDB) | |
| } | |
| func cmdOff() { | |
| guard let p = readPid() else { print("already OFF"); return } | |
| kill(p, SIGTERM); usleep(600_000) | |
| if kill(p, 0) != 0 { | |
| try? FileManager.default.removeItem(atPath: kPidFile) | |
| try? FileManager.default.removeItem(atPath: kStateFile) | |
| try? FileManager.default.removeItem(atPath: kCmdFile) | |
| print("unduck OFF (pid \(p) stopped, audio restored)") | |
| } else { print("could not stop pid \(p); try: kill \(p)") } | |
| } | |
| func readCmd() -> String { | |
| ((try? String(contentsOfFile: kCmdFile, encoding: .utf8)) ?? "auto").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| } | |
| func runDaemon(boostDB: Float) { | |
| if let old = readPid() { fputs("already ON (pid \(old))\n", stderr); exit(1) } | |
| let eng = Engine() | |
| eng.setBoostDB(boostDB) // clamped inside | |
| do { try eng.build() } catch { | |
| fputs("start failed: \(error.localizedDescription)\n", stderr) | |
| dlog("start failed: \(error.localizedDescription)") | |
| exit(1) | |
| } | |
| try? "\(getpid())".write(toFile: kPidFile, atomically: true, encoding: .utf8) | |
| // Report the ACTUAL applied gain (setBoostDB clamps + logs if over cap). | |
| dlog("unduck ON pid \(getpid()) boost=\(String(format: "%.1f", 20 * log10(eng.boostGain)))dB (applied during calls only)") | |
| let poll = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "unduck.poll")) | |
| poll.schedule(deadline: .now() + 1, repeating: 0.5) | |
| var trueStreak = 0, falseStreak = 0 | |
| var reportedCall = false | |
| var callStart: Date? | |
| var warnedStuck = false | |
| var warnedSilentBoost = false | |
| var quietPolls = 0 | |
| var lastReason: String? | |
| // Snapshot AFTER build() from the same world the graph was built in — | |
| // the old code snapshotted before/parallel to build (TOCTOU) and always | |
| // triggered one spurious rebuild on the first poll. | |
| var lastExclude: Set<AudioObjectID> = Set(tapExcludeObjects()) | |
| var lastUID: String? = deviceUID(defaultOutput()) | |
| poll.setEventHandler { | |
| let reason = callReason() | |
| let c = reason != nil | |
| if c { lastReason = reason } | |
| let mode = readCmd() | |
| if mode == "boost" { | |
| // Manual latch: FaceTime holds the mic open even when idle, so | |
| // auto-detect can't tell call from no-call. You decide. | |
| if !reportedCall { reportedCall = true; dlog("manual → boosting") } | |
| trueStreak = 0; falseStreak = 0 | |
| } else if mode == "unity" { | |
| if reportedCall { reportedCall = false; dlog("manual → unity") } | |
| trueStreak = 0; falseStreak = 0 | |
| } else { | |
| // Debounced: ~0.5s to engage (30ms ramp already de-clicks), | |
| // ~2s to release, so a transient mic blip can't slam +dB onto music. | |
| if c { trueStreak += 1; falseStreak = 0 } else { falseStreak += 1; trueStreak = 0 } | |
| if !reportedCall, trueStreak >= 1 { reportedCall = true } | |
| else if reportedCall, falseStreak >= 4 { reportedCall = false } | |
| } | |
| // Stuck-mic guard: FaceTime-idle-open or a wedged driver looks like a | |
| // forever call. Nudge toward manual `unity` instead of boosting forever. | |
| if reportedCall, mode == "auto" { | |
| if callStart == nil { callStart = Date(); warnedStuck = false } | |
| if !warnedStuck, let t0 = callStart, Date().timeIntervalSince(t0) > 30 * 60 { | |
| warnedStuck = true | |
| dlog("warning: mic held open >30min (\(lastReason ?? "unknown")) — stuck? run `unboost` if no call") | |
| } | |
| } else { callStart = nil; warnedStuck = false } | |
| // Filesystem reads happen on the poll queue only — the render thread | |
| // never touches files (it only does relaxed Atomic loads). | |
| var liveDB: Float? | |
| if let gs = try? String(contentsOfFile: kGainFile, encoding: .utf8), | |
| let gdb = Float(gs.trimmingCharacters(in: .whitespacesAndNewlines)), gdb >= 0, gdb <= 40 { | |
| liveDB = min(gdb, kMaxBoostDB) | |
| } | |
| // No audio lock: publish via lock-free Atomics. | |
| if reportedCall != eng.inCall { eng.inCall = reportedCall; dlog(reportedCall ? "call detected → boosting" : "call ended → unity") } | |
| // Live gain: `gain N` rewrites the file, applied here within ~0.5s. | |
| if let gdb = liveDB { | |
| let g = pow(10, gdb / 20) | |
| if abs(g - eng.boostGain) > kEpsilon { eng.setBoostGain(g); dlog("gain → \(gdb)dB") } | |
| } | |
| eng.rs.targetGain.store(reportedCall ? eng.boostGain : 1.0, ordering: .relaxed) | |
| // Silence-vs-fault watchdog (fail-OPEN philosophy): boosting with a | |
| // dead-silent tap for 5s straight means permission loss or a routing | |
| // fault, not quiet music. Warn loudly; don't auto-destroy the graph | |
| // mid-call (that would unmute/blast). inPeak decays per-callback, so | |
| // read it lock-free here. | |
| let meterNow = eng.rs.peakMeter.load(ordering: .relaxed) | |
| if reportedCall && meterNow < 0.0001 { quietPolls += 1 } else { quietPolls = 0; warnedSilentBoost = false } | |
| if !warnedSilentBoost, quietPolls >= 10 { | |
| warnedSilentBoost = true | |
| dlog("warning: boosting but tap silent for 5s (inPeak≈0) — check System Audio Recording permission or run `probe`") | |
| } | |
| // Rebuild ONLY when something actually changed (device or tap-exclusion | |
| // set), never blindly: every rebuild drops audio for a moment. | |
| // Set-compare: process-object order isn't stable. NOTE: ObjectIDs are | |
| // only stable while a process lives (OS-dependent #2) — bundleIDs in | |
| // build() cover restarts on 26+. | |
| let uid = deviceUID(defaultOutput()) | |
| let exclude = Set(tapExcludeObjects()) | |
| if uid != lastUID || exclude != lastExclude { | |
| dlog("device/call set changed → rebuilding graph") | |
| do { | |
| try eng.build() | |
| // Commit the snapshot ONLY on success, or a failed rebuild | |
| // would never be retried (old bug) AND the old graph is still | |
| // live thanks to make-before-break. | |
| lastUID = uid; lastExclude = exclude | |
| } catch { | |
| dlog("rebuild failed: \(error.localizedDescription) — keeping old graph") | |
| } | |
| } | |
| let snapGain = eng.boostGain, snapCb = eng.rs.callbackCount.load(ordering: .relaxed) | |
| try? "ON tap \(getpid()) mode=\(mode) gain=\(String(format: "%.1f", 20 * log10(snapGain)))dB call=\(reportedCall) raw=\(c) reason=\(lastReason ?? "-") cb=\(snapCb)\n".write(toFile: kStateFile, atomically: true, encoding: .utf8) | |
| } | |
| poll.resume() | |
| signal(SIGINT, SIG_IGN); signal(SIGTERM, SIG_IGN) | |
| let si = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) | |
| let st = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) | |
| let quitLock = NSLock() | |
| var gone = false | |
| let quit = { quitLock.withLock { if !gone { gone = true; eng.teardown(); Foundation.exit(0) } } } | |
| si.setEventHandler(handler: quit); st.setEventHandler(handler: quit) | |
| si.resume(); st.resume() | |
| RunLoop.main.run() | |
| } | |
| // MARK: probe — safe: unmuted tap, meter-only, music keeps playing | |
| // | |
| // Builds the same tap+aggregate+IOProc graph but with `.unmuted` (originals | |
| // keep playing) and an IOProc that only meters the tap input. If inPeak stays | |
| // 0.0000 while music plays, the tap hears silence: either the System Audio | |
| // Recording permission is missing, or the OS gates global taps on this machine | |
| // (then per-process taps are the fallback). If inPeak moves, capture works and | |
| // the fault is in replay routing. | |
| func runProbe() { | |
| if readPid() != nil { print("daemon is ON — run `off` first so it doesn't mute the originals"); exit(1) } | |
| let eng = Engine() | |
| eng.meterOnly = true | |
| do { try eng.build(muted: false) } catch { fputs("probe failed: \(error.localizedDescription)\n", stderr); exit(1) } | |
| print("probe: UNMUTED tap for 8s — music should keep playing") | |
| var heard = false | |
| for s in 1...8 { | |
| sleep(1) | |
| let cb = eng.rs.callbackCount.load(ordering: .relaxed) | |
| let pk = eng.rs.peakMeter.load(ordering: .relaxed) | |
| print("t+\(s)s cb=\(cb) inPeak=\(String(format: "%.4f", pk))") | |
| if pk > 0.0001 { heard = true } | |
| } | |
| eng.teardown() | |
| if heard { print("probe done: tap hears audio") } | |
| else { fputs("probe done: tap heard SILENCE — check System Audio Recording permission\n", stderr); exit(2) } | |
| } | |
| var boost: Float = min(kDuckDB, kMaxBoostDB), cmd: String? = nil, manualStart = false, gainArg: String? = nil | |
| var r = CommandLine.arguments.dropFirst() | |
| var idx = r.startIndex | |
| while idx != r.endIndex { | |
| let a = r[idx].lowercased() | |
| if a == "--gain", r.index(after: idx) != r.endIndex { idx = r.index(after: idx); boost = min(Float(r[idx]) ?? kDuckDB, kMaxBoostDB) } | |
| else if a == "--manual" { manualStart = true } | |
| else if a == "--daemon" { cmd = "--daemon" } | |
| else if cmd == nil { cmd = a } | |
| else if cmd == "gain", gainArg == nil { gainArg = String(r[idx]) } | |
| idx = r.index(after: idx) | |
| } | |
| func ensureDaemon() -> Bool { | |
| if readPid() != nil { return true } | |
| // Honor a previously stored `gain N` value instead of always defaulting. | |
| let gdb = storedGainDB() | |
| boost = gdb | |
| print("starting unduck (boost \(gdb)dB)…") | |
| spawnDaemon(extra: ["--gain", "\(gdb)"] + (manualStart ? ["--manual"] : [])) | |
| if readPid() == nil { print("start failed — rerun in foreground for errors"); return false } | |
| return true | |
| } | |
| switch cmd { | |
| case "--daemon": | |
| if manualStart { try? "unity".write(toFile: kCmdFile, atomically: true, encoding: .utf8) } | |
| runDaemon(boostDB: boost) | |
| case "on": | |
| if readPid() != nil { print("already ON"); break } | |
| print("starting unduck (boost \(boost)dB\(manualStart ? ", manual mode" : " in-call") firstly at unity)…") | |
| if manualStart { try? "unity".write(toFile: kCmdFile, atomically: true, encoding: .utf8) } | |
| else { try? FileManager.default.removeItem(atPath: kCmdFile) } | |
| try? "\(boost)".write(toFile: kGainFile, atomically: true, encoding: .utf8) | |
| spawnDaemon(extra: ["--gain", "\(boost)"] + (manualStart ? ["--manual"] : [])) | |
| print(readPid().map { "unduck ON (pid \($0))" } ?? "start failed — rerun in foreground for errors") | |
| case "gain": | |
| // Live boost change, no restart: `unduck gain 22` while boosted on a call. | |
| guard let gs = gainArg, let raw = Float(gs), raw >= 0, raw <= 40 else { | |
| print("usage: unduck gain <0-\(Int(kMaxBoostDB))> (dB, applied live while daemon runs)"); break | |
| } | |
| let gdb = min(raw, kMaxBoostDB) | |
| if gdb != raw { print("clamped to safety cap \(kMaxBoostDB)dB") } | |
| try? "\(gdb)".write(toFile: kGainFile, atomically: true, encoding: .utf8) | |
| print(readPid() == nil ? "gain \(gdb)dB stored (applies on next `on`)" : "gain → \(gdb)dB (live)") | |
| case "boost": | |
| // Latch +gain NOW. Starts the daemon first if needed. | |
| if ensureDaemon() { | |
| try? "boost".write(toFile: kCmdFile, atomically: true, encoding: .utf8) | |
| print("boost latched ON (manual mode — FaceTime mic state ignored)") | |
| } | |
| case "unboost", "unity": | |
| if ensureDaemon() { | |
| try? "unity".write(toFile: kCmdFile, atomically: true, encoding: .utf8) | |
| print("unity latched (transparent passthrough)") | |
| } | |
| case "auto": | |
| if ensureDaemon() { | |
| try? FileManager.default.removeItem(atPath: kCmdFile) | |
| print("resumed auto-detect (mic-based)") | |
| } | |
| case "off": cmdOff() | |
| case "probe": runProbe() | |
| case "permit": | |
| // Process taps need Screen Recording ("System Audio Recording") consent. | |
| // Without it the tap is created fine but hears silence (inPeak=0). | |
| if CGPreflightScreenCaptureAccess() { | |
| print("screen-capture access already GRANTED for this binary") | |
| } else { | |
| print("requesting access — allow it in the prompt / System Settings, then re-run `probe`") | |
| CGRequestScreenCaptureAccess() | |
| } | |
| case "toggle": | |
| if readPid() != nil { cmdOff() } else { | |
| // Mirror `on` exactly (old code forgot to clear the manual latch, so | |
| // toggle-after-boost resumed boosted). Default is auto-detect. | |
| let gdb = storedGainDB() | |
| try? "\(gdb)".write(toFile: kGainFile, atomically: true, encoding: .utf8) | |
| try? FileManager.default.removeItem(atPath: kCmdFile) | |
| spawnDaemon(extra: ["--gain", "\(gdb)"]) | |
| print(readPid().map { "unduck ON (pid \($0))" } ?? "start failed") | |
| } | |
| case nil, "status": | |
| if let p = readPid() { | |
| print("state: ON pid \(p)") | |
| if let st = try? String(contentsOfFile: kStateFile, encoding: .utf8) { print("daemon: \(st.trimmingCharacters(in: .whitespacesAndNewlines))") } | |
| } else { print("state: OFF") } | |
| // Show BOTH daemon state and a live query — they can disagree (poll lag, | |
| // stuck mic), and conflating them hid that. | |
| print("default output id \(defaultOutput()); call now (live query): \(callActive() ? "YES" : "no")") | |
| if cmd == nil { print("usage: on [--gain DB] [--manual] | gain <dB> | boost | unboost | auto | probe | permit | off | toggle | status") } | |
| if let lg = try? String(contentsOfFile: kLogFile, encoding: .utf8), | |
| !lg.isEmpty { | |
| print("--- log tail (\(kLogFile)) ---") | |
| print(lg.split(separator: "\n").suffix(5).joined(separator: "\n")) | |
| } | |
| default: | |
| fputs("unknown command '\(cmd ?? "?")'\n", stderr) | |
| fputs("usage: on [--gain DB] [--manual] | gain <dB> | boost | unboost | auto | probe | permit | off | toggle | status\n", stderr) | |
| exit(2) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment