Skip to content

Instantly share code, notes, and snippets.

@huynhducduy
Last active September 17, 2026 13:21
Show Gist options
  • Select an option

  • Save huynhducduy/ccd17ac154fc3e19dfd0abda1332745c to your computer and use it in GitHub Desktop.

Select an option

Save huynhducduy/ccd17ac154fc3e19dfd0abda1332745c to your computer and use it in GitHub Desktop.
#!/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
#!/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