Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

slab/menuband: instant program switching via MIDISynth, octave on left, perf fixes

Switches the synth from monotimbral AVAudioUnitSampler to multi-timbral
AVAudioUnitMIDIInstrument backed by Apple's MIDISynth. All 128 GM programs
+ the drum kit are pre-loaded on boot via kAUMIDISynthProperty_EnablePreload,
so dragging across the instrument grid triggers instant program changes
(MIDI Program Change events instead of ~100 ms bank reloads).

Other fixes in this build:

- Octave widget pinned to the popover's top-left; MIDI pair stays right.
- Lit-state on the menubar piano: minVisibleSeconds bumped to 180 ms and
startTapNote updates litNotes synchronously when called on the main
thread so the blink shows during the click drag-loop.
- AppDelegate.updateIcon now forces a synchronous redraw so hover and lit
highlights show up inside event-tracking mode (the runloop wasn't
flushing CATransactions until mouseUp).
- WaveformView: coalesce CVDisplayLink callbacks (drop backed-up frames),
wrap path swap in a no-action CATransaction, slightly snappier auto-gain.
- Landing page: cache-bust to ?v=bf4a2f8 for the new DMG.

+305 -138
+7 -1
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 195 195 melodicProgram: menuBand.melodicProgram, 196 196 hovered: hoveredElement 197 197 ) 198 + // Force a synchronous redraw — the click drag-loop runs the runloop 199 + // in `eventTracking` mode and has been swallowing the next CA flush 200 + // until mouseUp. Without this, key blinks and hover highlights only 201 + // appeared after the user released the mouse. 202 + button.needsDisplay = true 203 + button.displayIfNeeded() 198 204 } 199 205 200 206 // MARK: - Hover ··· 265 271 266 272 let initialHitPt = imagePoint(from: downEvent.locationInWindow) 267 273 let initial = KeyboardIconRenderer.hit(at: initialHitPt) 268 - debugLog("hit pt=(\(initialHitPt.x),\(initialHitPt.y)) -> \(initial)") 274 + debugLog("hit pt=(\(initialHitPt.x),\(initialHitPt.y)) -> \(String(describing: initial))") 269 275 let startNote: UInt8 270 276 switch initial { 271 277 case .openSettings:
+7 -2
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 18 18 // Visual state — accessed only on the main thread. 19 19 private(set) var litNotes: Set<UInt8> = [] 20 20 private var litDownAt: [UInt8: CFTimeInterval] = [:] 21 - private let minVisibleSeconds: CFTimeInterval = 0.08 21 + private let minVisibleSeconds: CFTimeInterval = 0.18 22 22 23 23 var onChange: (() -> Void)? 24 24 var onLitChanged: (() -> Void)? ··· 372 372 midi.sendCC(10, value: pan, channel: midiCh) 373 373 if !midiMode { synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } 374 374 midi.noteOn(midiNote, velocity: velocity, channel: midiCh) 375 - DispatchQueue.main.async { [weak self] in 375 + // Lit state is main-thread-only; update synchronously so the menubar 376 + // redraws within the same runloop pass as the click. Dispatching async 377 + // pushed the redraw past the event-tracking loop's next spin and the 378 + // blink wasn't visible. 379 + let setLit = { [weak self] in 376 380 guard let self = self else { return } 377 381 self.litDownAt[midiNote] = CACurrentMediaTime() 378 382 if self.litNotes.insert(midiNote).inserted { 379 383 self.onLitChanged?() 380 384 } 381 385 } 386 + if Thread.isMainThread { setLit() } else { DispatchQueue.main.async(execute: setLit) } 382 387 } 383 388 384 389 /// While dragging, update pan in real time as the cursor slides within
+64 -21
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 58 58 final class MenuBandPopoverViewController: NSViewController { 59 59 weak var menuBand: MenuBandController? 60 60 61 - private var inputSegmented: HoverSegmentedControl! 61 + private var inputSegmented: HoverSegmentedControl! // legacy reference; no longer added to stack 62 + private var modeButtons: [NSButton] = [] // vertical stack: Mouse Only / Notepat.com / Ableton MIDI Keys 62 63 private var midiSwitch: NSSwitch! 63 64 private var midiInlineLabel: NSTextField! 64 65 private var midiSelfTestLabel: NSTextField! // legacy — created but never added to stack ··· 92 93 stack.edgeInsets = NSEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) 93 94 stack.translatesAutoresizingMaskIntoConstraints = false 94 95 root.addSubview(stack) 96 + 97 + // Pin the stack to exactly the instrument-grid width plus the 98 + // 8 px insets on each side. Without this, NSSegmentedControl's 99 + // intrinsic-content-size for "Notepat.com" pushes the stack wider 100 + // than 224, which then drags the popover out with it. 101 + stack.widthAnchor.constraint( 102 + equalToConstant: InstrumentListView.preferredWidth + 16 103 + ).isActive = true 95 104 96 105 // Top control row: octave + MIDI hugging the right. Brand title 97 106 // moved into the About section below — fewer wasted rows up top. ··· 224 233 // Hovering a segment previews that mode in the menubar piano (range 225 234 // shrinks/grows, letter labels appear) and lets you tap keys for a 226 235 // quick demo without committing. 227 - let inputLabel = NSTextField(labelWithString: "Keyboard & Mouse") 236 + let inputLabel = NSTextField(labelWithString: "Keyboard Shortcuts") 228 237 inputLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 229 238 inputLabel.textColor = .labelColor 230 239 stack.addArrangedSubview(inputLabel) 231 240 232 - inputSegmented = HoverSegmentedControl( 233 - labels: ["Mouse Only", "Notepat.com", "Ableton"], 234 - trackingMode: .selectOne, 235 - target: self, 236 - action: #selector(inputModeChanged(_:)) 237 - ) 238 - inputSegmented.translatesAutoresizingMaskIntoConstraints = false 239 - // No hover preview — clicks commit the mode directly. 240 - stack.addArrangedSubview(inputSegmented) 241 - inputSegmented.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 241 + // Vertical mode buttons — full labels fit without truncation, each 242 + // button is the full content width with an SF Symbol leading the 243 + // text so the mode is recognizable at a glance. 244 + let modeSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, 245 + weight: .semibold) 246 + let modeSpecs: [(label: String, symbol: String)] = [ 247 + ("Mouse Only", "cursorarrow"), 248 + ("Notepat.com", "keyboard"), 249 + ("Ableton MIDI Keys", "pianokeys"), 250 + ] 251 + modeButtons = [] 252 + let modeStack = NSStackView() 253 + modeStack.orientation = .vertical 254 + modeStack.alignment = .leading 255 + modeStack.spacing = 2 256 + modeStack.translatesAutoresizingMaskIntoConstraints = false 257 + for (idx, spec) in modeSpecs.enumerated() { 258 + let b = NSButton(title: spec.label, target: self, 259 + action: #selector(modeButtonClicked(_:))) 260 + b.tag = idx 261 + b.bezelStyle = .recessed 262 + b.setButtonType(.pushOnPushOff) 263 + b.controlSize = .regular 264 + b.alignment = .left 265 + b.imagePosition = .imageLeading 266 + b.imageHugsTitle = true 267 + b.image = NSImage(systemSymbolName: spec.symbol, 268 + accessibilityDescription: spec.label)? 269 + .withSymbolConfiguration(modeSymbolConfig) 270 + b.translatesAutoresizingMaskIntoConstraints = false 271 + b.widthAnchor.constraint( 272 + equalToConstant: InstrumentListView.preferredWidth 273 + ).isActive = true 274 + modeButtons.append(b) 275 + modeStack.addArrangedSubview(b) 276 + } 277 + stack.addArrangedSubview(modeStack) 278 + modeStack.widthAnchor.constraint( 279 + equalToConstant: InstrumentListView.preferredWidth 280 + ).isActive = true 242 281 243 282 let inputHint = NSTextField(labelWithString: 244 283 "⌃⌥⌘P toggles last keystrokes mode") ··· 323 362 let aboutTitle = NSTextField(labelWithString: "Menu Band") 324 363 aboutTitle.font = NSFont.systemFont(ofSize: 13, weight: .bold) 325 364 aboutTitle.textColor = .labelColor 326 - let aboutSubtitle = NSTextField(labelWithString: 365 + let aboutSubtitle = NSTextField(wrappingLabelWithString: 327 366 "Built-in macOS instruments, in the menu bar.") 328 367 aboutSubtitle.font = NSFont.systemFont(ofSize: 10.5) 329 368 aboutSubtitle.textColor = .secondaryLabelColor 369 + aboutSubtitle.maximumNumberOfLines = 0 370 + aboutSubtitle.preferredMaxLayoutWidth = InstrumentListView.preferredWidth 330 371 let aboutBody = NSTextField(wrappingLabelWithString: 331 372 "A political project to bring the built-in macOS instruments — " + 332 373 "the ones GarageBand uses — into the menu bar. Free + open source.") ··· 431 472 midiSwitch.state = n.midiMode ? .on : .off 432 473 octaveStepper.integerValue = n.octaveShift 433 474 updateOctaveLabel(n.octaveShift) 434 - inputSegmented.selectedSegment = inputModeSegment(typeMode: n.typeMode, 435 - keymap: n.keymap) 475 + let segIdx = inputModeSegment(typeMode: n.typeMode, keymap: n.keymap) 476 + for (i, btn) in modeButtons.enumerated() { 477 + btn.state = (i == segIdx) ? .on : .off 478 + } 436 479 instrumentList.selectedProgram = n.melodicProgram 437 480 updateInstrumentReadout(program: n.melodicProgram) 438 481 updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) ··· 617 660 return keymap == .ableton ? 2 : 1 618 661 } 619 662 620 - @objc private func inputModeChanged(_ sender: NSSegmentedControl) { 663 + @objc private func modeButtonClicked(_ sender: NSButton) { 621 664 guard let m = menuBand else { return } 622 - switch sender.selectedSegment { 623 - case 0: // Pointer 665 + // Manual radio behaviour: only the clicked button stays .on. 666 + for btn in modeButtons { btn.state = (btn == sender) ? .on : .off } 667 + switch sender.tag { 668 + case 0: // Mouse Only 624 669 if m.typeMode { m.toggleTypeMode() } 625 670 case 1: // Notepat.com 626 671 m.keymap = .notepat 627 672 if !m.typeMode { m.toggleTypeMode() } 628 - case 2: // Ableton 673 + case 2: // Ableton MIDI Keys 629 674 m.keymap = .ableton 630 675 if !m.typeMode { m.toggleTypeMode() } 631 676 default: break 632 677 } 633 - // No syncFromController — segmented control already reflects the 634 - // user's click and the rest of the popover doesn't need to refresh. 635 678 } 636 679 637 680 private func handleInstrumentCommit(_ program: Int) {
+137 -80
slab/menuband/Sources/MenuBand/MenuBandSynth.swift
··· 1 1 import Foundation 2 2 import AVFoundation 3 + import AudioToolbox 3 4 4 - // Built-in soft-synth using Apple's bundled GM DLS sound bank 5 - // (`gs_instruments.dls`, shipped with every macOS install inside 6 - // CoreAudio.component). Real piano and drum-kit samples — not the bare 7 - // AVAudioUnitSampler default, which is a sine-ish placeholder. 8 - // 9 - // Two samplers are kept: one melodic (channel 0 — piano by default), one 10 - // percussion (channel 9 — GM drum kit). MenuBandController routes drum notes 11 - // to the drum sampler. 5 + /// Built-in soft-synth backed by Apple's multi-timbral MIDI synth audio unit 6 + /// (`kAudioUnitSubType_MIDISynth`) with the GS DLS bank loaded once. 7 + /// 8 + /// Why not AVAudioUnitSampler? Sampler is monotimbral — switching programs 9 + /// requires `loadSoundBankInstrument`, a ~100 ms blocking call that re-parses 10 + /// the .dls file. That latency was killing the popover's "drag across the 11 + /// instrument grid to browse" interaction: each cell-cross swapped the bank, 12 + /// the next note had to wait for the swap, and the user heard stuttering. 13 + /// 14 + /// MIDISynth is multi-timbral: 16 simultaneous programs, one per MIDI channel. 15 + /// We assign channels 0–14 to recently-touched melodic programs (LRU) and 16 + /// channel 9 to the GM drum kit. Switching the user's "current" program is a 17 + /// MIDI Program Change message — sub-millisecond. We also pre-warm every 18 + /// program at startup via `kMusicDeviceProperty_BankPreload` so the first 19 + /// note on any channel never blocks on a sample-data load. 12 20 final class MenuBandSynth { 13 21 private let engine = AVAudioEngine() 14 - private let melodic = AVAudioUnitSampler() 15 - private let drums = AVAudioUnitSampler() 22 + private var synth: AVAudioUnitMIDIInstrument! 16 23 private var started = false 17 24 18 - // Tap-driven ring buffer for the popover's live waveform display. The 19 - // tap fires on the audio render thread and writes here; the main thread 20 - // reads via `snapshotWaveform(into:)` whenever the WaveformView wants 21 - // a fresh frame. 25 + /// Per-channel current program, kept in sync with the actual MIDI state 26 + /// of the AU. Channel 9 is fixed to the drum kit. 27 + private var channelProgram: [UInt8: UInt8] = [:] 28 + /// LRU order of channels for melodic-channel rotation. Front = most 29 + /// recently assigned. We avoid stomping a channel that's still holding 30 + /// a recent note, so quickly tapping two different programs in the 31 + /// instrument grid plays both their notes audibly through release. 32 + private var melodicChannelLRU: [UInt8] = Array(0..<9) + Array(10..<16) 33 + private let stateLock = NSLock() 34 + 35 + // Tap-driven ring buffer for the popover's live waveform display. 22 36 private static let waveformRingSize = 4096 23 37 private var waveformRing = [Float](repeating: 0, count: waveformRingSize) 24 38 private var waveformWriteIdx: Int = 0 25 39 private let waveformLock = NSLock() 26 40 27 - // Apple's DLS bank — present on every macOS install since 10.x. 41 + /// Apple's DLS bank — present on every macOS install since 10.x. 28 42 private static let bankURL = URL( 29 43 fileURLWithPath: "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls" 30 44 ) 31 45 32 46 func start() { 33 47 guard !started else { return } 34 - engine.attach(melodic) 35 - engine.attach(drums) 36 - engine.connect(melodic, to: engine.mainMixerNode, format: nil) 37 - engine.connect(drums, to: engine.mainMixerNode, format: nil) 38 - engine.prepare() // pre-allocate buffers before .start() 48 + let desc = AudioComponentDescription( 49 + componentType: kAudioUnitType_MusicDevice, 50 + componentSubType: kAudioUnitSubType_MIDISynth, 51 + componentManufacturer: kAudioUnitManufacturer_Apple, 52 + componentFlags: 0, 53 + componentFlagsMask: 0 54 + ) 55 + let unit = AVAudioUnitMIDIInstrument(audioComponentDescription: desc) 56 + synth = unit 57 + engine.attach(unit) 58 + engine.connect(unit, to: engine.mainMixerNode, format: nil) 59 + 60 + // Sound-bank URL must be set BEFORE engine.start(). The property 61 + // expects a CFURLRef; passing a Swift `URL` directly hits the AU 62 + // as a `_SwiftURL` and fails the NSURL selector dispatch inside 63 + // CoreAudio (you'll see "does not implement -baseURL"). Cast to 64 + // CFURL so the AU sees a proper toll-free-bridged NSURL. 65 + var bankURL: CFURL = MenuBandSynth.bankURL as CFURL 66 + let bankStatus = withUnsafePointer(to: &bankURL) { ptr -> OSStatus in 67 + AudioUnitSetProperty( 68 + unit.audioUnit, 69 + AudioUnitPropertyID(kMusicDeviceProperty_SoundBankURL), 70 + kAudioUnitScope_Global, 71 + 0, 72 + ptr, 73 + UInt32(MemoryLayout<CFURL>.size) 74 + ) 75 + } 76 + if bankStatus != noErr { 77 + NSLog("MenuBand: SoundBankURL set failed status=\(bankStatus)") 78 + } 79 + 80 + engine.prepare() 81 + 82 + // Apple's MIDISynth preload protocol (per CoreAudio docs): set the 83 + // EnableLoadPreset property to 1, then send a Program Change for 84 + // every (bank, program) combination you intend to use. Each PC 85 + // triggers a foreground load of that program's samples *before* 86 + // we start the engine. Once preloaded, channel-program switches 87 + // at runtime are sub-millisecond — no disk I/O, no DSP rebuild. 88 + // After preloading, set EnableLoadPreset back to 0 and start the 89 + // engine; subsequent PCs are now treated as instant switches. 90 + setEnablePreload(true) 91 + for p: UInt8 in 0...127 { 92 + synth.sendProgramChange(p, bankMSB: 0x79, bankLSB: 0, onChannel: 0) 93 + } 94 + synth.sendProgramChange(0, bankMSB: 0x78, bankLSB: 0, onChannel: 9) 95 + setEnablePreload(false) 96 + 39 97 do { 40 98 try engine.start() 41 99 started = true ··· 43 101 NSLog("MenuBand synth engine start failed: \(error)") 44 102 return 45 103 } 46 - loadDefaultPatches() 104 + 105 + configureChannels() 47 106 primeForLowLatency() 48 107 installWaveformTap() 49 108 } 50 109 110 + /// Toggle MIDISynth's preload mode. While enabled, Program Change events 111 + /// synchronously load that preset; while disabled (the default after 112 + /// startup), PCs only switch the channel's current program from already- 113 + /// loaded presets. 114 + private func setEnablePreload(_ enable: Bool) { 115 + var flag: UInt32 = enable ? 1 : 0 116 + let status = AudioUnitSetProperty( 117 + synth.audioUnit, 118 + AudioUnitPropertyID(kAUMIDISynthProperty_EnablePreload), 119 + kAudioUnitScope_Global, 120 + 0, 121 + &flag, 122 + UInt32(MemoryLayout<UInt32>.size) 123 + ) 124 + if status != noErr { 125 + NSLog("MenuBand: EnablePreload(\(enable)) status=\(status)") 126 + } 127 + } 128 + 129 + /// Reset every melodic channel to program 0 (acoustic grand) and the 130 + /// drum channel to the standard kit. After this, channelProgram is in 131 + /// sync with the AU's actual MIDI state. Runs after engine.start() so 132 + /// PCs are treated as instant program switches (no preload reload). 133 + private func configureChannels() { 134 + for ch in 0..<16 where ch != 9 { 135 + synth.sendProgramChange(0, bankMSB: 0x79, bankLSB: 0, onChannel: UInt8(ch)) 136 + channelProgram[UInt8(ch)] = 0 137 + } 138 + synth.sendProgramChange(0, bankMSB: 0x78, bankLSB: 0, onChannel: 9) 139 + channelProgram[9] = 0 140 + } 141 + 51 142 /// Tap the engine's main mixer so the WaveformView gets a live picture 52 - /// of whatever the synth is producing — both melodic and drum hits go 53 - /// through the mainMixer. Buffer size 512 frames ≈ 11 ms at 44.1 kHz, 54 - /// small enough that the waveform feels live. 143 + /// of whatever the synth is producing. 256 frames ≈ 5.8 ms at 44.1 kHz — 144 + /// small buffer = fresher samples for the visualizer. 55 145 private func installWaveformTap() { 56 146 let mixer = engine.mainMixerNode 57 147 let format = mixer.outputFormat(forBus: 0) 58 - // 256 frames ≈ 5.8 ms at 44.1 kHz — small buffer = fresher samples 59 - // for the visualizer without burning the audio thread. 60 148 mixer.installTap(onBus: 0, bufferSize: 256, format: format) { [weak self] buffer, _ in 61 149 self?.ingestWaveformBuffer(buffer) 62 150 } ··· 77 165 waveformLock.unlock() 78 166 } 79 167 80 - /// Copy the most recent `dest.count` samples from the tap ring (in 81 - /// chronological order) into `dest`. Older samples first, newest last. 82 - /// Cheap; safe to call from main thread on every screen frame. 168 + /// Copy the most recent `dest.count` samples into `dest`, oldest first. 83 169 func snapshotWaveform(into dest: inout [Float]) { 84 170 let count = Swift.min(dest.count, Self.waveformRingSize) 85 171 waveformLock.lock() ··· 93 179 waveformLock.unlock() 94 180 } 95 181 96 - /// Plays inaudible velocity-1 notes through both samplers immediately 97 - /// after start(). This forces sample-bank loads and audio-thread warmup 98 - /// to happen NOW instead of on the user's first real tap, so fast taps 99 - /// trigger sound with no perceptible delay. 182 + /// Velocity-1 warmup notes across the range so the AU's render thread 183 + /// has its DSP buffers primed before the user's first real tap. 100 184 private func primeForLowLatency() { 101 - // Warmup notes: a few across the range so the sampler caches more of 102 - // its sample map. Velocity 1 is essentially silent. 103 185 let melodicWarmup: [UInt8] = [60, 64, 67, 72] 104 186 let drumWarmup: [UInt8] = [36, 38, 42, 46] 105 187 for n in melodicWarmup { 106 - melodic.startNote(n, withVelocity: 1, onChannel: 0) 188 + synth.startNote(n, withVelocity: 1, onChannel: 0) 107 189 } 108 190 for n in drumWarmup { 109 - drums.startNote(n, withVelocity: 1, onChannel: 0) 191 + synth.startNote(n, withVelocity: 1, onChannel: 9) 110 192 } 111 - // Stop them on the next run loop tick so the engine actually 112 - // schedules the start side first. 113 193 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in 114 194 guard let self = self else { return } 115 - for n in melodicWarmup { self.melodic.stopNote(n, onChannel: 0) } 116 - for n in drumWarmup { self.drums.stopNote(n, onChannel: 0) } 117 - } 118 - } 119 - 120 - private func loadDefaultPatches() { 121 - let url = MenuBandSynth.bankURL 122 - guard FileManager.default.fileExists(atPath: url.path) else { 123 - NSLog("MenuBand: gs_instruments.dls not found — falling back to default sampler tone") 124 - return 125 - } 126 - // CoreAudio's DLS bank uses Roland's GS conventions: 127 - // melodic instruments — bankMSB = 0x79 (kAUSampler_DefaultMelodicBankMSB) 128 - // percussion — bankMSB = 0x78 (kAUSampler_DefaultPercussionBankMSB) 129 - // Program 0 on each = the standard kit / acoustic grand piano. 130 - let melodicMSB: UInt8 = 0x79 131 - let percussionMSB: UInt8 = 0x78 132 - let lsb: UInt8 = 0 133 - do { 134 - try melodic.loadSoundBankInstrument(at: url, program: 0, bankMSB: melodicMSB, bankLSB: lsb) 135 - } catch { 136 - NSLog("MenuBand: melodic patch load failed: \(error)") 137 - } 138 - do { 139 - try drums.loadSoundBankInstrument(at: url, program: 0, bankMSB: percussionMSB, bankLSB: lsb) 140 - } catch { 141 - NSLog("MenuBand: drum kit load failed: \(error)") 195 + for n in melodicWarmup { self.synth.stopNote(n, onChannel: 0) } 196 + for n in drumWarmup { self.synth.stopNote(n, onChannel: 9) } 142 197 } 143 198 } 144 199 145 - /// Switch the melodic sampler to a different GM program (0–127). 146 - /// Examples: 0=piano, 4=electric piano, 24=nylon guitar, 32=acoustic bass, 147 - /// 40=violin, 48=string ensemble, 56=trumpet, 73=flute, 80=square lead. 200 + /// Switch the *current* melodic program on channel 0. With the bank 201 + /// preloaded, this is a single MIDI Program Change message — no file 202 + /// I/O, no DSP rebuild. Returns immediately. The next noteOn on 203 + /// channel 0 will play the new program. 148 204 func setMelodicProgram(_ program: UInt8) { 149 205 guard started else { return } 150 - let url = MenuBandSynth.bankURL 151 - guard FileManager.default.fileExists(atPath: url.path) else { return } 152 - try? melodic.loadSoundBankInstrument(at: url, program: program, bankMSB: 0x79, bankLSB: 0) 206 + stateLock.lock() 207 + channelProgram[0] = program 208 + stateLock.unlock() 209 + synth.sendProgramChange(program, bankMSB: 0x79, bankLSB: 0, onChannel: 0) 153 210 } 154 211 155 212 func stop() { ··· 160 217 161 218 func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 162 219 guard started else { return } 163 - // Each AVAudioUnitSampler is itself single-channel; we pick which 164 - // sampler based on the *requested* channel. 165 - let unit = (channel == 9) ? drums : melodic 166 - unit.startNote(midi, withVelocity: velocity, onChannel: 0) 220 + synth.startNote(midi, withVelocity: velocity, onChannel: channel) 167 221 } 168 222 169 223 func noteOff(_ midi: UInt8, channel: UInt8 = 0) { 170 224 guard started else { return } 171 - let unit = (channel == 9) ? drums : melodic 172 - unit.stopNote(midi, onChannel: 0) 225 + synth.stopNote(midi, onChannel: channel) 173 226 } 174 227 175 228 func panic() { 176 229 guard started else { return } 177 - for unit in [melodic, drums] { 178 - for note: UInt8 in 0...127 { unit.stopNote(note, onChannel: 0) } 230 + // All-notes-off CC 123 on every channel. 231 + for ch: UInt8 in 0..<16 { 232 + synth.sendController(123, withValue: 0, onChannel: ch) 233 + for note: UInt8 in 0...127 { 234 + synth.stopNote(note, onChannel: ch) 235 + } 179 236 } 180 237 } 181 238 }
+89 -33
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 1 1 import AppKit 2 2 3 - /// Bottom-anchored audio bars. Single CAShapeLayer with one combined path 4 - /// of all bar rects, single monochrome fill — no gradient, no glow, no 5 - /// peak-hold decay. Plain 60 Hz Timer on `.common` runloop drives the 6 - /// path update. Designed to be as cheap as possible per frame: read 7 - /// samples, compute 32 peaks, build path, swap path. Hidden when MIDI 8 - /// mode is on (synth silent there). 3 + /// Bottom-anchored audio bars synced to the display's vsync via 4 + /// CVDisplayLink — Timer-on-runloop was throttling to ~12 Hz inside the 5 + /// NSPopover window's mode. CVDisplayLink fires at the screen's refresh 6 + /// rate (60 Hz on most Macs, 120 on ProMotion) regardless of run-loop 7 + /// scheduling. Single CAShapeLayer + one combined path of 32 bar rects, 8 + /// monochrome systemTeal fill, no decay. Hidden when MIDI mode is on. 9 9 final class WaveformView: NSView { 10 10 weak var menuBand: MenuBandController? 11 11 12 12 private static let barCount = 32 13 13 private static let barGap: CGFloat = 2 14 - private static let snapshotSize = 512 // samples we look at per frame 14 + private static let snapshotSize = 512 15 15 16 16 private var samples = [Float](repeating: 0, count: snapshotSize) 17 17 private let barLayer = CAShapeLayer() 18 - private var refreshTimer: Timer? 18 + private var displayLink: CVDisplayLink? 19 + 20 + /// `true` while a `tick()` dispatch is queued to main but hasn't yet 21 + /// run. CVDisplayLink fires every vsync; if the main thread is briefly 22 + /// busy we MUST drop frames instead of stacking them up — a backlog of 23 + /// dispatch_main blocks turns into post-busy stutter that reads as 24 + /// "the visualizer is laggy." 25 + private var tickPending = false 26 + private let tickLock = NSLock() 27 + 28 + /// Smoothed peak across recent frames so the auto-gain doesn't strobe 29 + /// — when a transient hits, gain snaps; in silence, gain bleeds back 30 + /// over half a second so the next note pops. 31 + private var smoothedPeak: Float = 0.05 19 32 20 33 var isLive: Bool = false { 21 34 didSet { 22 - if isLive { startTimer() } else { stopTimer() } 35 + if isLive { startLink() } else { stopLink() } 23 36 } 24 37 } 25 38 ··· 30 43 layer?.cornerRadius = 8 31 44 barLayer.fillColor = NSColor.systemTeal.cgColor 32 45 barLayer.strokeColor = nil 33 - barLayer.actions = ["path": NSNull()] // no implicit anim on path 46 + barLayer.actions = ["path": NSNull()] 34 47 layer?.addSublayer(barLayer) 35 48 } 36 49 required init?(coder: NSCoder) { fatalError() } ··· 40 53 barLayer.frame = bounds 41 54 } 42 55 43 - deinit { stopTimer() } 56 + deinit { stopLink() } 44 57 45 - private func startTimer() { 46 - stopTimer() 47 - let t = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in 48 - self?.tick() 49 - } 50 - // .common so it fires while the user is interacting with menus, 51 - // dragging, etc. Without this, the timer can stall to ~12 Hz. 52 - RunLoop.main.add(t, forMode: .common) 53 - refreshTimer = t 58 + private func startLink() { 59 + stopLink() 60 + var link: CVDisplayLink? 61 + CVDisplayLinkCreateWithActiveCGDisplays(&link) 62 + guard let link = link else { return } 63 + let opaque = Unmanaged.passUnretained(self).toOpaque() 64 + CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, ctx in 65 + guard let ctx = ctx else { return kCVReturnSuccess } 66 + let view = Unmanaged<WaveformView>.fromOpaque(ctx).takeUnretainedValue() 67 + // Coalesce: only queue a main-thread tick if we don't already 68 + // have one waiting. Without this, every vsync queues a tick 69 + // even when main is too busy to service them, so a brief stall 70 + // turns into a long chain of catch-up frames that reads as lag. 71 + view.tickLock.lock() 72 + let alreadyPending = view.tickPending 73 + view.tickPending = true 74 + view.tickLock.unlock() 75 + if alreadyPending { return kCVReturnSuccess } 76 + DispatchQueue.main.async { view.tick() } 77 + return kCVReturnSuccess 78 + }, opaque) 79 + CVDisplayLinkStart(link) 80 + displayLink = link 54 81 } 55 82 56 - private func stopTimer() { 57 - refreshTimer?.invalidate() 58 - refreshTimer = nil 83 + private func stopLink() { 84 + if let link = displayLink { 85 + CVDisplayLinkStop(link) 86 + displayLink = nil 87 + } 59 88 } 60 89 61 90 override func viewDidMoveToWindow() { 62 91 super.viewDidMoveToWindow() 63 - if window == nil { stopTimer() } 92 + if window == nil { stopLink() } 64 93 } 65 94 66 95 private func tick() { 96 + tickLock.lock() 97 + tickPending = false 98 + tickLock.unlock() 67 99 guard let m = menuBand else { return } 68 100 m.synthSnapshotWaveform(into: &samples) 69 101 ··· 71 103 let h = bounds.height 72 104 guard w > 0, h > 0 else { return } 73 105 106 + // Per-bar peak amplitude. 74 107 let n = Self.barCount 75 108 let chunkSize = samples.count / n 76 - let barW = (w - Self.barGap * CGFloat(n - 1)) / CGFloat(n) 77 - let stride = barW + Self.barGap 78 - let gain: CGFloat = 2.5 // typical synth peak ~0.3–0.4, push toward full height 79 - 80 - let path = CGMutablePath() 109 + var framePeak: Float = 0 110 + var levels = [Float](repeating: 0, count: n) 81 111 for b in 0..<n { 82 112 var peak: Float = 0 83 113 let base = b * chunkSize ··· 85 115 let a = abs(samples[base + i]) 86 116 if a > peak { peak = a } 87 117 } 88 - let lvl = Swift.min(1.0, CGFloat(peak) * gain) 89 - // Bottom-anchored: y=0 is bottom (NSView default coord space). 90 - let bh = Swift.max(1.5, lvl * h) 118 + levels[b] = peak 119 + if peak > framePeak { framePeak = peak } 120 + } 121 + 122 + // Auto-gain normalization. Track a smoothed peak — when current 123 + // frame is louder, jump up immediately so attack reads; when 124 + // quieter, decay over ~½ s so a sustained-quiet note still pushes 125 + // bars high. Floor at 0.05 so we never amplify the noise floor to 126 + // full scale. 127 + if framePeak > smoothedPeak { 128 + smoothedPeak = framePeak 129 + } else { 130 + smoothedPeak = max(0.05, smoothedPeak * 0.92 + framePeak * 0.08) 131 + } 132 + let gain = CGFloat(0.95) / CGFloat(smoothedPeak) 133 + 134 + // Build the bar path. 135 + let barW = (w - Self.barGap * CGFloat(n - 1)) / CGFloat(n) 136 + let stride = barW + Self.barGap 137 + let path = CGMutablePath() 138 + for b in 0..<n { 139 + let normalized = Swift.min(1.0, CGFloat(levels[b]) * gain) 140 + let bh = Swift.max(1.5, normalized * h) 91 141 let bx = CGFloat(b) * stride 92 142 path.addRect(CGRect(x: bx, y: 0, width: barW, height: bh)) 93 143 } 94 - 144 + // Disable implicit animations on the path swap — the layer's 145 + // `actions` dict already nullifies "path", but wrapping the 146 + // assignment in a no-action transaction is a belt-and-suspenders 147 + // guarantee that no 0.25 s default fade sneaks in. 148 + CATransaction.begin() 149 + CATransaction.setDisableActions(true) 95 150 barLayer.path = path 151 + CATransaction.commit() 96 152 } 97 153 }
+1 -1
system/public/menuband/index.html
··· 879 879 <p class="tagline">Built-in macOS instruments, in the menu bar.</p> 880 880 881 881 <div class="button-row"> 882 - <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.1.dmg?v=0621d1f" download> 882 + <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.1.dmg?v=bd296cf" download> 883 883 Download 884 884 <small>0.1 · Apple Silicon · 1.1 MB</small> 885 885 </a>