diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -131,6 +131,10 @@ func synthSnapshotWaveform(into dest: inout [Float]) { synth.snapshotWaveform(into: &dest) } + func setWaveformCaptureEnabled(_ enabled: Bool) { + synth.setWaveformCaptureEnabled(enabled) + } + // Held preview note for sonic-browse hover over the instrument map. // Continuous tone — switching cells stops the old note + starts a new // one in the new program. Hover-out releases. Silent in MIDI mode diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -34,6 +34,13 @@ /// Fallback backend, always available immediately. private let melodic = AVAudioUnitSampler() private let drums = AVAudioUnitSampler() private var started = false + private var melodicConnected = false + private var drumsConnected = false + private var midiSynthConnected = false + private var waveformCaptureEnabled = false + private var activeNotes: Set = [] + private var idleSuspendWorkItem: DispatchWorkItem? + private let idleSuspendDelay: TimeInterval = 2.0 /// True once MIDISynth has loaded its bank, preloaded all programs, /// and successfully attached to the engine. `noteOn` and /// `setMelodicProgram` route through the MIDISynth when this flips. @@ -52,6 +59,7 @@ private static let waveformRingSize = 4096 private var waveformRing = [Float](repeating: 0, count: waveformRingSize) private var waveformWriteIdx: Int = 0 private let waveformLock = NSLock() + private var waveformTapInstalled = false /// Apple's DLS bank — present on every macOS install since 10.x. private static let bankURL = URL( @@ -62,8 +70,8 @@ func start() { guard !started else { return } engine.attach(melodic) engine.attach(drums) - engine.connect(melodic, to: engine.mainMixerNode, format: nil) - engine.connect(drums, to: engine.mainMixerNode, format: nil) + connectMelodicSamplerIfNeeded() + connectDrumsSamplerIfNeeded() engine.prepare() do { try engine.start() @@ -74,12 +82,12 @@ return } loadDefaultPatches() primeForLowLatency() - installWaveformTap() // Try to bring up the multi-timbral MIDISynth in the background. // If it works, we'll route notes through it for instant program // switching. If it doesn't, the user keeps the sampler fallback. startMIDISynthBackend() + scheduleIdleSuspendIfNeeded() } // MARK: - MIDISynth (multi-timbral, instant switching) @@ -124,10 +132,10 @@ NSLog("MenuBand: MIDISynth bank URL set failed status=\(bankStatus) — staying on sampler fallback") return } - // 2. Attach + connect to the engine. Engine is already running so - // this is hot-attach; AVAudioEngine handles graph reconfig. + // 2. Attach + connect to the engine. It may be running or paused + // for hidden idle; AVAudioEngine handles graph reconfig. engine.attach(avUnit) - engine.connect(avUnit, to: engine.mainMixerNode, format: nil) + connectMIDISynthIfNeeded(avUnit) // 3. Load just the programs we need. MIDISynth requires // EnablePreload(true) → PC → EnablePreload(false) to actually @@ -141,13 +149,105 @@ selectDrumKit(au) midiSynth = avUnit midiSynthReady = true + updateSamplerRoutingForActiveBackend() + scheduleIdleSuspendIfNeeded() NSLog("MenuBand: MIDISynth ready — instant program switching enabled") - // The sampler fallback is no longer needed for melodic playback — - // disconnect it to avoid double-triggering. Keep `drums` connected - // since the MIDISynth's drum-kit voice is on channel 9 of the same - // unit, so we'll just stop sending drum notes through `drums`. - // (Leaving the nodes attached but unrouted is safe.) + // The MIDISynth stays connected so keyboard input remains playable + // while the popover is hidden. The inactive sampler outputs are + // disconnected from the render graph until a fallback or GarageBand + // patch needs them again. + } + + private func connectMelodicSamplerIfNeeded() { + guard !melodicConnected else { return } + engine.connect(melodic, to: engine.mainMixerNode, format: nil) + melodicConnected = true + } + + private func disconnectMelodicSamplerIfNeeded() { + guard melodicConnected else { return } + stopAllSamplerNotes(melodic) + engine.disconnectNodeOutput(melodic) + melodicConnected = false + } + + private func connectDrumsSamplerIfNeeded() { + guard !drumsConnected else { return } + engine.connect(drums, to: engine.mainMixerNode, format: nil) + drumsConnected = true + } + + private func disconnectDrumsSamplerIfNeeded() { + guard drumsConnected else { return } + stopAllSamplerNotes(drums) + engine.disconnectNodeOutput(drums) + drumsConnected = false + } + + private func connectMIDISynthIfNeeded(_ avUnit: AVAudioUnit) { + guard !midiSynthConnected else { return } + engine.connect(avUnit, to: engine.mainMixerNode, format: nil) + midiSynthConnected = true + } + + private func updateSamplerRoutingForActiveBackend() { + guard started else { return } + if midiSynthReady { + if usingGarageBandPatch { + connectMelodicSamplerIfNeeded() + } else { + disconnectMelodicSamplerIfNeeded() + } + disconnectDrumsSamplerIfNeeded() + } else { + connectMelodicSamplerIfNeeded() + connectDrumsSamplerIfNeeded() + } + } + + private func stopAllSamplerNotes(_ sampler: AVAudioUnitSampler) { + for note: UInt8 in 0...127 { + sampler.stopNote(note, onChannel: 0) + } + } + + @discardableResult + private func resumeAudioEngineIfNeeded() -> Bool { + guard started else { return false } + idleSuspendWorkItem?.cancel() + idleSuspendWorkItem = nil + if engine.isRunning { + return true + } + do { + try engine.start() + return true + } catch { + NSLog("MenuBand synth engine resume failed: \(error)") + return false + } + } + + private func scheduleIdleSuspendIfNeeded() { + guard started, !waveformCaptureEnabled, activeNotes.isEmpty else { return } + idleSuspendWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.suspendAudioEngineForHiddenIdleIfNeeded() + } + idleSuspendWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + idleSuspendDelay, execute: workItem) + } + + private func suspendAudioEngineForHiddenIdleIfNeeded() { + idleSuspendWorkItem = nil + guard started, engine.isRunning, !waveformCaptureEnabled, activeNotes.isEmpty else { return } + removeWaveformTapIfNeeded() + engine.pause() + } + + private func noteKey(_ midi: UInt8, channel: UInt8) -> UInt16 { + (UInt16(channel) << 8) | UInt16(midi) } /// Set of (bankMSB << 8 | program) keys we've already faulted in. The @@ -215,7 +315,23 @@ } // MARK: - Audio tap for visualizer - private func installWaveformTap() { + func setWaveformCaptureEnabled(_ enabled: Bool) { + guard started else { return } + waveformCaptureEnabled = enabled + if enabled { + guard resumeAudioEngineIfNeeded() else { + waveformCaptureEnabled = false + return + } + installWaveformTapIfNeeded() + } else { + removeWaveformTapIfNeeded() + scheduleIdleSuspendIfNeeded() + } + } + + private func installWaveformTapIfNeeded() { + guard !waveformTapInstalled else { return } let mixer = engine.mainMixerNode let format = mixer.outputFormat(forBus: 0) // 256 frames ≈ 5.8 ms at 44.1 kHz — small buffer = fresh samples @@ -223,6 +339,13 @@ // for the visualizer without burning the audio thread. mixer.installTap(onBus: 0, bufferSize: 256, format: format) { [weak self] buffer, _ in self?.ingestWaveformBuffer(buffer) } + waveformTapInstalled = true + } + + private func removeWaveformTapIfNeeded() { + guard waveformTapInstalled else { return } + engine.mainMixerNode.removeTap(onBus: 0) + waveformTapInstalled = false } private func ingestWaveformBuffer(_ buffer: AVAudioPCMBuffer) { @@ -304,9 +427,11 @@ // load replaced its instrument data. usingGarageBandPatch = false if midiSynthReady, let au = midiSynth?.audioUnit { selectMelodicProgram(au, program: program) + updateSamplerRoutingForActiveBackend() return } guard started else { return } + connectMelodicSamplerIfNeeded() let url = MenuBandSynth.bankURL guard FileManager.default.fileExists(atPath: url.path) else { return } try? melodic.loadSoundBankInstrument(at: url, program: program, bankMSB: 0x79, bankLSB: 0) @@ -330,6 +455,7 @@ guard started else { return false } do { try melodic.loadInstrument(at: url) usingGarageBandPatch = true + updateSamplerRoutingForActiveBackend() return true } catch { NSLog("MenuBand: failed to load GB patch \(url.lastPathComponent): \(error)") @@ -339,9 +465,14 @@ } func stop() { guard started else { return } + idleSuspendWorkItem?.cancel() + idleSuspendWorkItem = nil + removeWaveformTapIfNeeded() engine.stop() started = false midiSynthReady = false + waveformCaptureEnabled = false + activeNotes.removeAll() } /// Send a CC#10 (pan) message on the given channel. Only takes @@ -357,6 +488,8 @@ } func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { guard started else { return } + guard resumeAudioEngineIfNeeded() else { return } + activeNotes.insert(noteKey(midi, channel: channel)) // Drums (channel 9) always route through MIDISynth/drums sampler // — drum kits are GM regardless of melodic backend choice. if channel == 9 { @@ -364,12 +497,14 @@ if midiSynthReady, let au = midiSynth?.audioUnit { sendMIDIEvent(au, status: 0x99, data1: midi, data2: velocity) return } + connectDrumsSamplerIfNeeded() drums.startNote(midi, withVelocity: velocity, onChannel: 0) return } // Melodic — sampler if a GB patch is loaded, MIDISynth if ready, // sampler-with-DLS otherwise. if usingGarageBandPatch { + connectMelodicSamplerIfNeeded() melodic.startNote(midi, withVelocity: velocity, onChannel: 0) return } @@ -377,11 +512,14 @@ if midiSynthReady, let au = midiSynth?.audioUnit { sendMIDIEvent(au, status: 0x90, data1: midi, data2: velocity) return } + connectMelodicSamplerIfNeeded() melodic.startNote(midi, withVelocity: velocity, onChannel: 0) } func noteOff(_ midi: UInt8, channel: UInt8 = 0) { guard started else { return } + activeNotes.remove(noteKey(midi, channel: channel)) + defer { scheduleIdleSuspendIfNeeded() } if channel == 9 { if midiSynthReady, let au = midiSynth?.audioUnit { sendMIDIEvent(au, status: 0x89, data1: midi) @@ -403,15 +541,16 @@ } func panic() { guard started else { return } + activeNotes.removeAll() + defer { scheduleIdleSuspendIfNeeded() } if midiSynthReady, let au = midiSynth?.audioUnit { for ch: UInt8 in 0..<16 { // CC 123 = All Notes Off. sendMIDIEvent(au, status: 0xB0 | ch, data1: 123, data2: 0) } - return } for unit in [melodic, drums] { - for note: UInt8 in 0...127 { unit.stopNote(note, onChannel: 0) } + stopAllSamplerNotes(unit) } } } diff --git a/slab/menuband/Sources/MenuBand/WaveformView.swift b/slab/menuband/Sources/MenuBand/WaveformView.swift --- a/slab/menuband/Sources/MenuBand/WaveformView.swift +++ b/slab/menuband/Sources/MenuBand/WaveformView.swift @@ -36,9 +36,12 @@ /// internal CVDisplayLink doesn't fire reliably when the panel isn't /// `main`. We drive draws ourselves and call `display()` so the redraw /// is synchronous instead of deferred. private var displayLink: CVDisplayLink? + private let pendingDisplayLock = NSLock() + private var pendingDisplay = false var isLive: Bool = false { didSet { + guard isLive != oldValue else { return } if isLive { stopDotMatrix() startLink() @@ -90,6 +93,7 @@ } private func startLink() { stopLink() + guard window != nil else { return } var link: CVDisplayLink? CVDisplayLinkCreateWithActiveCGDisplays(&link) guard let link = link else { return } @@ -99,10 +103,18 @@ guard let ctx = ctx else { return kCVReturnSuccess } let view = Unmanaged.fromOpaque(ctx).takeUnretainedValue() // display() is main-thread-only; hop over and draw synchronously // so the redraw can't be coalesced by the popover's runloop. - DispatchQueue.main.async { view.display() } + // Coalesce callbacks while the main queue is busy; otherwise a + // slow frame can build a backlog of stale draw requests. + guard view.markDisplayPending() else { return kCVReturnSuccess } + DispatchQueue.main.async { + view.display() + view.clearDisplayPending() + } return kCVReturnSuccess }, opaque) - CVDisplayLinkStart(link) + let status = CVDisplayLinkStart(link) + guard status == kCVReturnSuccess else { return } + menuBand?.setWaveformCaptureEnabled(true) displayLink = link } @@ -111,13 +123,33 @@ if let link = displayLink { CVDisplayLinkStop(link) displayLink = nil } + menuBand?.setWaveformCaptureEnabled(false) + clearDisplayPending() + } + + private func markDisplayPending() -> Bool { + pendingDisplayLock.lock() + defer { pendingDisplayLock.unlock() } + if pendingDisplay { return false } + pendingDisplay = true + return true + } + + private func clearDisplayPending() { + pendingDisplayLock.lock() + pendingDisplay = false + pendingDisplayLock.unlock() } deinit { stopLink() } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - if window == nil { stopLink() } + if window == nil { + stopLink() + } else if isLive && displayLink == nil { + startLink() + } } init() {