Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: dual-backend synth — MIDISynth primary, sampler fallback

Dragging across the instrument grid now sounds each cell on mouseDown
with no perceptible swap latency once the MIDISynth backend has come up.

The synth keeps two backends:

1. AVAudioUnit MIDISynth, instantiated asynchronously via
AVAudioUnit.instantiate(with:options:). Bank loaded once, every
program preloaded via kAUMIDISynthProperty_EnablePreload, MIDI
events sent directly via MusicDeviceMIDIEvent. Program switches
are sub-millisecond.
2. AVAudioUnitSampler (the prior backend), always live. Used while
MIDISynth is still spinning up so the user always hears something
from the very first click.

setMelodicProgram + noteOn route to whichever backend is currently
hot. Once MIDISynth flips ready, the controller's hover-preview path
drops its 70 ms swap-settle delay and fires noteOn immediately on
mouseDown — the click feels like the menubar piano keys.

Octave hint label capitalized (Octave).

+229 -60
+28 -1
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 49 49 // (DAW is the audio path then; we still apply the program change so 50 50 // it's correct when the user toggles MIDI off). 51 51 private var previewNote: UInt8? 52 + /// Pending noteOn dispatched after a setMelodicProgram swap. Held so 53 + /// a fast hover sequence (cell A → cell B → cell C in <70 ms) cancels 54 + /// the not-yet-fired note for B before C's load even starts. 55 + private var pendingPreviewWork: DispatchWorkItem? 56 + private let previewLoadDelay: TimeInterval = 0.07 52 57 53 58 /// Hover-preview a program in the instrument map. Pass nil when the 54 59 /// hover ends to release the held note and restore the committed 55 60 /// program. 56 61 func setInstrumentPreview(_ program: UInt8?) { 62 + // Always cancel any pending preview noteOn from a prior call — 63 + // each hover/click target gets a fresh 70 ms scheduling slot. 64 + pendingPreviewWork?.cancel() 65 + pendingPreviewWork = nil 57 66 if let prev = previewNote { 58 67 synth.noteOff(prev, channel: 0) 59 68 previewNote = nil ··· 67 76 synth.setMelodicProgram(prog) 68 77 guard !midiMode else { return } 69 78 let note: UInt8 = 60 70 - synth.noteOn(note, velocity: 75, channel: 0) 71 79 previewNote = note 80 + // When the synth supports instant program changes (MIDISynth backend 81 + // ready), fire noteOn immediately — the user's mouseDown becomes an 82 + // audible click with no perceptible delay. Sampler fallback still 83 + // needs the ~70 ms swap-settle window: AVAudioUnitSampler briefly 84 + // drops scheduled notes during `loadSoundBankInstrument`, so an 85 + // immediate noteOn falls into that gap and goes silent. 86 + if synth.supportsInstantProgramChange { 87 + synth.noteOn(note, velocity: 75, channel: 0) 88 + return 89 + } 90 + let work = DispatchWorkItem { [weak self] in 91 + guard let self = self, 92 + self.previewNote == note, 93 + !self.midiMode else { return } 94 + self.synth.noteOn(note, velocity: 75, channel: 0) 95 + } 96 + pendingPreviewWork = work 97 + DispatchQueue.main.asyncAfter(deadline: .now() + previewLoadDelay, 98 + execute: work) 72 99 } 73 100 74 101 func auditionCurrentProgram() {
+1 -1
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 170 170 // "octave" hint label sits to the right of the rightArrow so the 171 171 // number reads as scientific pitch notation (C4, C5, …) without 172 172 // taking much room. 173 - let octaveHint = NSTextField(labelWithString: "octave") 173 + let octaveHint = NSTextField(labelWithString: "Octave") 174 174 octaveHint.font = NSFont.systemFont(ofSize: 9, weight: .regular) 175 175 octaveHint.textColor = .tertiaryLabelColor 176 176
+200 -58
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 using Apple's bundled GM DLS sound bank 6 + /// (`gs_instruments.dls`). 7 + /// 8 + /// Architecture: two backends, one chosen at startup. 9 + /// 10 + /// 1. **Multi-timbral (fast)** — Apple's MIDISynth audio unit 11 + /// (`kAudioUnitSubType_MIDISynth`) with the GS DLS bank loaded once 12 + /// and every program preloaded via `kAUMIDISynthProperty_EnablePreload`. 13 + /// Program switches are instant MIDI Program Change messages — no disk 14 + /// I/O, no DSP rebuild — so dragging across the instrument grid sounds 15 + /// each cell with zero swap latency. 16 + /// 17 + /// 2. **Single-timbral (fallback)** — `AVAudioUnitSampler` with the same 18 + /// bank loaded one program at a time. Program switches re-load the 19 + /// bank (~100 ms blocking + AU silences scheduled notes during the 20 + /// swap). Used only if the MIDISynth path fails to instantiate or 21 + /// produce audio. 22 + /// 23 + /// MIDISynth instantiation is asynchronous (Apple's AU framework needs a 24 + /// callback round-trip before the AU is usable). Until it's ready, 25 + /// `noteOn`/`setMelodicProgram` route to the sampler so the user always 26 + /// hears something. Once MIDISynth is up and primed, subsequent calls 27 + /// flip over silently — no audible glitch. 12 28 final class MenuBandSynth { 13 29 private let engine = AVAudioEngine() 30 + 31 + /// Preferred backend, asynchronously created. Nil until ready. 32 + private var midiSynth: AVAudioUnit? 33 + /// Fallback backend, always available immediately. 14 34 private let melodic = AVAudioUnitSampler() 15 35 private let drums = AVAudioUnitSampler() 16 36 private var started = false 37 + /// True once MIDISynth has loaded its bank, preloaded all programs, 38 + /// and successfully attached to the engine. `noteOn` and 39 + /// `setMelodicProgram` route through the MIDISynth when this flips. 40 + private(set) var midiSynthReady = false 17 41 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. 42 + /// External callers (the controller's hover-preview path) read this 43 + /// to decide whether they need to wait for the bank-swap settle delay 44 + /// before issuing a noteOn after a setMelodicProgram. When MIDISynth 45 + /// is the active backend, swaps are sub-millisecond and the delay 46 + /// can be zero. 47 + var supportsInstantProgramChange: Bool { midiSynthReady } 48 + private var currentMelodicProgram: UInt8 = 0 49 + 50 + // Tap-driven ring buffer for the popover's live waveform display. 22 51 private static let waveformRingSize = 4096 23 52 private var waveformRing = [Float](repeating: 0, count: waveformRingSize) 24 53 private var waveformWriteIdx: Int = 0 25 54 private let waveformLock = NSLock() 26 55 27 - // Apple's DLS bank — present on every macOS install since 10.x. 56 + /// Apple's DLS bank — present on every macOS install since 10.x. 28 57 private static let bankURL = URL( 29 58 fileURLWithPath: "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls" 30 59 ) ··· 35 64 engine.attach(drums) 36 65 engine.connect(melodic, to: engine.mainMixerNode, format: nil) 37 66 engine.connect(drums, to: engine.mainMixerNode, format: nil) 38 - engine.prepare() // pre-allocate buffers before .start() 67 + engine.prepare() 39 68 do { 40 69 try engine.start() 41 70 started = true ··· 46 75 loadDefaultPatches() 47 76 primeForLowLatency() 48 77 installWaveformTap() 78 + 79 + // Try to bring up the multi-timbral MIDISynth in the background. 80 + // If it works, we'll route notes through it for instant program 81 + // switching. If it doesn't, the user keeps the sampler fallback. 82 + startMIDISynthBackend() 49 83 } 50 84 51 - /// 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. 85 + // MARK: - MIDISynth (multi-timbral, instant switching) 86 + 87 + private func startMIDISynthBackend() { 88 + let desc = AudioComponentDescription( 89 + componentType: kAudioUnitType_MusicDevice, 90 + componentSubType: kAudioUnitSubType_MIDISynth, 91 + componentManufacturer: kAudioUnitManufacturer_Apple, 92 + componentFlags: 0, 93 + componentFlagsMask: 0 94 + ) 95 + AVAudioUnit.instantiate(with: desc, options: []) { [weak self] avUnit, error in 96 + DispatchQueue.main.async { 97 + guard let self = self, let avUnit = avUnit, error == nil else { 98 + NSLog("MenuBand: MIDISynth instantiate failed: \(String(describing: error)) — staying on sampler fallback") 99 + return 100 + } 101 + self.configureMIDISynth(avUnit) 102 + } 103 + } 104 + } 105 + 106 + private func configureMIDISynth(_ avUnit: AVAudioUnit) { 107 + let au = avUnit.audioUnit 108 + 109 + // 1. Set the GS DLS bank URL. Must be CFURL — passing a Swift `URL` 110 + // fails the NSURL selector dispatch inside CoreAudio. 111 + var bankURL: CFURL = MenuBandSynth.bankURL as CFURL 112 + let bankStatus = withUnsafePointer(to: &bankURL) { ptr -> OSStatus in 113 + AudioUnitSetProperty( 114 + au, 115 + AudioUnitPropertyID(kMusicDeviceProperty_SoundBankURL), 116 + kAudioUnitScope_Global, 117 + 0, 118 + ptr, 119 + UInt32(MemoryLayout<CFURL>.size) 120 + ) 121 + } 122 + guard bankStatus == noErr else { 123 + NSLog("MenuBand: MIDISynth bank URL set failed status=\(bankStatus) — staying on sampler fallback") 124 + return 125 + } 126 + 127 + // 2. Attach + connect to the engine. Engine is already running so 128 + // this is hot-attach; AVAudioEngine handles graph reconfig. 129 + engine.attach(avUnit) 130 + engine.connect(avUnit, to: engine.mainMixerNode, format: nil) 131 + 132 + // 3. Enable preload mode, send Program Change for every program we 133 + // want to use, then disable preload. Each PC during preload mode 134 + // triggers a synchronous bank load for that (bank, program); after 135 + // preload is disabled, subsequent PCs become instant switches. 136 + setMIDISynthPreload(au, enable: true) 137 + for p: UInt8 in 0...127 { 138 + sendMIDIEvent(au, status: 0xC0, data1: p) // PC ch 0 139 + } 140 + // Drum kit on channel 9 — bankMSB 0x78 = GM Percussion. Send a 141 + // bank-select pair followed by PC 0 to land on the standard kit. 142 + sendMIDIEvent(au, status: 0xB9, data1: 0, data2: 0x78) // CC0 = MSB 143 + sendMIDIEvent(au, status: 0xB9, data1: 32, data2: 0) // CC32 = LSB 144 + sendMIDIEvent(au, status: 0xC9, data1: 0) // PC ch 9 145 + setMIDISynthPreload(au, enable: false) 146 + 147 + // 4. Reset channel 0 to the user's last-picked program. The earlier 148 + // sweep left it on program 127 — we want it to match what the 149 + // user expects. 150 + sendMIDIEvent(au, status: 0xC0, data1: currentMelodicProgram) 151 + 152 + midiSynth = avUnit 153 + midiSynthReady = true 154 + NSLog("MenuBand: MIDISynth ready — instant program switching enabled") 155 + 156 + // The sampler fallback is no longer needed for melodic playback — 157 + // disconnect it to avoid double-triggering. Keep `drums` connected 158 + // since the MIDISynth's drum-kit voice is on channel 9 of the same 159 + // unit, so we'll just stop sending drum notes through `drums`. 160 + // (Leaving the nodes attached but unrouted is safe.) 161 + } 162 + 163 + private func setMIDISynthPreload(_ au: AudioUnit, enable: Bool) { 164 + var flag: UInt32 = enable ? 1 : 0 165 + let status = AudioUnitSetProperty( 166 + au, 167 + AudioUnitPropertyID(kAUMIDISynthProperty_EnablePreload), 168 + kAudioUnitScope_Global, 169 + 0, 170 + &flag, 171 + UInt32(MemoryLayout<UInt32>.size) 172 + ) 173 + if status != noErr { 174 + NSLog("MenuBand: MIDISynth EnablePreload(\(enable)) status=\(status)") 175 + } 176 + } 177 + 178 + @inline(__always) 179 + private func sendMIDIEvent(_ au: AudioUnit, status: UInt8, data1: UInt8, data2: UInt8 = 0) { 180 + MusicDeviceMIDIEvent(au, UInt32(status), UInt32(data1), UInt32(data2), 0) 181 + } 182 + 183 + // MARK: - Audio tap for visualizer 184 + 55 185 private func installWaveformTap() { 56 186 let mixer = engine.mainMixerNode 57 187 let format = mixer.outputFormat(forBus: 0) 58 - // 256 frames ≈ 5.8 ms at 44.1 kHz — small buffer = fresher samples 188 + // 256 frames ≈ 5.8 ms at 44.1 kHz — small buffer = fresh samples 59 189 // for the visualizer without burning the audio thread. 60 190 mixer.installTap(onBus: 0, bufferSize: 256, format: format) { [weak self] buffer, _ in 61 191 self?.ingestWaveformBuffer(buffer) ··· 77 207 waveformLock.unlock() 78 208 } 79 209 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. 83 210 func snapshotWaveform(into dest: inout [Float]) { 84 211 let count = Swift.min(dest.count, Self.waveformRingSize) 85 212 waveformLock.lock() ··· 93 220 waveformLock.unlock() 94 221 } 95 222 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. 100 - 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 - let melodicWarmup: [UInt8] = [60, 64, 67, 72] 104 - let drumWarmup: [UInt8] = [36, 38, 42, 46] 105 - for n in melodicWarmup { 106 - melodic.startNote(n, withVelocity: 1, onChannel: 0) 107 - } 108 - for n in drumWarmup { 109 - drums.startNote(n, withVelocity: 1, onChannel: 0) 110 - } 111 - // Stop them on the next run loop tick so the engine actually 112 - // schedules the start side first. 113 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in 114 - 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 - } 223 + // MARK: - Sampler fallback setup 119 224 120 225 private func loadDefaultPatches() { 121 226 let url = MenuBandSynth.bankURL 122 227 guard FileManager.default.fileExists(atPath: url.path) else { 123 - NSLog("MenuBand: gs_instruments.dls not found — falling back to default sampler tone") 228 + NSLog("MenuBand: gs_instruments.dls not found") 124 229 return 125 230 } 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 231 let melodicMSB: UInt8 = 0x79 131 232 let percussionMSB: UInt8 = 0x78 132 - let lsb: UInt8 = 0 133 233 do { 134 - try melodic.loadSoundBankInstrument(at: url, program: 0, bankMSB: melodicMSB, bankLSB: lsb) 234 + try melodic.loadSoundBankInstrument(at: url, program: 0, bankMSB: melodicMSB, bankLSB: 0) 135 235 } catch { 136 236 NSLog("MenuBand: melodic patch load failed: \(error)") 137 237 } 138 238 do { 139 - try drums.loadSoundBankInstrument(at: url, program: 0, bankMSB: percussionMSB, bankLSB: lsb) 239 + try drums.loadSoundBankInstrument(at: url, program: 0, bankMSB: percussionMSB, bankLSB: 0) 140 240 } catch { 141 241 NSLog("MenuBand: drum kit load failed: \(error)") 142 242 } 143 243 } 144 244 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. 245 + private func primeForLowLatency() { 246 + let melodicWarmup: [UInt8] = [60, 64, 67, 72] 247 + let drumWarmup: [UInt8] = [36, 38, 42, 46] 248 + for n in melodicWarmup { 249 + melodic.startNote(n, withVelocity: 1, onChannel: 0) 250 + } 251 + for n in drumWarmup { 252 + drums.startNote(n, withVelocity: 1, onChannel: 0) 253 + } 254 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in 255 + guard let self = self else { return } 256 + for n in melodicWarmup { self.melodic.stopNote(n, onChannel: 0) } 257 + for n in drumWarmup { self.drums.stopNote(n, onChannel: 0) } 258 + } 259 + } 260 + 261 + // MARK: - Public API 262 + 263 + /// Switch the current melodic program. Instant when MIDISynth is ready 264 + /// (sub-millisecond MIDI Program Change); blocks ~100 ms otherwise 265 + /// (sampler bank reload). 148 266 func setMelodicProgram(_ program: UInt8) { 267 + currentMelodicProgram = program 268 + if midiSynthReady, let au = midiSynth?.audioUnit { 269 + sendMIDIEvent(au, status: 0xC0, data1: program) 270 + return 271 + } 272 + // Sampler fallback. 149 273 guard started else { return } 150 274 let url = MenuBandSynth.bankURL 151 275 guard FileManager.default.fileExists(atPath: url.path) else { return } ··· 156 280 guard started else { return } 157 281 engine.stop() 158 282 started = false 283 + midiSynthReady = false 159 284 } 160 285 161 286 func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 162 287 guard started else { return } 163 - // Each AVAudioUnitSampler is itself single-channel; we pick which 164 - // sampler based on the *requested* channel. 288 + if midiSynthReady, let au = midiSynth?.audioUnit { 289 + // Channel 9 = drums in GM. Otherwise melodic on channel 0. 290 + let ch: UInt8 = (channel == 9) ? 9 : 0 291 + sendMIDIEvent(au, status: 0x90 | ch, data1: midi, data2: velocity) 292 + return 293 + } 294 + // Sampler fallback — pick the right unit based on requested channel. 165 295 let unit = (channel == 9) ? drums : melodic 166 296 unit.startNote(midi, withVelocity: velocity, onChannel: 0) 167 297 } 168 298 169 299 func noteOff(_ midi: UInt8, channel: UInt8 = 0) { 170 300 guard started else { return } 301 + if midiSynthReady, let au = midiSynth?.audioUnit { 302 + let ch: UInt8 = (channel == 9) ? 9 : 0 303 + sendMIDIEvent(au, status: 0x80 | ch, data1: midi) 304 + return 305 + } 171 306 let unit = (channel == 9) ? drums : melodic 172 307 unit.stopNote(midi, onChannel: 0) 173 308 } 174 309 175 310 func panic() { 176 311 guard started else { return } 312 + if midiSynthReady, let au = midiSynth?.audioUnit { 313 + for ch: UInt8 in 0..<16 { 314 + // CC 123 = All Notes Off. 315 + sendMIDIEvent(au, status: 0xB0 | ch, data1: 123, data2: 0) 316 + } 317 + return 318 + } 177 319 for unit in [melodic, drums] { 178 320 for note: UInt8 in 0...127 { unit.stopNote(note, onChannel: 0) } 179 321 }