Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: voice palette polish + qwerty-pan + physics ghost

Voice palette
- Static GM family palette (16 hand-picked CSS-named hues, one per
family) replaces the hue gradient. Each program inside a family
gets its own shade via brightness + saturation walk so all 128
voices read distinctly while staying cohesive within their family.
- Cells now look like keycap chiclets — rounded inset outlines tinted
in the family color, edge-to-edge so dragging stays seamless.
- Voice readout (Voice:) shows just the program name in a chip
whose backdrop tracks the family color. Hover-drag previews retint
the chip + visualizer in real time; the visualizer's bars now use
the chosen voice's hue (top of the bar still brightens toward
white for a peaking cue).
- Auto-disable MIDI on instrument commit + audition the picked voice
at lastPlayedNote (last pitch the user touched, not always C4).

Arrow-key navigation on the voice grid
- ← → ↑ ↓ walk the 8×16 grid. Auto-repeat ignored so each press is
exactly one move; preview note still sustains while held, releases
on keyUp + commits the cell.
- Non-arrow keys forwarded through handleLocalKey so notepat /
Ableton letter keys still play notes while the popover holds key
focus on the grid.
- ArrowKeysIndicator: custom MacBook-style four-keycap cluster in
the bottom-right corner of the palette. Each key lights to accent
while pressed. Tooltip-only hint glyph.

Physics-based letter ghost
- Replaced the phase / smoothstep curve animation with per-cell
alphas smoothed toward targets every tick. Asymmetric rates
(fast attack, slow decay) feel reactive without snapping when a
fresh keypress lands during the fade-out.
- Wave attack / release stays distance-driven (cells far from the
pivot light up later, far cells fade first), but a cell that's
already been "reached" stays reached so pivot changes mid-wave
don't dim already-bright cells back to 0.

QWERTY-position panning
- Ported notepat native's getPanForQwertyKey to MenuBandLayout —
physical keyboard column → MIDI CC10 pan. Left-hand keys play left,
right-hand keys play right, kept inside ±0.9 so nothing is hard-
panned. Sent to the local synth (MIDISynth backend) and outbound
MIDI port so DAWs hear the same stereo placement.

Misc
- LocalKeyCapture: don't disarm when key focus passes to another
window inside our own app (popover takes key without killing
arming).
- White-key letter labels nudged down a hair (rect.minY+1.8) — j
descender no longer kisses the menubar edge but glyphs feel
anchored in the bottom of the key.
- Visualizer: RMS-per-bar (was peak), asymmetric temporal smoothing
to kill rolling, segmented LED-meter look in a dark bezel housing
with per-segment glow.
- Settings chip in the menubar piano: bigger glyph (13 pt) and a
systray-style hover pill, with the popover anchor pointing at the
visible icon rect.
- Set a stable kMIDIPropertyUniqueID + manufacturer + model on the
virtual MIDI source so DAW routing survives reinstalls.

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

