From 2bd09dcb89cb3981101ab79d3b430629dd83de19 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 4 Oct 2026 08:08:34 -0700 Subject: [PATCH] Bound Menu Band microphone recovery across sleep and wake --- .../MenuBand/InputMonitorRecovery.swift | 62 +++++++++++++++++++ .../MenuBand/MenuBandInputMonitor.swift | 46 ++++++++++---- .../Sources/MenuBand/MenuBandSynth.swift | 5 +- slab/menuband/bin/check-audio-smoothness.sh | 1 + .../menuband/bin/check-audio-smoothness.swift | 40 ++++++++++++ 5 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 slab/menuband/Sources/MenuBand/InputMonitorRecovery.swift diff --git a/slab/menuband/Sources/MenuBand/InputMonitorRecovery.swift b/slab/menuband/Sources/MenuBand/InputMonitorRecovery.swift new file mode 100644 index 0000000000..b091e943b8 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/InputMonitorRecovery.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Bounds microphone reopening independently of the audio callback threads. +/// A muted monitor still captures for tape, so audibility is not a recovery gate. +final class InputMonitorRecovery { + private let lock = NSLock() + private var attached = false + private var sleeping = false + private var attempts = 0 + private var generation = 0 + private var pending = false + private var eligibleAfter: TimeInterval = 0 + static let maximumAttempts = 3 + + var isSleeping: Bool { + lock.lock(); defer { lock.unlock() } + return sleeping + } + + func setAttached(_ value: Bool, now: TimeInterval) { + lock.lock(); defer { lock.unlock() } + attached = value + reset(now: now) + } + + func setSleeping(_ value: Bool, now: TimeInterval) { + lock.lock(); defer { lock.unlock() } + sleeping = value + reset(now: now) + } + + func deviceDidChange(now: TimeInterval) { + lock.lock(); defer { lock.unlock() } + reset(now: now) + } + + private func reset(now: TimeInterval) { + generation += 1 + attempts = 0 + pending = false + // Allow normal capture to resume before deciding the device is dead. + eligibleAfter = now + 10 + } + + func request(captures: Int, now: TimeInterval) -> Int? { + lock.lock(); defer { lock.unlock() } + guard attached, !sleeping, !pending, captures == 0, + now >= eligibleAfter, attempts < Self.maximumAttempts else { return nil } + pending = true + return generation + } + + /// Recheck on the execution queue: sleep, detach, or a device change may + /// have happened since the health timer queued this attempt. + func claim(_ ticket: Int) -> Int? { + lock.lock(); defer { lock.unlock() } + guard ticket == generation, pending, attached, !sleeping else { return nil } + pending = false + attempts += 1 + return attempts + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandInputMonitor.swift b/slab/menuband/Sources/MenuBand/MenuBandInputMonitor.swift index 579f5408d6..a35cf98e38 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandInputMonitor.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandInputMonitor.swift @@ -1,4 +1,5 @@ import Foundation +import AppKit import AVFoundation import AudioToolbox import CoreAudio @@ -89,11 +90,11 @@ final class MenuBandInputMonitor { return p }() private var nanScrubbed = 0 - /// Watchdog: capture callbacks per stats interval. Zero while attached - /// means the AUHAL went quiet (its device churned away under it) — - /// reopen on whatever the live input device is now. + /// Zero callbacks can also mean a sleeping/unavailable device. Recovery + /// must be bounded: reopening forever churns the system's mic indicator. private var captures = 0 - private var stalledIntervals = 0 + private let recovery = InputMonitorRecovery() + private var sleepObservers: [NSObjectProtocol] = [] private let watchdogQueue = DispatchQueue(label: "menuband.monitor.watchdog") /// IO cycle the host wants on the device while monitoring. Written on the /// input unit before start and re-asserted whenever the device flips @@ -119,6 +120,22 @@ final class MenuBandInputMonitor { private var pulls = 0 private var lastStatsLog: TimeInterval = 0 + init() { + let center = NSWorkspace.shared.notificationCenter + sleepObservers = [ + center.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: nil) { [weak self] _ in + self?.recovery.setSleeping(true, now: ProcessInfo.processInfo.systemUptime) + }, + center.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: nil) { [weak self] _ in + self?.recovery.setSleeping(false, now: ProcessInfo.processInfo.systemUptime) + }, + ] + } + + func resetRecoveryForDeviceChange() { + recovery.deviceDidChange(now: ProcessInfo.processInfo.systemUptime) + } + private var healthTimer: DispatchSourceTimer? private func startHealthTimer() { healthTimer?.cancel() @@ -209,6 +226,7 @@ final class MenuBandInputMonitor { } monoMixer.outputVolume = 0 attached = true + recovery.setAttached(true, now: ProcessInfo.processInfo.systemUptime) let status = AudioOutputUnitStart(inputUnit!) NSLog("MenuBand monitor: direct device input on \(device) ch=\(deviceChannels) sr=\(sampleRate) (start \(status))") @@ -219,6 +237,7 @@ final class MenuBandInputMonitor { /// the engine around this; the source node detaches cleanly either way. func detach(from engine: AVAudioEngine) { guard attached else { return } + recovery.setAttached(false, now: ProcessInfo.processInfo.systemUptime) stopHealthTimer() removeDryTap() removeBufferListener() @@ -252,7 +271,7 @@ final class MenuBandInputMonitor { /// the Scarlett). Close and reopen it on the current input device; the /// engine-side graph (source node, buses, tap) stays as it is. func restartInput() { - guard attached else { return } + guard attached, !recovery.isSleeping else { return } removeBufferListener() closeInputUnit() guard let device = Self.resolveInputDevice(), openInputUnit(on: device) else { @@ -751,14 +770,15 @@ final class MenuBandInputMonitor { pulls = 0 nanScrubbed = 0 os_unfair_lock_unlock(ringLock) - if attached, c == 0 { - stalledIntervals += 1 - NSLog("MenuBand monitor: WATCHDOG — no input callbacks for \(stalledIntervals * 5) s on device \(inputDevice); reopening on the live input") - watchdogQueue.async { [weak self] in - DispatchQueue.main.async { self?.restartInput() } + if let ticket = recovery.request(captures: c, now: now) { + DispatchQueue.main.async { [weak self] in + guard let self, let attempt = self.recovery.claim(ticket) else { return } + NSLog("MenuBand monitor: WATCHDOG — no input callbacks; recovery \(attempt)/\(InputMonitorRecovery.maximumAttempts)") + self.restartInput() + if attempt == InputMonitorRecovery.maximumAttempts { + NSLog("MenuBand monitor: WATCHDOG — retry budget exhausted; waiting for wake or device change") + } } - } else { - stalledIntervals = 0 } let leadMs = sampleRate > 0 ? Double(avgLead) / sampleRate * 1000 : 0 NSLog(String(format: "MenuBand monitor: health dev=%u captures=%d lead=%d frames (%.2f ms) pulls=%d underflows=%d drops=%d overflows=%d nan=%d wetUnderflows=%d enabled=%d gain=%.2f duck=%.2f (5 s)", @@ -806,6 +826,8 @@ final class MenuBandInputMonitor { } deinit { + stopHealthTimer() + for observer in sleepObservers { NSWorkspace.shared.notificationCenter.removeObserver(observer) } closeInputUnit() freeRing() ringLock.deallocate() diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index 09dd100f6c..1490dba45c 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -1735,7 +1735,10 @@ final class MenuBandSynth { private var bindingAuditTimer: DispatchSourceTimer? private var bindingAuditStrikes = 0 /// A real device-list change gives the audit its tries back. - func resetBindingAudit() { bindingAuditStrikes = 0 } + func resetBindingAudit() { + bindingAuditStrikes = 0 + inputMonitor.resetRecoveryForDeviceChange() + } /// After a plug-in/unplug: if the monitor is open on a device that the /// automatic/pinned pick no longer resolves to (the laptop mic while a diff --git a/slab/menuband/bin/check-audio-smoothness.sh b/slab/menuband/bin/check-audio-smoothness.sh index 416410d506..fdfaccb9fe 100644 --- a/slab/menuband/bin/check-audio-smoothness.sh +++ b/slab/menuband/bin/check-audio-smoothness.sh @@ -6,6 +6,7 @@ trap 'rm -rf "$scratch"' EXIT src="$root/Sources/MenuBand" nice -n 8 xcrun swiftc -O -parse-as-library \ "$src/MonitorReadCursor.swift" "$src/InterfaceResetCoordinator.swift" \ + "$src/InputMonitorRecovery.swift" \ "$src/MenuBandTape.swift" "$src/TapeCoverArt.swift" \ "$src/TakeMetadata.swift" "$src/MidiFile.swift" "$src/TapeFilePromise.swift" \ "$root/bin/check-audio-smoothness.swift" -o "$scratch/checks" diff --git a/slab/menuband/bin/check-audio-smoothness.swift b/slab/menuband/bin/check-audio-smoothness.swift index 306ec8ac59..af8187b915 100644 --- a/slab/menuband/bin/check-audio-smoothness.swift +++ b/slab/menuband/bin/check-audio-smoothness.swift @@ -10,6 +10,46 @@ private func check(_ condition: @autoclosure () -> Bool, _ message: String) { @main enum AudioSmoothnessChecks { static func main() throws { + let recovery = InputMonitorRecovery() + recovery.setAttached(true, now: 0) + check(recovery.request(captures: 0, now: 5) == nil, "no initial settling time") + check(recovery.request(captures: 100, now: 10) == nil, "healthy input was restarted") + var recoveryAttempts: [Int] = [] + for tick in 2...8640 { + if let ticket = recovery.request(captures: 0, now: Double(tick * 5)), + let attempt = recovery.claim(ticket) { recoveryAttempts.append(attempt) } + } + check(recoveryAttempts == [1, 2, 3], "overnight stall exceeded three microphone restarts") + recovery.setSleeping(true, now: 43201) + recovery.deviceDidChange(now: 43202) + check(recovery.request(captures: 0, now: 50000) == nil, "device change enabled recovery during sleep") + recovery.setSleeping(false, now: 50001) + check(recovery.request(captures: 0, now: 50006) == nil, "no wake settling time") + let beforeSleep = recovery.request(captures: 0, now: 50011)! + check(recovery.request(captures: 0, now: 50016) == nil, "queued duplicate restart") + recovery.setSleeping(true, now: 50017) + check(recovery.claim(beforeSleep) == nil, "queued restart survived sleep") + recovery.setSleeping(false, now: 50020) + let beforeDeviceChange = recovery.request(captures: 0, now: 50030)! + recovery.deviceDidChange(now: 50031) + check(recovery.claim(beforeDeviceChange) == nil, "queued restart survived device change") + let beforeDetach = recovery.request(captures: 0, now: 50041)! + recovery.setAttached(false, now: 50042) + check(recovery.claim(beforeDetach) == nil, "queued restart survived detach") + check(recovery.request(captures: 0, now: 50100) == nil, "detached input was restarted") + recovery.setAttached(true, now: 50200) + for tick in 0..<3 { + let ticket = recovery.request(captures: 0, now: Double(50210 + tick * 5))! + check(recovery.claim(ticket) == tick + 1, "reattach did not restore retry budget") + check(recovery.claim(ticket) == nil, "restart ticket executed twice") + } + recovery.deviceDidChange(now: 50300) + check(recovery.request(captures: 0, now: 50305) == nil, "no device-change settling time") + let freshTicket = recovery.request(captures: 0, now: 50310)! + check(recovery.claim(beforeDetach) == nil, "stale ticket consumed fresh request") + check(recovery.claim(freshTicket) == 1, "device change did not restore retry budget") + print("PASS: 12-hour microphone stall capped at three retries; sleep/wake, device change, detach, queued cancellation") + var cursor = MonitorReadCursor() let partial = cursor.plan(read: 100, written: 108, frames: 16, lead: 32) check(partial.count == 8 && partial.underrun, "underrun read unpublished samples") -- 2.51.2