From 05f19aa365678bd66d73766e6540ba67db407323 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 18 Sep 2026 15:53:55 -0700 Subject: [PATCH] menuband: surface cues skip a stalled engine instead of freezing the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AVAudioPlayerNode.play() parks its caller in awaitIOCycle until the engine renders a cycle, and engine.isRunning can read true while no cycles are happening. The idle-page rub called play() from the main thread on such an engine and the whole app wedged for minutes with the key-capture panel armed and the pitch-bend cursor locked — the recurring "focus lock". Gate both surface cues on the output node's lastRenderTime being fresh (≤250 ms) so a stalled engine drops the cue rather than the main thread. Co-Authored-By: Claude Fable 5.1 --- .../Sources/MenuBand/MenuBandSurfaceCue.swift | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/slab/menuband/Sources/MenuBand/MenuBandSurfaceCue.swift b/slab/menuband/Sources/MenuBand/MenuBandSurfaceCue.swift index 4c86d6a5ed..4e291a0643 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSurfaceCue.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSurfaceCue.swift @@ -82,7 +82,7 @@ final class MenuBandSurfaceCue { /// chime so rapid Tab presses always hear the freshest destination — /// `scheduleBuffer` on a live player appends, it does not replace. func playPadSwitch(toTrackDrum: Bool) -> Bool { - guard attached, let engine, engine.isRunning else { return false } + guard attached, let engine, engine.isRenderingLive else { return false } guard let buffer = makePadSwitchBuffer(toTrackDrum: toTrackDrum) else { return false } @@ -96,7 +96,7 @@ final class MenuBandSurfaceCue { /// per-event travel. The level snaps up to the target (a rub needs no /// attack — the loop has no transient) and the timer eases it back down. func rub(intensity: Float) { - guard attached, let engine, engine.isRunning, + guard attached, let engine, engine.isRenderingLive, let loop = rubLoop else { return } let target = max(0, min(1, intensity)) * Self.rubLevelCeiling rubLevel = max(rubLevel, target) @@ -224,3 +224,36 @@ final class MenuBandSurfaceCue { return buffer } } + +/// `AVAudioPlayerNode.play()` blocks its calling thread inside +/// `AVAudioClock awaitIOCycle` until the engine's render thread completes a +/// cycle. `engine.isRunning` is not proof that cycles are happening: after a +/// device pull, a hog-mode/device-override churn, or a pause racing in from +/// another thread, the flag can read true while the output unit is not +/// pulling. Both surface cues call `play()` from the main thread, so a +/// stalled engine froze the whole app mid-gesture — status item, key +/// capture panel and the locked pitch-bend cursor all wedged in place +/// (2026-09-18, main thread parked 130 s in `MenuBandSurfaceCue.rub`). +/// +/// The output node's `lastRenderTime` advances only when a cycle really +/// ran, so its age is the honest liveness signal. A cue is a preview, not a +/// note: when the engine has not rendered within the window it is skipped +/// rather than awaited. +extension AVAudioEngine { + /// Longest gap between render cycles still treated as alive. IO buffers + /// here are 256–512 frames (≈3–12 ms), so a quarter second is far past + /// any legitimate cycle and well short of what a person feels as a hang. + fileprivate static let liveRenderWindow: TimeInterval = 0.25 + + /// True only when the engine is running AND its output rendered within + /// `liveRenderWindow`. Nil / host-time-invalid render times count as not + /// live: a fresh `start()` populates them within one cycle, and a cue + /// skipped in that first few milliseconds is inaudible. + var isRenderingLive: Bool { + guard isRunning, let last = outputNode.lastRenderTime, + last.isHostTimeValid else { return false } + let age = AVAudioTime.seconds(forHostTime: mach_absolute_time()) + - AVAudioTime.seconds(forHostTime: last.hostTime) + return age >= 0 && age <= Self.liveRenderWindow + } +} -- 2.51.2