diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -195,6 +195,12 @@ typeMode: menuBand.typeMode, melodicProgram: menuBand.melodicProgram, hovered: hoveredElement ) + // Force a synchronous redraw — the click drag-loop runs the runloop + // in `eventTracking` mode and has been swallowing the next CA flush + // until mouseUp. Without this, key blinks and hover highlights only + // appeared after the user released the mouse. + button.needsDisplay = true + button.displayIfNeeded() } // MARK: - Hover @@ -265,7 +271,7 @@ } let initialHitPt = imagePoint(from: downEvent.locationInWindow) let initial = KeyboardIconRenderer.hit(at: initialHitPt) - debugLog("hit pt=(\(initialHitPt.x),\(initialHitPt.y)) -> \(initial)") + debugLog("hit pt=(\(initialHitPt.x),\(initialHitPt.y)) -> \(String(describing: initial))") let startNote: UInt8 switch initial { case .openSettings: 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 @@ -18,7 +18,7 @@ // Visual state — accessed only on the main thread. private(set) var litNotes: Set = [] private var litDownAt: [UInt8: CFTimeInterval] = [:] - private let minVisibleSeconds: CFTimeInterval = 0.08 + private let minVisibleSeconds: CFTimeInterval = 0.18 var onChange: (() -> Void)? var onLitChanged: (() -> Void)? @@ -372,13 +372,18 @@ tapNoteChannel[midiNote] = synthCh midi.sendCC(10, value: pan, channel: midiCh) if !midiMode { synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } midi.noteOn(midiNote, velocity: velocity, channel: midiCh) - DispatchQueue.main.async { [weak self] in + // Lit state is main-thread-only; update synchronously so the menubar + // redraws within the same runloop pass as the click. Dispatching async + // pushed the redraw past the event-tracking loop's next spin and the + // blink wasn't visible. + let setLit = { [weak self] in guard let self = self else { return } self.litDownAt[midiNote] = CACurrentMediaTime() if self.litNotes.insert(midiNote).inserted { self.onLitChanged?() } } + if Thread.isMainThread { setLit() } else { DispatchQueue.main.async(execute: setLit) } } /// While dragging, update pan in real time as the cursor slides within diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -58,7 +58,8 @@ /// richer feel than a plain NSMenu. final class MenuBandPopoverViewController: NSViewController { weak var menuBand: MenuBandController? - private var inputSegmented: HoverSegmentedControl! + private var inputSegmented: HoverSegmentedControl! // legacy reference; no longer added to stack + private var modeButtons: [NSButton] = [] // vertical stack: Mouse Only / Notepat.com / Ableton MIDI Keys private var midiSwitch: NSSwitch! private var midiInlineLabel: NSTextField! private var midiSelfTestLabel: NSTextField! // legacy — created but never added to stack @@ -92,6 +93,14 @@ stack.spacing = 6 stack.edgeInsets = NSEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) stack.translatesAutoresizingMaskIntoConstraints = false root.addSubview(stack) + + // Pin the stack to exactly the instrument-grid width plus the + // 8 px insets on each side. Without this, NSSegmentedControl's + // intrinsic-content-size for "Notepat.com" pushes the stack wider + // than 224, which then drags the popover out with it. + stack.widthAnchor.constraint( + equalToConstant: InstrumentListView.preferredWidth + 16 + ).isActive = true // Top control row: octave + MIDI hugging the right. Brand title // moved into the About section below — fewer wasted rows up top. @@ -224,21 +233,51 @@ // Ableton — global keystroke capture, one octave (Live's M-mode) // Hovering a segment previews that mode in the menubar piano (range // shrinks/grows, letter labels appear) and lets you tap keys for a // quick demo without committing. - let inputLabel = NSTextField(labelWithString: "Keyboard & Mouse") + let inputLabel = NSTextField(labelWithString: "Keyboard Shortcuts") inputLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) inputLabel.textColor = .labelColor stack.addArrangedSubview(inputLabel) - inputSegmented = HoverSegmentedControl( - labels: ["Mouse Only", "Notepat.com", "Ableton"], - trackingMode: .selectOne, - target: self, - action: #selector(inputModeChanged(_:)) - ) - inputSegmented.translatesAutoresizingMaskIntoConstraints = false - // No hover preview — clicks commit the mode directly. - stack.addArrangedSubview(inputSegmented) - inputSegmented.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true + // Vertical mode buttons — full labels fit without truncation, each + // button is the full content width with an SF Symbol leading the + // text so the mode is recognizable at a glance. + let modeSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, + weight: .semibold) + let modeSpecs: [(label: String, symbol: String)] = [ + ("Mouse Only", "cursorarrow"), + ("Notepat.com", "keyboard"), + ("Ableton MIDI Keys", "pianokeys"), + ] + modeButtons = [] + let modeStack = NSStackView() + modeStack.orientation = .vertical + modeStack.alignment = .leading + modeStack.spacing = 2 + modeStack.translatesAutoresizingMaskIntoConstraints = false + for (idx, spec) in modeSpecs.enumerated() { + let b = NSButton(title: spec.label, target: self, + action: #selector(modeButtonClicked(_:))) + b.tag = idx + b.bezelStyle = .recessed + b.setButtonType(.pushOnPushOff) + b.controlSize = .regular + b.alignment = .left + b.imagePosition = .imageLeading + b.imageHugsTitle = true + b.image = NSImage(systemSymbolName: spec.symbol, + accessibilityDescription: spec.label)? + .withSymbolConfiguration(modeSymbolConfig) + b.translatesAutoresizingMaskIntoConstraints = false + b.widthAnchor.constraint( + equalToConstant: InstrumentListView.preferredWidth + ).isActive = true + modeButtons.append(b) + modeStack.addArrangedSubview(b) + } + stack.addArrangedSubview(modeStack) + modeStack.widthAnchor.constraint( + equalToConstant: InstrumentListView.preferredWidth + ).isActive = true let inputHint = NSTextField(labelWithString: "⌃⌥⌘P toggles last keystrokes mode") @@ -323,10 +362,12 @@ // collapse into one section. let aboutTitle = NSTextField(labelWithString: "Menu Band") aboutTitle.font = NSFont.systemFont(ofSize: 13, weight: .bold) aboutTitle.textColor = .labelColor - let aboutSubtitle = NSTextField(labelWithString: + let aboutSubtitle = NSTextField(wrappingLabelWithString: "Built-in macOS instruments, in the menu bar.") aboutSubtitle.font = NSFont.systemFont(ofSize: 10.5) aboutSubtitle.textColor = .secondaryLabelColor + aboutSubtitle.maximumNumberOfLines = 0 + aboutSubtitle.preferredMaxLayoutWidth = InstrumentListView.preferredWidth let aboutBody = NSTextField(wrappingLabelWithString: "A political project to bring the built-in macOS instruments — " + "the ones GarageBand uses — into the menu bar. Free + open source.") @@ -431,8 +472,10 @@ guard isViewLoaded, let n = menuBand else { return } midiSwitch.state = n.midiMode ? .on : .off octaveStepper.integerValue = n.octaveShift updateOctaveLabel(n.octaveShift) - inputSegmented.selectedSegment = inputModeSegment(typeMode: n.typeMode, - keymap: n.keymap) + let segIdx = inputModeSegment(typeMode: n.typeMode, keymap: n.keymap) + for (i, btn) in modeButtons.enumerated() { + btn.state = (i == segIdx) ? .on : .off + } instrumentList.selectedProgram = n.melodicProgram updateInstrumentReadout(program: n.melodicProgram) updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) @@ -617,21 +660,21 @@ if !typeMode { return 0 } return keymap == .ableton ? 2 : 1 } - @objc private func inputModeChanged(_ sender: NSSegmentedControl) { + @objc private func modeButtonClicked(_ sender: NSButton) { guard let m = menuBand else { return } - switch sender.selectedSegment { - case 0: // Pointer + // Manual radio behaviour: only the clicked button stays .on. + for btn in modeButtons { btn.state = (btn == sender) ? .on : .off } + switch sender.tag { + case 0: // Mouse Only if m.typeMode { m.toggleTypeMode() } case 1: // Notepat.com m.keymap = .notepat if !m.typeMode { m.toggleTypeMode() } - case 2: // Ableton + case 2: // Ableton MIDI Keys m.keymap = .ableton if !m.typeMode { m.toggleTypeMode() } default: break } - // No syncFromController — segmented control already reflects the - // user's click and the rest of the popover doesn't need to refresh. } private func handleInstrumentCommit(_ program: Int) { 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 @@ -1,41 +1,99 @@ import Foundation import AVFoundation +import AudioToolbox -// Built-in soft-synth using Apple's bundled GM DLS sound bank -// (`gs_instruments.dls`, shipped with every macOS install inside -// CoreAudio.component). Real piano and drum-kit samples — not the bare -// AVAudioUnitSampler default, which is a sine-ish placeholder. -// -// Two samplers are kept: one melodic (channel 0 — piano by default), one -// percussion (channel 9 — GM drum kit). MenuBandController routes drum notes -// to the drum sampler. +/// Built-in soft-synth backed by Apple's multi-timbral MIDI synth audio unit +/// (`kAudioUnitSubType_MIDISynth`) with the GS DLS bank loaded once. +/// +/// Why not AVAudioUnitSampler? Sampler is monotimbral — switching programs +/// requires `loadSoundBankInstrument`, a ~100 ms blocking call that re-parses +/// the .dls file. That latency was killing the popover's "drag across the +/// instrument grid to browse" interaction: each cell-cross swapped the bank, +/// the next note had to wait for the swap, and the user heard stuttering. +/// +/// MIDISynth is multi-timbral: 16 simultaneous programs, one per MIDI channel. +/// We assign channels 0–14 to recently-touched melodic programs (LRU) and +/// channel 9 to the GM drum kit. Switching the user's "current" program is a +/// MIDI Program Change message — sub-millisecond. We also pre-warm every +/// program at startup via `kMusicDeviceProperty_BankPreload` so the first +/// note on any channel never blocks on a sample-data load. final class MenuBandSynth { private let engine = AVAudioEngine() - private let melodic = AVAudioUnitSampler() - private let drums = AVAudioUnitSampler() + private var synth: AVAudioUnitMIDIInstrument! private var started = false - // Tap-driven ring buffer for the popover's live waveform display. The - // tap fires on the audio render thread and writes here; the main thread - // reads via `snapshotWaveform(into:)` whenever the WaveformView wants - // a fresh frame. + /// Per-channel current program, kept in sync with the actual MIDI state + /// of the AU. Channel 9 is fixed to the drum kit. + private var channelProgram: [UInt8: UInt8] = [:] + /// LRU order of channels for melodic-channel rotation. Front = most + /// recently assigned. We avoid stomping a channel that's still holding + /// a recent note, so quickly tapping two different programs in the + /// instrument grid plays both their notes audibly through release. + private var melodicChannelLRU: [UInt8] = Array(0..<9) + Array(10..<16) + private let stateLock = NSLock() + + // Tap-driven ring buffer for the popover's live waveform display. private static let waveformRingSize = 4096 private var waveformRing = [Float](repeating: 0, count: waveformRingSize) private var waveformWriteIdx: Int = 0 private let waveformLock = NSLock() - // Apple's DLS bank — present on every macOS install since 10.x. + /// Apple's DLS bank — present on every macOS install since 10.x. private static let bankURL = URL( fileURLWithPath: "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls" ) 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) - engine.prepare() // pre-allocate buffers before .start() + let desc = AudioComponentDescription( + componentType: kAudioUnitType_MusicDevice, + componentSubType: kAudioUnitSubType_MIDISynth, + componentManufacturer: kAudioUnitManufacturer_Apple, + componentFlags: 0, + componentFlagsMask: 0 + ) + let unit = AVAudioUnitMIDIInstrument(audioComponentDescription: desc) + synth = unit + engine.attach(unit) + engine.connect(unit, to: engine.mainMixerNode, format: nil) + + // Sound-bank URL must be set BEFORE engine.start(). The property + // expects a CFURLRef; passing a Swift `URL` directly hits the AU + // as a `_SwiftURL` and fails the NSURL selector dispatch inside + // CoreAudio (you'll see "does not implement -baseURL"). Cast to + // CFURL so the AU sees a proper toll-free-bridged NSURL. + var bankURL: CFURL = MenuBandSynth.bankURL as CFURL + let bankStatus = withUnsafePointer(to: &bankURL) { ptr -> OSStatus in + AudioUnitSetProperty( + unit.audioUnit, + AudioUnitPropertyID(kMusicDeviceProperty_SoundBankURL), + kAudioUnitScope_Global, + 0, + ptr, + UInt32(MemoryLayout.size) + ) + } + if bankStatus != noErr { + NSLog("MenuBand: SoundBankURL set failed status=\(bankStatus)") + } + + engine.prepare() + + // Apple's MIDISynth preload protocol (per CoreAudio docs): set the + // EnableLoadPreset property to 1, then send a Program Change for + // every (bank, program) combination you intend to use. Each PC + // triggers a foreground load of that program's samples *before* + // we start the engine. Once preloaded, channel-program switches + // at runtime are sub-millisecond — no disk I/O, no DSP rebuild. + // After preloading, set EnableLoadPreset back to 0 and start the + // engine; subsequent PCs are now treated as instant switches. + setEnablePreload(true) + for p: UInt8 in 0...127 { + synth.sendProgramChange(p, bankMSB: 0x79, bankLSB: 0, onChannel: 0) + } + synth.sendProgramChange(0, bankMSB: 0x78, bankLSB: 0, onChannel: 9) + setEnablePreload(false) + do { try engine.start() started = true @@ -43,20 +101,50 @@ } catch { NSLog("MenuBand synth engine start failed: \(error)") return } - loadDefaultPatches() + + configureChannels() primeForLowLatency() installWaveformTap() } + /// Toggle MIDISynth's preload mode. While enabled, Program Change events + /// synchronously load that preset; while disabled (the default after + /// startup), PCs only switch the channel's current program from already- + /// loaded presets. + private func setEnablePreload(_ enable: Bool) { + var flag: UInt32 = enable ? 1 : 0 + let status = AudioUnitSetProperty( + synth.audioUnit, + AudioUnitPropertyID(kAUMIDISynthProperty_EnablePreload), + kAudioUnitScope_Global, + 0, + &flag, + UInt32(MemoryLayout.size) + ) + if status != noErr { + NSLog("MenuBand: EnablePreload(\(enable)) status=\(status)") + } + } + + /// Reset every melodic channel to program 0 (acoustic grand) and the + /// drum channel to the standard kit. After this, channelProgram is in + /// sync with the AU's actual MIDI state. Runs after engine.start() so + /// PCs are treated as instant program switches (no preload reload). + private func configureChannels() { + for ch in 0..<16 where ch != 9 { + synth.sendProgramChange(0, bankMSB: 0x79, bankLSB: 0, onChannel: UInt8(ch)) + channelProgram[UInt8(ch)] = 0 + } + synth.sendProgramChange(0, bankMSB: 0x78, bankLSB: 0, onChannel: 9) + channelProgram[9] = 0 + } + /// Tap the engine's main mixer so the WaveformView gets a live picture - /// of whatever the synth is producing — both melodic and drum hits go - /// through the mainMixer. Buffer size 512 frames ≈ 11 ms at 44.1 kHz, - /// small enough that the waveform feels live. + /// of whatever the synth is producing. 256 frames ≈ 5.8 ms at 44.1 kHz — + /// small buffer = fresher samples for the visualizer. private func installWaveformTap() { let mixer = engine.mainMixerNode let format = mixer.outputFormat(forBus: 0) - // 256 frames ≈ 5.8 ms at 44.1 kHz — small buffer = fresher samples - // for the visualizer without burning the audio thread. mixer.installTap(onBus: 0, bufferSize: 256, format: format) { [weak self] buffer, _ in self?.ingestWaveformBuffer(buffer) } @@ -77,9 +165,7 @@ waveformWriteIdx = idx waveformLock.unlock() } - /// Copy the most recent `dest.count` samples from the tap ring (in - /// chronological order) into `dest`. Older samples first, newest last. - /// Cheap; safe to call from main thread on every screen frame. + /// Copy the most recent `dest.count` samples into `dest`, oldest first. func snapshotWaveform(into dest: inout [Float]) { let count = Swift.min(dest.count, Self.waveformRingSize) waveformLock.lock() @@ -93,63 +179,34 @@ } waveformLock.unlock() } - /// Plays inaudible velocity-1 notes through both samplers immediately - /// after start(). This forces sample-bank loads and audio-thread warmup - /// to happen NOW instead of on the user's first real tap, so fast taps - /// trigger sound with no perceptible delay. + /// Velocity-1 warmup notes across the range so the AU's render thread + /// has its DSP buffers primed before the user's first real tap. private func primeForLowLatency() { - // Warmup notes: a few across the range so the sampler caches more of - // its sample map. Velocity 1 is essentially silent. let melodicWarmup: [UInt8] = [60, 64, 67, 72] let drumWarmup: [UInt8] = [36, 38, 42, 46] for n in melodicWarmup { - melodic.startNote(n, withVelocity: 1, onChannel: 0) + synth.startNote(n, withVelocity: 1, onChannel: 0) } for n in drumWarmup { - drums.startNote(n, withVelocity: 1, onChannel: 0) + synth.startNote(n, withVelocity: 1, onChannel: 9) } - // Stop them on the next run loop tick so the engine actually - // schedules the start side first. DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self = self else { return } - for n in melodicWarmup { self.melodic.stopNote(n, onChannel: 0) } - for n in drumWarmup { self.drums.stopNote(n, onChannel: 0) } - } - } - - private func loadDefaultPatches() { - let url = MenuBandSynth.bankURL - guard FileManager.default.fileExists(atPath: url.path) else { - NSLog("MenuBand: gs_instruments.dls not found — falling back to default sampler tone") - return - } - // CoreAudio's DLS bank uses Roland's GS conventions: - // melodic instruments — bankMSB = 0x79 (kAUSampler_DefaultMelodicBankMSB) - // percussion — bankMSB = 0x78 (kAUSampler_DefaultPercussionBankMSB) - // Program 0 on each = the standard kit / acoustic grand piano. - let melodicMSB: UInt8 = 0x79 - let percussionMSB: UInt8 = 0x78 - let lsb: UInt8 = 0 - do { - try melodic.loadSoundBankInstrument(at: url, program: 0, bankMSB: melodicMSB, bankLSB: lsb) - } catch { - NSLog("MenuBand: melodic patch load failed: \(error)") - } - do { - try drums.loadSoundBankInstrument(at: url, program: 0, bankMSB: percussionMSB, bankLSB: lsb) - } catch { - NSLog("MenuBand: drum kit load failed: \(error)") + for n in melodicWarmup { self.synth.stopNote(n, onChannel: 0) } + for n in drumWarmup { self.synth.stopNote(n, onChannel: 9) } } } - /// Switch the melodic sampler to a different GM program (0–127). - /// Examples: 0=piano, 4=electric piano, 24=nylon guitar, 32=acoustic bass, - /// 40=violin, 48=string ensemble, 56=trumpet, 73=flute, 80=square lead. + /// Switch the *current* melodic program on channel 0. With the bank + /// preloaded, this is a single MIDI Program Change message — no file + /// I/O, no DSP rebuild. Returns immediately. The next noteOn on + /// channel 0 will play the new program. func setMelodicProgram(_ program: UInt8) { guard started else { return } - let url = MenuBandSynth.bankURL - guard FileManager.default.fileExists(atPath: url.path) else { return } - try? melodic.loadSoundBankInstrument(at: url, program: program, bankMSB: 0x79, bankLSB: 0) + stateLock.lock() + channelProgram[0] = program + stateLock.unlock() + synth.sendProgramChange(program, bankMSB: 0x79, bankLSB: 0, onChannel: 0) } func stop() { @@ -160,22 +217,22 @@ } func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { guard started else { return } - // Each AVAudioUnitSampler is itself single-channel; we pick which - // sampler based on the *requested* channel. - let unit = (channel == 9) ? drums : melodic - unit.startNote(midi, withVelocity: velocity, onChannel: 0) + synth.startNote(midi, withVelocity: velocity, onChannel: channel) } func noteOff(_ midi: UInt8, channel: UInt8 = 0) { guard started else { return } - let unit = (channel == 9) ? drums : melodic - unit.stopNote(midi, onChannel: 0) + synth.stopNote(midi, onChannel: channel) } func panic() { guard started else { return } - for unit in [melodic, drums] { - for note: UInt8 in 0...127 { unit.stopNote(note, onChannel: 0) } + // All-notes-off CC 123 on every channel. + for ch: UInt8 in 0..<16 { + synth.sendController(123, withValue: 0, onChannel: ch) + for note: UInt8 in 0...127 { + synth.stopNote(note, onChannel: ch) + } } } } 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 @@ -1,25 +1,38 @@ import AppKit -/// Bottom-anchored audio bars. Single CAShapeLayer with one combined path -/// of all bar rects, single monochrome fill — no gradient, no glow, no -/// peak-hold decay. Plain 60 Hz Timer on `.common` runloop drives the -/// path update. Designed to be as cheap as possible per frame: read -/// samples, compute 32 peaks, build path, swap path. Hidden when MIDI -/// mode is on (synth silent there). +/// Bottom-anchored audio bars synced to the display's vsync via +/// CVDisplayLink — Timer-on-runloop was throttling to ~12 Hz inside the +/// NSPopover window's mode. CVDisplayLink fires at the screen's refresh +/// rate (60 Hz on most Macs, 120 on ProMotion) regardless of run-loop +/// scheduling. Single CAShapeLayer + one combined path of 32 bar rects, +/// monochrome systemTeal fill, no decay. Hidden when MIDI mode is on. final class WaveformView: NSView { weak var menuBand: MenuBandController? private static let barCount = 32 private static let barGap: CGFloat = 2 - private static let snapshotSize = 512 // samples we look at per frame + private static let snapshotSize = 512 private var samples = [Float](repeating: 0, count: snapshotSize) private let barLayer = CAShapeLayer() - private var refreshTimer: Timer? + private var displayLink: CVDisplayLink? + + /// `true` while a `tick()` dispatch is queued to main but hasn't yet + /// run. CVDisplayLink fires every vsync; if the main thread is briefly + /// busy we MUST drop frames instead of stacking them up — a backlog of + /// dispatch_main blocks turns into post-busy stutter that reads as + /// "the visualizer is laggy." + private var tickPending = false + private let tickLock = NSLock() + + /// Smoothed peak across recent frames so the auto-gain doesn't strobe + /// — when a transient hits, gain snaps; in silence, gain bleeds back + /// over half a second so the next note pops. + private var smoothedPeak: Float = 0.05 var isLive: Bool = false { didSet { - if isLive { startTimer() } else { stopTimer() } + if isLive { startLink() } else { stopLink() } } } @@ -30,7 +43,7 @@ layer?.backgroundColor = NSColor.black.withAlphaComponent(0.92).cgColor layer?.cornerRadius = 8 barLayer.fillColor = NSColor.systemTeal.cgColor barLayer.strokeColor = nil - barLayer.actions = ["path": NSNull()] // no implicit anim on path + barLayer.actions = ["path": NSNull()] layer?.addSublayer(barLayer) } required init?(coder: NSCoder) { fatalError() } @@ -40,30 +53,49 @@ super.layout() barLayer.frame = bounds } - deinit { stopTimer() } + deinit { stopLink() } - private func startTimer() { - stopTimer() - let t = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - self?.tick() - } - // .common so it fires while the user is interacting with menus, - // dragging, etc. Without this, the timer can stall to ~12 Hz. - RunLoop.main.add(t, forMode: .common) - refreshTimer = t + private func startLink() { + stopLink() + var link: CVDisplayLink? + CVDisplayLinkCreateWithActiveCGDisplays(&link) + guard let link = link else { return } + let opaque = Unmanaged.passUnretained(self).toOpaque() + CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, ctx in + guard let ctx = ctx else { return kCVReturnSuccess } + let view = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + // Coalesce: only queue a main-thread tick if we don't already + // have one waiting. Without this, every vsync queues a tick + // even when main is too busy to service them, so a brief stall + // turns into a long chain of catch-up frames that reads as lag. + view.tickLock.lock() + let alreadyPending = view.tickPending + view.tickPending = true + view.tickLock.unlock() + if alreadyPending { return kCVReturnSuccess } + DispatchQueue.main.async { view.tick() } + return kCVReturnSuccess + }, opaque) + CVDisplayLinkStart(link) + displayLink = link } - private func stopTimer() { - refreshTimer?.invalidate() - refreshTimer = nil + private func stopLink() { + if let link = displayLink { + CVDisplayLinkStop(link) + displayLink = nil + } } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - if window == nil { stopTimer() } + if window == nil { stopLink() } } private func tick() { + tickLock.lock() + tickPending = false + tickLock.unlock() guard let m = menuBand else { return } m.synthSnapshotWaveform(into: &samples) @@ -71,13 +103,11 @@ let w = bounds.width let h = bounds.height guard w > 0, h > 0 else { return } + // Per-bar peak amplitude. let n = Self.barCount let chunkSize = samples.count / n - let barW = (w - Self.barGap * CGFloat(n - 1)) / CGFloat(n) - let stride = barW + Self.barGap - let gain: CGFloat = 2.5 // typical synth peak ~0.3–0.4, push toward full height - - let path = CGMutablePath() + var framePeak: Float = 0 + var levels = [Float](repeating: 0, count: n) for b in 0.. peak { peak = a } } - let lvl = Swift.min(1.0, CGFloat(peak) * gain) - // Bottom-anchored: y=0 is bottom (NSView default coord space). - let bh = Swift.max(1.5, lvl * h) + levels[b] = peak + if peak > framePeak { framePeak = peak } + } + + // Auto-gain normalization. Track a smoothed peak — when current + // frame is louder, jump up immediately so attack reads; when + // quieter, decay over ~½ s so a sustained-quiet note still pushes + // bars high. Floor at 0.05 so we never amplify the noise floor to + // full scale. + if framePeak > smoothedPeak { + smoothedPeak = framePeak + } else { + smoothedPeak = max(0.05, smoothedPeak * 0.92 + framePeak * 0.08) + } + let gain = CGFloat(0.95) / CGFloat(smoothedPeak) + + // Build the bar path. + let barW = (w - Self.barGap * CGFloat(n - 1)) / CGFloat(n) + let stride = barW + Self.barGap + let path = CGMutablePath() + for b in 0..Menu Band

Built-in macOS instruments, in the menu bar.