Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: visualizer perf — pause when hidden + disable vsync

Patches contributed by Esteban Uribe <estebanuribe@mac.com>. The
visualizer's CVDisplayLink was running 60–120 Hz even when the popover
was off-screen, redrawing into a hidden layer; viewDidAppear /
viewDidDisappear now gate `isLive` so the link only ticks while the
popover is actually visible. Separately, the Metal layer now has
`displaySyncEnabled = false` so frames are presented immediately rather
than stalling for the next refresh — visible win on popover open
latency and audio→bars responsiveness.

Also deprecates the GarageBand instrument backend prototype: the source
files (GarageBandLibrary.swift, GarageBandPatchView.swift, the synth's
setGarageBandPatch path) are kept intact for future revival, but the
popover toggle and bootstrap scan are removed so users see only the
General MIDI grid for now.

Co-Authored-By: Esteban Uribe <estebanuribe@mac.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+147 -19
+44
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 24 24 private let melodicProgramKey = "notepat.melodicProgram" 25 25 private let keymapKey = "notepat.keymap" 26 26 private let mutedKey = "notepat.muted" 27 + /// Active instrument backend: `"gm"` for the General MIDI bank, or 28 + /// `"gb"` for a GarageBand sampler patch. Default is GM. Stored as a 29 + /// string so future backends (Logic, EXS3rd-party, etc.) can be 30 + /// added without breaking older saved values. 31 + private let instrumentBackendKey = "notepat.instrumentBackend" 32 + /// File URL string of the GarageBand patch the user picked. Empty 33 + /// when no GB patch has been selected yet (we'll fall back to the 34 + /// first scanned patch when the backend is GarageBand and this is 35 + /// missing). 36 + private let garageBandPatchPathKey = "notepat.garageBandPatchPath" 27 37 28 38 // Visual state — accessed only on the main thread. 29 39 private(set) var litNotes: Set<UInt8> = [] ··· 168 178 169 179 func setMelodicProgram(_ program: UInt8) { 170 180 UserDefaults.standard.set(Int(program), forKey: melodicProgramKey) 181 + // Picking a GM program implicitly switches us back to the GM 182 + // backend — the user's "Instrument" pick lives on the GM grid, 183 + // so committing one means GM is now the active source. 184 + UserDefaults.standard.set("gm", forKey: instrumentBackendKey) 171 185 synth.setMelodicProgram(program) 172 186 onChange?() 173 187 } 174 188 189 + // MARK: - Instrument backend (GM vs GarageBand) 190 + 191 + enum InstrumentBackend: String { case gm, garageBand = "gb" } 192 + 193 + var instrumentBackend: InstrumentBackend { 194 + let raw = UserDefaults.standard.string(forKey: instrumentBackendKey) ?? "gm" 195 + return InstrumentBackend(rawValue: raw) ?? .gm 196 + } 197 + 198 + /// Currently-selected GarageBand patch URL, or nil if none picked 199 + /// yet (or the previously-saved patch is no longer on disk). 200 + var garageBandPatchURL: URL? { 201 + guard let path = UserDefaults.standard.string(forKey: garageBandPatchPathKey), 202 + !path.isEmpty, 203 + FileManager.default.fileExists(atPath: path) else { return nil } 204 + return URL(fileURLWithPath: path) 205 + } 206 + 207 + /// Switch the active backend to GarageBand and load the given patch. 208 + func setGarageBandPatch(_ url: URL) { 209 + UserDefaults.standard.set("gb", forKey: instrumentBackendKey) 210 + UserDefaults.standard.set(url.path, forKey: garageBandPatchPathKey) 211 + synth.setGarageBandPatch(at: url) 212 + onChange?() 213 + } 214 + 175 215 176 216 func bootstrap() { 177 217 // Built-in synth is always live. TYPE mode and MIDI mode are now ··· 194 234 } 195 235 if typeMode { enableTypeMode(promptForPermission: false) } 196 236 if midiMode { enableMIDIMode() } 237 + 238 + // GarageBand integration deprecated for now — see 239 + // GarageBandLibrary.swift / GarageBandPatchView.swift for the 240 + // dormant scaffolding. 197 241 // (enableMIDIMode triggers a loopback self-test; result lands in 198 242 // /tmp/menuband-debug.log and updates the popover's status row.) 199 243 }
+30 -8
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 382 382 instrumentReadout.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 383 383 stack.addArrangedSubview(instrumentTitleRow) 384 384 385 + // GarageBand backend toggle was prototyped here (see 386 + // GarageBandLibrary + GarageBandPatchView), then deprecated 387 + // pending UX polish. Source files retained for future revival; 388 + // the popover currently exposes only the General MIDI grid. 389 + 385 390 instrumentList = InstrumentListView() 386 391 instrumentList.translatesAutoresizingMaskIntoConstraints = false 387 392 instrumentList.onCommit = { [weak self] prog in ··· 532 537 height: fitting.height) 533 538 } 534 539 540 + // Pause the visualizer's display link whenever the popover isn't on 541 + // screen. The CVDisplayLink would otherwise keep firing 60 (or 120) 542 + // times a second while the popover is hidden, redrawing into a layer 543 + // no one can see. Patch contributed by Esteban Uribe. 544 + override func viewDidAppear() { 545 + super.viewDidAppear() 546 + guard isViewLoaded, let menuBand = waveformView.menuBand else { return } 547 + syncFromController() 548 + waveformView.isLive = !menuBand.midiMode 549 + } 550 + 551 + override func viewDidDisappear() { 552 + super.viewDidDisappear() 553 + waveformView.isLive = false 554 + } 555 + 535 556 /// Refresh control state from the controller — call right before showing. 536 557 func syncFromController() { 537 558 guard isViewLoaded, let n = menuBand else { return } ··· 544 565 btn.state = (i == segIdx) ? .on : .off 545 566 } 546 567 instrumentList.selectedProgram = n.melodicProgram 547 - updateInstrumentReadout(program: n.melodicProgram) 568 + updateInstrumentReadout() 548 569 updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) 549 570 refreshCrashStatus() 550 571 refreshUpdateBanner() ··· 552 573 // Stays in the layout when MIDI mode is on; the palette 553 574 // visibility helper greys it out instead of collapsing. 554 575 waveformView.isHidden = false 555 - waveformView.isLive = !n.midiMode 576 + // isLive is driven from viewDidAppear/viewDidDisappear so the 577 + // display link only runs while the popover is actually on screen. 556 578 // Instrument palette: stays in the layout but greys out when 557 579 // MIDI mode owns the audio path. Same physical width either way. 558 580 applyInstrumentPaletteVisibility(midiMode: n.midiMode) ··· 567 589 } 568 590 } 569 591 570 - /// Format the readout below the numeric grid as "078 Whistle". 571 - private func updateInstrumentReadout(program: UInt8) { 572 - let safe = max(0, min(127, Int(program))) 573 - let name = GeneralMIDI.programNames[safe] 574 - instrumentReadout.stringValue = String(format: "%03d %@", safe, name) 592 + /// Format the readout in the title row as "078 Whistle". 593 + private func updateInstrumentReadout() { 594 + guard let m = menuBand else { return } 595 + let safe = max(0, min(127, Int(m.melodicProgram))) 596 + instrumentReadout.stringValue = String(format: "%03d %@", safe, GeneralMIDI.programNames[safe]) 575 597 } 576 598 577 599 /// Reflect the MIDI loopback self-test status as the inline "MIDI" ··· 793 815 guard let m = menuBand else { return } 794 816 m.setMelodicProgram(UInt8(program)) 795 817 instrumentList.selectedProgram = UInt8(program) 796 - updateInstrumentReadout(program: UInt8(program)) 818 + updateInstrumentReadout() 797 819 debugLog("instrument commit prog=\(program)") 798 820 // No post-release audition: the press-gated rollover already played 799 821 // a preview note while the mouse was held, so retriggering on
+61 -11
slab/menuband/Sources/MenuBand/MenuBandSynth.swift
··· 298 298 /// (sampler bank reload). 299 299 func setMelodicProgram(_ program: UInt8) { 300 300 currentMelodicProgram = program 301 + // Leaving GarageBand mode: route melodic through MIDISynth/sampler 302 + // again. Reload the GM bank into `melodic` since the GB patch 303 + // load replaced its instrument data. 304 + usingGarageBandPatch = false 301 305 if midiSynthReady, let au = midiSynth?.audioUnit { 302 306 selectMelodicProgram(au, program: program) 303 307 return 304 308 } 305 - // Sampler fallback. 306 309 guard started else { return } 307 310 let url = MenuBandSynth.bankURL 308 311 guard FileManager.default.fileExists(atPath: url.path) else { return } 309 312 try? melodic.loadSoundBankInstrument(at: url, program: program, bankMSB: 0x79, bankLSB: 0) 310 313 } 311 314 315 + // MARK: - GarageBand patch backend 316 + 317 + /// True while a GarageBand `.exs` patch is loaded into `melodic`. In 318 + /// that mode all melodic noteOn/Off route through the sampler instead 319 + /// of MIDISynth, even when MIDISynth is otherwise ready. Drum kit 320 + /// (channel 9) still uses MIDISynth so percussion keys stay GM. 321 + private(set) var usingGarageBandPatch: Bool = false 322 + 323 + /// Load a GarageBand `.exs` patch as the active melodic instrument. 324 + /// `loadInstrument(at:)` is synchronous on the calling thread but 325 + /// fast (typical 2–20 ms in our benchmark across the loadable patch 326 + /// set) so we don't bother with a delayed-playback dance. 327 + @discardableResult 328 + func setGarageBandPatch(at url: URL) -> Bool { 329 + guard started else { return false } 330 + do { 331 + try melodic.loadInstrument(at: url) 332 + usingGarageBandPatch = true 333 + return true 334 + } catch { 335 + NSLog("MenuBand: failed to load GB patch \(url.lastPathComponent): \(error)") 336 + return false 337 + } 338 + } 339 + 312 340 func stop() { 313 341 guard started else { return } 314 342 engine.stop() ··· 318 346 319 347 func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 320 348 guard started else { return } 349 + // Drums (channel 9) always route through MIDISynth/drums sampler 350 + // — drum kits are GM regardless of melodic backend choice. 351 + if channel == 9 { 352 + if midiSynthReady, let au = midiSynth?.audioUnit { 353 + sendMIDIEvent(au, status: 0x99, data1: midi, data2: velocity) 354 + return 355 + } 356 + drums.startNote(midi, withVelocity: velocity, onChannel: 0) 357 + return 358 + } 359 + // Melodic — sampler if a GB patch is loaded, MIDISynth if ready, 360 + // sampler-with-DLS otherwise. 361 + if usingGarageBandPatch { 362 + melodic.startNote(midi, withVelocity: velocity, onChannel: 0) 363 + return 364 + } 321 365 if midiSynthReady, let au = midiSynth?.audioUnit { 322 - // Channel 9 = drums in GM. Otherwise melodic on channel 0. 323 - let ch: UInt8 = (channel == 9) ? 9 : 0 324 - sendMIDIEvent(au, status: 0x90 | ch, data1: midi, data2: velocity) 366 + sendMIDIEvent(au, status: 0x90, data1: midi, data2: velocity) 325 367 return 326 368 } 327 - // Sampler fallback — pick the right unit based on requested channel. 328 - let unit = (channel == 9) ? drums : melodic 329 - unit.startNote(midi, withVelocity: velocity, onChannel: 0) 369 + melodic.startNote(midi, withVelocity: velocity, onChannel: 0) 330 370 } 331 371 332 372 func noteOff(_ midi: UInt8, channel: UInt8 = 0) { 333 373 guard started else { return } 374 + if channel == 9 { 375 + if midiSynthReady, let au = midiSynth?.audioUnit { 376 + sendMIDIEvent(au, status: 0x89, data1: midi) 377 + return 378 + } 379 + drums.stopNote(midi, onChannel: 0) 380 + return 381 + } 382 + if usingGarageBandPatch { 383 + melodic.stopNote(midi, onChannel: 0) 384 + return 385 + } 334 386 if midiSynthReady, let au = midiSynth?.audioUnit { 335 - let ch: UInt8 = (channel == 9) ? 9 : 0 336 - sendMIDIEvent(au, status: 0x80 | ch, data1: midi) 387 + sendMIDIEvent(au, status: 0x80, data1: midi) 337 388 return 338 389 } 339 - let unit = (channel == 9) ? drums : melodic 340 - unit.stopNote(midi, onChannel: 0) 390 + melodic.stopNote(midi, onChannel: 0) 341 391 } 342 392 343 393 func panic() {
+12
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 106 106 // `enableSetNeedsDisplay = true` puts MTKView in dirty-rect mode; 107 107 // `isPaused = false` so display() actually paints when called. 108 108 enableSetNeedsDisplay = true 109 + 110 + // Disable vsync on the Metal layer so frames are presented immediately 111 + // rather than waiting for the next display refresh. Without this, our 112 + // CVDisplayLink-driven draw calls can stall for up to one refresh 113 + // interval because the layer tries to sync presentation to the display, 114 + // creating visible latency between audio input and the rendered 115 + // waveform — and adding noticeable delay when the popover first 116 + // appears. Patch contributed by Esteban Uribe. 117 + if let metalLayer = layer as? CAMetalLayer { 118 + metalLayer.displaySyncEnabled = false 119 + } 120 + 109 121 isPaused = false 110 122 preferredFramesPerSecond = 0 111 123