Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: integrated chord finder + chromatic notepat coloring + tighter mini-meter

Floating palette + popover now host an inline chord-finder overlaid on
the metal visualizer. Held notes show as small chromatic pills along the
top of the bezel; chord-candidate cards line the bottom with the chord
name + colored chips for the notes you still need to play. Cards are
colored by their root note using the notepat ROYGBIV palette (sharps
fall back to neutral gray) and matched chords get a colored glow + shake
on lock-in. Suggestions filter against both octaves of the active
keymap so the missing-note chips always point to keys you can actually
reach.

Other changes:
- KeyboardIconRenderer.keyHeightScale knob, set by the floating palette
so the overlay piano shows tall traditional-aspect white keys instead
of menubar squares
- Mini menubar visualizer: 120fps tick, adaptive auto-gain (mirrors
WaveformView's smoothedPeak), snap-instant attack, and a hold-then-
decay envelope so the bars hang at peak ~250ms then bleed down over
~480ms before settling to a permanent flat-short floor
- Bars stay live even with popover/palette open; silent floor draws
three short flat bars instead of vanishing into the SF Symbol
- QWERTY keymap shrunk in the floating palette (scale 2.0 → 1.4) and
bezels right-sized for the new compact chord-finder

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

+885 -96
+111 -14
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 24 24 /// many menubar apps). When that happens we shrink the layout — 25 25 /// full piano → 1 octave → compact chip — until something fits. 26 26 private var visibilityTimer: Timer? 27 + /// 24fps redraw timer for the in-chip mini visualizer. Drives the 28 + /// per-bar sine wiggle + smooths the activity level so bars move 29 + /// continuously instead of snapping on note events. 30 + private var visualizerAnimTimer: Timer? 31 + private var visualizerSmoothedLevel: CGFloat = 0 32 + /// Running adaptive peak — mirrors the main waveform's auto-gain 33 + /// (`smoothedPeak` in WaveformView) so the menubar bars normalize 34 + /// to whatever the loudest recent sample was instead of sitting 35 + /// flat for quiet voices. Snaps up instantly on louder peaks, 36 + /// decays slowly so a sustained quiet note still lights the bars. 37 + private var visualizerSmoothedPeak: Float = 0.05 38 + /// Frames remaining in the post-attack "hold" window. While > 0, 39 + /// the bars stay pinned to whatever level the last attack reached 40 + /// instead of decaying — gives a more meter-like feel where the 41 + /// needle hangs at peak briefly before falling back. Combined 42 + /// with the slow release alpha below, the bars stay readable for 43 + /// ~700ms after the user lifts off the keys. 44 + private var visualizerHoldFrames: Int = 0 45 + /// Reused sample buffer for the RMS meter. 256 frames at 44.1kHz 46 + /// ≈ 5.8ms of audio — small window keeps the menubar bars 47 + /// snappy on note attack rather than smearing across the prior 48 + /// 20+ ms. 49 + private var visualizerSampleBuffer = [Float](repeating: 0, count: 256) 27 50 /// Set to true once we've shown the "no room even for compact" alert 28 51 /// so we don't spam the user every check. 29 52 private var hasAlertedNoSpace = false ··· 148 171 self.updateIcon() 149 172 self.popoverVC?.refreshHeldNotes() 150 173 self.floatingPlayPalette.refresh() 151 - self.waveformStrip.refreshAppearance() 174 + // strip retired — no-op 152 175 self.updateWaveformStrip() 153 176 } 154 177 menuBand.onInstrumentVisualChange = { [weak self] in ··· 156 179 guard let self = self else { return } 157 180 self.updateIcon() 158 181 self.floatingPlayPalette.refresh() 159 - self.waveformStrip.refreshAppearance() 182 + // strip retired — no-op 160 183 } 161 184 } 162 185 menuBand.bootstrap() ··· 202 225 } 203 226 updateIcon() 204 227 205 - // Pre-build the waveform strip panel so the first note press 206 - // doesn't stall on panel + Metal pipeline construction. 207 - waveformStrip.reposition(statusItemButton: statusItem.button) 208 - waveformStrip.warmUp() 228 + // Strip retired — no warmup needed. 209 229 210 230 registerTypeModeHotkey() 211 231 _ = registerFocusCaptureHotkey(MenuBandShortcutPreferences.focusShortcut) ··· 299 319 300 320 startAdaptiveLayoutChecks() 301 321 startShiftStateMonitors() 322 + startVisualizerAnimation() 302 323 303 324 // Retint the bundle's Finder icon to the user's accent color. 304 325 // Stored as an xattr on the bundle folder, so the signed payload ··· 543 564 /// launchctl kickstart -k gui/$(id -u)/computer.aestheticcomputer.menuband 544 565 /// 545 566 /// Clear with `defaults delete ... forceLayout`. 567 + /// 24fps redraw loop driving the mini visualizer's continuous 568 + /// motion. Level is exponentially smoothed toward the live note 569 + /// count so attacks ramp up over a couple frames and releases 570 + /// glide back to idle. Phase is just CACurrentMediaTime — the 571 + /// renderer uses it to spread bar wiggles by a per-bar offset. 572 + /// Suppressed when the popover or palette is open (no point 573 + /// burning frames on a hidden meter). 574 + private func startVisualizerAnimation() { 575 + visualizerAnimTimer?.invalidate() 576 + menuBand.setWaveformCaptureEnabled(true) 577 + // 120fps tick — half the perceptual latency of the prior 60fps 578 + // path. Combined with the 256-sample RMS window (~5.8ms), 579 + // adaptive auto-gain (matches the main visualizer's envelope), 580 + // and a snap-instant attack, the bars now move within ~10ms 581 + // of a note hitting the synth and reach full height even for 582 + // quiet voices. Bars are ALWAYS animating (popover or palette 583 + // open or not); when silent they fall to a flat/short floor 584 + // instead of vanishing, so the menubar always looks "alive." 585 + let timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] _ in 586 + guard let self = self else { return } 587 + self.menuBand.synthSnapshotWaveform(into: &self.visualizerSampleBuffer) 588 + var sumSq: Float = 0 589 + for s in self.visualizerSampleBuffer { sumSq += s * s } 590 + let rms = sqrt(sumSq / Float(self.visualizerSampleBuffer.count)) 591 + 592 + // Adaptive auto-gain — same envelope as WaveformView's 593 + // `smoothedPeak`. Snap up the moment we see a louder 594 + // sample so transients are captured at full scale; bleed 595 + // down slowly so a sustained quiet sound still pushes the 596 + // bars near the top once the peak settles. 597 + if rms > self.visualizerSmoothedPeak { 598 + self.visualizerSmoothedPeak = rms 599 + } else { 600 + self.visualizerSmoothedPeak = 601 + max(0.05, self.visualizerSmoothedPeak * 0.92 + rms * 0.08) 602 + } 603 + let gain = 0.95 / self.visualizerSmoothedPeak 604 + let target = CGFloat(min(1.0, rms * gain)) 605 + 606 + // Hold-then-decay envelope: snap-up on attack, hang at 607 + // peak for ~250ms, then bleed down slowly (~480ms half- 608 + // life). Total tail ≈ 700ms before bars settle to the 609 + // silent floor — gives the meter a satisfying "needle 610 + // hangs" feel instead of cutting back to flat the 611 + // instant a key releases. 612 + let holdFramesAtPeak = 30 // 250ms at 120fps 613 + let releaseAlpha: CGFloat = 0.012 // ~480ms half-life 614 + if target >= self.visualizerSmoothedLevel { 615 + self.visualizerSmoothedLevel = target 616 + self.visualizerHoldFrames = holdFramesAtPeak 617 + } else if self.visualizerHoldFrames > 0 { 618 + self.visualizerHoldFrames -= 1 619 + } else { 620 + self.visualizerSmoothedLevel += 621 + (target - self.visualizerSmoothedLevel) * releaseAlpha 622 + } 623 + KeyboardIconRenderer.miniVisualizerLevel = self.visualizerSmoothedLevel 624 + KeyboardIconRenderer.miniVisualizerPhase = CACurrentMediaTime() 625 + self.updateIcon() 626 + } 627 + RunLoop.main.add(timer, forMode: .common) 628 + visualizerAnimTimer = timer 629 + } 630 + 546 631 /// Watch shift state globally + locally so the menubar piano can 547 632 /// uppercase its letter labels while the user holds shift. The 548 633 /// uppercase letters are the visual cue that linger / bell-ring ··· 660 745 } 661 746 662 747 func applicationWillTerminate(_ notification: Notification) { 663 - waveformStrip.dismiss() 664 748 floatingPlayPalette.dismiss(reason: .programmatic) 665 749 menuBand.shutdown() 666 750 } ··· 818 902 // It's a temporal "you're capturing right now" hint, not a 819 903 // permanent overlay. 820 904 KeyboardIconRenderer.activeKeymap = menuBand.keymap 905 + // Note: miniVisualizerLevel + miniVisualizerPhase are driven by 906 + // the visualizerAnimTimer (24fps smoothed VU motion), not from 907 + // here — overriding them on every note-event redraw would 908 + // erase the smoothing. 821 909 statusItem.length = KeyboardIconRenderer.imageSize.width 822 910 button.image = KeyboardIconRenderer.image( 823 911 litNotes: menuBand.litNotes, ··· 1027 1115 case .openSettings: 1028 1116 showPopover() 1029 1117 return 1118 + case .openVisualizer: 1119 + // Mini-visualizer click → "big overlay" (the floating play 1120 + // palette window). Same target the popover's WaveformView 1121 + // routes to, so the user gets one entry point regardless of 1122 + // where they tap. 1123 + floatingPlayPalette.show() 1124 + return 1030 1125 case .note(let n): 1031 1126 startNote = n 1032 1127 case .none: ··· 1171 1266 // MARK: - Menubar waveform strip 1172 1267 1173 1268 private func updateWaveformStrip() { 1174 - if !menuBand.litNotes.isEmpty { 1175 - waveformStrip.reposition(statusItemButton: statusItem.button) 1176 - waveformStrip.showIfNeeded() 1177 - } else { 1178 - waveformStrip.scheduleHide() 1179 - } 1269 + // Strip retired — the menubar mini meter replaces it. We keep 1270 + // this method as a no-op so the call sites elsewhere stay 1271 + // wired without each one having to know about the retirement. 1180 1272 } 1181 1273 1182 1274 private func updateWaveformStripSuppression() { 1183 - waveformStrip.suppressed = popover.isShown || floatingPlayPalette.isShown 1275 + // Strip retired. The mini meter now stays animating at all 1276 + // times (including when the popover or palette is open) so 1277 + // the menubar icon always feels "live." When silent, the 1278 + // bars fall to a short floor instead of disappearing — see 1279 + // KeyboardIconRenderer.drawChipVisualizer for the silent- 1280 + // floor handling. This method is intentionally a no-op now. 1184 1281 } 1185 1282 }
+111
slab/menuband/Sources/MenuBand/ChordCandidateCard.swift
··· 1 + import AppKit 2 + import QuartzCore 3 + 4 + /// Shared chord-candidate card builder used by both the floating play 5 + /// palette and the popover. Each card shows the chord name (colored by 6 + /// the root note's chromatic identity) plus large colored chips for 7 + /// the notes still needed to complete the chord. Complete chords drop 8 + /// the chips and grow a colored glow so they read as "active". 9 + enum FloatingChordCandidateCard { 10 + /// Compact inline pill height. Everything (chord name + missing- 11 + /// note chips) lives in a single horizontal row so the card reads 12 + /// as a single tappable suggestion at a glance, no bigger than a 13 + /// piece of label tape on the visualizer. 14 + static let baseCardHeight: CGFloat = 24 15 + static let chipDiameter: CGFloat = 16 16 + 17 + static func build(candidate: MenuBandController.ChordCandidate, 18 + isDark: Bool) -> NSView { 19 + let card = NSView() 20 + card.wantsLayer = true 21 + card.layer?.cornerRadius = 5 22 + card.layer?.masksToBounds = false 23 + card.translatesAutoresizingMaskIntoConstraints = false 24 + 25 + let rootColor = NoteColors.color(pitchClass: candidate.rootPitchClass) 26 + let isSharpRoot = NoteColors.isAccidental(pitchClass: candidate.rootPitchClass) 27 + let cardAccent: NSColor = isSharpRoot 28 + ? NSColor(white: isDark ? 0.55 : 0.35, alpha: 1.0) 29 + : rootColor 30 + 31 + let nameLabel = NSTextField(labelWithString: candidate.name) 32 + nameLabel.font = NSFont.monospacedSystemFont( 33 + ofSize: candidate.isComplete ? 14 : 12, 34 + weight: .heavy 35 + ) 36 + nameLabel.drawsBackground = false 37 + nameLabel.translatesAutoresizingMaskIntoConstraints = false 38 + 39 + if candidate.isComplete { 40 + // Active chord: solid root-color fill + colored glow. 41 + // Glow plus the parent-supplied shake gives the chord- 42 + // locks-in feel without taking up extra vertical space. 43 + card.layer?.backgroundColor = cardAccent.withAlphaComponent(0.95).cgColor 44 + card.layer?.borderColor = cardAccent.shadow(withLevel: 0.30)?.cgColor 45 + ?? cardAccent.cgColor 46 + card.layer?.borderWidth = 1.5 47 + card.layer?.shadowColor = cardAccent.cgColor 48 + card.layer?.shadowRadius = 9 49 + card.layer?.shadowOpacity = 0.85 50 + card.layer?.shadowOffset = .zero 51 + nameLabel.textColor = NoteColors.textColor(on: cardAccent) 52 + } else { 53 + let bgAlpha: CGFloat = isDark ? 0.22 : 0.18 54 + card.layer?.backgroundColor = cardAccent.withAlphaComponent(bgAlpha).cgColor 55 + card.layer?.borderColor = cardAccent.withAlphaComponent(0.55).cgColor 56 + card.layer?.borderWidth = 1 57 + nameLabel.textColor = isDark 58 + ? NSColor.white.withAlphaComponent(0.95) 59 + : NSColor.black.withAlphaComponent(0.90) 60 + } 61 + 62 + let row = NSStackView() 63 + row.orientation = .horizontal 64 + row.alignment = .centerY 65 + row.spacing = 4 66 + row.translatesAutoresizingMaskIntoConstraints = false 67 + row.addArrangedSubview(nameLabel) 68 + if !candidate.isComplete { 69 + for pc in candidate.missingPitchClasses { 70 + row.addArrangedSubview(makeMissingNoteChip(pitchClass: pc)) 71 + } 72 + } 73 + card.addSubview(row) 74 + NSLayoutConstraint.activate([ 75 + row.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 7), 76 + row.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -7), 77 + row.centerYAnchor.constraint(equalTo: card.centerYAnchor), 78 + card.heightAnchor.constraint(equalToConstant: baseCardHeight), 79 + ]) 80 + return card 81 + } 82 + 83 + /// Compact missing-note chip — small rounded square in the note's 84 + /// chromatic color, lettered with the note name. Sized to sit 85 + /// comfortably inline next to the chord name. Sharps stay black 86 + /// with white text so accidental-bearing chords stay legible. 87 + private static func makeMissingNoteChip(pitchClass: Int) -> NSView { 88 + let chip = NSView() 89 + chip.wantsLayer = true 90 + chip.layer?.cornerRadius = 3 91 + chip.translatesAutoresizingMaskIntoConstraints = false 92 + let color = NoteColors.color(pitchClass: pitchClass) 93 + chip.layer?.backgroundColor = color.cgColor 94 + 95 + let pcs = MenuBandController.pitchClassNames 96 + let safe = ((pitchClass % 12) + 12) % 12 97 + let label = NSTextField(labelWithString: pcs[safe]) 98 + label.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .black) 99 + label.textColor = NoteColors.textColor(on: color) 100 + label.drawsBackground = false 101 + label.translatesAutoresizingMaskIntoConstraints = false 102 + chip.addSubview(label) 103 + NSLayoutConstraint.activate([ 104 + chip.widthAnchor.constraint(equalToConstant: chipDiameter), 105 + chip.heightAnchor.constraint(equalToConstant: chipDiameter), 106 + label.centerXAnchor.constraint(equalTo: chip.centerXAnchor), 107 + label.centerYAnchor.constraint(equalTo: chip.centerYAnchor), 108 + ]) 109 + return chip 110 + } 111 + }
+143 -20
slab/menuband/Sources/MenuBand/FloatingPlayPalette.swift
··· 277 277 private let waveformView = WaveformView() 278 278 private let waveformBezel = NSView() 279 279 private let heldNotesStack = NSStackView() 280 - private let heldNotesContainer = NSView() 280 + private let heldNotesRow = NSView() 281 + private let chordCandidatesStack = NSStackView() 282 + private let chordCandidatesRow = NSView() 283 + private var lastCompleteChordNames: Set<String> = [] 281 284 private let instrumentReadout = NSTextField(labelWithString: "") 282 285 private let instrumentTitleRow: NSStackView 283 286 private let pianoView: FloatingPianoView 284 287 private let dragHandle = FloatingPaletteDragHandleView() 285 288 private let closeButton = NSButton() 286 289 private let shortcutHintLabel = NSTextField(labelWithString: "") 290 + /// Large QWERTY keymap shown beneath the piano so the user can 291 + /// see at a glance which physical keys play which notes. Driven 292 + /// at 2× scale so it's legible at the floating palette's size. 293 + private let qwertyView = QwertyLayoutView() 287 294 288 295 var onClose: (() -> Void)? 289 296 var isPianoFocusActive: (() -> Bool)? 290 297 291 - private let pianoScale: CGFloat = 2.0 298 + private let pianoScale: CGFloat = 1.6 292 299 private let inset: CGFloat = 14 293 300 private let gap: CGFloat = 8 294 301 private let closeSize: CGFloat = 18 295 302 private let hintHeight: CGFloat = 42 303 + private let heldNotesRowHeight: CGFloat = 26 304 + private let chordCandidatesRowHeight: CGFloat = 30 296 305 private var waveformHeightConstraint: NSLayoutConstraint? 297 306 298 307 init(menuBand: MenuBandController) { ··· 315 324 waveformBezel.translatesAutoresizingMaskIntoConstraints = false 316 325 heldNotesStack.orientation = .horizontal 317 326 heldNotesStack.alignment = .centerY 318 - heldNotesStack.spacing = 4 327 + heldNotesStack.spacing = 6 319 328 heldNotesStack.translatesAutoresizingMaskIntoConstraints = false 320 - heldNotesContainer.translatesAutoresizingMaskIntoConstraints = false 321 - heldNotesContainer.addSubview(heldNotesStack) 329 + heldNotesRow.translatesAutoresizingMaskIntoConstraints = false 330 + heldNotesRow.addSubview(heldNotesStack) 331 + chordCandidatesStack.orientation = .horizontal 332 + chordCandidatesStack.alignment = .centerY 333 + chordCandidatesStack.spacing = 6 334 + chordCandidatesStack.translatesAutoresizingMaskIntoConstraints = false 335 + chordCandidatesRow.translatesAutoresizingMaskIntoConstraints = false 336 + chordCandidatesRow.addSubview(chordCandidatesStack) 322 337 instrumentReadout.lineBreakMode = .byTruncatingTail 323 338 instrumentReadout.alignment = .center 324 339 instrumentReadout.setContentHuggingPriority(.defaultHigh, for: .horizontal) ··· 332 347 dragHandle.translatesAutoresizingMaskIntoConstraints = false 333 348 closeButton.translatesAutoresizingMaskIntoConstraints = false 334 349 shortcutHintLabel.translatesAutoresizingMaskIntoConstraints = false 350 + qwertyView.scale = 1.4 351 + qwertyView.keymap = menuBand.keymap 352 + qwertyView.translatesAutoresizingMaskIntoConstraints = false 353 + // Mouse-tap on a keycap → route through the controller's 354 + // local-key handler so the floating palette's QWERTY map 355 + // plays the same notes the physical keyboard would. 356 + qwertyView.onKey = { [weak self] keyCode, isDown in 357 + guard let self = self, let menuBand = self.menuBand else { return } 358 + menuBand.handleLocalKey(keyCode: keyCode, isDown: isDown, 359 + isRepeat: false, flags: []) 360 + self.refresh() 361 + } 362 + // Held-note pills and chord-suggestion cards overlay the metal 363 + // visualizer directly so the waveform doubles as the canvas 364 + // for the chord readout. Stack vertically: pills on top, cards 365 + // beneath, both centered. Waveform draws underneath, peeking 366 + // through the translucent card backgrounds. 335 367 waveformBezel.addSubview(waveformView) 336 - addSubview(heldNotesContainer) 368 + waveformBezel.layer?.masksToBounds = false 369 + heldNotesRow.wantsLayer = true 370 + heldNotesRow.layer?.masksToBounds = false 371 + chordCandidatesRow.wantsLayer = true 372 + chordCandidatesRow.layer?.masksToBounds = false 373 + waveformBezel.addSubview(heldNotesRow) 374 + waveformBezel.addSubview(chordCandidatesRow) 337 375 addSubview(waveformBezel) 338 376 addSubview(instrumentTitleRow) 339 377 addSubview(pianoView) 378 + addSubview(qwertyView) 340 379 shortcutHintLabel.font = NSFont.systemFont(ofSize: 10) 341 380 shortcutHintLabel.textColor = .secondaryLabelColor 342 381 shortcutHintLabel.alignment = .center ··· 378 417 dragHandle.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), 379 418 dragHandle.heightAnchor.constraint(equalToConstant: closeSize), 380 419 381 - heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesContainer.centerXAnchor), 382 - heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesContainer.centerYAnchor), 383 - heldNotesContainer.topAnchor.constraint(equalTo: closeButton.bottomAnchor, constant: gap), 384 - heldNotesContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 385 - heldNotesContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 386 - heldNotesContainer.heightAnchor.constraint(equalToConstant: 22), 420 + heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesRow.centerXAnchor), 421 + heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesRow.centerYAnchor), 422 + heldNotesStack.leadingAnchor.constraint(greaterThanOrEqualTo: heldNotesRow.leadingAnchor, constant: 6), 423 + heldNotesStack.trailingAnchor.constraint(lessThanOrEqualTo: heldNotesRow.trailingAnchor, constant: -6), 424 + heldNotesRow.heightAnchor.constraint(equalToConstant: heldNotesRowHeight), 425 + 426 + chordCandidatesStack.centerXAnchor.constraint(equalTo: chordCandidatesRow.centerXAnchor), 427 + chordCandidatesStack.centerYAnchor.constraint(equalTo: chordCandidatesRow.centerYAnchor), 428 + chordCandidatesStack.leadingAnchor.constraint(greaterThanOrEqualTo: chordCandidatesRow.leadingAnchor, constant: 6), 429 + chordCandidatesStack.trailingAnchor.constraint(lessThanOrEqualTo: chordCandidatesRow.trailingAnchor, constant: -6), 430 + chordCandidatesRow.heightAnchor.constraint(equalToConstant: chordCandidatesRowHeight), 387 431 388 - waveformBezel.topAnchor.constraint(equalTo: heldNotesContainer.bottomAnchor, constant: gap), 432 + waveformBezel.topAnchor.constraint(equalTo: closeButton.bottomAnchor, constant: gap), 389 433 waveformBezel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 390 434 waveformBezel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 391 435 waveformView.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor, constant: bezelInset), ··· 394 438 waveformView.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -bezelInset), 395 439 waveformHeightConstraint, 396 440 441 + heldNotesRow.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor), 442 + heldNotesRow.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor), 443 + heldNotesRow.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: 8), 444 + 445 + chordCandidatesRow.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor), 446 + chordCandidatesRow.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor), 447 + chordCandidatesRow.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -8), 448 + 397 449 instrumentTitleRow.topAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: gap), 398 450 instrumentTitleRow.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 399 451 instrumentTitleRow.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), ··· 402 454 pianoView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 403 455 pianoView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 404 456 405 - shortcutHintLabel.topAnchor.constraint(equalTo: pianoView.bottomAnchor, constant: gap), 457 + qwertyView.topAnchor.constraint(equalTo: pianoView.bottomAnchor, constant: gap), 458 + qwertyView.centerXAnchor.constraint(equalTo: centerXAnchor), 459 + qwertyView.widthAnchor.constraint( 460 + equalToConstant: QwertyLayoutView.intrinsicSize.width * 1.4 461 + ), 462 + qwertyView.heightAnchor.constraint( 463 + equalToConstant: QwertyLayoutView.intrinsicSize.height * 1.4 464 + ), 465 + 466 + shortcutHintLabel.topAnchor.constraint(equalTo: qwertyView.bottomAnchor, constant: gap), 406 467 shortcutHintLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), 407 468 shortcutHintLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), 408 469 shortcutHintLabel.heightAnchor.constraint(equalToConstant: hintHeight), ··· 459 520 460 521 func clearInteraction() { 461 522 pianoView.clearInteraction() 523 + lastCompleteChordNames = [] 462 524 } 463 525 464 526 func setPresented(_ isPresented: Bool) { ··· 507 569 } 508 570 509 571 private func waveformHeight(for keyboard: NSSize) -> CGFloat { 510 - keyboard.height * 2.0 572 + keyboard.height * 1.25 511 573 } 512 574 513 575 private func refreshHeldNotes() { ··· 516 578 heldNotesStack.removeArrangedSubview(view) 517 579 view.removeFromSuperview() 518 580 } 519 - let names = menuBand.heldNoteNames() 581 + for view in chordCandidatesStack.arrangedSubviews { 582 + chordCandidatesStack.removeArrangedSubview(view) 583 + view.removeFromSuperview() 584 + } 585 + // Live state for the QWERTY keymap below the piano: light up 586 + // the physical keys the user is currently holding, and route 587 + // the qwertyView's onKey (mouse taps on caps) through the 588 + // same handleLocalKey path the keyboard uses so the overlay 589 + // is fully interactive. 590 + qwertyView.litKeyCodes = menuBand.heldKeyCodes() 591 + qwertyView.keymap = menuBand.keymap 520 592 let safe = max(0, min(127, Int(menuBand.effectiveMelodicProgram))) 521 593 let familyColor = menuBand.midiMode 522 594 ? NSColor.controlAccentColor 523 595 : InstrumentListView.colorForProgram(safe) 596 + qwertyView.voiceColor = familyColor 597 + 598 + // Held-notes row: each currently sounding note as its own large 599 + // pill so the chord shape reads at-a-glance. Always per-note — 600 + // chord recognition lives in the candidates row below. 601 + let names = menuBand.heldNoteNames() 524 602 for name in names { 525 603 heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: name, color: familyColor)) 526 604 } 605 + 606 + // Chord candidates: every chord shape that contains the held 607 + // pitch classes and whose missing notes are reachable on the 608 + // active keymap. Cards re-render every refresh; transitions 609 + // from incomplete → complete trigger a brief shake on the new 610 + // complete card so the user feels the chord "lock in". 611 + let candidates = menuBand.chordCandidates(maxResults: 8) 612 + let newComplete = Set(candidates.filter(\.isComplete).map(\.name)) 613 + let justCompleted = newComplete.subtracting(lastCompleteChordNames) 614 + for candidate in candidates { 615 + let card = makeChordCandidateCard(candidate: candidate, color: familyColor) 616 + chordCandidatesStack.addArrangedSubview(card) 617 + if candidate.isComplete && justCompleted.contains(candidate.name) { 618 + applyShake(to: card) 619 + } 620 + } 621 + lastCompleteChordNames = newComplete 527 622 } 528 623 529 624 private func makeHeldNoteBox(name: String, color: NSColor) -> NSView { 530 625 let box = NSView() 531 626 box.wantsLayer = true 532 627 box.layer?.cornerRadius = 4 533 - box.layer?.backgroundColor = color.withAlphaComponent(0.85).cgColor 628 + box.layer?.backgroundColor = color.withAlphaComponent(0.92).cgColor 629 + box.layer?.borderWidth = 1 630 + box.layer?.borderColor = color.shadow(withLevel: 0.35)?.cgColor ?? color.cgColor 534 631 box.translatesAutoresizingMaskIntoConstraints = false 535 632 let label = NSTextField(labelWithString: name) 536 - label.font = NSFont.monospacedSystemFont(ofSize: 10, weight: .heavy) 633 + label.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .heavy) 537 634 label.textColor = .black 538 635 label.drawsBackground = false 539 636 label.translatesAutoresizingMaskIntoConstraints = false ··· 541 638 NSLayoutConstraint.activate([ 542 639 label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 5), 543 640 label.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -5), 544 - label.topAnchor.constraint(equalTo: box.topAnchor, constant: 1), 545 - label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -1), 641 + label.topAnchor.constraint(equalTo: box.topAnchor, constant: 2), 642 + label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -2), 643 + box.heightAnchor.constraint(equalToConstant: 20), 546 644 ]) 547 645 return box 646 + } 647 + 648 + private func makeChordCandidateCard(candidate: MenuBandController.ChordCandidate, 649 + color: NSColor) -> NSView { 650 + FloatingChordCandidateCard.build(candidate: candidate, 651 + isDark: effectiveAppearance 652 + .bestMatch(from: [.aqua, .darkAqua]) == .darkAqua) 653 + } 654 + 655 + private func applyShake(to view: NSView) { 656 + guard let layer = view.layer else { return } 657 + let shake = CAKeyframeAnimation(keyPath: "transform.translation.x") 658 + shake.values = [0, -7, 7, -5, 5, -3, 3, 0] 659 + shake.keyTimes = [0, 0.12, 0.27, 0.42, 0.57, 0.72, 0.87, 1.0] 660 + shake.duration = 0.46 661 + shake.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 662 + layer.add(shake, forKey: "shake") 548 663 } 549 664 550 665 private func updateInstrumentReadout() { ··· 858 973 } 859 974 } 860 975 976 + /// Tall traditional-piano aspect for the floating overlay. Multiplies 977 + /// white-key height (and black-key height proportionally) so the keys 978 + /// read like a real keyboard instead of menubar squares. 979 + private let floatingPaletteKeyHeightScale: CGFloat = 2.4 980 + 861 981 private func withFloatingPaletteKeyboard<T>(menuBand: MenuBandController?, _ body: () -> T) -> T { 862 982 let oldLayout = KeyboardIconRenderer.displayLayout 863 983 let oldKeymap = KeyboardIconRenderer.activeKeymap 984 + let oldScale = KeyboardIconRenderer.keyHeightScale 864 985 KeyboardIconRenderer.displayLayout = .full 986 + KeyboardIconRenderer.keyHeightScale = floatingPaletteKeyHeightScale 865 987 if let menuBand = menuBand { 866 988 KeyboardIconRenderer.activeKeymap = menuBand.keymap 867 989 } 868 990 defer { 869 991 KeyboardIconRenderer.displayLayout = oldLayout 870 992 KeyboardIconRenderer.activeKeymap = oldKeymap 993 + KeyboardIconRenderer.keyHeightScale = oldScale 871 994 } 872 995 return body() 873 996 }
+195 -9
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 112 112 default: return 23.0 113 113 } 114 114 } 115 - static let whiteH: CGFloat = 21.0 115 + static let baseWhiteH: CGFloat = 21.0 116 + static let baseBlackH: CGFloat = 12.0 117 + /// Multiplier applied to white/black key HEIGHTS only (widths stay 118 + /// fixed). The menubar piano stays the compact icon-shape it has 119 + /// always been; the floating play palette swaps this to a larger 120 + /// value inside `withFloatingPaletteKeyboard` so the overlay reads 121 + /// like a traditional piano with tall white keys + properly 122 + /// proportioned blacks on top. 123 + static var keyHeightScale: CGFloat = 1.0 124 + static var whiteH: CGFloat { baseWhiteH * keyHeightScale } 125 + static var blackH: CGFloat { baseBlackH * keyHeightScale } 116 126 static var blackW: CGFloat { 117 127 switch displayLayout { 118 128 case .fullSlim: return 10.0 // proportional to slim white 119 129 default: return 13.5 120 130 } 121 131 } 122 - static let blackH: CGFloat = 12.0 // shorter so the white-below strip is 123 - // tall enough to drag across easily 124 132 static let pad: CGFloat = 0.5 125 133 134 + // Mini visualizer — three vertical LED bars that REPLACE the three 135 + // horizontal "staff lines" of the SF Symbol `music.note.list` 136 + // inside the settings chip. So the chip reads as a music-note + 137 + // mini-meter pair: glyph on the left for settings, bars on the 138 + // right that pulse with note activity and are the click target for 139 + // the floating play palette (the "big overlay"). Single icon on 140 + // the menu, single visualizer affordance. Replaces both the 141 + // previous slide-down strip AND the leftmost-of-piano slot we 142 + // experimented with. 143 + /// Width of the visualizer slot inside the chip — set to match the 144 + /// natural left-half extent of the SF Symbol music.note.list at 145 + /// pointSize 13 (measured: lines occupy x ∈ [2.0, 8.5] of a 17pt 146 + /// symbol, ~6.5pt wide). The slot is slightly wider than the 147 + /// strict bounding box so the bars have a tiny breathing margin. 148 + static let miniVisualizerW: CGFloat = 7.5 149 + /// Hidden during popover/palette display so we don't render two 150 + /// visualizers at once. 151 + static var miniVisualizerVisible: Bool = true 152 + /// 0..1 amplitude derived from current note activity, smoothed at 153 + /// the AppDelegate's animation tick so bars move continuously 154 + /// instead of stepping. 155 + static var miniVisualizerLevel: CGFloat = 0.0 156 + /// Wall-clock seconds, updated once per animation tick. Drives the 157 + /// per-bar phase offset so bars wiggle independently — reads as 158 + /// live audio metering rather than three identical pulses. 159 + static var miniVisualizerPhase: CFTimeInterval = 0 160 + 126 161 // Settings — simple monochrome music note that reads like a native 127 162 // status-bar icon. Click → popup menu with TYPE / MIDI / Instrument / 128 163 // About. ··· 138 173 139 174 enum HitResult: Equatable { 140 175 case openSettings 176 + case openVisualizer 141 177 case note(UInt8) 142 178 } 143 179 ··· 190 226 return NSSize(width: totalW, height: totalH) 191 227 } 192 228 229 + /// Where the VU bars actually DRAW — the original 3 staff-line 230 + /// band only, AppKit y [chip.midY - 1.5, chip.midY + 4.25] (height 231 + /// 5.75pt). Bars never grow past these bounds; this rect is also 232 + /// the click hit-test area. 233 + static var miniVisualizerRect: NSRect { 234 + let chip = settingsIconRect 235 + return NSRect(x: chip.midX - 6.5, 236 + y: chip.midY - 1.5, 237 + width: 6.5, 238 + height: 5.75) 239 + } 240 + 241 + /// Punch-out region for the visualizer overlay. Slightly bigger 242 + /// than the bars draw rect on the LEFT and BOTTOM only — those 243 + /// are the edges where SF Symbol anti-aliasing leaves stray 244 + /// pixels of the staff lines after a tight destinationOut, and 245 + /// the menubar background (which can be a saturated theme color 246 + /// like green on the user's setup) bleeds through them. Right + 247 + /// top stay tight so the music-note's stem fragment (which sits 248 + /// just below this rect) isn't accidentally erased. 249 + private static var miniVisualizerPunchRect: NSRect { 250 + let r = miniVisualizerRect 251 + return NSRect(x: r.minX - 1.0, // +1pt to the left 252 + y: r.minY - 1.0, // +1pt down 253 + width: r.width + 1.0, 254 + height: r.height + 1.0) 255 + } 256 + 193 257 static var pianoImageSize: NSSize { 194 258 pianoImageSize(layout: .fixedCanvas) 195 259 } ··· 235 299 } 236 300 // Piano. 237 301 NSGraphicsContext.saveGraphicsState() 302 + // Clip the leftmost ~1.5pt of the canvas before drawing 303 + // piano keys: the leftmost white key's stroke (lineWidth 304 + // 0.7, plus 2.5pt rounded-corner radius at the tl/bl 305 + // corners) renders as a visible vertical line + curve at 306 + // the icon's far-left edge. Earlier the clip was at x≥0.6 307 + // — wide enough to swallow the stroke's left half, but the 308 + // corner curves still leaked. Pushing the clip to x≥1.5 309 + // hides both. The leftmost key's body still draws (the 310 + // clip only swallows about 0.5pt of fill area, indistinct 311 + // visually). 312 + NSBezierPath(rect: NSRect(x: 1.5, 313 + y: 0, 314 + width: imageSize.width, 315 + height: imageSize.height)).addClip() 238 316 // Dark-mode awareness: in light mode the piano reads as 239 317 // a real piano (white keys white, black keys dark 240 318 // accent). In dark mode we swap the relationship — white ··· 367 445 NSGraphicsContext.restoreGraphicsState() 368 446 369 447 if includeSettings { 370 - // Single settings chip — glyph + color reflect MIDI/DAW state. 371 - // MIDI on → `waveform` tinted accent (signal flowing to DAW); 372 - // MIDI off → `slider.horizontal.3` in label color (generic). 448 + // Settings chip — `music.note` glyph on the LEFT (click 449 + // = popover) + 3 LED visualizer bars on the RIGHT (click 450 + // = floating play palette). Together they replace the 451 + // old `music.note.list` SF Symbol — same footprint, but 452 + // the bars now actually pulse with note activity instead 453 + // of being decorative staff lines. 454 + let chipHovered = (hovered == .openSettings) || (hovered == .openVisualizer) 373 455 drawSettingsChip(in: settingsRect, hoverRect: settingsHitRect, 374 456 midiOn: enabled, 375 - hovered: hovered == .openSettings, 457 + hovered: chipHovered, 376 458 flash: settingsFlash, 377 - voiceNumber: Int(melodicProgram)) 459 + voiceNumber: Int(melodicProgram), 460 + visualizerHovered: hovered == .openVisualizer, 461 + visualizerVisible: miniVisualizerVisible, 462 + visualizerLevel: miniVisualizerLevel) 378 463 } 379 464 return true 380 465 } ··· 419 504 // MARK: - Hit testing 420 505 421 506 static func hit(at point: NSPoint) -> HitResult? { 507 + // Visualizer is a sub-rect inside the settings chip so it has 508 + // to be tested *before* the broader settings hit zone. 509 + if miniVisualizerVisible && miniVisualizerRect.contains(point) { 510 + return .openVisualizer 511 + } 422 512 if settingsHitRect.contains(point) { return .openSettings } 423 513 let whites = whiteList() 424 514 var whiteIndex: [Int: Int] = [:] ··· 655 745 /// 656 746 /// Alternates considered: "music.quarternote.3", "pianokeys", 657 747 /// "music.note", "scroll", "speaker.wave.2", "waveform". 748 + /// Three thin VERTICAL VU bars occupying the EXACT bounding box 749 + /// the staff lines of SF Symbol music.note.list occupied at 750 + /// pointSize 13 (measured by rendering the symbol and scanning the 751 + /// pixel buffer). Bar heights track the smoothed `level` plus a 752 + /// phase-shifted sine wiggle keyed to `miniVisualizerPhase` so the 753 + /// meter feels live even when the level is steady — reads as 754 + /// audio metering, not a binary-toggle indicator. 755 + /// Single amplitude square (replaces the prior 3-bar VU). Side 756 + /// scales with the smoothed `level` from 0 (invisible) to the 757 + /// band's height — at peak the square fills the entire staff- 758 + /// lines slot. Centered horizontally + vertically inside `rect` 759 + /// so it reads as a unified pulsing block, not a meter chasing 760 + /// a baseline. 761 + private static func drawChipAmplitudeSquare(in rect: NSRect, level: CGFloat, 762 + hovered: Bool, color: NSColor, 763 + baseAlpha: CGFloat) { 764 + let lvl = max(0, min(1, level)) 765 + // Quarter-power compander stretches the low end so quiet 766 + // sustain plays still drive the square visibly. 767 + let dramatic = pow(lvl, 0.45) 768 + let alpha: CGFloat = hovered ? 1.0 : baseAlpha 769 + // Side caps at the smaller of width/height so the square 770 + // doesn't squish into a rectangle; the staff-lines band is 771 + // 5.75pt tall × 6.5pt wide so the cap is height-limited. 772 + let maxSide = min(rect.width, rect.height) 773 + let side = dramatic * maxSide 774 + if side < 0.6 { return } // skip drawing when essentially zero 775 + let sq = NSRect(x: rect.midX - side / 2, 776 + y: rect.midY - side / 2, 777 + width: side, height: side) 778 + color.withAlphaComponent(alpha).setFill() 779 + NSBezierPath(roundedRect: sq, xRadius: 0.6, yRadius: 0.6).fill() 780 + } 781 + 782 + /// Linearly interpolates between the idle "3 horizontal staff 783 + /// lines" silhouette of music.note.list and the active "3 vertical 784 + /// VU bars" silhouette as `level` rises and falls. Audio attack 785 + /// pushes the lines outward into bars; audio decay slides them 786 + /// back into staff lines. Same drawer handles every t in [0, 1], 787 + /// so the transition is continuous and reversible. 788 + private static func drawChipVisualizer(in rect: NSRect, level: CGFloat, 789 + hovered: Bool, color: NSColor, 790 + baseAlpha: CGFloat) { 791 + let lvl = max(0, min(1, level)) 792 + let alpha: CGFloat = hovered ? 1.0 : baseAlpha 793 + let t = pow(lvl, 0.45) 794 + 795 + // Active silhouette: 3 vertical VU bars side by side at the 796 + // band's bottom. Bars are ALWAYS drawn — even at silence — 797 + // so the chip stays alive. A minimum floor height keeps the 798 + // three "flat / short" bars visible when there is no audio. 799 + let phase = CGFloat(miniVisualizerPhase) 800 + let wiggleFreqs: [CGFloat] = [5.1, 6.4, 5.7] 801 + let wigglePhases: [CGFloat] = [0.0, 1.3, 2.5] 802 + let peakFracs: [CGFloat] = [0.82, 1.0, 0.82] 803 + let barCount = 3 804 + let barW: CGFloat = 1.6 805 + let barGap = (rect.width - CGFloat(barCount) * barW) / CGFloat(barCount - 1) 806 + // Silent floor: bars never fall below ~22% of the slot so the 807 + // user always sees a small flat row of three nubs in the chip. 808 + let silentFloor: CGFloat = 0.22 809 + 810 + for i in 0..<barCount { 811 + let wiggle = sin(phase * wiggleFreqs[i] + wigglePhases[i]) * 0.22 * t 812 + let amp = max(silentFloor, min(1.0, t * peakFracs[i] + wiggle)) 813 + let h = amp * rect.height 814 + let x = rect.minX + CGFloat(i) * (barW + barGap) 815 + let bar = NSRect(x: x, y: rect.minY, width: barW, height: h) 816 + color.withAlphaComponent(alpha).setFill() 817 + NSBezierPath(roundedRect: bar, xRadius: 0.3, yRadius: 0.3).fill() 818 + } 819 + } 820 + 658 821 private static func drawSettingsChip(in _: NSRect, hoverRect _: NSRect, 659 822 midiOn: Bool, hovered: Bool, 660 823 flash: CGFloat = 0, 661 - voiceNumber: Int = 0) { 824 + voiceNumber: Int = 0, 825 + visualizerHovered: Bool = false, 826 + visualizerVisible: Bool = true, 827 + visualizerLevel: CGFloat = 0) { 662 828 // Standard systray pill: hover/click paints a soft rounded 663 829 // backdrop centered on the icon glyph (NOT the full hit area, so 664 830 // the piano-side empty space stays unhighlighted). Same look as ··· 690 856 let color = (f > 0.001) 691 857 ? baseColor.blended(withFraction: f, of: .white) ?? baseColor 692 858 : baseColor 859 + // Draw the SF Symbol music.note.list at its original pointSize 860 + // (13) over the full chip. At rest the staff lines should 861 + // remain visible (the chip reads as the natural system glyph). 862 + // Only when there's audible signal do we punch out the staff 863 + // lines and overlay animated VU bars in the resulting hole. 693 864 drawTintedSymbol("music.note.list", in: iconBox, pointSize: 13.0, color: color) 865 + // Always punch out the staff-lines slot and overlay 3 vertical 866 + // VU bars — the chip is "always live." When silent, the bars 867 + // fall to a short flat floor instead of vanishing back into 868 + // the SF Symbol's staff lines, so the menubar reads as the 869 + // app's own meter affordance at all times. 870 + if visualizerVisible, let ctx = NSGraphicsContext.current { 871 + ctx.saveGraphicsState() 872 + ctx.compositingOperation = .destinationOut 873 + NSColor.black.set() 874 + miniVisualizerPunchRect.fill() 875 + ctx.restoreGraphicsState() 876 + drawChipVisualizer(in: miniVisualizerRect, level: visualizerLevel, 877 + hovered: visualizerHovered, 878 + color: color, baseAlpha: alpha) 879 + } 694 880 // Linger / bell-ring flourish — small accent tilde tucked at 695 881 // the top-right of the music-note glyph whenever shift is held 696 882 // or caps lock is latched. Visual cue that any key press will
+97 -25
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 79 79 return sorted.map { Self.noteName($0) } 80 80 } 81 81 82 + /// One possible chord matching the currently held notes. `missing` 83 + /// lists pitch classes (0..11) the user still needs to add to 84 + /// finish the chord; empty when the chord is fully held. 85 + struct ChordCandidate { 86 + let name: String 87 + let rootPitchClass: Int 88 + let pitchClasses: Set<Int> 89 + let missingPitchClasses: [Int] 90 + let missingNoteNames: [String] 91 + var isComplete: Bool { missingPitchClasses.isEmpty } 92 + } 93 + 94 + /// Pitch-class names indexed 0=C..11=B. Shared across the chord 95 + /// readout APIs so display strings stay consistent. 96 + static let pitchClassNames = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"] 97 + 98 + /// Chord-pattern table — intervals from root + display suffix. 99 + /// Covers common triads + 6/7ths so casual menubar playing gets a 100 + /// useful readout without dragging in a full chord-theory engine. 101 + private static let chordPatterns: [(intervals: Set<Int>, suffix: String)] = [ 102 + ([0, 4, 7], ""), 103 + ([0, 3, 7], "m"), 104 + ([0, 3, 6], "dim"), 105 + ([0, 4, 8], "aug"), 106 + ([0, 2, 7], "sus2"), 107 + ([0, 5, 7], "sus4"), 108 + ([0, 4, 7, 10], "7"), 109 + ([0, 4, 7, 11], "maj7"), 110 + ([0, 3, 7, 10], "m7"), 111 + ([0, 3, 7, 11], "mMaj7"), 112 + ([0, 3, 6, 9], "dim7"), 113 + ([0, 3, 6, 10], "m7♭5"), 114 + ([0, 4, 8, 10], "aug7"), 115 + ([0, 4, 7, 9], "6"), 116 + ([0, 3, 7, 9], "m6"), 117 + ] 118 + 82 119 /// Best-guess chord name from the currently-held pitch classes. 83 120 /// Returns nil when fewer than 3 pitch classes are held or nothing 84 - /// matches a known shape. The patterns cover the most common 85 - /// triads + 7ths so casual playing on the menubar piano gets a 86 - /// useful readout without dragging in a full chord-theory engine. 121 + /// matches a known shape exactly. 87 122 func currentChordName() -> String? { 88 123 let pcs = Set(litNotes.map { Int($0) % 12 }) 89 124 guard pcs.count >= 3 else { return nil } 90 - // Pattern table — intervals from root + display suffix. 91 - let patterns: [(intervals: Set<Int>, suffix: String)] = [ 92 - ([0, 4, 7], ""), 93 - ([0, 3, 7], "m"), 94 - ([0, 3, 6], "dim"), 95 - ([0, 4, 8], "aug"), 96 - ([0, 2, 7], "sus2"), 97 - ([0, 5, 7], "sus4"), 98 - ([0, 4, 7, 10], "7"), 99 - ([0, 4, 7, 11], "maj7"), 100 - ([0, 3, 7, 10], "m7"), 101 - ([0, 3, 7, 11], "mMaj7"), 102 - ([0, 3, 6, 9], "dim7"), 103 - ([0, 3, 6, 10], "m7♭5"), 104 - ([0, 4, 8, 10], "aug7"), 105 - ([0, 4, 7, 9], "6"), 106 - ([0, 3, 7, 9], "m6"), 107 - ] 108 - let pitches = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"] 109 - // Try each held pitch class as the candidate root. 110 125 for root in pcs { 111 126 let intervals = Set(pcs.map { ($0 - root + 12) % 12 }) 112 - for (pat, suffix) in patterns where pat == intervals { 113 - return "\(pitches[root])\(suffix)" 127 + for (pat, suffix) in Self.chordPatterns where pat == intervals { 128 + return "\(Self.pitchClassNames[root])\(suffix)" 114 129 } 115 130 } 116 131 return nil 132 + } 133 + 134 + /// Pitch classes (0..11) reachable in the active keymap, octave- 135 + /// independent. Used to filter chord suggestions down to ones the 136 + /// user could actually finish playing on the current QWERTY layout. 137 + func keymapPitchClasses() -> Set<Int> { 138 + let table = (keymap == .ableton) 139 + ? MenuBandLayout.semitoneByKeyCodeAbleton 140 + : MenuBandLayout.semitoneByKeyCode 141 + var pcs: Set<Int> = [] 142 + for st in table where st != Int8.min { 143 + pcs.insert(((Int(st) % 12) + 12) % 12) 144 + } 145 + return pcs 146 + } 147 + 148 + /// Possible chord completions for the currently-held notes. Each 149 + /// candidate carries its full pitch-class set and the notes still 150 + /// needed to finish the chord. Filters to chords whose missing 151 + /// notes are reachable on the active keymap so the suggestions stay 152 + /// actionable. Sorted: complete first, then by ascending missing- 153 + /// note count, triads before 7ths, alphabetical tiebreaker. 154 + func chordCandidates(maxResults: Int = 6) -> [ChordCandidate] { 155 + let heldPCs = Set(litNotes.map { Int($0) % 12 }) 156 + guard !heldPCs.isEmpty else { return [] } 157 + let availablePCs = keymapPitchClasses() 158 + var out: [ChordCandidate] = [] 159 + for root in 0..<12 { 160 + for (intervals, suffix) in Self.chordPatterns { 161 + let pcs = Set(intervals.map { (root + $0) % 12 }) 162 + if !heldPCs.isSubset(of: pcs) { continue } 163 + let missing = pcs.subtracting(heldPCs) 164 + if !missing.isSubset(of: availablePCs) { continue } 165 + let sortedMissing = missing.sorted() 166 + let names = sortedMissing.map { Self.pitchClassNames[$0] } 167 + let name = "\(Self.pitchClassNames[root])\(suffix)" 168 + out.append(ChordCandidate( 169 + name: name, 170 + rootPitchClass: root, 171 + pitchClasses: pcs, 172 + missingPitchClasses: sortedMissing, 173 + missingNoteNames: names 174 + )) 175 + } 176 + } 177 + out.sort { a, b in 178 + if a.isComplete != b.isComplete { return a.isComplete && !b.isComplete } 179 + if a.missingPitchClasses.count != b.missingPitchClasses.count { 180 + return a.missingPitchClasses.count < b.missingPitchClasses.count 181 + } 182 + if a.pitchClasses.count != b.pitchClasses.count { 183 + return a.pitchClasses.count < b.pitchClasses.count 184 + } 185 + return a.name < b.name 186 + } 187 + if out.count > maxResults { out = Array(out.prefix(maxResults)) } 188 + return out 117 189 } 118 190 119 191 var midiMode: Bool {
+110 -15
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 95 95 /// rest so the layout doesn't wobble when notes come and go. 96 96 private var heldNotesStack: NSStackView! 97 97 private var heldNotesContainer: NSView! 98 + /// Chord-candidate cards live in their own row directly below the 99 + /// visualizer bezel, mirroring the floating play palette so the 100 + /// popover gets the same searchable chord readout. 101 + private var chordCandidatesStack: NSStackView! 102 + private var chordCandidatesRow: NSView! 103 + private var lastCompleteChordNames: Set<String> = [] 98 104 private var instrumentSeparator: NSView! 99 105 private var octaveStepper: NSStepper! 100 106 private var octaveLabel: NSTextField! ··· 461 467 waveformView = WaveformView() 462 468 waveformView.menuBand = menuBand 463 469 waveformView.translatesAutoresizingMaskIntoConstraints = false 470 + // Click on the visualizer → toggle the floating play palette 471 + // (the "big overlay"). Same target the menubar mini-meter 472 + // routes to, so users discover the overlay from either entry 473 + // point. Settings-button toggle still works from its own row. 474 + let waveformClick = NSClickGestureRecognizer( 475 + target: self, 476 + action: #selector(waveformViewClicked(_:)) 477 + ) 478 + waveformView.addGestureRecognizer(waveformClick) 464 479 465 480 waveformBezel = NSView() 466 481 waveformBezel.wantsLayer = true ··· 497 512 heldNotesStack.centerXAnchor.constraint(equalTo: heldNotesContainer.centerXAnchor), 498 513 heldNotesStack.centerYAnchor.constraint(equalTo: heldNotesContainer.centerYAnchor), 499 514 ]) 500 - stack.addArrangedSubview(heldNotesContainer) 501 - heldNotesContainer.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 502 - heldNotesContainer.heightAnchor.constraint(equalToConstant: 22).isActive = true 503 515 516 + // Visualizer bezel doubles as the chord-finder canvas: held- 517 + // note pills overlay the top of the meter, chord-candidate 518 + // cards overlay the bottom, both translucent enough that the 519 + // live waveform peeks through underneath. Bezel grows tall 520 + // enough to host both — same integrated layout the floating 521 + // play palette uses, so the two surfaces feel identical. 504 522 stack.addArrangedSubview(waveformBezel) 505 523 waveformBezel.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 506 - waveformBezel.heightAnchor.constraint(equalToConstant: 64).isActive = true 524 + waveformBezel.heightAnchor.constraint(equalToConstant: 80).isActive = true 525 + waveformBezel.layer?.masksToBounds = false 526 + waveformBezel.addSubview(heldNotesContainer) 527 + NSLayoutConstraint.activate([ 528 + heldNotesContainer.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor), 529 + heldNotesContainer.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor), 530 + heldNotesContainer.topAnchor.constraint(equalTo: waveformBezel.topAnchor, constant: 4), 531 + heldNotesContainer.heightAnchor.constraint(equalToConstant: 22), 532 + ]) 533 + 534 + chordCandidatesStack = NSStackView() 535 + chordCandidatesStack.orientation = .horizontal 536 + chordCandidatesStack.alignment = .centerY 537 + chordCandidatesStack.spacing = 5 538 + chordCandidatesStack.translatesAutoresizingMaskIntoConstraints = false 539 + chordCandidatesRow = NSView() 540 + chordCandidatesRow.translatesAutoresizingMaskIntoConstraints = false 541 + chordCandidatesRow.wantsLayer = true 542 + chordCandidatesRow.layer?.masksToBounds = false 543 + chordCandidatesRow.addSubview(chordCandidatesStack) 544 + NSLayoutConstraint.activate([ 545 + chordCandidatesStack.centerXAnchor.constraint(equalTo: chordCandidatesRow.centerXAnchor), 546 + chordCandidatesStack.centerYAnchor.constraint(equalTo: chordCandidatesRow.centerYAnchor), 547 + chordCandidatesStack.leadingAnchor.constraint(greaterThanOrEqualTo: chordCandidatesRow.leadingAnchor, constant: 6), 548 + chordCandidatesStack.trailingAnchor.constraint(lessThanOrEqualTo: chordCandidatesRow.trailingAnchor, constant: -6), 549 + ]) 550 + waveformBezel.addSubview(chordCandidatesRow) 551 + NSLayoutConstraint.activate([ 552 + chordCandidatesRow.leadingAnchor.constraint(equalTo: waveformBezel.leadingAnchor), 553 + chordCandidatesRow.trailingAnchor.constraint(equalTo: waveformBezel.trailingAnchor), 554 + chordCandidatesRow.bottomAnchor.constraint(equalTo: waveformBezel.bottomAnchor, constant: -4), 555 + chordCandidatesRow.heightAnchor.constraint(equalToConstant: 28), 556 + ]) 507 557 508 558 // (MIDI switch lives in the title row above — see octave + MIDI block.) 509 559 ··· 949 999 /// reads as part of the same instrument). 950 1000 func refreshHeldNotes() { 951 1001 guard isViewLoaded, let m = menuBand else { return } 952 - // Mirror the controller's held key codes onto the QWERTY 953 - // map so the physical keys light up as the user plays. 954 1002 qwertyMap?.litKeyCodes = m.heldKeyCodes() 955 1003 let names = m.heldNoteNames() 956 - // Rebuild the floating-box stack from scratch — at most a 957 - // few notes held at any moment, so reusing views isn't worth 958 - // the bookkeeping. When nothing is held, the stack has no 959 - // arranged subviews and the reserved 22 px row is empty 960 - // (no visible chrome). 961 1004 for v in heldNotesStack.arrangedSubviews { 962 1005 heldNotesStack.removeArrangedSubview(v) 963 1006 v.removeFromSuperview() ··· 966 1009 let famColor = m.midiMode 967 1010 ? NSColor.controlAccentColor 968 1011 : InstrumentListView.colorForProgram(safe) 969 - for name in names { 970 - heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: name, 1012 + // When 3+ notes form a recognized triad/seventh, the controller's 1013 + // chord-detector returns a name like "Cmaj7" — show that as a 1014 + // single banner box on top of the meter instead of three 1015 + // separate note labels. Falls back to per-note boxes for 1016 + // anything that doesn't resolve to a known shape (and for 1017 + // 1-2 note plays where there's no chord to compute). 1018 + if let chord = m.currentChordName() { 1019 + heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: chord, 971 1020 color: famColor)) 1021 + } else { 1022 + for name in names { 1023 + heldNotesStack.addArrangedSubview(makeHeldNoteBox(name: name, 1024 + color: famColor)) 1025 + } 972 1026 } 1027 + 1028 + // Chord-candidate cards: every chord shape that contains the 1029 + // held pitch classes and whose missing notes are reachable on 1030 + // the active keymap. Same shared builder + chromatic root 1031 + // coloring as the floating palette so both surfaces feel like 1032 + // one tool. Only show a few in the popover — it's narrower 1033 + // than the floating overlay. 1034 + guard let chordRow = chordCandidatesStack else { return } 1035 + for v in chordRow.arrangedSubviews { 1036 + chordRow.removeArrangedSubview(v) 1037 + v.removeFromSuperview() 1038 + } 1039 + let candidates = m.chordCandidates(maxResults: 3) 1040 + let isDark = view.effectiveAppearance 1041 + .bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 1042 + let newComplete = Set(candidates.filter(\.isComplete).map(\.name)) 1043 + let justCompleted = newComplete.subtracting(lastCompleteChordNames) 1044 + for candidate in candidates { 1045 + let card = FloatingChordCandidateCard.build(candidate: candidate, isDark: isDark) 1046 + chordRow.addArrangedSubview(card) 1047 + if candidate.isComplete && justCompleted.contains(candidate.name) { 1048 + let shake = CAKeyframeAnimation(keyPath: "transform.translation.x") 1049 + shake.values = [0, -7, 7, -5, 5, -3, 3, 0] 1050 + shake.keyTimes = [0, 0.12, 0.27, 0.42, 0.57, 0.72, 0.87, 1.0] 1051 + shake.duration = 0.46 1052 + shake.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 1053 + card.layer?.add(shake, forKey: "shake") 1054 + } 1055 + } 1056 + lastCompleteChordNames = newComplete 973 1057 } 974 1058 975 1059 /// Small floating note badge — rounded layer-painted box with ··· 979 1063 let box = NSView() 980 1064 box.wantsLayer = true 981 1065 box.layer?.cornerRadius = 4 982 - box.layer?.backgroundColor = color.withAlphaComponent(0.85).cgColor 1066 + box.layer?.backgroundColor = color.withAlphaComponent(0.92).cgColor 1067 + box.layer?.borderWidth = 1 1068 + box.layer?.borderColor = color.shadow(withLevel: 0.35)?.cgColor ?? color.cgColor 983 1069 box.translatesAutoresizingMaskIntoConstraints = false 984 1070 let label = NSTextField(labelWithString: name) 985 - label.font = NSFont.monospacedSystemFont(ofSize: 10, weight: .heavy) 1071 + label.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .heavy) 986 1072 label.textColor = .black 987 1073 label.drawsBackground = false 988 1074 label.translatesAutoresizingMaskIntoConstraints = false ··· 1593 1679 } 1594 1680 1595 1681 @objc private func playPaletteToggleButtonClicked(_ sender: NSButton) { 1682 + onPlayPaletteToggle?() 1683 + updatePlayPaletteShortcutControls() 1684 + } 1685 + 1686 + @objc private func waveformViewClicked(_ sender: NSClickGestureRecognizer) { 1687 + // Route through the same callback the explicit toggle button 1688 + // uses — keep one source of truth for "open big overlay" so 1689 + // future changes (e.g., suppress when already shown) only need 1690 + // to touch onPlayPaletteToggle, not multiple call sites. 1596 1691 onPlayPaletteToggle?() 1597 1692 updatePlayPaletteShortcutControls() 1598 1693 }
+87
slab/menuband/Sources/MenuBand/NoteColors.swift
··· 1 + import AppKit 2 + 3 + /// Swift port of the notepat per-note ROYGBIV palette 4 + /// (system/public/aesthetic.computer/lib/note-colors.mjs). Sharps and 5 + /// flats are intentionally black so the chord readout matches the look 6 + /// of a real notepat strip — naturals carry the chromatic identity, 7 + /// accidentals read as the gaps between them. 8 + enum NoteColors { 9 + private static let base: [Character: NSColor] = [ 10 + "c": rgb(255, 50, 50), 11 + "d": rgb(255, 160, 0), 12 + "e": rgb(255, 230, 0), 13 + "f": rgb(50, 200, 50), 14 + "g": rgb(50, 120, 255), 15 + "a": rgb(130, 50, 200), 16 + "b": rgb(180, 80, 255), 17 + ] 18 + private static let dayglo: [Character: NSColor] = [ 19 + "c": rgb(255, 40, 80), 20 + "d": rgb(255, 180, 0), 21 + "e": rgb(255, 255, 50), 22 + "f": rgb(50, 255, 100), 23 + "g": rgb(50, 200, 255), 24 + "a": rgb(180, 50, 255), 25 + "b": rgb(255, 80, 255), 26 + ] 27 + private static let muted: [Character: NSColor] = [ 28 + "c": rgb(139, 26, 26), 29 + "d": rgb(180, 100, 0), 30 + "e": rgb(180, 150, 0), 31 + "f": rgb(20, 90, 20), 32 + "g": rgb(20, 60, 120), 33 + "a": rgb(50, 0, 90), 34 + "b": rgb(90, 30, 150), 35 + ] 36 + private static let black = NSColor.black 37 + 38 + /// Pitch-class → letter (natural) + sharp flag. Pitch classes use 39 + /// 0=C..11=B. Sharps are merged into the natural to the left 40 + /// (e.g., C# → "c" + sharp), matching how notepat treats them. 41 + private static let letters: [Character] = [ 42 + "c","c","d","d","e","f","f","g","g","a","a","b", 43 + ] 44 + private static let isSharp: [Bool] = [ 45 + false, true, false, true, false, false, true, false, true, false, true, false, 46 + ] 47 + 48 + /// Color for a pitch class at the given octave (4 = base octave). 49 + /// Sharps return black regardless of octave. 50 + static func color(pitchClass: Int, octave: Int = 4) -> NSColor { 51 + let pc = ((pitchClass % 12) + 12) % 12 52 + if isSharp[pc] { return black } 53 + let letter = letters[pc] 54 + let delta = octave - 4 55 + if delta >= 1 { 56 + return dayglo[letter] ?? base[letter] ?? .white 57 + } else if delta <= -1 { 58 + return muted[letter] ?? base[letter] ?? .white 59 + } else { 60 + return base[letter] ?? .white 61 + } 62 + } 63 + 64 + /// True for sharps/flats (black-key pitch classes). 65 + static func isAccidental(pitchClass: Int) -> Bool { 66 + let pc = ((pitchClass % 12) + 12) % 12 67 + return isSharp[pc] 68 + } 69 + 70 + /// Pick a foreground color that reads against the given background — 71 + /// black on bright naturals, white on the all-black sharps and on 72 + /// the muted lower-octave palette. 73 + static func textColor(on background: NSColor) -> NSColor { 74 + let rgb = background.usingColorSpace(.deviceRGB) ?? background 75 + let lum = 0.299 * rgb.redComponent 76 + + 0.587 * rgb.greenComponent 77 + + 0.114 * rgb.blueComponent 78 + return lum > 0.55 ? .black : .white 79 + } 80 + 81 + private static func rgb(_ r: Int, _ g: Int, _ b: Int) -> NSColor { 82 + NSColor(deviceRed: CGFloat(r) / 255.0, 83 + green: CGFloat(g) / 255.0, 84 + blue: CGFloat(b) / 255.0, 85 + alpha: 1.0) 86 + } 87 + }
+31 -13
slab/menuband/Sources/MenuBand/QwertyLayoutView.swift
··· 38 38 private var heldByPointer: UInt16? 39 39 40 40 static let intrinsicSize = NSSize(width: 180, height: 46) 41 - override var intrinsicContentSize: NSSize { Self.intrinsicSize } 41 + /// Multiplier applied to all keycap dimensions + label font size. 42 + /// Default 1.0 = popover layout (compact). The floating play 43 + /// palette sets a larger value so the keymap fills the overlay. 44 + var scale: CGFloat = 1.0 { 45 + didSet { 46 + invalidateIntrinsicContentSize() 47 + needsDisplay = true 48 + } 49 + } 50 + override var intrinsicContentSize: NSSize { 51 + NSSize(width: Self.intrinsicSize.width * scale, 52 + height: Self.intrinsicSize.height * scale) 53 + } 54 + private var scaledKeySize: CGFloat { Self.keySize * scale } 55 + private var scaledKeyGap: CGFloat { Self.keyGap * scale } 56 + private var scaledCornerRadius: CGFloat { Self.cornerRadius * scale } 57 + private var scaledLabelFontSize: CGFloat { 8.5 * scale } 42 58 43 59 override init(frame frameRect: NSRect) { 44 60 super.init(frame: frameRect) ··· 89 105 /// that isn't there. 90 106 private func forEachVisibleCap(_ body: (_ cap: (kc: UInt16, label: String), _ rect: NSRect) -> Void) { 91 107 let r = bounds 108 + let kSize = scaledKeySize 109 + let kGap = scaledKeyGap 92 110 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 111 + let rowSpan = totalRows * kSize + (totalRows - 1) * kGap 112 + let topY = r.midY + rowSpan / 2 - kSize 113 + let homeRowSpan = CGFloat(Self.rows[1].count) * kSize + 114 + CGFloat(Self.rows[1].count - 1) * kGap + 115 + Self.rowOffsets[1] * kSize 98 116 let leftX = r.midX - homeRowSpan / 2 99 117 let octaves = MenuBandLayout.octaveKeyCodes(for: keymap) 100 118 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 119 + let y = topY - CGFloat(rIdx) * (kSize + kGap) 120 + let xOffset = Self.rowOffsets[rIdx] * kSize 103 121 for (cIdx, cap) in row.enumerated() { 104 122 let st = semitone(cap.kc) 105 123 let isOctaveKey = (cap.kc == octaves.down || cap.kc == octaves.up) 106 124 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) 125 + let x = leftX + xOffset + CGFloat(cIdx) * (kSize + kGap) 126 + let kr = NSRect(x: x, y: y, width: kSize, height: kSize) 109 127 body(cap, kr) 110 128 } 111 129 } ··· 178 196 mapped: Bool, isBlack: Bool, lit: Bool, 179 197 isOctaveKey: Bool = false) { 180 198 let path = NSBezierPath(roundedRect: rect, 181 - xRadius: Self.cornerRadius, 182 - yRadius: Self.cornerRadius) 199 + xRadius: scaledCornerRadius, 200 + yRadius: scaledCornerRadius) 183 201 // Fills mirror the menubar piano: white-key-mapped letters 184 202 // get a near-white cap, black-key-mapped letters get a 185 203 // near-black cap. Octave-shift keys get a muted accent fill ··· 230 248 textColor = NSColor.labelColor.withAlphaComponent(0.55) 231 249 } 232 250 let attrs: [NSAttributedString.Key: Any] = [ 233 - .font: NSFont.systemFont(ofSize: 8.5, weight: .heavy), 251 + .font: NSFont.systemFont(ofSize: scaledLabelFontSize, weight: .heavy), 234 252 .foregroundColor: textColor, 235 253 ] 236 254 let s = NSAttributedString(string: label, attributes: attrs)