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.6 — voice palette LCD + qwerty map + dark mode

Voice palette
- Voice title typeset in YWFT Processing Bold (bundled in
Resources/, registered at launch via CTFontManagerRegisterFontsForURL).
Adapts text color + drop shadow per appearance — dark voices brighten
in dark mode, light voices darken in light mode, with a contrast
shadow always present so the title pops on any backdrop.
- Held notes are floating boxes (no surrounding bezel), centered above
the visualizer in the voice's family color. Reserved row stays put
when no notes are held so the layout doesn't wobble.
- Voice-number subscript on the menubar music-note icon: tiny digits
in the bottom-right corner, first digit anchored at the corner,
multi-digit values flow rightward into a reserved 12 px pad. Hover
pill + click hit area cover the whole icon + badge as one target.
Skipped for program 0 (default Acoustic Grand) so unmodified state
stays uncluttered.

Menubar piano
- Dark-mode aware: white keys flip to a soft macOS dark-gray (white
0.20 / 0.13), black keys flip to a brighter accent. Groove darkens.
White-key letter labels flip too (light text on dark caps in dark
mode). Lit (active) state stays accent across both modes.
- Octave-shift slide: the WHOLE board scrolls left/right past the
menubar slot in two phases (off → re-enter from opposite side)
when the user shifts octave. Octave UP slides left (camera pans
toward higher notes); DOWN slides right. 340 ms, linear.
- Music-note glyph flashes (blends toward white) on every fresh note
hit (peak 0.7) and on every octave change (peak 1.0), decaying over
180 ms. Reads as activity feedback without being noisy.

QWERTY layout view
- New panel above the arrow-keys cluster — three-row macOS keyboard
drawn as keycaps. Each cap colored by its mapped piano note: white-
mapped letters wear near-white caps with dark glyphs; black-mapped
letters wear near-black caps with white glyphs. Octave-shifter
letters get a muted accent fill (notepat = ,/. ; Ableton = z/x).
Held key codes light to accent. Inverts piano-key color
conventions intentionally so the keymap reads as "which physical
keys play piano keys."

Arrow keys cluster
- MacBook-style four-keycap D-pad in the bottom-right of the palette
panel. ↑ alone on top, ◀▼▶ in the bottom row. Hover rollover
highlights individual keys; click drives the same selection path
the keyboard arrows do (preview note while held, commit on release).
Per-direction lighting from physical arrow keys too.

Visualizer
- RMS-per-bar (was peak) + asymmetric temporal smoothing kills the
rolling motion. Segmented LED-meter look: 10 stacked LED segments
per bar in a dark bezel housing, per-segment glow, full-fill spells
"MIDI" in dot-matrix when MIDI mode is on (system accent color).
Bezel border tracks the chosen voice's hue (or accent in MIDI).

Hold-to-ramp octave shift
- ,/. keys shift octave; holding either ramps after a 280 ms
hesitation at 160 ms intervals. Each step plays a percussive
Tambourine click whose velocity scales with the new octave so the
audible weight of each notch is distinct without being tonal.

Layout / labels
- "Ableton MIDI Keys" → "Ableton Computer Keyboard", canonical
Ableton logo (drawn from the SVG path the kidlisp.com editor uses).
- Notepat.com mode button uses the live notepat.com favicon
(NotepatFavicon: lazy-fetched + cached in Application Support,
refreshed every 24h, observers re-bind via NotificationCenter).
- "Keyboard Shortcuts" → "Layout"; Layout block lives below the
voice palette + arrow keys instead of above.
- About paragraph: "Menu Band brings the built-in macOS instruments
into the menu bar." (bold "Menu Band").
- Voice palette cells are chiclet keycaps with family-tinted
outlines + per-program shading (each of 128 voices a distinct
shade within its family). Drag stays seamless (no gaps); click
commits + auto-disables MIDI so the audition note sounds.
- Arrow-key navigation on the voice grid (auto-repeat suppressed —
one tap = one cell). Non-arrow keys forwarded to handleLocalKey
so notepat letter keys still play notes while the popover holds
key focus.

Misc
- LocalKeyCapture: panel doesn't steal key when popover is already
the key window — popover stays visually active (controls don't
dim into inactive state) while letter capture still works via
app-level local monitor.
- QWERTY-position panning ported from notepat native: physical
keyboard column → MIDI CC10 pan. Sent to local synth + outbound
MIDI before each note-on.
- Stable kMIDIPropertyUniqueID on the virtual MIDI source so DAW
routing survives reinstalls.
- Letter ghost rewritten as physics: per-cell alphas smooth toward
targets every tick (asymmetric attack/decay rates). No snap on
fresh keypress mid-decay.

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

