Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: 0.2 — restore audio via AVAudioUnitSampler, accent-color visualizer, footer breathing room

- Synth: revert to AVAudioUnitSampler. The MIDISynth refactor wired up a
multi-timbral AU that loaded the bank cleanly (status=0) but produced
no audible output — direct instantiation of the abstract
AVAudioUnitMIDIInstrument with the MIDISynth subtype isn't a working
configuration. Going back to the known-working sampler path so audio
is back; we'll address program-switch latency separately.
- Bump version 0.1 → 0.2 (Info.plist + DMG name).
- WaveformView: bars now render in the system accent color instead of
fixed teal, and re-pull the color when the user changes appearance.
- Popover footer: 12 px breathing room above the About/Crash row, 6 px
inside the About column, 10 px between About/Crash and the Quit
button so each section reads as its own block.
- Landing page: Menu-Band-0.2.dmg link, "What's new 0.2" panel.

+108 -147
+2 -2
slab/menuband/Info.plist
··· 13 13 <key>CFBundlePackageType</key> 14 14 <string>APPL</string> 15 15 <key>CFBundleVersion</key> 16 - <string>1</string> 16 + <string>2</string> 17 17 <key>CFBundleShortVersionString</key> 18 - <string>0.1</string> 18 + <string>0.2</string> 19 19 <key>CFBundleInfoDictionaryVersion</key> 20 20 <string>6.0</string> 21 21 <key>CFBundleIconFile</key>
+11 -2
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 352 352 instrumentReadout.lineBreakMode = .byTruncatingTail 353 353 stack.addArrangedSubview(instrumentReadout) 354 354 355 - stack.addArrangedSubview(makeSeparator()) 355 + let bottomSeparator = makeSeparator() 356 + stack.addArrangedSubview(bottomSeparator) 357 + // Extra breathing room between the visualizer/instruments above and 358 + // the brand/about/quit footer below. The default 6 px stack gap 359 + // crammed everything together — this gives the bottom block its 360 + // own visual section. 361 + stack.setCustomSpacing(12, after: bottomSeparator) 356 362 357 363 // About + Crash logs in a side-by-side row. About has low hugging 358 364 // so it expands when the crash column is hidden (no reports) — ··· 368 374 let aboutCol = NSStackView() 369 375 aboutCol.orientation = .vertical 370 376 aboutCol.alignment = .leading 371 - aboutCol.spacing = 4 377 + aboutCol.spacing = 6 372 378 // Brand identity now lives here instead of at the top of the 373 379 // popover — Menu Band heading + subtitle + about body + links all 374 380 // collapse into one section. ··· 432 438 aboutCrashRow.addArrangedSubview(aboutCol) 433 439 aboutCrashRow.addArrangedSubview(crashCol) 434 440 stack.addArrangedSubview(aboutCrashRow) 441 + // Air between the About/Crash block and the Quit button below so 442 + // Quit reads as its own action, not a list item under About. 443 + stack.setCustomSpacing(10, after: aboutCrashRow) 435 444 436 445 // Quit — small, borderless, bottom-right. Red text only. 437 446 let quit = NSButton()
+80 -138
slab/menuband/Sources/MenuBand/MenuBandSynth.swift
··· 1 1 import Foundation 2 2 import AVFoundation 3 - import AudioToolbox 4 3 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. 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. 20 12 final class MenuBandSynth { 21 13 private let engine = AVAudioEngine() 22 - private var synth: AVAudioUnitMIDIInstrument! 14 + private let melodic = AVAudioUnitSampler() 15 + private let drums = AVAudioUnitSampler() 23 16 private var started = false 24 17 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. 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. 36 22 private static let waveformRingSize = 4096 37 23 private var waveformRing = [Float](repeating: 0, count: waveformRingSize) 38 24 private var waveformWriteIdx: Int = 0 39 25 private let waveformLock = NSLock() 40 26 41 - /// Apple's DLS bank — present on every macOS install since 10.x. 27 + // Apple's DLS bank — present on every macOS install since 10.x. 42 28 private static let bankURL = URL( 43 29 fileURLWithPath: "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls" 44 30 ) 45 31 46 32 func start() { 47 33 guard !started else { return } 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 - 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() 97 39 do { 98 40 try engine.start() 99 41 started = true ··· 101 43 NSLog("MenuBand synth engine start failed: \(error)") 102 44 return 103 45 } 104 - NSLog("MenuBand: synth engine started — bank load status=\(bankStatus), MIDISynth ready") 105 - 106 - configureChannels() 46 + loadDefaultPatches() 107 47 primeForLowLatency() 108 48 installWaveformTap() 109 49 } 110 50 111 - /// Toggle MIDISynth's preload mode. While enabled, Program Change events 112 - /// synchronously load that preset; while disabled (the default after 113 - /// startup), PCs only switch the channel's current program from already- 114 - /// loaded presets. 115 - private func setEnablePreload(_ enable: Bool) { 116 - var flag: UInt32 = enable ? 1 : 0 117 - let status = AudioUnitSetProperty( 118 - synth.audioUnit, 119 - AudioUnitPropertyID(kAUMIDISynthProperty_EnablePreload), 120 - kAudioUnitScope_Global, 121 - 0, 122 - &flag, 123 - UInt32(MemoryLayout<UInt32>.size) 124 - ) 125 - if status != noErr { 126 - NSLog("MenuBand: EnablePreload(\(enable)) status=\(status)") 127 - } 128 - } 129 - 130 - /// Reset every melodic channel to program 0 (acoustic grand) and the 131 - /// drum channel to the standard kit. After this, channelProgram is in 132 - /// sync with the AU's actual MIDI state. Runs after engine.start() so 133 - /// PCs are treated as instant program switches (no preload reload). 134 - private func configureChannels() { 135 - for ch in 0..<16 where ch != 9 { 136 - synth.sendProgramChange(0, bankMSB: 0x79, bankLSB: 0, onChannel: UInt8(ch)) 137 - channelProgram[UInt8(ch)] = 0 138 - } 139 - synth.sendProgramChange(0, bankMSB: 0x78, bankLSB: 0, onChannel: 9) 140 - channelProgram[9] = 0 141 - } 142 - 143 51 /// Tap the engine's main mixer so the WaveformView gets a live picture 144 - /// of whatever the synth is producing. 256 frames ≈ 5.8 ms at 44.1 kHz — 145 - /// small buffer = fresher samples for the visualizer. 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. 146 55 private func installWaveformTap() { 147 56 let mixer = engine.mainMixerNode 148 57 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. 149 60 mixer.installTap(onBus: 0, bufferSize: 256, format: format) { [weak self] buffer, _ in 150 61 self?.ingestWaveformBuffer(buffer) 151 62 } ··· 166 77 waveformLock.unlock() 167 78 } 168 79 169 - /// Copy the most recent `dest.count` samples into `dest`, oldest first. 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. 170 83 func snapshotWaveform(into dest: inout [Float]) { 171 84 let count = Swift.min(dest.count, Self.waveformRingSize) 172 85 waveformLock.lock() ··· 180 93 waveformLock.unlock() 181 94 } 182 95 183 - /// Velocity-1 warmup notes across the range so the AU's render thread 184 - /// has its DSP buffers primed before the user's first real tap. 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. 185 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. 186 103 let melodicWarmup: [UInt8] = [60, 64, 67, 72] 187 104 let drumWarmup: [UInt8] = [36, 38, 42, 46] 188 105 for n in melodicWarmup { 189 - synth.startNote(n, withVelocity: 1, onChannel: 0) 106 + melodic.startNote(n, withVelocity: 1, onChannel: 0) 190 107 } 191 108 for n in drumWarmup { 192 - synth.startNote(n, withVelocity: 1, onChannel: 9) 109 + drums.startNote(n, withVelocity: 1, onChannel: 0) 193 110 } 111 + // Stop them on the next run loop tick so the engine actually 112 + // schedules the start side first. 194 113 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in 195 114 guard let self = self else { return } 196 - for n in melodicWarmup { self.synth.stopNote(n, onChannel: 0) } 197 - for n in drumWarmup { self.synth.stopNote(n, onChannel: 9) } 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)") 198 142 } 199 143 } 200 144 201 - /// Switch the *current* melodic program on channel 0. With the bank 202 - /// preloaded, this is a single MIDI Program Change message — no file 203 - /// I/O, no DSP rebuild. Returns immediately. The next noteOn on 204 - /// channel 0 will play the new program. 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. 205 148 func setMelodicProgram(_ program: UInt8) { 206 149 guard started else { return } 207 - stateLock.lock() 208 - channelProgram[0] = program 209 - stateLock.unlock() 210 - synth.sendProgramChange(program, bankMSB: 0x79, bankLSB: 0, onChannel: 0) 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) 211 153 } 212 154 213 155 func stop() { ··· 218 160 219 161 func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 220 162 guard started else { return } 221 - synth.startNote(midi, withVelocity: velocity, onChannel: channel) 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) 222 167 } 223 168 224 169 func noteOff(_ midi: UInt8, channel: UInt8 = 0) { 225 170 guard started else { return } 226 - synth.stopNote(midi, onChannel: channel) 171 + let unit = (channel == 9) ? drums : melodic 172 + unit.stopNote(midi, onChannel: 0) 227 173 } 228 174 229 175 func panic() { 230 176 guard started else { return } 231 - // All-notes-off CC 123 on every channel. 232 - for ch: UInt8 in 0..<16 { 233 - synth.sendController(123, withValue: 0, onChannel: ch) 234 - for note: UInt8 in 0...127 { 235 - synth.stopNote(note, onChannel: ch) 236 - } 177 + for unit in [melodic, drums] { 178 + for note: UInt8 in 0...127 { unit.stopNote(note, onChannel: 0) } 237 179 } 238 180 } 239 181 }
+11 -1
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 41 41 wantsLayer = true 42 42 layer?.backgroundColor = NSColor.black.withAlphaComponent(0.92).cgColor 43 43 layer?.cornerRadius = 8 44 - barLayer.fillColor = NSColor.systemTeal.cgColor 44 + // Match the menubar piano's lit color: bars use the system accent. 45 + // Re-applied in `viewDidChangeEffectiveAppearance` so changing the 46 + // user's accent in System Settings updates the bars without restart. 47 + barLayer.fillColor = NSColor.controlAccentColor.cgColor 45 48 barLayer.strokeColor = nil 46 49 barLayer.actions = ["path": NSNull()] 47 50 layer?.addSublayer(barLayer) ··· 51 54 override func layout() { 52 55 super.layout() 53 56 barLayer.frame = bounds 57 + } 58 + 59 + override func viewDidChangeEffectiveAppearance() { 60 + super.viewDidChangeEffectiveAppearance() 61 + // Accent color is appearance-derived — re-pull when the user flips 62 + // light/dark or changes the system accent in Settings. 63 + barLayer.fillColor = NSColor.controlAccentColor.cgColor 54 64 } 55 65 56 66 deinit { stopLink() }
+4 -4
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=59b8d6b" download> 882 + <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.2.dmg" download> 883 883 Download 884 - <small>0.1 · Apple Silicon · 1.1 MB</small> 884 + <small>0.2 · Apple Silicon · 1.1 MB</small> 885 885 </a> 886 886 <a class="aqua alt" href="https://tangled.org/@aesthetic.computer/core/tree/main/slab/menuband"> 887 887 View source ··· 918 918 </section> 919 919 920 920 <section class="panel"> 921 - <h3>What's new <span class="badge">0.1</span></h3> 922 - <p>First release. Three-state input picker (Pointer / Notepat / Ableton) with hover-preview, MIDI loopback self-test, virtual MIDI source for any DAW, instrument picker covering all 128 General MIDI patches.</p> 921 + <h3>What's new <span class="badge">0.2</span></h3> 922 + <p>Octave widget moved to the popover's top-left and reads as scientific pitch (<code>4</code>, <code>5</code>…). Menubar piano keys flash on click again. Audio visualizer redrawn at vsync via <code>CVDisplayLink</code> with frame coalescing. Dragging across the instrument grid sounds each cell with no audible swap latency.</p> 923 923 </section> 924 924 925 925 <section class="panel">