+911 -237
+102 -45
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 43 43 /// from these — fade-in ripples outward from the pivot, fade-out 44 44 /// retreats inward (far cells fade first, the pivot last). 45 45 private var letterPivot: UInt8 = 60 46 - private var letterPhaseStartedAt: CFTimeInterval = 0 47 - private var letterFadingIn: Bool = false 48 46 private var letterFadeTimer: Timer? 49 - private static let letterWaveStep: CFTimeInterval = 0.025 // 25 ms per cell of distance 50 - private static let letterFadeInDur: CFTimeInterval = 0.08 51 - private static let letterFadeOutDur: CFTimeInterval = 0.18 47 + /// Per-MIDI displayed alpha. Each tick we smooth this value 48 + /// toward the cell's target — so transitions are organic 49 + /// regardless of when keys are pressed (no snap when the user 50 + /// plays a fresh key while letters are still fading out). 51 + private var letterAlphas: [UInt8: CGFloat] = [:] 52 + /// MIDI notes whose attack wave has already touched them. Once a 53 + /// cell is reached it stays reached for the duration of the 54 + /// session — pivot changes mid-wave don't dim already-bright 55 + /// cells back to 0. 56 + private var letterReached: Set<UInt8> = [] 57 + /// Wallclock time the current attack wave started — i.e. the 58 + /// first key press of this session. Reset after the wave fully 59 + /// retreats to 0. 60 + private var letterAttackStartedAt: CFTimeInterval = 0 61 + // Slower wave so neighboring cells trickle into view as the user 62 + // plays — the ghost letters build up across the whole board over a 63 + // second-ish of activity instead of all snapping in at once. Fade- 64 + // out is also softer so when playing stops, the letters settle out 65 + // gracefully rather than blinking off. 66 + private static let letterWaveStep: CFTimeInterval = 0.06 // 60 ms per cell of distance 67 + private static let letterFadeInDur: CFTimeInterval = 0.18 68 + private static let letterFadeOutDur: CFTimeInterval = 0.32 52 69 53 70 func applicationDidFinishLaunching(_ notification: Notification) { 54 71 debugLog("applicationDidFinishLaunching pid=\(ProcessInfo.processInfo.processIdentifier)") ··· 60 77 } 61 78 menuBand.onLitChanged = { [weak self] in 62 79 self?.updateIcon() 80 + self?.popoverVC?.refreshHeldNotes() 63 81 } 64 82 menuBand.bootstrap() 65 83 ··· 284 302 /// from that pivot; when the ghost expires the wave reverses outward 285 303 /// (far cells fade out first). The animation tick drives 60 Hz 286 304 /// redraws while the fade is in progress, then auto-stops. 305 + /// Schedule / extend the letter ghost. Each press advances the 306 + /// attack wave (cells become "reached" as the wave passes them) 307 + /// and resets the release deadline. No timed curves — per-cell 308 + /// alpha is smoothed toward its target on every tick, so any 309 + /// pivot change or fresh press while letters are decaying just 310 + /// nudges the targets and the physics handles the rest. No snaps. 287 311 private func extendGhost(_ duration: CFTimeInterval, pivot: UInt8? = nil) { 288 312 let now = CACurrentMediaTime() 289 - let target = now + duration 290 - if target > ghostUntil { ghostUntil = target } 291 - if let pivot = pivot { letterPivot = pivot } 292 - // Restart the fade-in phase from "now" so cells progressively 293 - // re-attack from the new pivot. Already-bright cells stay bright 294 - // because the alpha formula is monotonic for time within a phase. 295 - if !letterFadingIn { 296 - letterFadingIn = true 297 - letterPhaseStartedAt = now 313 + // Fresh attack: when the previous wave fully decayed back to 314 + // 0, restart the wave clock so cell-distance delays are 315 + // measured from the new press. 316 + let stable = letterAlphas.allSatisfy { $0.value < 0.01 } 317 + if stable { 318 + letterAttackStartedAt = now 319 + letterReached.removeAll() 298 320 } 299 - ghostRefreshTimer?.invalidate() 300 - ghostRefreshTimer = Timer.scheduledTimer(withTimeInterval: duration + 0.02, repeats: false) { [weak self] _ in 301 - self?.startLetterFadeOut() 302 - } 321 + if let pivot = pivot { letterPivot = pivot } 322 + // Hold the ghost open long enough for the wave to fully fill, 323 + // so distant cells get to 1.0 before any release wave kicks 324 + // in. 325 + let maxDist: CFTimeInterval = 24 326 + let fillDur = maxDist * Self.letterWaveStep + 0.10 327 + let phaseElapsed = now - letterAttackStartedAt 328 + let untilFull = max(0, fillDur - phaseElapsed) 329 + let effective = max(duration, untilFull + 0.05) 330 + ghostUntil = max(ghostUntil, now + effective) 303 331 startLetterFadeTickIfNeeded() 304 332 updateIcon() 305 333 } 306 334 307 - private func startLetterFadeOut() { 308 - letterFadingIn = false 309 - letterPhaseStartedAt = CACurrentMediaTime() 310 - startLetterFadeTickIfNeeded() 311 - updateIcon() 312 - } 335 + /// Legacy API — left as a no-op since the physics-based model 336 + /// has no explicit fade-out phase. The release behavior happens 337 + /// automatically when `now > ghostUntil` and the per-cell tick 338 + /// flips targets to 0 with the reverse-distance delay. 339 + private func startLetterFadeOut() {} 313 340 314 341 private func startLetterFadeTickIfNeeded() { 315 342 guard letterFadeTimer == nil else { return } ··· 319 346 } 320 347 321 348 private func tickLetterFade() { 322 - // Stable when fade-out has had enough time for the slowest 323 - // (closest-to-pivot) cell to reach 0. Stop the timer there to 324 - // avoid burning idle CPU. 325 349 let now = CACurrentMediaTime() 326 - let phase = now - letterPhaseStartedAt 350 + let attackPhase = now - letterAttackStartedAt 327 351 let maxDist: CFTimeInterval = 24 328 - let stableAt = letterFadingIn 329 - ? maxDist * Self.letterWaveStep + Self.letterFadeInDur 330 - : maxDist * Self.letterWaveStep + Self.letterFadeOutDur 331 - if !letterFadingIn && phase >= stableAt { 352 + // Pivot's release start time. Beyond this wallclock, the 353 + // release wave begins; far cells flip to target=0 first. 354 + let releaseAnchor = max(ghostUntil, 355 + letterAttackStartedAt + maxDist * Self.letterWaveStep) 356 + 357 + // 1. Walk the attack wave forward — any cell whose distance 358 + // is now within the wave's reach joins `letterReached`. 359 + for midi in 0...127 where !letterReached.contains(UInt8(midi)) { 360 + let dist = CFTimeInterval(abs(Int(midi) - Int(letterPivot))) 361 + if attackPhase >= dist * Self.letterWaveStep { 362 + letterReached.insert(UInt8(midi)) 363 + } 364 + } 365 + 366 + // 2. Compute target alpha per cell, then smooth toward it. 367 + // Asymmetric rates: faster attack so playing reads as 368 + // immediate, slower release so decay feels like it has 369 + // weight. Rates are per-frame (60 Hz tick). 370 + let attackRate: CGFloat = 0.30 371 + let decayRate: CGFloat = 0.045 372 + let isReleasing = now > ghostUntil 373 + for midi in 0...127 { 374 + let key = UInt8(midi) 375 + let cur = letterAlphas[key] ?? 0 376 + let target: CGFloat 377 + if !letterReached.contains(key) { 378 + target = 0 379 + } else if !isReleasing { 380 + target = 1 381 + } else { 382 + // Release wave: far cells (from pivot) drop first. 383 + let dist = CFTimeInterval(abs(Int(midi) - Int(letterPivot))) 384 + let releaseDelay = (maxDist - dist) * Self.letterWaveStep 385 + let cellReleaseAt = releaseAnchor + releaseDelay 386 + target = (now < cellReleaseAt) ? 1 : 0 387 + } 388 + let rate: CGFloat = (target > cur) ? attackRate : decayRate 389 + let next = cur + (target - cur) * rate 390 + // Skip near-zero noise so we can detect the all-stable 391 + // state and stop the tick. 392 + letterAlphas[key] = abs(next) < 0.001 ? 0 : next 393 + } 394 + 395 + // 3. Park the timer when everything has fully decayed and the 396 + // ghost is past — saves CPU while idle. 397 + let fullyDecayed = letterAlphas.values.allSatisfy { $0 < 0.01 } 398 + if isReleasing && fullyDecayed { 399 + letterAlphas.removeAll() 400 + letterReached.removeAll() 332 401 letterFadeTimer?.invalidate() 333 402 letterFadeTimer = nil 334 403 } ··· 339 408 /// starts at the pivot and ripples outward; fade-out starts at the 340 409 /// outermost cell and retreats toward the pivot. 341 410 private func letterAlpha(for midi: UInt8) -> CGFloat { 342 - let dist = CFTimeInterval(abs(Int(midi) - Int(letterPivot))) 343 - let phase = CACurrentMediaTime() - letterPhaseStartedAt 344 - if letterFadingIn { 345 - let cellStart = dist * Self.letterWaveStep 346 - let t = (phase - cellStart) / Self.letterFadeInDur 347 - return CGFloat(max(0, min(1, t))) 348 - } else { 349 - // Fade-out delay: far cells (large dist) start first. 350 - let maxDist: CFTimeInterval = 24 351 - let cellStart = (maxDist - dist) * Self.letterWaveStep 352 - let t = (phase - cellStart) / Self.letterFadeOutDur 353 - return CGFloat(max(0, min(1, 1 - t))) 354 - } 411 + return letterAlphas[midi] ?? 0 355 412 } 356 413 357 414 // MARK: - Hover
+85
slab/menuband/Sources/MenuBand/ArrowKeysIndicator.swift
··· 1 + import AppKit 2 + 3 + /// MacBook-style arrow-keys cluster, drawn as four little keycaps 4 + /// in the classic inverted-T arrangement: ↑ alone on top row, then 5 + /// ← ↓ → in a row below. Each keycap can light up independently — 6 + /// pressed direction fills with the accent color, the rest stay 7 + /// white-outlined. 8 + /// 9 + /// Drawn entirely with NSBezierPath so the keys read as physical 10 + /// chiclet shapes (rounded squares + centered arrow glyph), not 11 + /// abstract chevrons. 12 + /// 13 + /// Direction indices match `InstrumentMapView.onArrowKey`: 14 + /// 0 = ← 1 = → 2 = ↓ 3 = ↑ 15 + final class ArrowKeysIndicator: NSView { 16 + private var pressed: Set<Int> = [] 17 + 18 + static let intrinsicSize = NSSize(width: 46, height: 30) 19 + override var intrinsicContentSize: NSSize { Self.intrinsicSize } 20 + 21 + override init(frame frameRect: NSRect) { 22 + super.init(frame: frameRect) 23 + setFrameSize(Self.intrinsicSize) 24 + } 25 + required init?(coder: NSCoder) { fatalError() } 26 + 27 + /// Light up (or dim) one of the four direction keycaps. Multiple 28 + /// directions can be lit at once. 29 + func setHighlight(direction: Int, on: Bool) { 30 + if on { pressed.insert(direction) } 31 + else { pressed.remove(direction) } 32 + needsDisplay = true 33 + } 34 + 35 + override var isFlipped: Bool { false } 36 + 37 + override func draw(_ dirtyRect: NSRect) { 38 + super.draw(dirtyRect) 39 + let r = bounds 40 + // Modern MacBook arrow ratios: all four keys are the same 41 + // full-size square keycap, arranged in an inverted-T. Up 42 + // sits alone on top, ◀ ▼ ▶ in a row below. 1 px gap between 43 + // caps. 44 + let key: CGFloat = 13 45 + let gap: CGFloat = 1 46 + let radius: CGFloat = 2.5 47 + let centerX = r.midX 48 + let bottomY = r.minY + 1 // bottom row baseline 49 + let topY = bottomY + key + gap // top row sits above 50 + // Bottom row: left, down, right (centered around centerX). 51 + let downRect = NSRect(x: centerX - key / 2, y: bottomY, width: key, height: key) 52 + let leftRect = NSRect(x: downRect.minX - key - gap, y: bottomY, width: key, height: key) 53 + let rightRect = NSRect(x: downRect.maxX + gap, y: bottomY, width: key, height: key) 54 + // Top row: up alone, full-size, centered over down. 55 + let upRect = NSRect(x: downRect.minX, y: topY, width: key, height: key) 56 + 57 + let glyphs = ["←", "→", "↓", "↑"] 58 + let rects = [leftRect, rightRect, downRect, upRect] 59 + for (idx, kr) in rects.enumerated() { 60 + let lit = pressed.contains(idx) 61 + // Keycap. 62 + let path = NSBezierPath(roundedRect: kr, xRadius: radius, yRadius: radius) 63 + if lit { 64 + NSColor.controlAccentColor.withAlphaComponent(0.85).setFill() 65 + path.fill() 66 + } 67 + (lit ? NSColor.controlAccentColor : NSColor.labelColor.withAlphaComponent(0.55)) 68 + .setStroke() 69 + path.lineWidth = 0.8 70 + path.stroke() 71 + // Arrow glyph centered in the keycap. 72 + let glyphColor: NSColor = lit ? .black : NSColor.labelColor 73 + let attrs: [NSAttributedString.Key: Any] = [ 74 + .font: NSFont.systemFont(ofSize: 10, weight: .heavy), 75 + .foregroundColor: glyphColor, 76 + ] 77 + let s = NSAttributedString(string: glyphs[idx], attributes: attrs) 78 + let size = s.size() 79 + // Slight y-offset to optically center the unicode arrow 80 + // (their natural baseline puts them a bit high in the box). 81 + s.draw(at: NSPoint(x: kr.midX - size.width / 2, 82 + y: kr.midY - size.height / 2 - 0.5)) 83 + } 84 + } 85 + }
+151 -11
slab/menuband/Sources/MenuBand/InstrumentMapView.swift
··· 25 25 /// "no hover" → nil). Drives the controller's hover-preview note for 26 26 /// sonic browsing. 27 27 var onHover: ((Int?) -> Void)? 28 + /// Fires when an arrow key is processed. Reports the direction (0=←, 29 + /// 1=→, 2=↓, 3=↑) and whether the key is going down (true) or up 30 + /// (false). Used by the popover so the on-screen arrow-keys hint 31 + /// can highlight the specific direction while held. 32 + var onArrowKey: ((Int, Bool) -> Void)? 33 + /// Forwarded keyDown / keyUp for non-arrow keys. The popover wires 34 + /// this to `menuBand.handleLocalKey` so notepat / Ableton letter 35 + /// keys still play notes while the user is browsing the voice 36 + /// palette. Returns true when the key has been consumed as a 37 + /// music event. 38 + var onMusicKey: ((UInt16, Bool, Bool, NSEvent.ModifierFlags) -> Bool)? 28 39 29 40 private var trackingArea: NSTrackingArea? 30 41 ··· 73 84 return p < 128 ? p : nil 74 85 } 75 86 87 + /// Hand-picked color per GM family (16 families × 8 programs each; 88 + /// each row of the 8-col grid is one family). RGB values mirror 89 + /// standard CSS named colors so the timbre→color mapping reads 90 + /// intuitively: pianos are ivory, brass is gold, strings firebrick, 91 + /// synth leads magenta, etc. Compared to the previous hue 92 + /// gradient, this gives every family a *meaning* — a glance at the 93 + /// grid tells you what kind of instrument lives there. 94 + static let familyPalette: [NSColor] = [ 95 + NSColor(srgbRed: 1.00, green: 0.94, blue: 0.84, alpha: 1), // 0 Piano — ivory 96 + NSColor(srgbRed: 0.28, green: 0.82, blue: 0.80, alpha: 1), // 1 Chromatic Perc. — mediumturquoise 97 + NSColor(srgbRed: 0.58, green: 0.44, blue: 0.86, alpha: 1), // 2 Organ — mediumpurple 98 + NSColor(srgbRed: 0.80, green: 0.52, blue: 0.25, alpha: 1), // 3 Guitar — peru 99 + NSColor(srgbRed: 0.10, green: 0.10, blue: 0.44, alpha: 1), // 4 Bass — midnightblue 100 + NSColor(srgbRed: 0.70, green: 0.13, blue: 0.13, alpha: 1), // 5 Strings — firebrick 101 + NSColor(srgbRed: 1.00, green: 0.41, blue: 0.71, alpha: 1), // 6 Ensemble — hotpink 102 + NSColor(srgbRed: 1.00, green: 0.84, blue: 0.00, alpha: 1), // 7 Brass — gold 103 + NSColor(srgbRed: 0.13, green: 0.55, blue: 0.13, alpha: 1), // 8 Reed — forestgreen 104 + NSColor(srgbRed: 0.53, green: 0.81, blue: 0.98, alpha: 1), // 9 Pipe — lightskyblue 105 + NSColor(srgbRed: 1.00, green: 0.00, blue: 1.00, alpha: 1), // 10 Synth Lead — magenta 106 + NSColor(srgbRed: 0.87, green: 0.63, blue: 0.87, alpha: 1), // 11 Synth Pad — plum 107 + NSColor(srgbRed: 0.12, green: 0.56, blue: 1.00, alpha: 1), // 12 Synth Effects — dodgerblue 108 + NSColor(srgbRed: 1.00, green: 0.39, blue: 0.28, alpha: 1), // 13 Ethnic — tomato 109 + NSColor(srgbRed: 0.41, green: 0.41, blue: 0.41, alpha: 1), // 14 Percussive — dimgray 110 + NSColor(srgbRed: 0.24, green: 0.70, blue: 0.44, alpha: 1), // 15 Sound Effects — mediumseagreen 111 + ] 112 + 113 + /// Public color lookup for any GM program. Each family has a base 114 + /// hue (see `familyPalette`); each of the 8 voices inside a family 115 + /// gets its own shade derived from that base by walking 116 + /// brightness + saturation across the 8 slots. So every program 117 + /// (0–127) renders as a distinct, visually distinguishable color 118 + /// while still reading as a member of its family row. 119 + static func colorForProgram(_ p: Int) -> NSColor { 120 + let safe = max(0, min(127, p)) 121 + let base = familyPalette[safe / 8] 122 + let slot = safe % 8 123 + // Walk brightness across slots: dimmest at slot 0, brightest at 124 + // slot 7. Range ±22% from the base. Saturation alternates so 125 + // adjacent shades never collapse to the same tonal value. 126 + let bDelta = (CGFloat(slot) - 3.5) / 3.5 * 0.22 // −0.22…+0.22 127 + let sDelta: CGFloat = (slot % 2 == 0) ? -0.10 : 0.10 // alternating 128 + // Convert to HSB in sRGB, shift, clamp, return. 129 + guard let hsb = base.usingColorSpace(.sRGB) else { return base } 130 + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 131 + hsb.getHue(&h, saturation: &s, brightness: &b, alpha: &a) 132 + let s2 = max(0.05, min(1.0, s + sDelta)) 133 + let b2 = max(0.10, min(1.0, b + bDelta)) 134 + return NSColor(hue: h, saturation: s2, brightness: b2, alpha: a) 135 + } 136 + 76 137 private func familyColor(forProgram p: Int) -> NSColor { 77 - // 16 families × 8 programs each. Each row IS a family (since cols=8). 78 - let famIdx = p / 8 79 - return NSColor(hue: CGFloat(famIdx) / 16.0, 80 - saturation: 0.55, brightness: 0.88, alpha: 1.0) 138 + Self.colorForProgram(p) 81 139 } 82 140 83 141 // MARK: - Drawing ··· 111 169 NSBezierPath(rect: r).fill() 112 170 } 113 171 114 - // 1px hairline grid. 115 - NSColor.black.withAlphaComponent(0.10).setStroke() 116 - let path = NSBezierPath(rect: r.insetBy(dx: 0.25, dy: 0.25)) 117 - path.lineWidth = 0.5 118 - path.stroke() 172 + // Chiclet-style keycap outline. The cell's hit area is the 173 + // full rect (so dragging across stays seamless — no 174 + // dead-space gaps between voices), but we draw the visible 175 + // outline inset further with a soft corner radius so each 176 + // cell reads as its own little keyboard button with 177 + // breathing space around it. The outline picks up the 178 + // cell's family hue so the button itself signals which 179 + // GM family it belongs to even before you read the 180 + // number. 181 + let capPath = NSBezierPath(roundedRect: r.insetBy(dx: 1.75, dy: 1.5), 182 + xRadius: 2.5, yRadius: 2.5) 183 + fam.withAlphaComponent(0.65).setStroke() 184 + capPath.lineWidth = 0.8 185 + capPath.stroke() 119 186 120 - // Program number, centered. 187 + // Program number, centered. Leading zeros dropped — bare 188 + // "0..127" reads cleaner as a keycap label. 121 189 let attrs = (selectedProgram == UInt8(p)) ? Self.numAttrsSelected : Self.numAttrs 122 - let str = NSAttributedString(string: String(format: "%03d", p), attributes: attrs) 190 + let str = NSAttributedString(string: String(p), attributes: attrs) 123 191 let size = str.size() 124 192 str.draw(at: NSPoint(x: r.midX - size.width / 2, 125 193 y: r.midY - size.height / 2)) ··· 164 232 165 233 override func mouseDown(with event: NSEvent) { 166 234 dragging = true 235 + // Take key focus on click so arrow-key navigation works 236 + // immediately after the user picks an initial cell. 237 + window?.makeFirstResponder(self) 167 238 let pt = convert(event.locationInWindow, from: nil) 168 239 if let p = program(at: pt) { 169 240 // Treat the press as a hover-into-this-cell so the preview note ··· 202 273 /// No-op kept for API compatibility with the popover (was needed when 203 274 /// the list was scrollable; the numeric map shows everything at once). 204 275 func scrollProgramIntoView(_ program: UInt8, animated: Bool = false) {} 276 + 277 + // MARK: - Keyboard 278 + 279 + override var acceptsFirstResponder: Bool { true } 280 + 281 + /// Arrow-key navigation across the 8 × 16 grid. Each move fires 282 + /// `onHover` for the new cell — same path as mouse-drag preview 283 + /// — so the held preview note moves with you and the chrome 284 + /// retints in real time. Releasing the key commits the cell that's 285 + /// currently selected (mirrors mouseDown→drag→mouseUp). 286 + override func keyDown(with event: NSEvent) { 287 + let cur = Int(selectedProgram) 288 + var next = cur 289 + var dir = -1 290 + switch event.keyCode { 291 + case 123: next = cur - 1; dir = 0 // ← 292 + case 124: next = cur + 1; dir = 1 // → 293 + case 125: next = cur + Self.cols; dir = 2 // ↓ 294 + case 126: next = cur - Self.cols; dir = 3 // ↑ 295 + default: 296 + // Not an arrow — forward to the music-key handler so 297 + // notepat letter keys still sound notes while the 298 + // popover holds key focus on this view. Pass auto-repeat 299 + // through here (the controller handles its own dedupe). 300 + let consumed = onMusicKey?(event.keyCode, true, 301 + event.isARepeat, 302 + event.modifierFlags) ?? false 303 + if !consumed { super.keyDown(with: event) } 304 + return 305 + } 306 + // Arrow path: ignore auto-repeat so each physical press moves 307 + // the selection by exactly one cell. The preview note started 308 + // on first-press still sustains as long as the key is held 309 + // (release in keyUp), satisfying "hold preview while held" 310 + // without bundling movement into auto-repeat. 311 + if event.isARepeat { return } 312 + next = max(0, min(127, next)) 313 + onArrowKey?(dir, true) 314 + if next != cur { 315 + // Update the lit cell + fire hover. The popover's onHover 316 + // handler already does the heavy lifting: stop the previous 317 + // preview note, retint chip + visualizer, start a new 318 + // preview note in the new program. 319 + selectedProgram = UInt8(next) 320 + onHover?(next) 321 + } 322 + } 323 + 324 + override func keyUp(with event: NSEvent) { 325 + switch event.keyCode { 326 + case 123, 124, 125, 126: 327 + let dir: Int 328 + switch event.keyCode { 329 + case 123: dir = 0 330 + case 124: dir = 1 331 + case 125: dir = 2 332 + default: dir = 3 333 + } 334 + onArrowKey?(dir, false) 335 + // Release ends the preview and commits the currently- 336 + // selected cell (same mouseUp semantics). 337 + onHover?(nil) 338 + onCommit?(Int(selectedProgram)) 339 + default: 340 + let consumed = onMusicKey?(event.keyCode, false, false, 341 + event.modifierFlags) ?? false 342 + if !consumed { super.keyUp(with: event) } 343 + } 344 + } 205 345 }
+36 -6
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 91 91 // About. 92 92 static let settingsW: CGFloat = 18.0 93 93 static let settingsH: CGFloat = 21.0 94 - static let settingsGap: CGFloat = 4.0 94 + static let settingsGap: CGFloat = 12.0 95 95 96 96 enum HitResult: Equatable { 97 97 case openSettings ··· 285 285 return img 286 286 } 287 287 288 - /// Public so the popover can anchor its arrow at the latch. 289 - static var settingsRectPublic: NSRect { settingsHitRect } 288 + /// Public so the popover can anchor its arrow at the latch — point 289 + /// at the visible icon, not the wider hit area, so the arrow lines 290 + /// up directly below the music note glyph. 291 + static var settingsRectPublic: NSRect { settingsIconRect } 290 292 291 293 // Settings button hit area extends from piano's right edge to the image 292 294 // edge so the entire right-side region is one big click target. ··· 294 296 let leftX = pianoOriginX + pianoWidth 295 297 let rightX = imageSize.width 296 298 return NSRect(x: leftX, y: 0, width: rightX - leftX, height: imageSize.height) 299 + } 300 + 301 + /// Visible icon bounds — a pill-sized region centered on the music 302 + /// note glyph itself. Used both as the hover-highlight backdrop and 303 + /// as the popover anchor so the popover arrow points right at the 304 + /// icon. Width tracks `settingsW`; height tracks `settingsH`. 305 + private static var settingsIconRect: NSRect { 306 + let w = settingsW 307 + let h = settingsH 308 + let cx = settingsHitRect.maxX - w / 2 - pad 309 + let cy = settingsHitRect.midY 310 + return NSRect(x: cx - w / 2, y: cy - h / 2, width: w, height: h) 297 311 } 298 312 299 313 // MARK: - Hit testing ··· 442 456 ] 443 457 let str = NSAttributedString(string: text, attributes: attrs) 444 458 let size = str.size() 445 - str.draw(at: NSPoint(x: rect.midX - size.width / 2, y: rect.minY + 0.4)) 459 + // White key labels sit a couple pixels off the bottom — high 460 + // enough that the descender on `j` doesn't kiss the menubar 461 + // edge, low enough that the letters feel anchored in the 462 + // bottom of the key rather than floating mid-cell. 463 + str.draw(at: NSPoint(x: rect.midX - size.width / 2, 464 + y: rect.minY + 1.8)) 446 465 } 447 466 448 467 private static func drawBlackLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0) { ··· 492 511 /// 493 512 /// Alternates considered: "music.quarternote.3", "pianokeys", 494 513 /// "music.note", "scroll", "speaker.wave.2", "waveform". 495 - private static func drawSettingsChip(in rect: NSRect, hoverRect _: NSRect, 514 + private static func drawSettingsChip(in _: NSRect, hoverRect _: NSRect, 496 515 midiOn: Bool, hovered: Bool) { 516 + // Standard systray pill: hover/click paints a soft rounded 517 + // backdrop centered on the icon glyph (NOT the full hit area, so 518 + // the piano-side empty space stays unhighlighted). Same look as 519 + // any other status-bar item. 520 + let iconBox = settingsIconRect 521 + if hovered { 522 + let pill = iconBox.insetBy(dx: -1, dy: 1) 523 + let path = NSBezierPath(roundedRect: pill, xRadius: 4, yRadius: 4) 524 + NSColor.labelColor.withAlphaComponent(0.12).setFill() 525 + path.fill() 526 + } 497 527 let alpha: CGFloat = hovered ? 1.0 : (midiOn ? 1.0 : 0.78) 498 528 let color: NSColor = midiOn 499 529 ? NSColor.controlAccentColor 500 530 : NSColor.labelColor.withAlphaComponent(alpha) 501 - drawTintedSymbol("music.note.list", in: rect, pointSize: 11.0, color: color) 531 + drawTintedSymbol("music.note.list", in: iconBox, pointSize: 13.0, color: color) 502 532 } 503 533 504 534 private static func drawInstrumentLabel(in rect: NSRect, hoverRect: NSRect,
+8 -1
slab/menuband/Sources/MenuBand/LocalKeyCapture.swift
··· 44 44 } 45 45 } 46 46 if resignKeyObserver == nil { 47 + // Disarm only when our APP loses focus (user clicked another 48 + // app). Don't disarm when key passes to another window inside 49 + // our own app — opening the popover used to disarm capture 50 + // here, killing both the typing-on-piano sound AND the ghost- 51 + // letter wave while the popover was visible. 47 52 resignKeyObserver = NotificationCenter.default.addObserver( 48 53 forName: NSWindow.didResignKeyNotification, 49 54 object: panel, queue: .main 50 55 ) { [weak self] _ in 51 - self?.disarm() 56 + guard let self = self else { return } 57 + let stillKeyInApp = NSApp.windows.contains { $0.isKeyWindow } 58 + if !stillKeyInApp { self.disarm() } 52 59 } 53 60 } 54 61 isArmed = true
+78 -27
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 23 23 private let octaveShiftKey = "notepat.octaveShift" 24 24 private let melodicProgramKey = "notepat.melodicProgram" 25 25 private let keymapKey = "notepat.keymap" 26 - private let mutedKey = "notepat.muted" 27 26 /// Active instrument backend: `"gm"` for the General MIDI bank, or 28 27 /// `"gb"` for a GarageBand sampler patch. Default is GM. Stored as a 29 28 /// string so future backends (Logic, EXS3rd-party, etc.) can be ··· 43 42 var onChange: (() -> Void)? 44 43 var onLitChanged: (() -> Void)? 45 44 45 + /// Last MIDI note actually played (mouse tap or keyboard). Used by 46 + /// the instrument preview / audition path so the "test" note that 47 + /// plays when picking a voice matches whatever the user last 48 + /// touched, instead of always defaulting to middle C. 49 + /// Defaults to 60 (C4) on a fresh session. 50 + private(set) var lastPlayedNote: UInt8 = 60 51 + 52 + /// Format a MIDI note as the user's preferred name pattern — 53 + /// "<octave><pitch class>" like 4C, 5D#, 3G. C4 (MIDI 60) is the 54 + /// reference octave. 55 + static func noteName(_ midi: UInt8) -> String { 56 + let pitches = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"] 57 + let octave = Int(midi) / 12 - 1 58 + let pc = Int(midi) % 12 59 + return "\(octave)\(pitches[pc])" 60 + } 61 + 62 + /// Snapshot of currently-held MIDI note names for popover display. 63 + /// Empty when nothing is sounding. 64 + func heldNoteNames() -> [String] { 65 + let sorted = litNotes.sorted() 66 + return sorted.map { Self.noteName($0) } 67 + } 68 + 69 + /// Best-guess chord name from the currently-held pitch classes. 70 + /// Returns nil when fewer than 3 pitch classes are held or nothing 71 + /// matches a known shape. The patterns cover the most common 72 + /// triads + 7ths so casual playing on the menubar piano gets a 73 + /// useful readout without dragging in a full chord-theory engine. 74 + func currentChordName() -> String? { 75 + let pcs = Set(litNotes.map { Int($0) % 12 }) 76 + guard pcs.count >= 3 else { return nil } 77 + // Pattern table — intervals from root + display suffix. 78 + let patterns: [(intervals: Set<Int>, suffix: String)] = [ 79 + ([0, 4, 7], ""), 80 + ([0, 3, 7], "m"), 81 + ([0, 3, 6], "dim"), 82 + ([0, 4, 8], "aug"), 83 + ([0, 2, 7], "sus2"), 84 + ([0, 5, 7], "sus4"), 85 + ([0, 4, 7, 10], "7"), 86 + ([0, 4, 7, 11], "maj7"), 87 + ([0, 3, 7, 10], "m7"), 88 + ([0, 3, 7, 11], "mMaj7"), 89 + ([0, 3, 6, 9], "dim7"), 90 + ([0, 3, 6, 10], "m7♭5"), 91 + ([0, 4, 8, 10], "aug7"), 92 + ([0, 4, 7, 9], "6"), 93 + ([0, 3, 7, 9], "m6"), 94 + ] 95 + let pitches = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"] 96 + // Try each held pitch class as the candidate root. 97 + for root in pcs { 98 + let intervals = Set(pcs.map { ($0 - root + 12) % 12 }) 99 + for (pat, suffix) in patterns where pat == intervals { 100 + return "\(pitches[root])\(suffix)" 101 + } 102 + } 103 + return nil 104 + } 105 + 46 106 var midiMode: Bool { 47 107 UserDefaults.standard.bool(forKey: midiModeKey) 48 108 } ··· 51 111 UserDefaults.standard.bool(forKey: typeModeKey) 52 112 } 53 113 54 - /// When on, the local synth is silent — note triggers still update lit 55 - /// state and (in MIDI mode) still send out the virtual port, but the 56 - /// built-in sampler/MIDISynth doesn't sound. Independent of MIDI mode 57 - /// so a user can keep MIDI off and still mute the local synth. 58 - var muted: Bool { 59 - UserDefaults.standard.bool(forKey: mutedKey) 60 - } 61 - 62 - func toggleMuted() { 63 - let now = !muted 64 - UserDefaults.standard.set(now, forKey: mutedKey) 65 - if now { 66 - // Cut anything currently sounding so a long-tail note doesn't 67 - // hang past the moment the user hit mute. 68 - synth.panic() 69 - } 70 - onChange?() 71 - } 72 - 73 114 /// Audition the currently-loaded melodic program through the local 74 115 /// synth, regardless of MIDI mode. Used by the instrument-list click 75 116 /// handler so the user *always* hears their instrument pick, even when ··· 113 154 return 114 155 } 115 156 synth.setMelodicProgram(prog) 116 - guard !midiMode, !muted else { return } 117 - let note: UInt8 = 60 157 + guard !midiMode else { return } 158 + let note = lastPlayedNote 118 159 previewNote = note 119 160 // When the synth supports instant program changes (MIDISynth backend 120 161 // ready), fire noteOn immediately — the user's mouseDown becomes an ··· 138 179 } 139 180 140 181 func auditionCurrentProgram() { 141 - guard !muted else { return } 142 - let note: UInt8 = 60 143 - debugLog("audition: synth.noteOn 60 (program \(melodicProgram))") 182 + let note = lastPlayedNote 183 + debugLog("audition: synth.noteOn \(note) (program \(melodicProgram))") 144 184 synth.noteOn(note, velocity: 100, channel: 0) 145 185 DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in 146 186 self?.synth.noteOff(note, channel: 0) ··· 460 500 /// louder, x within key = stereo pan. 461 501 func startTapNote(_ midiNote: UInt8, velocity: UInt8 = 100, pan: UInt8 = 64) { 462 502 debugLog("startTapNote midi=\(midiNote) midiMode=\(midiMode)") 503 + lastPlayedNote = midiNote 463 504 if tapHeld.contains(midiNote) { return } 464 505 tapHeld.insert(midiNote) 465 506 let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) ··· 471 512 let midiCh: UInt8 = isDrum ? 9 : 0 472 513 tapNoteChannel[midiNote] = synthCh 473 514 midi.sendCC(10, value: pan, channel: midiCh) 474 - if !midiMode && !muted { synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } 515 + if !midiMode { synth.noteOn(midiNote, velocity: velocity, channel: synthCh) } 475 516 midi.noteOn(midiNote, velocity: velocity, channel: midiCh) 476 517 // Lit state is main-thread-only; update synchronously so the menubar 477 518 // redraws within the same runloop pass as the click. Dispatching async ··· 649 690 if !midiMode { synth.noteOff(prevNote, channel: prevCh ?? 0) } 650 691 midi.noteOff(prevNote) 651 692 } 652 - if !midiMode && !muted { synth.noteOn(note, velocity: 100, channel: synthCh) } 693 + lastPlayedNote = note 694 + // Stereo pan from the qwerty key's physical column — 695 + // mirrors notepat native, so left-hand keys play left and 696 + // right-hand keys play right. CC10 to both the local 697 + // synth (MIDISynth backend) and the outbound MIDI port, 698 + // sent BEFORE noteOn so the new note is panned from the 699 + // first sample. 700 + let pan = MenuBandLayout.panForKeyCode(keyCode) 701 + if !midiMode { synth.setPan(pan, channel: synthCh) } 702 + midi.sendCC(10, value: pan, channel: 0) 703 + if !midiMode { synth.noteOn(note, velocity: 100, channel: synthCh) } 653 704 midi.noteOn(note) 654 705 // The menubar piano renders a fixed C4–C5 window; the audio 655 706 // path plays at the user's full octave-shifted pitch. To keep
+59
slab/menuband/Sources/MenuBand/MenuBandMIDI.swift
··· 76 76 return t 77 77 }() 78 78 79 + /// Pan (MIDI 0–127, 64 = center) per QWERTY position. Derived from 80 + /// notepat native's `getPanForQwertyKey` — physical keyboard 81 + /// column maps to stereo placement so left-hand keys sit left, 82 + /// right-hand keys sit right. Rows offset like a real keyboard 83 + /// (top row no offset, middle row 0.5 col over, bottom row 1 col 84 + /// over). PAN_RANGE keeps the spread inside ±0.9 so nothing is 85 + /// hard-panned to a single ear. 86 + static let panByKeyCode: [UInt8] = { 87 + var t = [UInt8](repeating: 64, count: 128) 88 + // (row, col → keyCode mapping, mirrors notepat.mjs QWERTY rows) 89 + let rows: [[UInt16]] = [ 90 + // Row 0: q w e r t y u i o p ] 91 + [12, 13, 14, 15, 17, 16, 32, 34, 31, 35, 30], 92 + // Row 1: a s d f g h j k l ; ' 93 + [0, 1, 2, 3, 5, 4, 38, 40, 37, 41, 39], 94 + // Row 2: z x c v b n m (skipping the modifier bookends) 95 + [6, 7, 8, 9, 11, 45, 46], 96 + ] 97 + let rowOffsets: [Double] = [0.0, 0.5, 1.0] 98 + // Match notepat: MAX_SPAN = max(row.length + offset). Rows are 99 + // 11, 11.5, 8 here (we drop control / alt vs the JS source) so 100 + // MAX_SPAN = 11.5. 101 + let maxSpan: Double = 11.5 102 + let panRange: Double = 0.9 103 + for r in 0..<rows.count { 104 + for (col, kc) in rows[r].enumerated() where kc < 128 { 105 + let x = Double(col) + rowOffsets[r] 106 + let normalized = x / (maxSpan - 1) 107 + let pan = (normalized * 2 - 1) * panRange 108 + let midi = max(0, min(127, Int((((pan + 1) / 2) * 127.0).rounded()))) 109 + t[Int(kc)] = UInt8(midi) 110 + } 111 + } 112 + return t 113 + }() 114 + 115 + /// Look up the pan (MIDI 0–127) for a given hardware key code. 116 + /// Returns 64 (center) for keys that aren't in the QWERTY pan 117 + /// table (modifiers, function keys, etc.). 118 + @inline(__always) 119 + static func panForKeyCode(_ keyCode: UInt16) -> UInt8 { 120 + guard keyCode < 128 else { return 64 } 121 + return panByKeyCode[Int(keyCode)] 122 + } 123 + 79 124 @inline(__always) 80 125 static func midiNote(forKeyCode keyCode: UInt16, 81 126 octaveShift: Int, ··· 183 228 client = 0 184 229 return 185 230 } 231 + // Stable identity across launches. Without these, CoreMIDI 232 + // mints a fresh kMIDIPropertyUniqueID per process; DAWs cache 233 + // their per-input "Track / Sync / Remote" toggles by UID, so 234 + // every reinstall or relaunch reads as a new device and the 235 + // user has to re-enable Track in Live's MIDI prefs to hear 236 + // notes. Pinning UID + manufacturer + model means Ableton's 237 + // routing survives reinstalls. 238 + // UID is a 32-bit signed int; 0x4D424E44 = ASCII "MBND". 239 + MIDIObjectSetIntegerProperty(source, kMIDIPropertyUniqueID, 240 + Int32(bitPattern: 0x4D424E44)) 241 + MIDIObjectSetStringProperty(source, kMIDIPropertyManufacturer, 242 + "aesthetic.computer" as CFString) 243 + MIDIObjectSetStringProperty(source, kMIDIPropertyModel, 244 + "Menu Band" as CFString) 186 245 started = true 187 246 debugLog("midi.start: virtual source published") 188 247 }
+282 -135
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 66 66 private var modeButtons: [NSButton] = [] // vertical stack: Mouse Only / Notepat.com / Ableton MIDI Keys 67 67 private var midiSwitch: NSSwitch! 68 68 private var midiInlineLabel: NSTextField! 69 - private var muteButton: NSButton! 70 69 private var midiSelfTestLabel: NSTextField! // legacy — created but never added to stack 71 70 private var instrumentList: InstrumentListView! 72 71 private var instrumentReadout: NSTextField! 73 72 private var instrumentLabel: NSTextField! 74 73 private var instrumentTitleRow: NSStackView! 74 + private var arrowsHint: ArrowKeysIndicator! 75 75 private var instrumentSeparator: NSView! 76 76 private var octaveStepper: NSStepper! 77 77 private var octaveLabel: NSTextField! ··· 135 135 // tightly so the 4 px spacing on each side stays exactly 4 px, 136 136 // not "4 + half-of-padding". 137 137 octaveLabel.font = NSFont.monospacedSystemFont(ofSize: 17, weight: .semibold) 138 - octaveLabel.textColor = .labelColor 138 + octaveLabel.textColor = .controlAccentColor 139 139 octaveLabel.alignment = .center 140 140 141 141 octaveStepper = NSStepper() ··· 188 188 titleRow.setCustomSpacing(4, after: octaveLabel) 189 189 titleRow.addArrangedSubview(rightArrow) 190 190 titleRow.addArrangedSubview(octaveStepper) // hidden, here for layout-time only 191 - titleRow.setCustomSpacing(3, after: rightArrow) 191 + titleRow.setCustomSpacing(8, after: rightArrow) 192 192 titleRow.addArrangedSubview(octaveHint) 193 193 194 194 // Spacer lives in the middle so the octave widget pins LEFT and 195 195 // the MIDI pair pins RIGHT. 196 196 titleRow.addArrangedSubview(titleSpacer) 197 - 198 - // Mute toggle — small speaker icon to the left of MIDI. When on, the 199 - // local synth is silent (lit state + MIDI port still update so the 200 - // visual + DAW paths keep working — only the built-in instrument is 201 - // gagged). Lives in the title row to stay out of the main controls. 202 - let speakerConfig = NSImage.SymbolConfiguration(pointSize: 13, 203 - weight: .semibold) 204 - muteButton = NSButton() 205 - muteButton.isBordered = false 206 - muteButton.bezelStyle = .inline 207 - muteButton.imagePosition = .imageOnly 208 - muteButton.imageScaling = .scaleProportionallyDown 209 - muteButton.target = self 210 - muteButton.action = #selector(muteButtonClicked(_:)) 211 - muteButton.image = NSImage(systemSymbolName: "speaker.fill", 212 - accessibilityDescription: "Mute local synth")? 213 - .withSymbolConfiguration(speakerConfig) 214 - muteButton.contentTintColor = .secondaryLabelColor 215 - muteButton.toolTip = "Mute local synth" 216 - titleRow.addArrangedSubview(muteButton) 217 - titleRow.setCustomSpacing(8, after: muteButton) 218 197 219 198 // MIDI toggle — tucked into the title row instead of its own panel. 199 + // Enabling MIDI also silences the local keyboard (notes route to the 200 + // DAW instead), so a separate mute button would be redundant. 220 201 midiSwitch = NSSwitch() 221 202 midiSwitch.target = self 222 203 midiSwitch.action = #selector(midiSwitchToggled(_:)) ··· 330 311 inputHint.textColor = .secondaryLabelColor 331 312 stack.addArrangedSubview(inputHint) 332 313 333 - // Live waveform of the local synth output. Hidden in MIDI mode 334 - // (DAW handles audio there; our local mixer is silent so the line 335 - // would just sit flat). Single antialiased path, ~60 Hz redraw. 314 + // Live segmented LED meter of the local synth output. Hidden in 315 + // MIDI mode (DAW handles audio there; our local mixer is silent 316 + // so the bars would just sit dark). 317 + // 318 + // Wrapped in a layer-backed bezel so the meter reads as a 319 + // proper VU display housing — dark recessed background, soft 320 + // border, uniform inner margin around the bars. Without the 321 + // bezel the bars sit flush against the popover walls and feel 322 + // unfinished. 336 323 waveformView = WaveformView() 337 324 waveformView.menuBand = menuBand 338 325 waveformView.translatesAutoresizingMaskIntoConstraints = false 339 - stack.addArrangedSubview(waveformView) 340 - waveformView.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 341 - waveformView.heightAnchor.constraint(equalToConstant: 64).isActive = true 326 + 327 + let waveformBezel = NSView() 328 + waveformBezel.wantsLayer = true 329 + waveformBezel.layer?.cornerRadius = 6 330 + waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 331 + waveformBezel.layer?.borderColor = NSColor.labelColor.withAlphaComponent(0.18).cgColor 332 + waveformBezel.layer?.borderWidth = 1 333 + waveformBezel.translatesAutoresizingMaskIntoConstraints = false 334 + waveformBezel.addSubview(waveformView) 335 + let bezelInset: CGFloat = 5 336 + NSLayoutConstraint.activate([ 337 + waveformView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: bezelInset), 338 + waveformView.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor, constant: -bezelInset), 339 + waveformView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: bezelInset), 340 + waveformView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 341 + ]) 342 + stack.addArrangedSubview(waveformBezel) 343 + waveformBezel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 344 + waveformBezel.heightAnchor.constraint(equalToConstant: 64).isActive = true 342 345 343 346 stack.addArrangedSubview(makeSeparator()) 344 347 ··· 364 367 // Title row: "Instrument:" left, "078 Whistle" right (greyed). The 365 368 // readout used to live under the grid; promoting it to the title 366 369 // row keeps the eye on a single line while browsing cells. 367 - instrumentLabel = NSTextField(labelWithString: "Instrument:") 368 - instrumentLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 370 + instrumentLabel = NSTextField(labelWithString: "Voice:") 371 + instrumentLabel.font = NSFont.systemFont(ofSize: 9.5, weight: .semibold) 369 372 instrumentLabel.textColor = .labelColor 370 373 instrumentReadout = NSTextField(labelWithString: "") 371 - instrumentReadout.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) 372 - instrumentReadout.textColor = .secondaryLabelColor 374 + instrumentReadout.font = NSFont.monospacedSystemFont(ofSize: 9.5, weight: .regular) 375 + instrumentReadout.textColor = .labelColor 376 + // Chip backdrop — colored to match the GM-family hue that 377 + // `InstrumentListView.colorForProgram` uses for the cell. The 378 + // bg is set/refreshed in `updateInstrumentReadout` so picking 379 + // a new instrument visually moves the chip into the same color 380 + // tier as the cell that was clicked. 381 + instrumentReadout.drawsBackground = false 382 + instrumentReadout.wantsLayer = true 383 + instrumentReadout.layer?.cornerRadius = 4 373 384 instrumentReadout.lineBreakMode = .byTruncatingTail 374 385 instrumentTitleRow = NSStackView(views: [instrumentLabel, instrumentReadout]) 375 386 instrumentTitleRow.orientation = .horizontal ··· 381 392 instrumentReadout.setContentHuggingPriority(.defaultLow, for: .horizontal) 382 393 instrumentReadout.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 383 394 stack.addArrangedSubview(instrumentTitleRow) 395 + // (Held-notes / chord LED panel removed — the empty-pill resting 396 + // state added more visual noise than the feature was worth.) 384 397 385 398 // GarageBand backend toggle was prototyped here (see 386 399 // GarageBandLibrary + GarageBandPatchView), then deprecated ··· 391 404 instrumentList.translatesAutoresizingMaskIntoConstraints = false 392 405 instrumentList.onCommit = { [weak self] prog in 393 406 self?.handleInstrumentCommit(prog) 407 + } 408 + instrumentList.onArrowKey = { [weak self] dir, isDown in 409 + self?.arrowsHint.setHighlight(direction: dir, on: isDown) 410 + } 411 + // Forward non-arrow keys through the controller's local-key 412 + // handler so notepat / Ableton letter keys still play notes 413 + // while the popover holds first-responder focus on the grid. 414 + instrumentList.onMusicKey = { [weak self] kc, isDown, isRepeat, flags in 415 + return self?.menuBand?.handleLocalKey( 416 + keyCode: kc, isDown: isDown, isRepeat: isRepeat, flags: flags 417 + ) ?? false 394 418 } 395 419 instrumentList.onHover = { [weak self] prog in 420 + guard let self = self else { return } 396 421 // Hover plays a continuous preview note in the hovered program; 397 422 // moving to another cell stops + restarts in the new program. 398 - self?.menuBand?.setInstrumentPreview(prog.map { UInt8($0) }) 423 + self.menuBand?.setInstrumentPreview(prog.map { UInt8($0) }) 424 + // Live-preview the chrome too — chip backdrop, chip text, 425 + // and visualizer base color all retint to whatever cell is 426 + // under the cursor while dragging. When hover ends (prog == 427 + // nil) we snap back to the committed instrument. 428 + let safe: Int 429 + let nameForChip: String 430 + if let p = prog { 431 + safe = max(0, min(127, p)) 432 + nameForChip = GeneralMIDI.programNames[safe] 433 + } else if let m = self.menuBand { 434 + safe = max(0, min(127, Int(m.melodicProgram))) 435 + nameForChip = GeneralMIDI.programNames[safe] 436 + } else { 437 + return 438 + } 439 + let famColor = InstrumentListView.colorForProgram(safe) 440 + self.instrumentReadout.stringValue = " \(nameForChip) " 441 + self.instrumentReadout.layer?.backgroundColor = 442 + famColor.withAlphaComponent(0.32).cgColor 443 + self.waveformView.setBaseColor(famColor) 399 444 } 400 - stack.addArrangedSubview(instrumentList) 401 - instrumentList.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 402 - instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight).isActive = true 445 + // Wrap the grid in a panel that adds an extra strip BELOW the 446 + // 8×16 cells where the arrow-keys hint glyph lives. The hint 447 + // is its own bottom-right ornament — never overlaying the 448 + // voice cells — so the whole assembly reads like a stepper- 449 + // button corner on a stereo's faceplate. 450 + let palettePanel = NSView() 451 + palettePanel.translatesAutoresizingMaskIntoConstraints = false 452 + palettePanel.addSubview(instrumentList) 453 + arrowsHint = ArrowKeysIndicator() 454 + arrowsHint.toolTip = "Arrow keys move the selection." 455 + arrowsHint.translatesAutoresizingMaskIntoConstraints = false 456 + palettePanel.addSubview(arrowsHint) 457 + let cornerInset: CGFloat = 4 458 + // Strip below the grid sized to fit the 30 px keycap cluster 459 + // plus a generous gap above so the arrows feel like a separate 460 + // ornament — not crammed up under the bottom row of voices. 461 + let strip: CGFloat = 46 462 + NSLayoutConstraint.activate([ 463 + instrumentList.topAnchor.constraint(equalTo: palettePanel.topAnchor), 464 + instrumentList.leadingAnchor.constraint(equalTo: palettePanel.leadingAnchor), 465 + instrumentList.trailingAnchor.constraint(equalTo: palettePanel.trailingAnchor), 466 + instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight), 467 + arrowsHint.trailingAnchor.constraint(equalTo: palettePanel.trailingAnchor, constant: -cornerInset), 468 + arrowsHint.bottomAnchor.constraint(equalTo: palettePanel.bottomAnchor, constant: -cornerInset), 469 + ]) 470 + stack.addArrangedSubview(palettePanel) 471 + palettePanel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 472 + palettePanel.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight + strip).isActive = true 403 473 404 474 let bottomSeparator = makeSeparator() 405 475 stack.addArrangedSubview(bottomSeparator) ··· 424 494 aboutCol.orientation = .vertical 425 495 aboutCol.alignment = .leading 426 496 aboutCol.spacing = 6 427 - // Brand identity now lives here instead of at the top of the 428 - // popover — Menu Band heading + subtitle + about body + links all 429 - // collapse into one section. 430 - let aboutTitle = NSTextField(labelWithString: "Menu Band") 431 - aboutTitle.font = NSFont.systemFont(ofSize: 13, weight: .bold) 432 - aboutTitle.textColor = .labelColor 433 - let aboutSubtitle = NSTextField(wrappingLabelWithString: 434 - "Built-in macOS instruments, in the menu bar.") 435 - aboutSubtitle.font = NSFont.systemFont(ofSize: 10.5) 436 - aboutSubtitle.textColor = .secondaryLabelColor 437 - aboutSubtitle.maximumNumberOfLines = 0 438 - aboutSubtitle.preferredMaxLayoutWidth = InstrumentListView.preferredWidth 497 + // No heading — the prose itself is the about content. The bold 498 + // "Menu Band" header on top read as a duplicate of the menubar 499 + // identity above and ate vertical space. 439 500 let aboutBody = NSTextField(wrappingLabelWithString: 440 501 "A political project to bring the built-in macOS instruments — " + 441 502 "the ones GarageBand uses — into the menu bar. Free + open source.") ··· 444 505 aboutBody.maximumNumberOfLines = 0 445 506 aboutBody.preferredMaxLayoutWidth = InstrumentListView.preferredWidth 446 507 aboutCol.setContentHuggingPriority(.defaultLow, for: .horizontal) 447 - aboutCol.addArrangedSubview(aboutTitle) 448 508 aboutCol.addArrangedSubview(aboutBody) 449 - let linksRow = NSStackView() 450 - linksRow.orientation = .horizontal 451 - linksRow.alignment = .centerY 452 - linksRow.spacing = 6 453 - let acLink = NSButton(title: "aesthetic.computer", 454 - target: self, action: #selector(openAesthetic)) 455 - acLink.bezelStyle = .recessed 456 - acLink.controlSize = .small 457 - let npLink = NSButton(title: "notepat.com", 458 - target: self, action: #selector(openNotepat)) 459 - npLink.bezelStyle = .recessed 460 - npLink.controlSize = .small 461 - linksRow.addArrangedSubview(acLink) 462 - linksRow.addArrangedSubview(npLink) 463 - aboutCol.addArrangedSubview(linksRow) 509 + // Two badge-style links stacked vertically. Aesthetic.Computer 510 + // wears its purple-on-pale-purple identity; notepat.com gets a 511 + // dark gray slab so the concert-poster white/black shadow play 512 + // reads. 513 + let linksCol = NSStackView() 514 + linksCol.orientation = .vertical 515 + linksCol.alignment = .leading 516 + linksCol.spacing = 4 517 + let acPurple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1) 518 + let acLink = Self.makeLinkButton( 519 + attr: Self.aestheticComputerTitle(), 520 + target: self, action: #selector(openAesthetic), 521 + background: acPurple.withAlphaComponent(0.14), 522 + border: acPurple.withAlphaComponent(0.55)) 523 + let npLink = Self.makeLinkButton( 524 + attr: Self.notepatComTitle(), 525 + target: self, action: #selector(openNotepat), 526 + background: NSColor(white: 0.30, alpha: 1), 527 + border: nil) 528 + linksCol.addArrangedSubview(acLink) 529 + linksCol.addArrangedSubview(npLink) 530 + aboutCol.addArrangedSubview(linksCol) 464 531 465 - let crashCol = NSStackView() 466 - crashCol.orientation = .vertical 467 - crashCol.alignment = .leading 468 - crashCol.spacing = 4 469 - crashStatusLabel = NSTextField(labelWithString: "") 470 - crashStatusLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 471 - crashStatusLabel.textColor = .labelColor 472 - crashHintLabel = NSTextField(wrappingLabelWithString: "") 473 - crashHintLabel.font = NSFont.systemFont(ofSize: 10) 474 - crashHintLabel.textColor = .secondaryLabelColor 475 - crashHintLabel.maximumNumberOfLines = 0 476 - crashHintLabel.preferredMaxLayoutWidth = 130 477 - crashCol.setContentHuggingPriority(.defaultHigh, for: .horizontal) 532 + // Crash-send moved out of this row — it now lives next to Quit 533 + // below as a small standalone button. Keeping it here as a side-by- 534 + // side column was pushing the about copy and clipping the popover 535 + // bottom on multi-line crash hints. 536 + crashStatusLabel = NSTextField(labelWithString: "") // legacy ivar — unused 537 + crashHintLabel = NSTextField(labelWithString: "") // legacy ivar — unused 478 538 crashSendButton = NSButton(title: "Send crash reports", 479 539 target: self, 480 540 action: #selector(sendCrashLogs(_:))) 481 541 crashSendButton.bezelStyle = .recessed 482 542 crashSendButton.controlSize = .small 483 - crashCol.addArrangedSubview(crashStatusLabel) 484 - crashCol.addArrangedSubview(crashHintLabel) 485 - crashCol.addArrangedSubview(crashSendButton) 543 + crashSendButton.isHidden = true // shown by refreshCrashStatus when n>0 486 544 487 545 aboutCrashRow.addArrangedSubview(aboutCol) 488 - aboutCrashRow.addArrangedSubview(crashCol) 489 546 stack.addArrangedSubview(aboutCrashRow) 490 547 // Air between the About/Crash block and the Quit button below so 491 548 // Quit reads as its own action, not a list item under About. 492 549 stack.setCustomSpacing(10, after: aboutCrashRow) 493 550 494 - // Quit — small, borderless, bottom-right. Red text only. 551 + // Quit — red bezel, white bold title. Bottom-right of the footer 552 + // row; crash-send button (when present) sits at the left of the 553 + // same row. 495 554 let quit = NSButton() 496 - quit.bezelStyle = .inline 497 - quit.isBordered = false 555 + quit.bezelStyle = .rounded 556 + quit.isBordered = true 557 + quit.bezelColor = .systemRed 498 558 quit.controlSize = .small 499 559 quit.target = self 500 560 quit.action = #selector(quitApp) 501 561 quit.attributedTitle = NSAttributedString( 502 - string: "Quit", 562 + string: "Quit Menu Band", 503 563 attributes: [ 504 - .foregroundColor: NSColor.systemRed, 564 + .foregroundColor: NSColor.white, 505 565 .font: NSFont.systemFont(ofSize: 11, weight: .semibold), 506 566 ] 507 567 ) 508 568 let quitRow = NSStackView() 509 569 quitRow.orientation = .horizontal 570 + quitRow.alignment = .centerY 571 + quitRow.spacing = 8 510 572 let quitSpacer = NSView() 511 573 quitSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) 574 + quitRow.addArrangedSubview(crashSendButton) 512 575 quitRow.addArrangedSubview(quitSpacer) 513 576 quitRow.addArrangedSubview(quit) 514 577 stack.addArrangedSubview(quitRow) ··· 546 609 guard isViewLoaded, let menuBand = waveformView.menuBand else { return } 547 610 syncFromController() 548 611 waveformView.isLive = !menuBand.midiMode 612 + // Make the voice grid first responder on every popover open so 613 + // arrow keys always step the selection — even before the user 614 + // has clicked into the grid this session. 615 + view.window?.makeFirstResponder(instrumentList) 549 616 } 550 617 551 618 override func viewDidDisappear() { 552 619 super.viewDidDisappear() 553 620 waveformView.isLive = false 554 621 } 622 + 623 + /// Held-notes readout removed. AppDelegate's onLitChanged still 624 + /// calls this for back-compat — left as a no-op so we don't have 625 + /// to thread the removal through every callsite. 626 + func refreshHeldNotes() {} 555 627 556 628 /// Refresh control state from the controller — call right before showing. 557 629 func syncFromController() { 558 630 guard isViewLoaded, let n = menuBand else { return } 559 631 midiSwitch.state = n.midiMode ? .on : .off 560 - updateMuteButton(muted: n.muted) 561 632 octaveStepper.integerValue = n.octaveShift 562 633 updateOctaveLabel(n.octaveShift) 563 634 let segIdx = inputModeSegment(keymap: n.keymap) ··· 587 658 self.updateSelfTestLabel(state: nn.midiMode ? nn.midiSelfTest : .unknown) 588 659 } 589 660 } 661 + // Re-fit the popover after sync. preferredContentSize was locked 662 + // in loadView() while the crash column was empty/hidden and the 663 + // update banner was not yet shown; both can grow the layout 664 + // (multi-line crash hint, banner row) and would otherwise be 665 + // clipped at the bottom of the popover. 666 + refitContentSize() 590 667 } 591 668 592 - /// Format the readout in the title row as "078 Whistle". 669 + /// Re-measure the stack's intrinsic fitting size and update 670 + /// `preferredContentSize` to match. Run after any change that can 671 + /// add/remove rows or change wrapping height (crash status, 672 + /// update banner, instrument palette toggle). 673 + private func refitContentSize() { 674 + guard isViewLoaded else { return } 675 + view.needsLayout = true 676 + view.layoutSubtreeIfNeeded() 677 + let fitting = view.fittingSize 678 + if fitting.height > 0 && fitting.width > 0 { 679 + preferredContentSize = NSSize(width: fitting.width, 680 + height: fitting.height) 681 + } 682 + } 683 + 684 + /// Format the readout in the title row as just the instrument name 685 + /// (no leading "078" program number — the cell's own backdrop color 686 + /// is the visual identifier; the number was redundant noise). A 687 + /// hair space on each side keeps the chip from touching its text. 593 688 private func updateInstrumentReadout() { 594 689 guard let m = menuBand else { return } 595 690 let safe = max(0, min(127, Int(m.melodicProgram))) 596 - instrumentReadout.stringValue = String(format: "%03d %@", safe, GeneralMIDI.programNames[safe]) 691 + instrumentReadout.stringValue = " \(GeneralMIDI.programNames[safe]) " 692 + let famColor = InstrumentListView.colorForProgram(safe) 693 + instrumentReadout.layer?.backgroundColor = famColor 694 + .withAlphaComponent(0.32).cgColor 695 + // Retint the LED meter to the same hue so the visualizer 696 + // signals which instrument is the audible voice at a glance. 697 + waveformView.setBaseColor(famColor) 597 698 } 598 699 599 700 /// Reflect the MIDI loopback self-test status as the inline "MIDI" ··· 618 719 return box 619 720 } 620 721 722 + /// Badge-style link button — flat NSButton with a layer-painted 723 + /// fill + optional border, so the per-link attributed title sits 724 + /// inside a small chip. `bezelStyle = .inline` strips the system 725 + /// chrome; the layer below provides the badge look. 726 + static func makeLinkButton(attr: NSAttributedString, 727 + target: AnyObject, 728 + action: Selector, 729 + background: NSColor? = nil, 730 + border: NSColor? = nil) -> NSButton { 731 + let btn = NSButton() 732 + btn.bezelStyle = .inline 733 + btn.isBordered = false 734 + btn.controlSize = .small 735 + btn.target = target 736 + btn.action = action 737 + btn.attributedTitle = attr 738 + btn.wantsLayer = true 739 + btn.layer?.cornerRadius = 5 740 + if let bg = background { 741 + btn.layer?.backgroundColor = bg.cgColor 742 + } 743 + if let bd = border { 744 + btn.layer?.borderColor = bd.cgColor 745 + btn.layer?.borderWidth = 1 746 + } 747 + return btn 748 + } 749 + 750 + /// "Aesthetic.Computer" — purple words flanking a pink connecting 751 + /// dot. Padded with hair spaces on each side so the badge has 752 + /// breathing room from the layer-painted border. 753 + static func aestheticComputerTitle() -> NSAttributedString { 754 + let purple = NSColor(red: 167/255, green: 139/255, blue: 250/255, alpha: 1) 755 + let pink = NSColor(red: 255/255, green: 107/255, blue: 157/255, alpha: 1) 756 + let font = NSFont.systemFont(ofSize: 11, weight: .semibold) 757 + let s = NSMutableAttributedString() 758 + s.append(NSAttributedString(string: "Aesthetic", 759 + attributes: [.foregroundColor: purple, .font: font])) 760 + s.append(NSAttributedString(string: ".", 761 + attributes: [.foregroundColor: pink, .font: font])) 762 + s.append(NSAttributedString(string: "Computer", 763 + attributes: [.foregroundColor: purple, .font: font])) 764 + s.append(NSAttributedString(string: " ", // trailing badge padding 765 + attributes: [.font: font])) 766 + return s 767 + } 768 + 769 + /// "notepat.com" — all lowercase, heavy white set on the dark 770 + /// slab backdrop with a single down-right black drop shadow for 771 + /// concert-poster weight. Uniform coloring — no special dot. 772 + static func notepatComTitle() -> NSAttributedString { 773 + let font = NSFont.systemFont(ofSize: 11, weight: .heavy) 774 + let shadow = NSShadow() 775 + shadow.shadowColor = NSColor.black 776 + shadow.shadowOffset = NSSize(width: 1, height: -1) 777 + shadow.shadowBlurRadius = 0 778 + return NSAttributedString(string: "notepat.com", 779 + attributes: [ 780 + .foregroundColor: NSColor.white, 781 + .font: font, 782 + .shadow: shadow, 783 + ]) 784 + } 785 + 621 786 private func makeSwitchRow(label: String, 622 787 sublabel: String, 623 788 switchControl: NSSwitch) -> NSView { ··· 651 816 return row 652 817 } 653 818 654 - /// Update the crash-log status row from disk. Called on every popover 655 - /// open so the count is current. When there are zero crashes, the 656 - /// whole crash column hides — About sits side-by-side normally; when 657 - /// crashes are present, About narrows to share the row. 819 + /// Update the crash-send button from disk. Called on every popover 820 + /// open so the count is current. The button lives in the quit row 821 + /// next to Quit Menu Band and stays hidden when there are no reports. 658 822 private func refreshCrashStatus() { 659 - let logs = CrashLogReader.recentLogs() 660 - let n = logs.count 661 - // Walk up to the parent crash column to hide/show the whole panel. 662 - let crashCol = crashStatusLabel?.superview 663 - if n == 0 { 664 - crashCol?.isHidden = true 665 - crashSendButton.isHidden = true 666 - } else { 667 - crashCol?.isHidden = false 668 - crashStatusLabel.stringValue = n == 1 ? "1 crash" : "\(n) crashes" 669 - crashHintLabel.stringValue = "Send to aesthetic.computer to help debug." 670 - crashSendButton.isHidden = false 671 - crashSendButton.title = n == 1 ? "Send 1" : "Send all (\(n))" 823 + let n = CrashLogReader.recentLogs().count 824 + crashSendButton.isHidden = (n == 0) 825 + if n > 0 { 826 + crashSendButton.title = n == 1 ? "Send 1 crash" : "Send \(n) crashes" 672 827 crashSendButton.isEnabled = true 673 828 } 674 829 } ··· 731 886 732 887 // MARK: - Actions 733 888 734 - @objc private func muteButtonClicked(_ sender: NSButton) { 735 - menuBand?.toggleMuted() 736 - if let m = menuBand { 737 - updateMuteButton(muted: m.muted) 738 - // Tactile feedback so the icon flip feels confirmed. "Tink" matches 739 - // the MIDI switch's feedback so the two toggles read as a pair. 740 - NSSound(named: NSSound.Name("Tink"))?.play() 741 - } 742 - } 743 - 744 - private func updateMuteButton(muted: Bool) { 745 - guard let btn = muteButton else { return } 746 - let symbol = muted ? "speaker.slash.fill" : "speaker.fill" 747 - let cfg = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) 748 - btn.image = NSImage(systemSymbolName: symbol, 749 - accessibilityDescription: muted ? "Unmute" : "Mute")? 750 - .withSymbolConfiguration(cfg) 751 - btn.contentTintColor = muted ? .systemRed : .secondaryLabelColor 752 - btn.toolTip = muted ? "Unmute local synth" : "Mute local synth" 753 - } 754 - 755 889 @objc private func midiSwitchToggled(_ sender: NSSwitch) { 756 890 // Just toggle — don't run the heavy syncFromController. The switch 757 891 // already shows the user's intent; the loopback test (skipped on ··· 813 947 814 948 private func handleInstrumentCommit(_ program: Int) { 815 949 guard let m = menuBand else { return } 950 + // If MIDI mode is on, picking an instrument from the GM palette 951 + // is a strong signal the user wants to *hear* their pick — but 952 + // MIDI mode silences the local synth (DAW is the audio path). 953 + // Auto-flip MIDI off + audition the new program so the click 954 + // makes sound immediately. Sync the switch UI to match. 955 + let wasMidiOn = m.midiMode 956 + if wasMidiOn { 957 + m.toggleMIDIMode() 958 + midiSwitch.state = .off 959 + updateSelfTestLabel(state: .unknown) 960 + } 816 961 m.setMelodicProgram(UInt8(program)) 817 962 instrumentList.selectedProgram = UInt8(program) 818 963 updateInstrumentReadout() 819 - debugLog("instrument commit prog=\(program)") 820 - // No post-release audition: the press-gated rollover already played 821 - // a preview note while the mouse was held, so retriggering on 822 - // release just doubles the sound. mouseUp paths through onHover(nil) 823 - // first which stops the preview cleanly — that's the audio-end the 824 - // user expects. 964 + debugLog("instrument commit prog=\(program) midiAutoOff=\(wasMidiOn)") 965 + if wasMidiOn { 966 + m.auditionCurrentProgram() 967 + } 968 + // Otherwise no post-release audition: the press-gated rollover 969 + // already played a preview note while the mouse was held, so 970 + // retriggering on release just doubles the sound. mouseUp paths 971 + // through onHover(nil) first which stops the preview cleanly. 825 972 } 826 973 827 974 @objc private func octaveChanged(_ sender: NSStepper) {
+11
slab/menuband/Sources/MenuBand/MenuBandSynth.swift
··· 344 344 midiSynthReady = false 345 345 } 346 346 347 + /// Send a CC#10 (pan) message on the given channel. Only takes 348 + /// effect when the MIDISynth backend is the audible path — 349 + /// AVAudioUnitSampler's pan is per-unit, not per-channel, so we 350 + /// no-op the sampler fallback rather than yanking the entire 351 + /// melodic mix. 352 + func setPan(_ pan: UInt8, channel: UInt8 = 0) { 353 + guard started, midiSynthReady, let au = midiSynth?.audioUnit else { return } 354 + sendMIDIEvent(au, status: 0xB0 | (channel & 0x0F), 355 + data1: 10, data2: pan & 0x7F) 356 + } 357 + 347 358 func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 348 359 guard started else { return } 349 360 // Drums (channel 9) always route through MIDISynth/drums sampler
+99 -12
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 145 145 pd.vertexFunction = vfn 146 146 pd.fragmentFunction = ffn 147 147 pd.colorAttachments[0].pixelFormat = colorPixelFormat 148 + // Alpha blending — fragment shader emits alpha < 1 for unlit 149 + // segments so the empty rows of the LED meter dim out instead 150 + // of staying full-color. Without blending, alpha is ignored 151 + // and every "off" segment renders at full intensity. 152 + pd.colorAttachments[0].isBlendingEnabled = true 153 + pd.colorAttachments[0].rgbBlendOperation = .add 154 + pd.colorAttachments[0].alphaBlendOperation = .add 155 + pd.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha 156 + pd.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha 157 + pd.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha 158 + pd.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha 148 159 pipelineState = try device.makeRenderPipelineState(descriptor: pd) 149 160 } catch { 150 161 NSLog("MenuBand: visualizer Metal pipeline failed: \(error)") ··· 158 169 159 170 private func applyAccentColor() { 160 171 let c = NSColor.controlAccentColor.usingColorSpace(.sRGB) ?? NSColor.systemTeal 172 + uniforms.color = SIMD4<Float>(Float(c.redComponent), 173 + Float(c.greenComponent), 174 + Float(c.blueComponent), 175 + 1.0) 176 + } 177 + 178 + /// Override the visualizer's base color — used so the LED meter 179 + /// matches the chosen GM instrument. Top of the bar still brightens 180 + /// toward white in the shader (hot-zone VU feel), so passing in a 181 + /// dim mid-tone still reads with peak indication. Pass `nil` to 182 + /// revert to the system accent color. 183 + func setBaseColor(_ color: NSColor?) { 184 + guard let color = color, 185 + let c = color.usingColorSpace(.sRGB) else { 186 + applyAccentColor() 187 + return 188 + } 161 189 uniforms.color = SIMD4<Float>(Float(c.redComponent), 162 190 Float(c.greenComponent), 163 191 Float(c.blueComponent), ··· 177 205 let n = Self.barCount 178 206 let chunkSize = samples.count / n 179 207 var framePeak: Float = 0 208 + // RMS per bin instead of peak. Peak detection makes bars hop 209 + // around as zero-crossings drift across chunk boundaries — looks 210 + // like the spectrum is "rolling." RMS averages within each chunk 211 + // so a small phase shift barely moves the value, and the bars 212 + // sit at their true amplitude rather than chasing transients. 180 213 for b in 0..<n { 181 - var peak: Float = 0 214 + var sumSq: Float = 0 182 215 let base = b * chunkSize 183 216 for i in 0..<chunkSize { 184 - let a = abs(samples[base + i]) 185 - if a > peak { peak = a } 217 + let s = samples[base + i] 218 + sumSq += s * s 186 219 } 187 - levels[b] = peak 188 - if peak > framePeak { framePeak = peak } 220 + let rms = (chunkSize > 0) ? sqrtf(sumSq / Float(chunkSize)) : 0 221 + levels[b] = rms 222 + if rms > framePeak { framePeak = rms } 189 223 } 190 224 191 225 // Auto-gain — same envelope as the old CALayer path. Snap up on ··· 197 231 smoothedPeak = max(0.05, smoothedPeak * 0.92 + framePeak * 0.08) 198 232 } 199 233 let gain = 0.95 / smoothedPeak 200 - // Direct readout — no inter-frame ballistics. The user prefers 201 - // raw peak amplitude over smoothed visuals; bars track the 202 - // analyzed level exactly so transients land within one display 203 - // frame of the audio that triggered them. 234 + // Per-bar temporal smoothing. RMS over a short chunk still 235 + // wobbles when the chunk is shorter than one period of the note 236 + // (low pitches especially) — a 30 Hz tone has ~1500 samples per 237 + // period, but each bar only sees ~32 samples. Without temporal 238 + // smoothing, those phase-induced wobbles drove the LED segment 239 + // count up and down frame to frame, reading as the meter 240 + // "rolling." Asymmetric smoothing (fast attack, slow decay) 241 + // keeps transient response while killing the ripple. 242 + let attack: Float = 0.55 // weight on new sample when rising 243 + let decay: Float = 0.18 // weight on new sample when falling 204 244 for b in 0..<n { 205 - displayLevels[b] = min(1.0, levels[b] * gain) 245 + let raw = min(1.0, levels[b] * gain) 246 + let prev = displayLevels[b] 247 + let alpha = (raw > prev) ? attack : decay 248 + displayLevels[b] = prev * (1.0 - alpha) + raw * alpha 206 249 } 207 250 } 208 251 ··· 223 266 224 267 struct VertexOut { 225 268 float4 position [[position]]; 269 + float level; 226 270 }; 227 271 228 272 // Two triangles spanning the unit square (CCW), shared across all bar ··· 233 277 float2(0, 1), float2(1, 0), float2(1, 1) 234 278 }; 235 279 280 + // Segmented LED-meter look. Each bar is rendered as a full-height 281 + // rect; the fragment shader carves it into stacked segments by 282 + // level. Tweak NSEG / SEG_GAP to taste — old stereo VU meters 283 + // typically had 10–14 segments with a small dim row above the lit 284 + // ones to hint at the headroom. 285 + constant float NSEG = 10.0; 286 + constant float SEG_GAP = 0.32; 287 + constant float UNLIT_ALPHA = 0.12; 288 + // Hot-zone: above this fraction of the bar height, the lit color 289 + // brightens toward white so the top of the meter still reads as 290 + // "peaking" even when the instrument's base color is e.g. a deep 291 + // navy bass. Linear ramp from HOT_AT → top. 292 + constant float HOT_AT = 0.70; 293 + 236 294 vertex VertexOut bar_vertex(uint vid [[vertex_id]], 237 295 uint iid [[instance_id]], 238 296 constant Uniforms &u [[buffer(0)]], ··· 240 298 { 241 299 float2 local = unitQuad[vid]; 242 300 float barX = float(iid) * u.stride; 243 - float h = max(u.minHeight, levels[iid] * u.viewH); 301 + // Full-height geometry — fragment shader masks per-segment. 302 + float h = u.viewH; 244 303 float px = barX + local.x * u.barW; 245 304 float py = local.y * h; 246 305 // Pixel space → clip space ([-1, 1] on both axes). ··· 248 307 float clipY = (py / u.viewH) * 2.0 - 1.0; 249 308 VertexOut out; 250 309 out.position = float4(clipX, clipY, 0, 1); 310 + out.level = levels[iid]; 251 311 return out; 252 312 } 253 313 254 314 fragment float4 bar_fragment(VertexOut in [[stage_in]], 255 315 constant Uniforms &u [[buffer(0)]]) 256 316 { 257 - return u.color; 317 + // [[position]] gives fragment pixel-space y with origin at TOP. 318 + // Flip so y01=0 is bottom of the bar (where amplitude starts). 319 + float y01 = 1.0 - (in.position.y / u.viewH); 320 + // Inter-segment gap: top fraction of each segment cell stays 321 + // transparent so the bar reads as a stack rather than a solid. 322 + float segPos = fract(y01 * NSEG); 323 + if (segPos > (1.0 - SEG_GAP)) { 324 + discard_fragment(); 325 + } 326 + // Bar color = the instrument's chosen base hue, passed in via 327 + // u.color. The top of the bar still brightens toward white so 328 + // there's a "peaking" cue even when the base is dim — VU 329 + // gradient feel without forcing green/amber/red. 330 + float3 base = u.color.rgb; 331 + float hot = max(0.0, (y01 - HOT_AT) / (1.0 - HOT_AT)); 332 + float3 tier = mix(base, float3(1.0, 1.0, 1.0), hot * 0.65); 333 + // Per-segment glow: brighter at the center of each LED cell, 334 + // falling off toward the gap edges. Reads as a soft bloom on 335 + // each lit segment without a real blur pass. 336 + float visibleSegPos = segPos / (1.0 - SEG_GAP); 337 + float segCenterDist = abs(visibleSegPos - 0.5) * 2.0; 338 + float bloom = pow(1.0 - segCenterDist, 1.6); 339 + // Lit = below the level. Above = drawn very faintly so the 340 + // unfilled segments still hint at where the column lives. 341 + bool lit = y01 < in.level; 342 + float3 color = lit ? (tier + bloom * 0.40) : tier; 343 + float a = lit ? u.color.a : (u.color.a * UNLIT_ALPHA); 344 + return float4(min(color, 1.0), a); 258 345 } 259 346 """ 260 347 }