+1328 -148
+26 -26
slab/menuband/Info.plist
··· 2 2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 3 <plist version="1.0"> 4 4 <dict> 5 - <key>CFBundleIdentifier</key> 6 - <string>computer.aestheticcomputer.menuband</string> 7 - <key>CFBundleExecutable</key> 8 - <string>MenuBand</string> 9 - <key>CFBundleName</key> 10 - <string>Menu Band</string> 11 - <key>CFBundleDisplayName</key> 12 - <string>Menu Band</string> 13 - <key>CFBundlePackageType</key> 14 - <string>APPL</string> 15 - <key>CFBundleVersion</key> 16 - <string>5</string> 17 - <key>CFBundleShortVersionString</key> 18 - <string>0.5</string> 19 - <key>CFBundleInfoDictionaryVersion</key> 20 - <string>6.0</string> 21 - <key>CFBundleIconFile</key> 22 - <string>AppIcon</string> 23 - <key>LSMinimumSystemVersion</key> 24 - <string>11.0</string> 25 - <key>LSUIElement</key> 26 - <true/> 27 - <key>NSHighResolutionCapable</key> 28 - <true/> 29 - <key>NSHumanReadableCopyright</key> 30 - <string>By aesthetic.computer</string> 5 + <key>CFBundleDisplayName</key> 6 + <string>Menu Band</string> 7 + <key>CFBundleExecutable</key> 8 + <string>MenuBand</string> 9 + <key>CFBundleIconFile</key> 10 + <string>AppIcon</string> 11 + <key>CFBundleIdentifier</key> 12 + <string>computer.aestheticcomputer.menuband</string> 13 + <key>CFBundleInfoDictionaryVersion</key> 14 + <string>6.0</string> 15 + <key>CFBundleName</key> 16 + <string>Menu Band</string> 17 + <key>CFBundlePackageType</key> 18 + <string>APPL</string> 19 + <key>CFBundleShortVersionString</key> 20 + <string>0.6</string> 21 + <key>CFBundleVersion</key> 22 + <string>6</string> 23 + <key>LSMinimumSystemVersion</key> 24 + <string>11.0</string> 25 + <key>LSUIElement</key> 26 + <true/> 27 + <key>NSHighResolutionCapable</key> 28 + <true/> 29 + <key>NSHumanReadableCopyright</key> 30 + <string>By aesthetic.computer</string> 31 31 </dict> 32 32 </plist>
+4 -1
slab/menuband/Package.swift
··· 7 7 targets: [ 8 8 .executableTarget( 9 9 name: "MenuBand", 10 - path: "Sources/MenuBand" 10 + path: "Sources/MenuBand", 11 + resources: [ 12 + .process("Resources"), 13 + ] 11 14 ), 12 15 ] 13 16 )
+35
slab/menuband/Sources/MenuBand/AbletonLogo.swift
··· 1 + import AppKit 2 + 3 + /// Programmatically draws the canonical Ableton logo (the same SVG 4 + /// glyph the kidlisp.com editor uses for its Ableton boot screen): 5 + /// four 3×24 vertical bars on the left, four 24×3 horizontal bars 6 + /// on the right, total 51×24. Returns it as a template-rendered 7 + /// NSImage so AppKit tints it with the surrounding label color. 8 + enum AbletonLogo { 9 + /// Build a fresh NSImage at the requested height, preserving the 10 + /// 51:24 aspect ratio. Used for the popover's "Layout" mode 11 + /// button. 12 + static func image(height: CGFloat) -> NSImage { 13 + let aspect: CGFloat = 51.0 / 24.0 14 + let size = NSSize(width: height * aspect, height: height) 15 + let img = NSImage(size: size) 16 + img.lockFocusFlipped(true) 17 + NSColor.labelColor.setFill() 18 + let scale = height / 24.0 19 + // Vertical bars: 3 wide, 24 tall, at x = 0, 6, 12, 18. 20 + for x in stride(from: 0.0, through: 18.0, by: 6.0) { 21 + NSRect(x: x * scale, y: 0, 22 + width: 3 * scale, height: 24 * scale).fill() 23 + } 24 + // Horizontal bars: 24 wide, 3 tall, at y = 0, 7, 14, 21, 25 + // anchored to x = 27. lockFocusFlipped(true) means y=0 is 26 + // the TOP of the image — same orientation as the SVG. 27 + for y in stride(from: 0.0, through: 21.0, by: 7.0) { 28 + NSRect(x: 27 * scale, y: y * scale, 29 + width: 24 * scale, height: 3 * scale).fill() 30 + } 31 + img.unlockFocus() 32 + img.isTemplate = true 33 + return img 34 + } 35 + }
+167 -4
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 44 44 /// retreats inward (far cells fade first, the pivot last). 45 45 private var letterPivot: UInt8 = 60 46 46 private var letterFadeTimer: Timer? 47 + 48 + // Menubar-icon "activity" animation: a brief horizontal slide of 49 + // the piano + a flash of the music-note glyph whenever the user 50 + // shifts octave or plays a note. Driven by a single 60 Hz timer 51 + // that runs only while either effect is in progress. 52 + private var lastKnownOctaveShift: Int = 0 53 + private var lastLitCount: Int = 0 54 + private var slideDirection: Int = 0 55 + private var slideStartedAt: CFTimeInterval = 0 56 + private var flashStrength: CGFloat = 0 57 + private var flashStartedAt: CFTimeInterval = 0 58 + private var iconAnimTimer: Timer? 59 + private static let slideDuration: CFTimeInterval = 0.34 60 + private static let flashDuration: CFTimeInterval = 0.18 47 61 /// Per-MIDI displayed alpha. Each tick we smooth this value 48 62 /// toward the cell's target — so transitions are organic 49 63 /// regardless of when keys are pressed (no snap when the user ··· 69 83 70 84 func applicationDidFinishLaunching(_ notification: Notification) { 71 85 debugLog("applicationDidFinishLaunching pid=\(ProcessInfo.processInfo.processIdentifier)") 86 + Self.registerBundledFonts() 72 87 Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in 73 88 debugLog("heartbeat") 74 89 } 75 90 menuBand.onChange = { [weak self] in 76 - DispatchQueue.main.async { self?.updateIcon() } 91 + DispatchQueue.main.async { 92 + guard let self = self else { return } 93 + // Trigger the slide + flash whenever the octave shift 94 + // changes — acts as the visual feedback for the 95 + // ,/. keys (or popover stepper). Direction matches 96 + // the change so up=right, down=left. 97 + let cur = self.menuBand.octaveShift 98 + if cur != self.lastKnownOctaveShift { 99 + // Octave UP → piano slides LEFT (the camera is 100 + // panning right toward higher notes). Octave DOWN 101 + // → piano slides RIGHT (camera pans left). Reads 102 + // like the player physically dragged the 103 + // keyboard sideways under their hand. 104 + let dir = (cur > self.lastKnownOctaveShift) ? -1 : 1 105 + self.lastKnownOctaveShift = cur 106 + self.kickIconAnim(slide: dir, flash: 1.0) 107 + } 108 + self.updateIcon() 109 + // Refresh the popover too so live state changes 110 + // (octave shift via , / . , MIDI mode flip, etc.) 111 + // reflect immediately while the popover is open. 112 + if self.popover.isShown { 113 + self.popoverVC?.syncFromController() 114 + } 115 + } 77 116 } 78 117 menuBand.onLitChanged = { [weak self] in 79 - self?.updateIcon() 80 - self?.popoverVC?.refreshHeldNotes() 118 + guard let self = self else { return } 119 + // Subtle flash on every fresh note hit so the icon 120 + // pulses with playing activity. Only on count 121 + // increment — releases don't re-fire the flash. 122 + let cur = self.menuBand.litNotes.count 123 + if cur > self.lastLitCount { 124 + self.kickIconAnim(slide: 0, flash: 0.7) 125 + } 126 + self.lastLitCount = cur 127 + self.updateIcon() 128 + self.popoverVC?.refreshHeldNotes() 81 129 } 82 130 menuBand.bootstrap() 83 131 ··· 264 312 menuBand.shutdown() 265 313 } 266 314 315 + /// Register the YWFT Processing font files we ship in the SPM 316 + /// bundle so AppKit can find them by PostScript name. Called 317 + /// once at launch — the system caches the registration for the 318 + /// process lifetime. Failures are logged but non-fatal; the 319 + /// caller should fall back to the system font. 320 + private static func registerBundledFonts() { 321 + let bundle = Bundle.module 322 + for name in ["ywft-processing-regular", "ywft-processing-bold"] { 323 + guard let url = bundle.url(forResource: name, withExtension: "ttf") else { 324 + NSLog("MenuBand: bundled font missing — \(name).ttf") 325 + continue 326 + } 327 + var error: Unmanaged<CFError>? 328 + if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) { 329 + NSLog("MenuBand: font register failed for \(name): \(error?.takeRetainedValue().localizedDescription ?? "?")") 330 + } 331 + } 332 + } 333 + 334 + // MARK: - Icon animation (slide + flash) 335 + 336 + /// Kick off the icon's transient animation. `slide` ∈ {-1, 0, +1}: 337 + /// −1 scrolls the piano left, +1 right, 0 leaves it alone. `flash` 338 + /// is the peak brightness boost on the music-note glyph (0…1). 339 + /// Either parameter can re-trigger an already-running animation 340 + /// — most recent value wins. 341 + private func kickIconAnim(slide: Int, flash: CGFloat) { 342 + let now = CACurrentMediaTime() 343 + if slide != 0 { 344 + slideDirection = slide 345 + slideStartedAt = now 346 + } 347 + if flash > 0.001 { 348 + flashStrength = max(flashStrength, flash) 349 + flashStartedAt = now 350 + } 351 + startIconAnimTimerIfNeeded() 352 + updateIcon() 353 + } 354 + 355 + private func startIconAnimTimerIfNeeded() { 356 + guard iconAnimTimer == nil else { return } 357 + iconAnimTimer = Timer.scheduledTimer( 358 + withTimeInterval: 1.0/60.0, repeats: true 359 + ) { [weak self] _ in 360 + self?.tickIconAnim() 361 + } 362 + } 363 + 364 + private func tickIconAnim() { 365 + let now = CACurrentMediaTime() 366 + var slideActive = false 367 + var flashActive = false 368 + if slideDirection != 0 { 369 + if (now - slideStartedAt) >= Self.slideDuration { 370 + slideDirection = 0 371 + } else { 372 + slideActive = true 373 + } 374 + } 375 + if flashStrength > 0.01 { 376 + if (now - flashStartedAt) >= Self.flashDuration { 377 + flashStrength = 0 378 + } else { 379 + flashActive = true 380 + } 381 + } 382 + if !slideActive && !flashActive { 383 + iconAnimTimer?.invalidate() 384 + iconAnimTimer = nil 385 + } 386 + updateIcon() 387 + } 388 + 389 + /// Computed slide offset for the current frame. Two-phase 390 + /// "masked scroll" so the whole board reads as physically 391 + /// scrolling past the menubar slot: 392 + /// phase 1: image slides off in `slideDirection` until it's 393 + /// fully off the slot (offset = ±imageWidth) 394 + /// phase 2: image enters from the OPPOSITE side and settles 395 + /// back at offset 0 396 + /// Result: instead of a back-and-forth shove, the user gets a 397 + /// physical sense of the piano scrolling — like dragging a long 398 + /// keyboard sideways and seeing one octave's worth slide past. 399 + private func currentSlideOffset() -> CGFloat { 400 + guard slideDirection != 0 else { return 0 } 401 + let elapsed = CACurrentMediaTime() - slideStartedAt 402 + if elapsed >= Self.slideDuration { return 0 } 403 + let t = elapsed / Self.slideDuration // 0…1 404 + let w = KeyboardIconRenderer.imageSize.width 405 + let dir = CGFloat(slideDirection) 406 + if t < 0.5 { 407 + // Linear slide off — keeps speed constant so the scroll 408 + // reads as a real surface moving past. 409 + let phase = CGFloat(t / 0.5) 410 + return phase * w * dir 411 + } else { 412 + // Re-entry from the opposite side, easing into rest. 413 + let phase = CGFloat((t - 0.5) / 0.5) 414 + return -(1 - phase) * w * dir 415 + } 416 + } 417 + 418 + /// Computed flash strength for the current frame. Linear decay 419 + /// from the peak value down to 0 over `flashDuration`. 420 + private func currentFlashStrength() -> CGFloat { 421 + guard flashStrength > 0.01 else { return 0 } 422 + let elapsed = CACurrentMediaTime() - flashStartedAt 423 + if elapsed >= Self.flashDuration { return 0 } 424 + let t = CGFloat(elapsed / Self.flashDuration) 425 + return flashStrength * (1 - t) 426 + } 427 + 267 428 private func updateIcon() { 268 429 guard let button = statusItem.button else { return } 269 430 // Use *effective* keymap/typeMode so popover hover-preview can ··· 287 448 hovered: hoveredElement, 288 449 letterAlpha: { [weak self] midi in 289 450 self?.letterAlpha(for: midi) ?? 0 290 - } 451 + }, 452 + slideOffsetX: currentSlideOffset(), 453 + settingsFlash: currentFlashStrength() 291 454 ) 292 455 // Force a synchronous redraw — the click drag-loop runs the runloop 293 456 // in `eventTracking` mode and has been swallowing the next CA flush
+96 -29
slab/menuband/Sources/MenuBand/ArrowKeysIndicator.swift
··· 2 2 3 3 /// MacBook-style arrow-keys cluster, drawn as four little keycaps 4 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. 5 + /// ← ↓ → in a row below. Doubles as a clickable D-pad — mousing 6 + /// over a cap lights it up (rollover), pressing fires the same 7 + /// action a real arrow keystroke would. 12 8 /// 13 9 /// Direction indices match `InstrumentMapView.onArrowKey`: 14 10 /// 0 = ← 1 = → 2 = ↓ 3 = ↑ 15 11 final class ArrowKeysIndicator: NSView { 16 12 private var pressed: Set<Int> = [] 13 + private var hovered: Int? 14 + private var trackingArea: NSTrackingArea? 15 + 16 + /// Fires when one of the keycaps is clicked. Same semantics as a 17 + /// physical arrow keystroke — `isDown` is true on mouseDown, 18 + /// false on mouseUp. The popover wires this to the same path the 19 + /// keyboard arrows drive (preview note while held, commit on 20 + /// release). 21 + var onClick: ((Int, Bool) -> Void)? 17 22 18 23 static let intrinsicSize = NSSize(width: 46, height: 30) 19 24 override var intrinsicContentSize: NSSize { Self.intrinsicSize } ··· 24 29 } 25 30 required init?(coder: NSCoder) { fatalError() } 26 31 27 - /// Light up (or dim) one of the four direction keycaps. Multiple 28 - /// directions can be lit at once. 32 + override func updateTrackingAreas() { 33 + super.updateTrackingAreas() 34 + if let ta = trackingArea { removeTrackingArea(ta) } 35 + let ta = NSTrackingArea(rect: bounds, 36 + options: [.mouseEnteredAndExited, .mouseMoved, 37 + .activeAlways, .inVisibleRect], 38 + owner: self, userInfo: nil) 39 + addTrackingArea(ta) 40 + trackingArea = ta 41 + } 42 + 43 + /// Pressed-state setter — used by the keyboard path so on-screen 44 + /// keycaps light up while a real arrow key is held. 29 45 func setHighlight(direction: Int, on: Bool) { 30 46 if on { pressed.insert(direction) } 31 47 else { pressed.remove(direction) } ··· 34 50 35 51 override var isFlipped: Bool { false } 36 52 37 - override func draw(_ dirtyRect: NSRect) { 38 - super.draw(dirtyRect) 53 + // MARK: - Geometry 54 + 55 + /// Compute the four keycap rects in the same layout as `draw`. 56 + /// Returned in direction index order: 0=←, 1=→, 2=↓, 3=↑. 57 + private func keyRects() -> [NSRect] { 39 58 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 59 let key: CGFloat = 13 45 60 let gap: CGFloat = 1 46 - let radius: CGFloat = 2.5 47 61 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). 62 + let bottomY = r.minY + 1 63 + let topY = bottomY + key + gap 51 64 let downRect = NSRect(x: centerX - key / 2, y: bottomY, width: key, height: key) 52 65 let leftRect = NSRect(x: downRect.minX - key - gap, y: bottomY, width: key, height: key) 53 66 let rightRect = NSRect(x: downRect.maxX + gap, y: bottomY, width: key, height: key) 54 - // Top row: up alone, full-size, centered over down. 55 67 let upRect = NSRect(x: downRect.minX, y: topY, width: key, height: key) 68 + return [leftRect, rightRect, downRect, upRect] 69 + } 70 + 71 + private func direction(at point: NSPoint) -> Int? { 72 + for (idx, kr) in keyRects().enumerated() where kr.contains(point) { 73 + return idx 74 + } 75 + return nil 76 + } 77 + 78 + // MARK: - Mouse 56 79 80 + override func mouseEntered(with event: NSEvent) { 81 + updateHover(at: convert(event.locationInWindow, from: nil)) 82 + } 83 + override func mouseMoved(with event: NSEvent) { 84 + updateHover(at: convert(event.locationInWindow, from: nil)) 85 + } 86 + override func mouseExited(with event: NSEvent) { 87 + if hovered != nil { hovered = nil; needsDisplay = true } 88 + } 89 + private func updateHover(at point: NSPoint) { 90 + let d = direction(at: point) 91 + if d != hovered { hovered = d; needsDisplay = true } 92 + } 93 + 94 + override func mouseDown(with event: NSEvent) { 95 + let pt = convert(event.locationInWindow, from: nil) 96 + guard let dir = direction(at: pt) else { return } 97 + pressed.insert(dir) 98 + needsDisplay = true 99 + onClick?(dir, true) 100 + } 101 + override func mouseUp(with event: NSEvent) { 102 + // Fire keyUp for every pressed cap so we can't get stuck if 103 + // the cursor leaves the cap during the drag. 104 + for dir in pressed { 105 + onClick?(dir, false) 106 + } 107 + pressed.removeAll() 108 + needsDisplay = true 109 + } 110 + 111 + // MARK: - Drawing 112 + 113 + override func draw(_ dirtyRect: NSRect) { 114 + super.draw(dirtyRect) 115 + let radius: CGFloat = 2.5 57 116 let glyphs = ["←", "→", "↓", "↑"] 58 - let rects = [leftRect, rightRect, downRect, upRect] 59 - for (idx, kr) in rects.enumerated() { 117 + for (idx, kr) in keyRects().enumerated() { 60 118 let lit = pressed.contains(idx) 61 - // Keycap. 119 + let isHover = (!lit && hovered == idx) 62 120 let path = NSBezierPath(roundedRect: kr, xRadius: radius, yRadius: radius) 63 121 if lit { 64 122 NSColor.controlAccentColor.withAlphaComponent(0.85).setFill() 123 + path.fill() 124 + } else if isHover { 125 + // Rollover wash — soft accent tint behind the glyph, 126 + // no fill on the rest of the cap so it reads as a 127 + // "ready to click" state, not "selected". 128 + NSColor.controlAccentColor.withAlphaComponent(0.18).setFill() 65 129 path.fill() 66 130 } 67 - (lit ? NSColor.controlAccentColor : NSColor.labelColor.withAlphaComponent(0.55)) 68 - .setStroke() 131 + let stroke: NSColor = lit 132 + ? NSColor.controlAccentColor 133 + : isHover 134 + ? NSColor.controlAccentColor.withAlphaComponent(0.75) 135 + : NSColor.labelColor.withAlphaComponent(0.55) 136 + stroke.setStroke() 69 137 path.lineWidth = 0.8 70 138 path.stroke() 71 - // Arrow glyph centered in the keycap. 72 - let glyphColor: NSColor = lit ? .black : NSColor.labelColor 139 + let glyphColor: NSColor = lit 140 + ? .black 141 + : (isHover ? .controlAccentColor : NSColor.labelColor) 73 142 let attrs: [NSAttributedString.Key: Any] = [ 74 143 .font: NSFont.systemFont(ofSize: 10, weight: .heavy), 75 144 .foregroundColor: glyphColor, 76 145 ] 77 146 let s = NSAttributedString(string: glyphs[idx], attributes: attrs) 78 147 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 148 s.draw(at: NSPoint(x: kr.midX - size.width / 2, 82 149 y: kr.midY - size.height / 2 - 0.5)) 83 150 }
+128 -19
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 92 92 static let settingsW: CGFloat = 18.0 93 93 static let settingsH: CGFloat = 21.0 94 94 static let settingsGap: CGFloat = 12.0 95 + /// Reserved space on the right of the icon for the voice-number 96 + /// badge to flow into when the program has 2+ digits. The music 97 + /// note + settingsHitRect stay anchored where they were; this 98 + /// pad just gives multi-digit numbers somewhere to grow without 99 + /// clipping at the image edge. 100 + static let voiceBadgeRightPad: CGFloat = 12.0 95 101 96 102 enum HitResult: Equatable { 97 103 case openSettings ··· 142 148 } 143 149 144 150 static var imageSize: NSSize { 145 - let totalW = ceil(pad + pianoWidth + settingsGap + settingsW + pad) 151 + let totalW = ceil(pad + pianoWidth + settingsGap + settingsW + pad + voiceBadgeRightPad) 146 152 let totalH = ceil(whiteH + pad * 2) 147 153 return NSSize(width: totalW, height: totalH) 148 154 } ··· 158 164 typeMode: Bool = false, 159 165 melodicProgram: UInt8 = 0, 160 166 hovered: HitResult? = nil, 161 - letterAlpha: ((UInt8) -> CGFloat)? = nil) -> NSImage { 167 + letterAlpha: ((UInt8) -> CGFloat)? = nil, 168 + slideOffsetX: CGFloat = 0, 169 + settingsFlash: CGFloat = 0) -> NSImage { 162 170 let whites = whiteList() 163 171 var whiteIndex: [Int: Int] = [:] 164 172 for (i, m) in whites.enumerated() { whiteIndex[m] = i } ··· 166 174 167 175 let img = NSImage(size: size, flipped: false) { _ in 168 176 177 + // Octave slide: translate the WHOLE icon (piano + settings 178 + // chip) by the requested offset so the entire menubar 179 + // board scrolls left/right as one unit when the user 180 + // changes octave. This is intentionally OUTSIDE any 181 + // sub-save state so it affects every subsequent draw. 182 + if abs(slideOffsetX) > 0.01 { 183 + let xform = NSAffineTransform() 184 + xform.translateX(by: slideOffsetX, yBy: 0) 185 + xform.concat() 186 + } 169 187 // Piano. 170 188 NSGraphicsContext.saveGraphicsState() 171 - // Touched / active = a brighter version of the user's accent 172 - // color (controlAccentColor lightened ~25% toward white) so the 173 - // pressed state pops without leaving the accent palette. 189 + // Dark-mode awareness: in light mode the piano reads as 190 + // a real piano (white keys white, black keys dark 191 + // accent). In dark mode we swap the relationship — white 192 + // keys go a soft macOS dark-gray, black keys flip to a 193 + // brighter accent so they still pop above the white 194 + // keys. Lit (active) state always rides the accent 195 + // palette so a pressed key contrasts both modes. 196 + let isDark = NSApp.effectiveAppearance.bestMatch( 197 + from: [.aqua, .darkAqua]) == .darkAqua 174 198 let lit = NSColor.controlAccentColor.highlight(withLevel: 0.30) 175 199 ?? NSColor.controlAccentColor 176 - let groove = NSColor.black.withAlphaComponent(0.55) 177 - let whiteHi = NSColor.white 178 - let whiteLo = NSColor(white: 0.88, alpha: 1.0) 179 - let blackHi = NSColor.controlAccentColor.shadow(withLevel: 0.30) ?? NSColor.controlAccentColor 180 - let blackLo = NSColor.controlAccentColor.shadow(withLevel: 0.55) ?? NSColor.controlAccentColor 200 + let groove = NSColor.black.withAlphaComponent(isDark ? 0.85 : 0.55) 201 + let whiteHi: NSColor 202 + let whiteLo: NSColor 203 + let blackHi: NSColor 204 + let blackLo: NSColor 205 + if isDark { 206 + // Soft macOS dark-gray for the "white" keys. 207 + whiteHi = NSColor(white: 0.20, alpha: 1.0) 208 + whiteLo = NSColor(white: 0.13, alpha: 1.0) 209 + // Brighter accent for the "black" keys so they 210 + // stand out above the dark grays. 211 + blackHi = NSColor.controlAccentColor.highlight(withLevel: 0.10) 212 + ?? NSColor.controlAccentColor 213 + blackLo = NSColor.controlAccentColor.highlight(withLevel: 0.30) 214 + ?? NSColor.controlAccentColor 215 + } else { 216 + whiteHi = NSColor.white 217 + whiteLo = NSColor(white: 0.88, alpha: 1.0) 218 + blackHi = NSColor.controlAccentColor.shadow(withLevel: 0.30) 219 + ?? NSColor.controlAccentColor 220 + blackLo = NSColor.controlAccentColor.shadow(withLevel: 0.55) 221 + ?? NSColor.controlAccentColor 222 + } 181 223 182 224 // "Edges" of the *active* range — used for the rounded outer 183 225 // corners. Active range may be right-aligned (Ableton) so the ··· 278 320 // MIDI off → `slider.horizontal.3` in label color (generic). 279 321 drawSettingsChip(in: settingsRect, hoverRect: settingsHitRect, 280 322 midiOn: enabled, 281 - hovered: hovered == .openSettings) 323 + hovered: hovered == .openSettings, 324 + flash: settingsFlash, 325 + voiceNumber: Int(melodicProgram)) 282 326 return true 283 327 } 284 328 img.isTemplate = false ··· 290 334 /// up directly below the music note glyph. 291 335 static var settingsRectPublic: NSRect { settingsIconRect } 292 336 293 - // Settings button hit area extends from piano's right edge to the image 294 - // edge so the entire right-side region is one big click target. 337 + // Settings button hit area extends from the piano's right edge 338 + // all the way to the image's right edge — including the voice- 339 + // badge pad — so clicking either the music-note glyph OR the 340 + // badge digits opens the popover. The icon is positioned via 341 + // `settingsIconRect` directly (not via this hit rect) so the 342 + // glyph stays put even though the hit zone now covers the pad. 295 343 private static var settingsHitRect: NSRect { 296 344 let leftX = pianoOriginX + pianoWidth 297 345 let rightX = imageSize.width ··· 305 353 private static var settingsIconRect: NSRect { 306 354 let w = settingsW 307 355 let h = settingsH 308 - let cx = settingsHitRect.maxX - w / 2 - pad 309 - let cy = settingsHitRect.midY 356 + // Anchor the icon to the image's "old" right edge — i.e. 357 + // before the voice-badge pad was added. Otherwise the 358 + // music-note glyph would drift right whenever the badge pad 359 + // claims its slice of the image. 360 + let oldRight = imageSize.width - voiceBadgeRightPad 361 + let cx = oldRight - w / 2 - pad 362 + let cy = imageSize.height / 2 310 363 return NSRect(x: cx - w / 2, y: cy - h / 2, width: w, height: h) 311 364 } 312 365 ··· 449 502 450 503 private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool, alpha: CGFloat = 1.0) { 451 504 guard alpha > 0.01 else { return } 452 - let base: NSColor = lit ? .white : NSColor(white: 0.28, alpha: 1.0) 505 + // Lit cells always wear pure-white labels (over the bright 506 + // accent fill). Unlit cells need to flip with the 507 + // appearance: dark-text in light mode (over a near-white 508 + // keycap) becomes light-text in dark mode (over a dark-gray 509 + // keycap). 510 + let isDark = NSApp.effectiveAppearance.bestMatch( 511 + from: [.aqua, .darkAqua]) == .darkAqua 512 + let unlitBase = isDark 513 + ? NSColor(white: 0.85, alpha: 1.0) 514 + : NSColor(white: 0.28, alpha: 1.0) 515 + let base: NSColor = lit ? .white : unlitBase 453 516 let attrs: [NSAttributedString.Key: Any] = [ 454 517 .font: NSFont.systemFont(ofSize: 9.0, weight: .heavy), 455 518 .foregroundColor: base.withAlphaComponent(alpha), ··· 512 575 /// Alternates considered: "music.quarternote.3", "pianokeys", 513 576 /// "music.note", "scroll", "speaker.wave.2", "waveform". 514 577 private static func drawSettingsChip(in _: NSRect, hoverRect _: NSRect, 515 - midiOn: Bool, hovered: Bool) { 578 + midiOn: Bool, hovered: Bool, 579 + flash: CGFloat = 0, 580 + voiceNumber: Int = 0) { 516 581 // Standard systray pill: hover/click paints a soft rounded 517 582 // backdrop centered on the icon glyph (NOT the full hit area, so 518 583 // the piano-side empty space stays unhighlighted). Same look as 519 584 // any other status-bar item. 520 585 let iconBox = settingsIconRect 521 586 if hovered { 522 - let pill = iconBox.insetBy(dx: -1, dy: 1) 587 + // Pill encompasses the music note AND the voice-badge 588 + // pad as one target — hovering either part highlights 589 + // the same chip, so the user gets unified feedback. 590 + let pill = NSRect( 591 + x: iconBox.minX - 1, 592 + y: iconBox.minY + 1, 593 + width: (iconBox.width + Self.voiceBadgeRightPad + 1), 594 + height: iconBox.height - 2 595 + ) 523 596 let path = NSBezierPath(roundedRect: pill, xRadius: 4, yRadius: 4) 524 597 NSColor.labelColor.withAlphaComponent(0.12).setFill() 525 598 path.fill() 526 599 } 527 600 let alpha: CGFloat = hovered ? 1.0 : (midiOn ? 1.0 : 0.78) 528 - let color: NSColor = midiOn 601 + let baseColor: NSColor = midiOn 529 602 ? NSColor.controlAccentColor 530 603 : NSColor.labelColor.withAlphaComponent(alpha) 604 + // Blend toward pure white based on `flash` (0..1). Used to 605 + // signal "activity" when the user taps an octave key or 606 + // plays a note — the music note icon briefly gets brighter 607 + // before settling back. 608 + let f = max(0, min(1, flash)) 609 + let color = (f > 0.001) 610 + ? baseColor.blended(withFraction: f, of: .white) ?? baseColor 611 + : baseColor 531 612 drawTintedSymbol("music.note.list", in: iconBox, pointSize: 13.0, color: color) 613 + // Voice-number subscript: tiny digits in the bottom-right 614 + // corner. The first digit sits where the single-digit case 615 + // looked good; additional digits FLOW RIGHT from there 616 + // (instead of growing leftward across the music-note glyph). 617 + // Single digit is the visual anchor; multi-digit values 618 + // extend toward / past the menubar slot's right edge. 619 + // Skipped for program 0 (default Acoustic Grand) so the 620 + // unmodified state stays uncluttered. 621 + if voiceNumber > 0 { 622 + let label = String(voiceNumber) 623 + // Negative kerning tightens the digits so multi-digit 624 + // values feel more like a tag than spaced text. 625 + let attrs: [NSAttributedString.Key: Any] = [ 626 + .font: NSFont.monospacedDigitSystemFont(ofSize: 7, weight: .heavy), 627 + .foregroundColor: color, 628 + .kern: -0.4, 629 + ] 630 + let str = NSAttributedString(string: label, attributes: attrs) 631 + // Width of a single "0" — anchor for the first digit. 632 + let oneDigit = NSAttributedString(string: "0", attributes: [ 633 + .font: NSFont.monospacedDigitSystemFont(ofSize: 7, weight: .heavy), 634 + ]).size() 635 + // Nudge the first digit a small tad LEFT so it visually 636 + // hugs the music-note glyph; remaining digits flow 637 + // rightward into the reserved badge pad. 638 + let leftX = iconBox.maxX - oneDigit.width - 1 639 + str.draw(at: NSPoint(x: leftX, y: iconBox.minY - 1)) 640 + } 532 641 } 533 642 534 643 private static func drawInstrumentLabel(in rect: NSRect, hoverRect: NSRect,
+12 -1
slab/menuband/Sources/MenuBand/LocalKeyCapture.swift
··· 34 34 // instead of the previously-foreground app. Without `activate`, our 35 35 // panel can't become key and keys go elsewhere. 36 36 NSApp.activate(ignoringOtherApps: true) 37 - panel.makeKeyAndOrderFront(nil) 37 + // If another window in our app is already key (e.g. the 38 + // popover), DON'T steal key — `addLocalMonitorForEvents` 39 + // catches keys at the app level regardless of which specific 40 + // window is key, so the panel doesn't need to be key for 41 + // capture to work. Keeping the popover key prevents its 42 + // controls from rendering in the inactive/grey state. 43 + let otherKey = NSApp.windows.contains { $0.isKeyWindow && $0 !== panel } 44 + if otherKey { 45 + panel.orderFront(nil) 46 + } else { 47 + panel.makeKeyAndOrderFront(nil) 48 + } 38 49 if monitor == nil { 39 50 monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in 40 51 guard let self = self else { return event }
+92
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 59 59 return "\(octave)\(pitches[pc])" 60 60 } 61 61 62 + /// Snapshot of hardware key codes currently held. Used by the 63 + /// popover's QWERTY layout view to highlight which physical 64 + /// keys are sounding right now. 65 + func heldKeyCodes() -> Set<UInt16> { 66 + heldLock.lock(); defer { heldLock.unlock() } 67 + return Set(heldNotes.keys) 68 + } 69 + 62 70 /// Snapshot of currently-held MIDI note names for popover display. 63 71 /// Empty when nothing is sounding. 64 72 func heldNoteNames() -> [String] { ··· 176 184 pendingPreviewWork = work 177 185 DispatchQueue.main.asyncAfter(deadline: .now() + previewLoadDelay, 178 186 execute: work) 187 + } 188 + 189 + // MARK: - Octave hold-to-ramp 190 + 191 + /// The keyCode currently driving the octave-hold timer. nil when 192 + /// no octave key is held. 193 + private var octaveHoldKey: UInt16? 194 + private var octaveHoldTimer: Timer? 195 + private static let octaveHoldDelay: TimeInterval = 0.28 196 + private static let octaveHoldInterval: TimeInterval = 0.16 197 + 198 + /// Apply a single octave step + percussive click + UI refresh. 199 + /// Clamps to ±4 octaves; no-ops once the user is at the limit so 200 + /// the click stops giving false feedback while held against the 201 + /// stop. 202 + private func octaveStepOnce(delta: Int) { 203 + let next = max(-4, min(4, octaveShift + delta)) 204 + if next == octaveShift { return } 205 + octaveShift = next 206 + playOctaveClick(for: next) 207 + } 208 + 209 + private func startOctaveHold(keyCode: UInt16, delta: Int) { 210 + octaveHoldKey = keyCode 211 + octaveHoldTimer?.invalidate() 212 + octaveHoldTimer = Timer.scheduledTimer( 213 + withTimeInterval: Self.octaveHoldDelay, repeats: false 214 + ) { [weak self] _ in 215 + guard let self = self, 216 + self.octaveHoldKey == keyCode else { return } 217 + // After the initial hesitation, fire on a steady tempo 218 + // until the user lets go. 219 + self.octaveHoldTimer = Timer.scheduledTimer( 220 + withTimeInterval: Self.octaveHoldInterval, repeats: true 221 + ) { [weak self] _ in 222 + guard let self = self, 223 + self.octaveHoldKey == keyCode else { return } 224 + self.octaveStepOnce(delta: delta) 225 + } 226 + } 227 + } 228 + 229 + private func stopOctaveHold(forKeyCode keyCode: UInt16) { 230 + guard octaveHoldKey == keyCode else { return } 231 + octaveHoldTimer?.invalidate() 232 + octaveHoldTimer = nil 233 + octaveHoldKey = nil 234 + } 235 + 236 + /// Tiny percussive click for octave shifts. Uses a high-mid drum 237 + /// (Tambourine, GM key 54) on the drum channel so it reads as 238 + /// punctual rather than tonal — but the velocity is mapped from 239 + /// the new octave so each step has its own audible weight, 240 + /// scaling brighter / sharper as you move up. 241 + private func playOctaveClick(for newShift: Int) { 242 + // Velocity range so all octaves are distinct but none are 243 + // jarring: −4 → ~50, 0 → ~85, +4 → ~120. 244 + let v = max(40, min(127, 85 + newShift * 9)) 245 + synth.noteOn(54, velocity: UInt8(v), channel: 9) 246 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in 247 + self?.synth.noteOff(54, channel: 9) 248 + } 179 249 } 180 250 181 251 func auditionCurrentProgram() { ··· 706 776 self?.setMelodicProgram(program) 707 777 } 708 778 } 779 + } 780 + return true 781 + } 782 + 783 + // Octave shift: notepat uses , (43) / . (47); Ableton uses z (6) / 784 + // x (7) since those are unmapped in Live's M-mode keymap and the 785 + // comma/period live next to mapped notes there. Acts globally — 786 + // works the same in TYPE mode and via the popover's local-key 787 + // forwarding. Hold-to-ramp: a single tap moves one octave; 788 + // holding the key sets up our own metronome (~280 ms initial 789 + // delay, then ~160 ms between repeats) so the octave notches 790 + // predictably while the key is held — independent of the OS's 791 + // key-repeat settings. 792 + let (octDownKC, octUpKC) = MenuBandLayout.octaveKeyCodes(for: keymap) 793 + if keyCode == octDownKC || keyCode == octUpKC { 794 + let delta = (keyCode == octDownKC) ? -1 : +1 795 + if isDown { 796 + if isRepeat { return true } // we drive our own repeats 797 + octaveStepOnce(delta: delta) 798 + startOctaveHold(keyCode: keyCode, delta: delta) 799 + } else { 800 + stopOctaveHold(forKeyCode: keyCode) 709 801 } 710 802 return true 711 803 }
+13
slab/menuband/Sources/MenuBand/MenuBandMIDI.swift
··· 121 121 return panByKeyCode[Int(keyCode)] 122 122 } 123 123 124 + /// Hardware key codes used as octave-down / octave-up shifters in 125 + /// the given keymap. Notepat reserves , (43) and . (47); Ableton 126 + /// remaps to z (6) and x (7) because those sit unmapped in Live's 127 + /// M-mode layout (where comma and period are right next to mapped 128 + /// notes and would steal accidental presses). 129 + @inline(__always) 130 + static func octaveKeyCodes(for keymap: Keymap) -> (down: UInt16, up: UInt16) { 131 + switch keymap { 132 + case .ableton: return (6, 7) 133 + case .notepat: return (43, 47) 134 + } 135 + } 136 + 124 137 @inline(__always) 125 138 static func midiNote(forKeyCode keyCode: UInt16, 126 139 octaveShift: Int,
+374 -64
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 72 72 private var instrumentLabel: NSTextField! 73 73 private var instrumentTitleRow: NSStackView! 74 74 private var arrowsHint: ArrowKeysIndicator! 75 + private var qwertyMap: QwertyLayoutView! 76 + /// Horizontal stack of small floating boxes, one per currently- 77 + /// held note. Sits above the visualizer; empty (zero-height) at 78 + /// rest so the layout doesn't wobble when notes come and go. 79 + private var heldNotesStack: NSStackView! 80 + private var heldNotesContainer: NSView! 75 81 private var instrumentSeparator: NSView! 76 82 private var octaveStepper: NSStepper! 77 83 private var octaveLabel: NSTextField! ··· 81 87 private var updateBanner: NSView! 82 88 private var updateLabel: NSTextField! 83 89 private var waveformView: WaveformView! 90 + private var waveformBezel: NSView! 84 91 85 92 override func loadView() { 86 93 // Plain solid-color background — no NSVisualEffectView. The visual ··· 256 263 // Hovering a segment previews that mode in the menubar piano (range 257 264 // shrinks/grows, letter labels appear) and lets you tap keys for a 258 265 // quick demo without committing. 259 - let inputLabel = NSTextField(labelWithString: "Keyboard Shortcuts") 266 + // Layout block — built here, but appended to the stack 267 + // *below* the palettePanel so the Layout choice reads as a 268 + // configuration knob you reach for after picking a voice. 269 + let inputLabel = NSTextField(labelWithString: "Layout") 260 270 inputLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 261 271 inputLabel.textColor = .labelColor 262 - stack.addArrangedSubview(inputLabel) 263 272 264 273 // Vertical mode buttons — full labels fit without truncation, each 265 274 // button is the full content width with an SF Symbol leading the ··· 270 279 // local capture (click menubar piano → type to play, no Accessibility 271 280 // needed) that mode is handled implicitly by simply not triggering 272 281 // global capture. The popover now just picks the keymap layout. 273 - let modeSpecs: [(label: String, symbol: String)] = [ 274 - ("Notepat.com", "keyboard"), 275 - ("Ableton MIDI Keys", "pianokeys"), 282 + // Custom branding: Notepat.com uses the live favicon 283 + // (`NotepatFavicon.image`, lazily fetched + cached); Ableton 284 + // gets the canonical logo we render programmatically. 285 + let modeSpecs: [(label: String, image: NSImage?)] = [ 286 + ("Notepat.com", 287 + NotepatFavicon.image 288 + ?? NSImage(systemSymbolName: "keyboard", 289 + accessibilityDescription: "Notepat.com")? 290 + .withSymbolConfiguration(modeSymbolConfig)), 291 + ("Ableton Computer Keyboard", 292 + AbletonLogo.image(height: 11)), 276 293 ] 277 294 modeButtons = [] 278 295 let modeStack = NSStackView() ··· 290 307 b.alignment = .left 291 308 b.imagePosition = .imageLeading 292 309 b.imageHugsTitle = true 293 - b.image = NSImage(systemSymbolName: spec.symbol, 294 - accessibilityDescription: spec.label)? 295 - .withSymbolConfiguration(modeSymbolConfig) 310 + b.image = spec.image 296 311 b.translatesAutoresizingMaskIntoConstraints = false 297 312 b.widthAnchor.constraint( 298 313 equalToConstant: InstrumentListView.preferredWidth ··· 300 315 modeButtons.append(b) 301 316 modeStack.addArrangedSubview(b) 302 317 } 303 - stack.addArrangedSubview(modeStack) 304 318 modeStack.widthAnchor.constraint( 305 319 equalToConstant: InstrumentListView.preferredWidth 306 320 ).isActive = true ··· 309 323 "⌃⌥⌘P toggles last keystrokes mode") 310 324 inputHint.font = NSFont.systemFont(ofSize: 10) 311 325 inputHint.textColor = .secondaryLabelColor 312 - stack.addArrangedSubview(inputHint) 326 + // Layout label / mode buttons / hint are inserted into the 327 + // popover stack farther down — see the `palettePanel` 328 + // insertion point. 329 + let layoutBlock = (label: inputLabel, picker: modeStack, hint: inputHint) 313 330 314 331 // Live segmented LED meter of the local synth output. Hidden in 315 332 // MIDI mode (DAW handles audio there; our local mixer is silent ··· 324 341 waveformView.menuBand = menuBand 325 342 waveformView.translatesAutoresizingMaskIntoConstraints = false 326 343 327 - let waveformBezel = NSView() 344 + waveformBezel = NSView() 328 345 waveformBezel.wantsLayer = true 329 346 waveformBezel.layer?.cornerRadius = 6 330 347 waveformBezel.layer?.backgroundColor = NSColor(white: 0.06, alpha: 1.0).cgColor 331 - waveformBezel.layer?.borderColor = NSColor.labelColor.withAlphaComponent(0.18).cgColor 332 348 waveformBezel.layer?.borderWidth = 1 349 + // Border color is set in `updateInstrumentReadout` so the 350 + // housing tracks the chosen voice's family hue. 333 351 waveformBezel.translatesAutoresizingMaskIntoConstraints = false 334 352 waveformBezel.addSubview(waveformView) 335 353 let bezelInset: CGFloat = 5 ··· 339 357 waveformView.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: bezelInset), 340 358 waveformView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 341 359 ]) 360 + // Held-notes goes ABOVE the visualizer so the actively- 361 + // sounding pitches read like a label on the meter housing. 362 + // Build the floating-boxes container HERE so the ivar is 363 + // populated before we add it to the stack. 364 + heldNotesStack = NSStackView() 365 + heldNotesStack.orientation = .horizontal 366 + heldNotesStack.alignment = .centerY 367 + heldNotesStack.spacing = 4 368 + heldNotesStack.translatesAutoresizingMaskIntoConstraints = false 369 + heldNotesContainer = NSView() 370 + heldNotesContainer.translatesAutoresizingMaskIntoConstraints = false 371 + heldNotesContainer.addSubview(heldNotesStack) 372 + NSLayoutConstraint.activate([ 373 + heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesContainer.centerXAnchor), 374 + heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesContainer.centerYAnchor), 375 + ]) 376 + stack.addArrangedSubview(heldNotesContainer) 377 + heldNotesContainer.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 378 + heldNotesContainer.heightAnchor.constraint(equalToConstant: 22).isActive = true 379 + 342 380 stack.addArrangedSubview(waveformBezel) 343 381 waveformBezel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 344 382 waveformBezel.heightAnchor.constraint(equalToConstant: 64).isActive = true 345 383 346 - stack.addArrangedSubview(makeSeparator()) 347 - 348 384 // (MIDI switch lives in the title row above — see octave + MIDI block.) 349 385 350 386 // MIDI self-test status — populated by the controller after each ··· 354 390 // label color in the title row instead (green = ok, red = failed). 355 391 midiSelfTestLabel = NSTextField(labelWithString: "") 356 392 393 + // No divider above the Voice row — the visualizer's lit 394 + // bezel below is enough visual separation. Allocate the ivar 395 + // anyway so the dim/animation hooks below don't crash; just 396 + // never add it to the stack. 357 397 instrumentSeparator = makeSeparator() 358 - stack.addArrangedSubview(instrumentSeparator) 398 + instrumentSeparator.isHidden = true 359 399 360 400 // Instrument named-list. All 128 GM programs in a scrollable list, 361 401 // family-colored stripe on the left, name on the right. Hover plays ··· 367 407 // Title row: "Instrument:" left, "078 Whistle" right (greyed). The 368 408 // readout used to live under the grid; promoting it to the title 369 409 // row keeps the eye on a single line while browsing cells. 370 - instrumentLabel = NSTextField(labelWithString: "Voice:") 371 - instrumentLabel.font = NSFont.systemFont(ofSize: 9.5, weight: .semibold) 372 - instrumentLabel.textColor = .labelColor 410 + // No leading "Voice:" label — the family-tinted chip itself 411 + // *is* the voice title now. Bigger, bolder, sits like a 412 + // chapter heading under the LED meter. 413 + instrumentLabel = NSTextField(labelWithString: "") 414 + instrumentLabel.isHidden = true 373 415 instrumentReadout = NSTextField(labelWithString: "") 374 - instrumentReadout.font = NSFont.monospacedSystemFont(ofSize: 9.5, weight: .regular) 416 + // Big, bold, family-colored title with a soft drop shadow — 417 + // no chip backdrop. Text color carries the GM family hue 418 + // (set/refreshed in updateInstrumentReadout); the shadow gives 419 + // it presence without the rectangular bg framing it had before. 420 + instrumentReadout.font = NSFont.systemFont(ofSize: 18, weight: .black) 375 421 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 422 instrumentReadout.drawsBackground = false 382 423 instrumentReadout.wantsLayer = true 383 - instrumentReadout.layer?.cornerRadius = 4 384 424 instrumentReadout.lineBreakMode = .byTruncatingTail 385 - instrumentTitleRow = NSStackView(views: [instrumentLabel, instrumentReadout]) 425 + instrumentReadout.alignment = .center 426 + let titleShadow = NSShadow() 427 + titleShadow.shadowColor = NSColor.black.withAlphaComponent(0.55) 428 + titleShadow.shadowBlurRadius = 3 429 + titleShadow.shadowOffset = NSSize(width: 0, height: -1) 430 + instrumentReadout.shadow = titleShadow 431 + // Center the chip in its row by flanking it with greedy 432 + // spacers — without these, .fill distribution lets the 433 + // (now-shrunken) text hug the leading edge. 434 + let titleLeftSpacer = NSView() 435 + let titleRightSpacer = NSView() 436 + titleLeftSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) 437 + titleRightSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) 438 + instrumentTitleRow = NSStackView(views: [titleLeftSpacer, instrumentReadout, titleRightSpacer]) 386 439 instrumentTitleRow.orientation = .horizontal 387 - instrumentTitleRow.alignment = .firstBaseline 388 - instrumentTitleRow.spacing = 6 389 - // Hugging high on the label, low on the readout, so the readout 390 - // expands to fill the trailing space and truncates cleanly. 440 + instrumentTitleRow.alignment = .centerY 441 + instrumentTitleRow.distribution = .fill 442 + instrumentTitleRow.spacing = 0 391 443 instrumentLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) 392 - instrumentReadout.setContentHuggingPriority(.defaultLow, for: .horizontal) 393 - instrumentReadout.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 444 + instrumentReadout.setContentHuggingPriority(.defaultHigh, for: .horizontal) 445 + instrumentReadout.setContentCompressionResistancePriority(.required, for: .horizontal) 446 + titleLeftSpacer.widthAnchor.constraint(equalTo: titleRightSpacer.widthAnchor).isActive = true 394 447 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.) 448 + 449 + // (held-notes floating-boxes container is built ABOVE the 450 + // visualizer — see the block before `waveformBezel`.) 397 451 398 452 // GarageBand backend toggle was prototyped here (see 399 453 // GarageBandLibrary + GarageBandPatchView), then deprecated ··· 437 491 return 438 492 } 439 493 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) 494 + self.instrumentReadout.stringValue = nameForChip 495 + self.instrumentReadout.textColor = famColor 496 + // Don't retint the LED bezel during hover-drag while 497 + // MIDI mode is on — it stays accent-colored as a status 498 + // badge. 499 + if let m = self.menuBand, !m.midiMode { 500 + self.waveformView.setBaseColor(famColor) 501 + self.waveformBezel?.layer?.borderColor = 502 + famColor.withAlphaComponent(0.55).cgColor 503 + } 444 504 } 445 505 // Wrap the grid in a panel that adds an extra strip BELOW the 446 506 // 8×16 cells where the arrow-keys hint glyph lives. The hint ··· 453 513 arrowsHint = ArrowKeysIndicator() 454 514 arrowsHint.toolTip = "Arrow keys move the selection." 455 515 arrowsHint.translatesAutoresizingMaskIntoConstraints = false 516 + arrowsHint.onClick = { [weak self] dir, isDown in 517 + // Drive the same selection path the physical arrow keys 518 + // do — preview while pressed, commit on release. 519 + self?.simulateArrow(direction: dir, isDown: isDown) 520 + } 456 521 palettePanel.addSubview(arrowsHint) 457 522 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 523 + // Strip below the grid: full-width QWERTY keymap on top of 524 + // the strip, arrow-keys cluster anchored to its bottom-right 525 + // corner. Reads like a tiny laptop keyboard glued to the 526 + // base of the voice grid. 527 + let strip: CGFloat = 100 528 + qwertyMap = QwertyLayoutView() 529 + qwertyMap.translatesAutoresizingMaskIntoConstraints = false 530 + // Pointer-driven play: clicks/drags on caps route through the 531 + // same handleLocalKey path the physical keyboard uses, so notes 532 + // light up, octave keys shift, and lit-state updates round-trip 533 + // back into the layout view automatically. 534 + qwertyMap.onKey = { [weak self] kc, isDown in 535 + _ = self?.menuBand?.handleLocalKey( 536 + keyCode: kc, isDown: isDown, isRepeat: false, flags: [] 537 + ) 538 + } 539 + palettePanel.addSubview(qwertyMap) 462 540 NSLayoutConstraint.activate([ 463 541 instrumentList.topAnchor.constraint(equalTo: palettePanel.topAnchor), 464 542 instrumentList.leadingAnchor.constraint(equalTo: palettePanel.leadingAnchor), 465 543 instrumentList.trailingAnchor.constraint(equalTo: palettePanel.trailingAnchor), 466 544 instrumentList.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight), 545 + qwertyMap.centerXAnchor.constraint(equalTo: palettePanel.centerXAnchor), 546 + // Anchor the QWERTY map to the BOTTOM of the strip 547 + // (just above the arrow keys) instead of the top — 548 + // visually clusters it with the arrow-keys cluster as 549 + // one keyboard ornament, and frees space above for the 550 + // grid to breathe. 551 + qwertyMap.bottomAnchor.constraint(equalTo: arrowsHint.topAnchor, constant: -4), 552 + qwertyMap.widthAnchor.constraint(equalToConstant: QwertyLayoutView.intrinsicSize.width), 553 + qwertyMap.heightAnchor.constraint(equalToConstant: QwertyLayoutView.intrinsicSize.height), 467 554 arrowsHint.trailingAnchor.constraint(equalTo: palettePanel.trailingAnchor, constant: -cornerInset), 468 555 arrowsHint.bottomAnchor.constraint(equalTo: palettePanel.bottomAnchor, constant: -cornerInset), 469 556 ]) ··· 471 558 palettePanel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 472 559 palettePanel.heightAnchor.constraint(equalToConstant: InstrumentListView.preferredHeight + strip).isActive = true 473 560 474 - let bottomSeparator = makeSeparator() 475 - stack.addArrangedSubview(bottomSeparator) 476 - // Extra breathing room between the visualizer/instruments above and 477 - // the brand/about/quit footer below. The default 6 px stack gap 478 - // crammed everything together — this gives the bottom block its 479 - // own visual section. 480 - stack.setCustomSpacing(12, after: bottomSeparator) 561 + // Layout block (built earlier, appended here so it sits below 562 + // the voice grid + arrow keys). 563 + stack.setCustomSpacing(8, after: palettePanel) 564 + stack.addArrangedSubview(layoutBlock.label) 565 + stack.addArrangedSubview(layoutBlock.picker) 566 + stack.addArrangedSubview(layoutBlock.hint) 567 + 568 + // No divider above the about/brand block — the palette + Layout 569 + // section above gives plenty of separation. Custom airspace 570 + // before the about block. 571 + stack.setCustomSpacing(14, after: layoutBlock.hint) 481 572 482 573 // About + Crash logs in a side-by-side row. About has low hugging 483 574 // so it expands when the crash column is hidden (no reports) — ··· 497 588 // No heading — the prose itself is the about content. The bold 498 589 // "Menu Band" header on top read as a duplicate of the menubar 499 590 // identity above and ate vertical space. 500 - let aboutBody = NSTextField(wrappingLabelWithString: 501 - "A political project to bring the built-in macOS instruments — " + 502 - "the ones GarageBand uses — into the menu bar. Free + open source.") 591 + let aboutBody = NSTextField(wrappingLabelWithString: "") 503 592 aboutBody.font = NSFont.systemFont(ofSize: 10.5) 504 593 aboutBody.textColor = .secondaryLabelColor 505 594 aboutBody.maximumNumberOfLines = 0 595 + aboutBody.lineBreakMode = .byWordWrapping 596 + // "Menu Band" stays bold + label-colored; the rest of the 597 + // sentence is regular weight in secondary color so the eye 598 + // catches the brand first. 599 + let aboutText = NSMutableAttributedString() 600 + let bodyFont = NSFont.systemFont(ofSize: 10.5) 601 + let boldFont = NSFont.systemFont(ofSize: 10.5, weight: .bold) 602 + aboutText.append(NSAttributedString(string: "Menu Band", 603 + attributes: [.font: boldFont, .foregroundColor: NSColor.labelColor])) 604 + aboutText.append(NSAttributedString( 605 + string: " brings the built-in macOS instruments into the menu bar.", 606 + attributes: [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor])) 607 + aboutBody.attributedStringValue = aboutText 506 608 aboutBody.preferredMaxLayoutWidth = InstrumentListView.preferredWidth 507 609 aboutCol.setContentHuggingPriority(.defaultLow, for: .horizontal) 508 610 aboutCol.addArrangedSubview(aboutBody) ··· 608 710 super.viewDidAppear() 609 711 guard isViewLoaded, let menuBand = waveformView.menuBand else { return } 610 712 syncFromController() 611 - waveformView.isLive = !menuBand.midiMode 713 + applyVisualizerForMidiMode(menuBand.midiMode) 612 714 // Make the voice grid first responder on every popover open so 613 715 // arrow keys always step the selection — even before the user 614 716 // has clicked into the grid this session. 615 717 view.window?.makeFirstResponder(instrumentList) 718 + // Refresh the Notepat mode button if a freshly-cached 719 + // favicon has landed since loadView() ran. One-shot observer 720 + // re-installs each time the popover appears so we don't leak 721 + // listeners. 722 + NotificationCenter.default.removeObserver(self, 723 + name: .notepatFaviconLoaded, object: nil) 724 + NotificationCenter.default.addObserver(forName: .notepatFaviconLoaded, 725 + object: nil, queue: .main) { [weak self] _ in 726 + guard let self = self, 727 + self.modeButtons.indices.contains(0), 728 + let img = NotepatFavicon.image else { return } 729 + self.modeButtons[0].image = img 730 + } 616 731 } 617 732 618 733 override func viewDidDisappear() { ··· 620 735 waveformView.isLive = false 621 736 } 622 737 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() {} 738 + /// Drive the voice grid's selection from the on-screen arrow 739 + /// keycaps as if the physical arrow key had been pressed — 740 + /// `isDown` triggers the move + preview note, the matching `up` 741 + /// commits the cell. Mirrors `InstrumentMapView.keyDown` / 742 + /// `keyUp`'s logic so click-on-the-D-pad and arrow-key-on-the- 743 + /// keyboard share one code path. 744 + private func simulateArrow(direction dir: Int, isDown: Bool) { 745 + guard let list = instrumentList else { return } 746 + if isDown { 747 + let cur = Int(list.selectedProgram) 748 + var next = cur 749 + switch dir { 750 + case 0: next = cur - 1 // ← 751 + case 1: next = cur + 1 // → 752 + case 2: next = cur + InstrumentListView.cols // ↓ 753 + case 3: next = cur - InstrumentListView.cols // ↑ 754 + default: return 755 + } 756 + next = max(0, min(127, next)) 757 + arrowsHint.setHighlight(direction: dir, on: true) 758 + if next != cur { 759 + list.selectedProgram = UInt8(next) 760 + list.onHover?(next) 761 + } 762 + } else { 763 + arrowsHint.setHighlight(direction: dir, on: false) 764 + list.onHover?(nil) 765 + list.onCommit?(Int(list.selectedProgram)) 766 + } 767 + } 768 + 769 + /// Refresh the active-notes LCD from the controller. Hidden when 770 + /// nothing is held; otherwise lit with the held note names in 771 + /// the GM family color of the current voice (so the LCD readout 772 + /// reads as part of the same instrument). 773 + func refreshHeldNotes() { 774 + guard isViewLoaded, let m = menuBand else { return } 775 + // Mirror the controller's held key codes onto the QWERTY 776 + // map so the physical keys light up as the user plays. 777 + qwertyMap?.litKeyCodes = m.heldKeyCodes() 778 + let names = m.heldNoteNames() 779 + // Rebuild the floating-box stack from scratch — at most a 780 + // few notes held at any moment, so reusing views isn't worth 781 + // the bookkeeping. When nothing is held, the stack has no 782 + // arranged subviews and the reserved 22 px row is empty 783 + // (no visible chrome). 784 + for v in heldNotesStack.arrangedSubviews { 785 + heldNotesStack.removeArrangedSubview(v) 786 + v.removeFromSuperview() 787 + } 788 + let safe = max(0, min(127, Int(m.melodicProgram))) 789 + let famColor = m.midiMode 790 + ? NSColor.controlAccentColor 791 + : InstrumentListView.colorForProgram(safe) 792 + for name in names { 793 + heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: name, 794 + color: famColor)) 795 + } 796 + } 797 + 798 + /// Small floating note badge — rounded layer-painted box with 799 + /// the note name in heavy mono. No surrounding bezel; the boxes 800 + /// just appear above the visualizer when you press keys. 801 + private func makeHeldNoteBox(name: String, color: NSColor) -> NSView { 802 + let box = NSView() 803 + box.wantsLayer = true 804 + box.layer?.cornerRadius = 4 805 + box.layer?.backgroundColor = color.withAlphaComponent(0.85).cgColor 806 + box.translatesAutoresizingMaskIntoConstraints = false 807 + let label = NSTextField(labelWithString: name) 808 + label.font = NSFont.monospacedSystemFont(ofSize: 10, weight: .heavy) 809 + label.textColor = .black 810 + label.drawsBackground = false 811 + label.translatesAutoresizingMaskIntoConstraints = false 812 + box.addSubview(label) 813 + NSLayoutConstraint.activate([ 814 + label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 5), 815 + label.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -5), 816 + label.topAnchor.constraint(equalTo: box.topAnchor, constant: 1), 817 + label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -1), 818 + ]) 819 + return box 820 + } 627 821 628 822 /// Refresh control state from the controller — call right before showing. 629 823 func syncFromController() { 630 824 guard isViewLoaded, let n = menuBand else { return } 631 825 midiSwitch.state = n.midiMode ? .on : .off 826 + refreshHeldNotes() 632 827 octaveStepper.integerValue = n.octaveShift 633 828 updateOctaveLabel(n.octaveShift) 634 829 let segIdx = inputModeSegment(keymap: n.keymap) ··· 637 832 } 638 833 instrumentList.selectedProgram = n.melodicProgram 639 834 updateInstrumentReadout() 835 + // Keep the QWERTY layout's keymap + tint synced with the 836 + // controller. Voice color picks up the family hue for the 837 + // current voice; keymap variant follows the controller. 838 + let safe = max(0, min(127, Int(n.melodicProgram))) 839 + qwertyMap?.keymap = n.keymap 840 + qwertyMap?.voiceColor = n.midiMode 841 + ? .controlAccentColor 842 + : InstrumentListView.colorForProgram(safe) 640 843 updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) 641 844 refreshCrashStatus() 642 845 refreshUpdateBanner() ··· 688 891 private func updateInstrumentReadout() { 689 892 guard let m = menuBand else { return } 690 893 let safe = max(0, min(127, Int(m.melodicProgram))) 691 - instrumentReadout.stringValue = " \(GeneralMIDI.programNames[safe]) " 894 + let title = GeneralMIDI.programNames[safe] 692 895 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) 896 + // Dark hues (Bass / Strings / Percussive) wash out against 897 + // the dark popover background; light hues (Piano ivory) wash 898 + // out in light mode. Adjust per-appearance so the title 899 + // always has presence. 900 + let isDark = view.effectiveAppearance.bestMatch( 901 + from: [.aqua, .darkAqua]) == .darkAqua 902 + let textColor: NSColor = isDark 903 + ? (famColor.highlight(withLevel: 0.35) ?? famColor) 904 + : (famColor.shadow(withLevel: 0.25) ?? famColor) 905 + // Soft drop shadow opposite the system appearance so the 906 + // glyph anchors on the bg without a chip backdrop. 907 + let shadow = NSShadow() 908 + shadow.shadowColor = (isDark ? NSColor.black : NSColor.white) 909 + .withAlphaComponent(0.55) 910 + shadow.shadowOffset = NSSize(width: 0, height: -1) 911 + shadow.shadowBlurRadius = 2 912 + // Try YWFT Processing first (bundled in Resources/), fall 913 + // back through the Processing-IDE family, then the system 914 + // black-weight as a last resort. 915 + let titleFont = NSFont(name: "YWFTProcessing-Bold", size: 18) 916 + ?? NSFont(name: "YWFTProcessing-Regular", size: 18) 917 + ?? NSFont(name: "Processing-Sans-Pro-Bold", size: 18) 918 + ?? NSFont.systemFont(ofSize: 18, weight: .black) 919 + instrumentReadout.attributedStringValue = NSAttributedString( 920 + string: title, 921 + attributes: [ 922 + .font: titleFont, 923 + .foregroundColor: textColor, 924 + .shadow: shadow, 925 + ] 926 + ) 927 + // The visualizer is the only piece of chrome that does NOT 928 + // track the voice color in MIDI mode — there it reads as a 929 + // status badge ("MIDI" dot-matrix in system accent), so we 930 + // skip the retint when MIDI is on. 931 + if m.midiMode { 932 + waveformView.setBaseColor(.controlAccentColor) 933 + waveformBezel?.layer?.borderColor = NSColor.controlAccentColor 934 + .withAlphaComponent(0.55).cgColor 935 + } else { 936 + waveformView.setBaseColor(famColor) 937 + waveformBezel?.layer?.borderColor = famColor 938 + .withAlphaComponent(0.55).cgColor 939 + } 698 940 } 941 + 942 + // Appearance changes (light/dark toggle) refresh on next popover 943 + // open via syncFromController — viewDidChangeEffectiveAppearance 944 + // isn't on NSViewController in macOS so we don't try to hook it 945 + // mid-session. 699 946 700 947 /// Reflect the MIDI loopback self-test status as the inline "MIDI" 701 948 /// label's color. No textual chrome — the color is the indicator. ··· 901 1148 // means the popover's geometry stays stable and the user can see 902 1149 // exactly what's available without MIDI engaged. 903 1150 if let m = menuBand { 904 - waveformView.isLive = !m.midiMode 1151 + applyVisualizerForMidiMode(m.midiMode) 905 1152 applyInstrumentPaletteVisibility(midiMode: m.midiMode) 906 1153 } 907 1154 } 908 1155 1156 + /// 16-bar × 10-segment dot-matrix pattern that spells "MIDI" 1157 + /// across the full height of the LED bezel. Each letter is 3 1158 + /// bars wide, with a 1-bar gap between them: 3+1+3+1+3+1+3 = 15 1159 + /// bars, with a 1-bar right margin. Letters span every segment 1160 + /// (bits 0–9) so the readout fills the whole display. 1161 + static let midiDotPattern: [UInt32] = { 1162 + var p = [UInt32](repeating: 0, count: 16) 1163 + let FULL : UInt32 = 0x3FF // segs 0..9 (all rows) 1164 + let TOP_BOT: UInt32 = 0x201 // segs 0, 9 only 1165 + let TOP_ROW: UInt32 = 0x100 // seg 8 (M's middle peak) 1166 + let MID_BAR: UInt32 = 0x1FE // segs 1..8 (D's right edge) 1167 + // M (3 cols): full left + middle peak + full right. 1168 + p[0] = FULL 1169 + p[1] = TOP_ROW 1170 + p[2] = FULL 1171 + // gap p[3] 1172 + // I (3 cols) 1173 + p[4] = TOP_BOT 1174 + p[5] = FULL 1175 + p[6] = TOP_BOT 1176 + // gap p[7] 1177 + // D (3 cols): full left, top+bottom middle, mid-only right. 1178 + p[8] = FULL 1179 + p[9] = TOP_BOT 1180 + p[10] = MID_BAR 1181 + // gap p[11] 1182 + // I (3 cols) 1183 + p[12] = TOP_BOT 1184 + p[13] = FULL 1185 + p[14] = TOP_BOT 1186 + return p 1187 + }() 1188 + 909 1189 private func applyInstrumentPaletteVisibility(midiMode: Bool, animated: Bool = false) { 910 1190 // Greyed-out, not hidden: keep the rows in place so the popover 911 1191 // doesn't reflow when MIDI flips. We dim alpha rather than touching ··· 963 1243 updateInstrumentReadout() 964 1244 debugLog("instrument commit prog=\(program) midiAutoOff=\(wasMidiOn)") 965 1245 if wasMidiOn { 1246 + // Auto-off implicitly re-enables the local-synth audio 1247 + // path; bring the visualizer back to live VU + voice 1248 + // color in the same step. Single source of truth: the 1249 + // controller's `midiMode` boolean drives both the synth 1250 + // routing AND the meter visual state. 1251 + applyVisualizerForMidiMode(false) 966 1252 m.auditionCurrentProgram() 967 1253 } 968 1254 // Otherwise no post-release audition: the press-gated rollover 969 1255 // already played a preview note while the mouse was held, so 970 1256 // retriggering on release just doubles the sound. mouseUp paths 971 1257 // through onHover(nil) first which stops the preview cleanly. 1258 + } 1259 + 1260 + /// Single source-of-truth wiring between `midiMode` and the 1261 + /// visualizer's three modes (live VU vs MIDI dot-matrix) plus 1262 + /// its base color (voice family vs system accent). Called from 1263 + /// every place that flips midiMode — keeps the meter from 1264 + /// getting stuck in a stale state when MIDI is auto-disabled by 1265 + /// picking a new voice. 1266 + func applyVisualizerForMidiMode(_ midiOn: Bool) { 1267 + guard let m = menuBand else { return } 1268 + waveformView.isLive = !midiOn 1269 + if midiOn { 1270 + waveformView.setDotMatrix(Self.midiDotPattern) 1271 + waveformView.setBaseColor(.controlAccentColor) 1272 + waveformBezel?.layer?.borderColor = NSColor.controlAccentColor 1273 + .withAlphaComponent(0.55).cgColor 1274 + } else { 1275 + waveformView.setDotMatrix(nil) 1276 + let safe = max(0, min(127, Int(m.melodicProgram))) 1277 + let famColor = InstrumentListView.colorForProgram(safe) 1278 + waveformView.setBaseColor(famColor) 1279 + waveformBezel?.layer?.borderColor = famColor 1280 + .withAlphaComponent(0.55).cgColor 1281 + } 972 1282 } 973 1283 974 1284 @objc private func octaveChanged(_ sender: NSStepper) {
+79
slab/menuband/Sources/MenuBand/NotepatFavicon.swift
··· 1 + import AppKit 2 + 3 + /// Lazily-loaded notepat.com favicon, cached on disk. The Notepat 4 + /// mode button in the popover uses this as its glyph so the brand 5 + /// is always live — whatever favicon the site is reporting today is 6 + /// what shows up on the button. 7 + /// 8 + /// Cache strategy: 9 + /// 1. On first access we synchronously return whatever's on disk 10 + /// (or `nil` if we haven't fetched yet). 11 + /// 2. In parallel, we kick off an async refresh from the network. 12 + /// When it lands, the file is overwritten and observers fire. 13 + /// 3. Subsequent app launches read the cached file immediately, 14 + /// refreshing in the background if it's older than the TTL. 15 + enum NotepatFavicon { 16 + private static let url = URL(string: "https://notepat.com/favicon.ico")! 17 + /// 24h refresh interval — the favicon doesn't change often, but 18 + /// a long-running session shouldn't drift forever. 19 + private static let refreshInterval: TimeInterval = 24 * 3600 20 + 21 + /// Cached image (in-memory). Refreshes automatically on first 22 + /// access if the disk cache is missing or stale. 23 + static var image: NSImage? { 24 + if let cached = cachedImage { return cached } 25 + // First access — load from disk and trigger refresh. 26 + loadFromDisk() 27 + triggerRefreshIfNeeded() 28 + return cachedImage 29 + } 30 + 31 + private static var cachedImage: NSImage? 32 + private static var lastDiskMTime: Date? 33 + private static var refreshInFlight = false 34 + 35 + private static var cacheURL: URL { 36 + let fm = FileManager.default 37 + let base = (try? fm.url(for: .applicationSupportDirectory, 38 + in: .userDomainMask, 39 + appropriateFor: nil, 40 + create: true)) ?? fm.temporaryDirectory 41 + let dir = base.appendingPathComponent("MenuBand", isDirectory: true) 42 + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) 43 + return dir.appendingPathComponent("notepat-favicon.ico") 44 + } 45 + 46 + private static func loadFromDisk() { 47 + guard let data = try? Data(contentsOf: cacheURL) else { return } 48 + cachedImage = NSImage(data: data) 49 + let attrs = try? FileManager.default.attributesOfItem(atPath: cacheURL.path) 50 + lastDiskMTime = attrs?[.modificationDate] as? Date 51 + } 52 + 53 + private static func triggerRefreshIfNeeded() { 54 + let stale = (lastDiskMTime.map { Date().timeIntervalSince($0) } ?? .infinity) 55 + > refreshInterval 56 + guard stale, !refreshInFlight else { return } 57 + refreshInFlight = true 58 + let task = URLSession.shared.dataTask(with: url) { data, _, _ in 59 + defer { refreshInFlight = false } 60 + guard let data = data, NSImage(data: data) != nil else { return } 61 + try? data.write(to: cacheURL, options: .atomic) 62 + DispatchQueue.main.async { 63 + cachedImage = NSImage(data: data) 64 + lastDiskMTime = Date() 65 + NotificationCenter.default.post(name: .notepatFaviconLoaded, 66 + object: nil) 67 + } 68 + } 69 + task.resume() 70 + } 71 + 72 + /// Posted on the main queue when a fresh favicon lands. Observers 73 + /// (e.g. the popover) can re-bind their image to the latest cache. 74 + static let didLoadNotification = Notification.Name.notepatFaviconLoaded 75 + } 76 + 77 + extension Notification.Name { 78 + static let notepatFaviconLoaded = Notification.Name("NotepatFaviconLoaded") 79 + }
+241
slab/menuband/Sources/MenuBand/QwertyLayoutView.swift
··· 1 + import AppKit 2 + 3 + /// Tiny QWERTY keyboard map drawn into the popover. Three rows of 4 + /// keycaps in the macOS layout (top row offset 0, home row 0.5, bottom 5 + /// row 1.0) — same row-offset logic the QWERTY-pan uses. Note-mapped 6 + /// keys are tinted with the current voice's family color so the user 7 + /// can see at a glance which physical keys play notes; currently-held 8 + /// keys light up with the accent color. 9 + final class QwertyLayoutView: NSView { 10 + /// Hardware key codes currently held by the user. Driven from the 11 + /// controller's `heldKeyCodes` snapshot. Updating triggers a 12 + /// redraw. 13 + var litKeyCodes: Set<UInt16> = [] { 14 + didSet { needsDisplay = true } 15 + } 16 + /// Keymap variant — controls which key codes are colored as 17 + /// "note-mapped" (Notepat = 2-octave layout, Ableton = Live's 18 + /// M-mode QWERTY). 19 + var keymap: Keymap = .notepat { 20 + didSet { needsDisplay = true } 21 + } 22 + /// Current voice's family color — used to tint note-mapped 23 + /// keycaps so the keymap reads as part of the chosen instrument. 24 + var voiceColor: NSColor = .controlAccentColor { 25 + didSet { needsDisplay = true } 26 + } 27 + 28 + /// Pointer-driven key event. Fires on mouseDown over a cap (isDown=true), 29 + /// when the cursor drags off that cap onto another (isDown=false for the 30 + /// old, true for the new), and on mouseUp (isDown=false). The popover 31 + /// wires this to `MenuBandController.handleLocalKey` so taps/drags on 32 + /// the layout map play the same notes the physical keyboard would. 33 + var onKey: ((_ keyCode: UInt16, _ isDown: Bool) -> Void)? 34 + 35 + /// Hardware key code currently held by the pointer, if any. Lets us 36 + /// release-then-press when a drag crosses into a new cap and clean 37 + /// up on mouseUp. 38 + private var heldByPointer: UInt16? 39 + 40 + static let intrinsicSize = NSSize(width: 180, height: 46) 41 + override var intrinsicContentSize: NSSize { Self.intrinsicSize } 42 + 43 + override init(frame frameRect: NSRect) { 44 + super.init(frame: frameRect) 45 + setFrameSize(Self.intrinsicSize) 46 + } 47 + required init?(coder: NSCoder) { fatalError() } 48 + 49 + override var isFlipped: Bool { false } 50 + 51 + /// Three-row macOS QWERTY layout — keys identified by `kVK_*` 52 + /// codes, mirroring `MenuBandLayout.panByKeyCode` so the visual 53 + /// position reflects the keypad-driven pan. 54 + private static let rows: [[(kc: UInt16, label: String)]] = [ 55 + // Top row: q w e r t y u i o p ] 56 + [(12, "q"), (13, "w"), (14, "e"), (15, "r"), (17, "t"), 57 + (16, "y"), (32, "u"), (34, "i"), (31, "o"), (35, "p"), (30, "]")], 58 + // Home row: a s d f g h j k l ; ' 59 + [(0, "a"), (1, "s"), (2, "d"), (3, "f"), (5, "g"), 60 + (4, "h"), (38, "j"), (40, "k"), (37, "l"), (41, ";"), (39, "'")], 61 + // Bottom row: z x c v b n m 62 + [(6, "z"), (7, "x"), (8, "c"), (9, "v"), (11, "b"), 63 + (45, "n"), (46, "m")], 64 + ] 65 + private static let rowOffsets: [CGFloat] = [0, 0.5, 1.0] 66 + private static let keySize: CGFloat = 14 67 + private static let keyGap: CGFloat = 1 68 + private static let cornerRadius: CGFloat = 2.5 69 + 70 + override func draw(_ dirtyRect: NSRect) { 71 + super.draw(dirtyRect) 72 + forEachVisibleCap { cap, rect in 73 + let st = semitone(cap.kc) 74 + let octaves = MenuBandLayout.octaveKeyCodes(for: keymap) 75 + let isOctaveKey = (cap.kc == octaves.down || cap.kc == octaves.up) 76 + let black = (st.map { Self.isBlackKey(semitone: $0) }) ?? false 77 + let isLit = litKeyCodes.contains(cap.kc) 78 + drawKeycap(rect: rect, label: cap.label, 79 + mapped: st != nil, isBlack: black, lit: isLit, 80 + isOctaveKey: isOctaveKey) 81 + } 82 + } 83 + 84 + /// Walk the visible caps in draw-order, handing each its layout 85 + /// rect. Hit testing and drawing share this so a cap rendered on 86 + /// screen is exactly the cap a click on that pixel resolves to. 87 + /// Skips unmapped Ableton keys (matching the draw-time hide rule) 88 + /// so a click on empty space doesn't accidentally trigger a key 89 + /// that isn't there. 90 + private func forEachVisibleCap(_ body: (_ cap: (kc: UInt16, label: String), _ rect: NSRect) -> Void) { 91 + let r = bounds 92 + let totalRows = CGFloat(Self.rows.count) 93 + let rowSpan = totalRows * Self.keySize + (totalRows - 1) * Self.keyGap 94 + let topY = r.midY + rowSpan / 2 - Self.keySize 95 + let homeRowSpan = CGFloat(Self.rows[1].count) * Self.keySize + 96 + CGFloat(Self.rows[1].count - 1) * Self.keyGap + 97 + Self.rowOffsets[1] * Self.keySize 98 + let leftX = r.midX - homeRowSpan / 2 99 + let octaves = MenuBandLayout.octaveKeyCodes(for: keymap) 100 + for (rIdx, row) in Self.rows.enumerated() { 101 + let y = topY - CGFloat(rIdx) * (Self.keySize + Self.keyGap) 102 + let xOffset = Self.rowOffsets[rIdx] * Self.keySize 103 + for (cIdx, cap) in row.enumerated() { 104 + let st = semitone(cap.kc) 105 + let isOctaveKey = (cap.kc == octaves.down || cap.kc == octaves.up) 106 + if keymap == .ableton && st == nil && !isOctaveKey { continue } 107 + let x = leftX + xOffset + CGFloat(cIdx) * (Self.keySize + Self.keyGap) 108 + let kr = NSRect(x: x, y: y, width: Self.keySize, height: Self.keySize) 109 + body(cap, kr) 110 + } 111 + } 112 + } 113 + 114 + /// Hit-test a point in view coordinates against the visible caps. 115 + /// Returns the matching key code or nil if the point misses every 116 + /// cap (gaps between keys, empty area around the layout). 117 + private func keyCode(at point: NSPoint) -> UInt16? { 118 + var hit: UInt16? 119 + forEachVisibleCap { cap, rect in 120 + if hit == nil && rect.contains(point) { hit = cap.kc } 121 + } 122 + return hit 123 + } 124 + 125 + // MARK: - Pointer events 126 + // 127 + // mouseDown / mouseDragged / mouseUp fire onKey so a tap on a cap 128 + // plays the same note the matching physical key would. Drag rolls 129 + // across caps with proper release-then-press so chords don't get 130 + // stuck and notes glissando smoothly. 131 + 132 + override func mouseDown(with event: NSEvent) { 133 + let p = convert(event.locationInWindow, from: nil) 134 + guard let kc = keyCode(at: p) else { return } 135 + heldByPointer = kc 136 + onKey?(kc, true) 137 + } 138 + 139 + override func mouseDragged(with event: NSEvent) { 140 + let p = convert(event.locationInWindow, from: nil) 141 + let kc = keyCode(at: p) 142 + if kc == heldByPointer { return } 143 + if let prev = heldByPointer { onKey?(prev, false) } 144 + heldByPointer = kc 145 + if let new = kc { onKey?(new, true) } 146 + } 147 + 148 + override func mouseUp(with event: NSEvent) { 149 + if let prev = heldByPointer { onKey?(prev, false) } 150 + heldByPointer = nil 151 + } 152 + 153 + /// Returns the semitone offset for a key in the active keymap, or 154 + /// nil if unmapped. Used to know whether the key plays a note + 155 + /// whether that note is a white or black piano key. 156 + private func semitone(_ kc: UInt16) -> Int? { 157 + guard kc < 128 else { return nil } 158 + let table = (keymap == .ableton) 159 + ? MenuBandLayout.semitoneByKeyCodeAbleton 160 + : MenuBandLayout.semitoneByKeyCode 161 + let v = table[Int(kc)] 162 + return (v == Int8.min) ? nil : Int(v) 163 + } 164 + 165 + /// Black keys = sharps/flats: pitch classes 1, 3, 6, 8, 10 166 + /// (C#, D#, F#, G#, A#). White = the rest. We mod by 12 against 167 + /// middle C (semitone 0 → C natural) so the mapping holds across 168 + /// octave shifts. 169 + private static func isBlackKey(semitone: Int) -> Bool { 170 + let pc = ((semitone % 12) + 12) % 12 171 + switch pc { 172 + case 1, 3, 6, 8, 10: return true 173 + default: return false 174 + } 175 + } 176 + 177 + private func drawKeycap(rect: NSRect, label: String, 178 + mapped: Bool, isBlack: Bool, lit: Bool, 179 + isOctaveKey: Bool = false) { 180 + let path = NSBezierPath(roundedRect: rect, 181 + xRadius: Self.cornerRadius, 182 + yRadius: Self.cornerRadius) 183 + // Fills mirror the menubar piano: white-key-mapped letters 184 + // get a near-white cap, black-key-mapped letters get a 185 + // near-black cap. Octave-shift keys get a muted accent fill 186 + // so they read as "control" rather than note. Unmapped 187 + // letters stay outline-only. Lit overrides everything with 188 + // accent color so the user can see what they're playing in 189 + // real time. The instrument's family color stays out of the 190 + // picture so the keymap reads as "this is which physical 191 + // keys play piano keys" rather than another voice-colored 192 + // ornament. 193 + if lit { 194 + NSColor.controlAccentColor.withAlphaComponent(0.85).setFill() 195 + path.fill() 196 + } else if isOctaveKey && !mapped { 197 + NSColor.controlAccentColor.withAlphaComponent(0.18).setFill() 198 + path.fill() 199 + } else if mapped && isBlack { 200 + NSColor(white: 0.08, alpha: 1.0).setFill() 201 + path.fill() 202 + } else if mapped { 203 + NSColor(white: 0.92, alpha: 1.0).setFill() 204 + path.fill() 205 + } 206 + let stroke: NSColor 207 + if lit { 208 + stroke = NSColor.controlAccentColor 209 + } else if isOctaveKey && !mapped { 210 + stroke = NSColor.controlAccentColor.withAlphaComponent(0.65) 211 + } else { 212 + stroke = NSColor.labelColor.withAlphaComponent(mapped ? 0.45 : 0.30) 213 + } 214 + stroke.setStroke() 215 + path.lineWidth = 0.7 216 + path.stroke() 217 + // Letter glyph centered in the cap, color follows the piano- 218 + // key convention: white caps get black text, black caps get 219 + // white text. Octave-shift caps wear the accent color. 220 + let textColor: NSColor 221 + if lit { 222 + textColor = .black 223 + } else if isOctaveKey && !mapped { 224 + textColor = .controlAccentColor 225 + } else if mapped && isBlack { 226 + textColor = .white 227 + } else if mapped { 228 + textColor = NSColor(white: 0.10, alpha: 1.0) 229 + } else { 230 + textColor = NSColor.labelColor.withAlphaComponent(0.55) 231 + } 232 + let attrs: [NSAttributedString.Key: Any] = [ 233 + .font: NSFont.systemFont(ofSize: 8.5, weight: .heavy), 234 + .foregroundColor: textColor, 235 + ] 236 + let s = NSAttributedString(string: label, attributes: attrs) 237 + let size = s.size() 238 + s.draw(at: NSPoint(x: rect.midX - size.width / 2, 239 + y: rect.midY - size.height / 2 - 0.5)) 240 + } 241 + }
slab/menuband/Sources/MenuBand/Resources/ywft-processing-bold.ttf

This is a binary file and will not be displayed.

slab/menuband/Sources/MenuBand/Resources/ywft-processing-regular.ttf

This is a binary file and will not be displayed.

+61 -4
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 40 40 var isLive: Bool = false { 41 41 didSet { 42 42 if isLive { 43 + stopDotMatrix() 43 44 startLink() 44 45 } else { 45 46 stopLink() ··· 47 48 for i in 0..<displayLevels.count { displayLevels[i] = 0 } 48 49 display() // one final paint to clear bars 49 50 } 51 + } 52 + } 53 + 54 + /// When true, the bars stop running the live VU and instead 55 + /// render the static `dotMasks` pattern (used to spell "MIDI" 56 + /// while in MIDI mode). Reset masks + uniform when switching 57 + /// out so live mode resumes cleanly. 58 + private var dotMatrixActive: Bool = false 59 + /// Per-bar 32-bit mask. Bit i = whether segment i (0=bottom, 60 + /// 9=top) is lit. Sized to `barCount`. 61 + private var dotMasks: [Float] = Array(repeating: 0, count: 32) 62 + 63 + /// Render a static dot-matrix pattern instead of the live VU 64 + /// bars. Pass nil to clear and return to live mode. 65 + func setDotMatrix(_ mask: [UInt32]?) { 66 + if let mask = mask { 67 + // Encode UInt32 → Float for transport (Float can hold up 68 + // to 2^24 exactly, and our masks are 10 bits). 69 + for i in 0..<dotMasks.count { 70 + dotMasks[i] = Float(i < mask.count ? mask[i] : 0) 71 + } 72 + uniforms.dotMatrix = 1 73 + dotMatrixActive = true 74 + // Static frame — nudge a single redraw so the pattern 75 + // appears even when the live link is off. 76 + display() 77 + } else { 78 + stopDotMatrix() 79 + } 80 + } 81 + 82 + private func stopDotMatrix() { 83 + if dotMatrixActive { 84 + for i in 0..<dotMasks.count { dotMasks[i] = 0 } 85 + uniforms.dotMatrix = 0 86 + dotMatrixActive = false 87 + display() 50 88 } 51 89 } 52 90 ··· 262 300 float stride; 263 301 float minHeight; 264 302 float4 color; 303 + float dotMatrix; 265 304 }; 266 305 267 306 struct VertexOut { 268 307 float4 position [[position]]; 269 308 float level; 309 + float mask; 270 310 }; 271 311 272 312 // Two triangles spanning the unit square (CCW), shared across all bar ··· 294 334 vertex VertexOut bar_vertex(uint vid [[vertex_id]], 295 335 uint iid [[instance_id]], 296 336 constant Uniforms &u [[buffer(0)]], 297 - constant float *levels [[buffer(1)]]) 337 + constant float *levels [[buffer(1)]], 338 + constant float *masks [[buffer(2)]]) 298 339 { 299 340 float2 local = unitQuad[vid]; 300 341 float barX = float(iid) * u.stride; ··· 308 349 VertexOut out; 309 350 out.position = float4(clipX, clipY, 0, 1); 310 351 out.level = levels[iid]; 352 + out.mask = masks[iid]; 311 353 return out; 312 354 } 313 355 ··· 336 378 float visibleSegPos = segPos / (1.0 - SEG_GAP); 337 379 float segCenterDist = abs(visibleSegPos - 0.5) * 2.0; 338 380 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; 381 + // Lit = below the level (live VU) OR the corresponding bit 382 + // is set in this bar's dot-matrix mask. The mask path lets 383 + // us spell static text out of the LED segments — used in 384 + // MIDI mode to render "MIDI". 385 + uint segIndex = uint(floor(y01 * NSEG)); 386 + uint mask = uint(in.mask); 387 + bool maskLit = (u.dotMatrix > 0.5) && (((mask >> segIndex) & 1u) != 0u); 388 + bool levelLit = y01 < in.level; 389 + bool lit = maskLit || levelLit; 342 390 float3 color = lit ? (tier + bloom * 0.40) : tier; 343 391 float a = lit ? u.color.a : (u.color.a * UNLIT_ALPHA); 344 392 return float4(min(color, 1.0), a); ··· 353 401 var stride: Float = 0 354 402 var minHeight: Float = 1.5 355 403 var color: SIMD4<Float> = SIMD4<Float>(0, 1, 1, 1) 404 + /// Set to 1 when the bars should render the dot-matrix pattern 405 + /// (see `dotMasks`) instead of the continuous-level VU. Used in 406 + /// MIDI mode to spell "MIDI" out of the LED segments. 407 + var dotMatrix: Float = 0 356 408 } 357 409 358 410 extension WaveformView: MTKViewDelegate { ··· 395 447 enc.setVertexBytes(ptr.baseAddress!, 396 448 length: MemoryLayout<Float>.size * n, 397 449 index: 1) 450 + } 451 + dotMasks.withUnsafeBufferPointer { ptr in 452 + enc.setVertexBytes(ptr.baseAddress!, 453 + length: MemoryLayout<Float>.size * n, 454 + index: 2) 398 455 } 399 456 enc.drawPrimitives(type: .triangle, 400 457 vertexStart: 0,