Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

menuband: shift/capslock linger mode + slim layout + multi-channel synth fix

YWFT font now sticks while dragging through the instrument-map preview.
The hover handler used to set instrumentReadout.stringValue, which wipes
the field's attributedStringValue (font + Riso shadow) back to defaults
mid-drag. Both the committed-update path and the hover-drag path now
funnel through a shared applyInstrumentReadoutStyle helper.

New .fullSlim display tier sits between .full and .oneOctave: 2 octaves
at whiteW=17 / blackW=10 (~74% of normal). Saves 84px on the menubar
before having to drop an octave. Pinned via the `forceLayout`
UserDefaults key for testing.

Shift held OR caps lock latched arms linger / bell-ring mode:
- Menubar piano labels render uppercase as the visual cue
- A small accent tilde flourish appears at the top-right of the
music note glyph
- Sustained voices (pianos, organs, strings) skip the immediate
noteOff so the synth's release envelope rings; cleanup pair
fires at +6s
- Staccato voices (mallets, plucks, percussive family) get a
doppler retrigger tail — exponentially decaying velocity over
growing intervals, tail caps at ~7s. Voices categorized via
GeneralMIDI.lingerCategory(for:).
- Live notes round-robin channels 0-3, doppler retriggers use
channels 4-7 (independent rotators) so a fresh press of the
same key never cuts the still-decaying linger

MenuBandSynth was hardcoding melodic noteOn/Off to status 0x90/0x80
(channel 0 only) regardless of the channel argument, so all the
"round-robin" allocation collapsed to a single synth voice. Fixed:
noteOn/Off now mask the channel into the status byte, and
selectMelodicProgram broadcasts bank+PC to channels 0-7 so every
rotation channel plays the chosen voice.

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

+393 -46
+80 -2
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 34 34 /// closes the popover when the user clicks the settings chip — piano 35 35 /// taps keep the popover open. 36 36 private var clickAwayMonitor: Any? 37 + 38 + /// Global + local .flagsChanged monitors that drive the shift → 39 + /// uppercase-label cue. Stored so we can clean them up if needed 40 + /// (NSEvent monitors are otherwise leaked on app exit, which is 41 + /// fine here but we keep the references for symmetry). 42 + private var globalShiftMonitor: Any? 43 + private var localShiftMonitor: Any? 37 44 private var popoverEscMonitor: Any? 38 45 39 46 /// Sandbox-friendly local key capture. Armed when the user clicks the ··· 92 99 func applicationDidFinishLaunching(_ notification: Notification) { 93 100 debugLog("applicationDidFinishLaunching pid=\(ProcessInfo.processInfo.processIdentifier)") 94 101 Self.registerBundledFonts() 102 + // Apply forceLayout *before* statusItem creation so the initial 103 + // status-item length matches the pinned layout's imageSize. 104 + // Otherwise the icon flashes the default `.full` width until 105 + // the next updateIcon() catches up. 106 + applyForcedLayoutIfAny() 95 107 Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in 96 108 debugLog("heartbeat") 97 109 } ··· 285 297 _ = vc.view 286 298 287 299 startAdaptiveLayoutChecks() 300 + startShiftStateMonitors() 288 301 289 302 // Retint the bundle's Finder icon to the user's accent color. 290 303 // Stored as an xattr on the bundle folder, so the signed payload ··· 520 533 return NSScreen.screens.contains { $0.frame.intersects(window.frame) } 521 534 } 522 535 536 + /// `forceLayout` UserDefaults key: lets the user pin the layout to 537 + /// `full` / `fullSlim` / `oneOctave` / `compact` regardless of how 538 + /// much menubar room exists. Intended both as a dev/QA aid and as 539 + /// a preference for users who like a specific footprint. 540 + /// 541 + /// defaults write computer.aestheticcomputer.menuband forceLayout fullSlim 542 + /// launchctl kickstart -k gui/$(id -u)/computer.aestheticcomputer.menuband 543 + /// 544 + /// Clear with `defaults delete ... forceLayout`. 545 + /// Watch shift state globally + locally so the menubar piano can 546 + /// uppercase its letter labels while the user holds shift. The 547 + /// uppercase letters are the visual cue that linger / bell-ring 548 + /// mode is armed; lowercase = normal. 549 + private func startShiftStateMonitors() { 550 + let handler: (NSEvent) -> Void = { [weak self] event in 551 + guard let self = self else { return } 552 + // Caps lock latches the mode; shift is the momentary. Either 553 + // arms linger and shows uppercase labels. 554 + let armed = event.modifierFlags.contains(.shift) 555 + || event.modifierFlags.contains(.capsLock) 556 + if KeyboardIconRenderer.labelsUppercase != armed { 557 + KeyboardIconRenderer.labelsUppercase = armed 558 + self.updateIcon() 559 + } 560 + } 561 + globalShiftMonitor = NSEvent.addGlobalMonitorForEvents( 562 + matching: .flagsChanged 563 + ) { event in handler(event) } 564 + localShiftMonitor = NSEvent.addLocalMonitorForEvents( 565 + matching: .flagsChanged 566 + ) { event in handler(event); return event } 567 + // Initial-state sync: .flagsChanged only fires on changes, so 568 + // a caps-lock already on at launch wouldn't paint uppercase 569 + // until something toggles. Read the current modifier mask once 570 + // at startup to seed the renderer correctly. 571 + let initial = NSEvent.modifierFlags 572 + let initialArmed = initial.contains(.shift) || initial.contains(.capsLock) 573 + if KeyboardIconRenderer.labelsUppercase != initialArmed { 574 + KeyboardIconRenderer.labelsUppercase = initialArmed 575 + updateIcon() 576 + } 577 + } 578 + 579 + private func applyForcedLayoutIfAny() { 580 + let raw = UserDefaults.standard.string(forKey: "forceLayout") ?? "" 581 + guard !raw.isEmpty, 582 + let layout = KeyboardIconRenderer.DisplayLayout(rawValue: raw) else { 583 + KeyboardIconRenderer.forceLayout = nil 584 + return 585 + } 586 + KeyboardIconRenderer.forceLayout = layout 587 + KeyboardIconRenderer.displayLayout = layout 588 + debugLog("forceLayout pinned to \(raw)") 589 + } 590 + 523 591 private func startAdaptiveLayoutChecks() { 524 592 // Initial fit pass — give the system a beat to lay out before 525 593 // probing visibility. ··· 536 604 } 537 605 538 606 private func adaptLayoutForAvailableSpace() { 607 + // Forced layouts disable auto-resize entirely — the renderer 608 + // stays pinned to whatever the user (or QA) chose. 609 + if KeyboardIconRenderer.forceLayout != nil { return } 539 610 let current = KeyboardIconRenderer.displayLayout 540 611 let visible = isStatusItemVisible() 541 612 ··· 963 1034 964 1035 let initialPt = imagePoint(from: downEvent.locationInWindow) 965 1036 let (vel0, pan0) = NoteExpression.values(for: startNote, at: initialPt) 966 - menuBand.startTapNote(startNote, velocity: vel0, pan: pan0) 1037 + let initialShift = downEvent.modifierFlags.contains(.shift) 1038 + || downEvent.modifierFlags.contains(.capsLock) 1039 + menuBand.startTapNote(startNote, velocity: vel0, pan: pan0, linger: initialShift) 967 1040 // Arm sandbox-friendly local capture on a real piano click. We 968 1041 // skip arming when global TYPE mode is already on — the global 969 1042 // tap is already handling keys, doubling up would re-trigger ··· 991 1064 if let prev = current { menuBand.stopTapNote(prev) } 992 1065 if let nxt = hovered { 993 1066 let (v, p) = NoteExpression.values(for: nxt, at: pt) 994 - menuBand.startTapNote(nxt, velocity: v, pan: p) 1067 + // Sample shift+capslock state per-note during the drag 1068 + // so the user can shift-press, release shift mid-drag, 1069 + // and still get linger from a latched caps lock. 1070 + let shiftNow = next.modifierFlags.contains(.shift) 1071 + || next.modifierFlags.contains(.capsLock) 1072 + menuBand.startTapNote(nxt, velocity: v, pan: p, linger: shiftNow) 995 1073 } 996 1074 current = hovered 997 1075 } else if let c = current {
+32
slab/menuband/Sources/MenuBand/GeneralMIDI.swift
··· 59 59 ("Sound FX", 120...127), 60 60 ] 61 61 62 + /// How a voice should behave when held under shift / linger mode. 63 + /// Sustained voices ring out via the synth's release envelope when 64 + /// we skip the noteOff. Staccato voices have ~1-2s natural decay 65 + /// and would just go silent — those get a doppler-style retrigger 66 + /// tail (decaying velocity, growing intervals) instead so the 67 + /// linger has audible texture. 68 + enum LingerCategory { case sustained, staccato } 69 + 70 + static func lingerCategory(for program: UInt8) -> LingerCategory { 71 + switch Int(program) { 72 + case 0...7: return .sustained // Pianos: long natural release 73 + case 8...15: return .staccato // Chromatic Percussion (mallets, bells) 74 + case 16...23: return .sustained // Organs hold forever 75 + case 24...31: return .staccato // Guitars (plucked) 76 + case 32...37: return .staccato // Acoustic / Electric / Slap basses 77 + case 38...39: return .sustained // Synth basses 78 + case 40...44: return .sustained // Bowed strings 79 + case 45...47: return .staccato // Pizzicato, Harp, Timpani 80 + case 48...55: return .sustained // Ensembles + choir 81 + case 56...63: return .sustained // Brass 82 + case 64...71: return .sustained // Reeds 83 + case 72...79: return .sustained // Pipes 84 + case 80...95: return .sustained // Synth lead + pad 85 + case 96...103: return .sustained // Synth FX (long ambient tails) 86 + case 104...108: return .staccato // Sitar, Banjo, Shamisen, Koto, Kalimba 87 + case 109...111: return .sustained // Bagpipe, Fiddle, Shanai 88 + case 112...119: return .staccato // Percussive family (woodblock, taiko, etc.) 89 + case 120...127: return .sustained // Sound FX (mostly long) 90 + default: return .sustained 91 + } 92 + } 93 + 62 94 /// Three-letter family abbreviation for the menubar picker label. 63 95 static func familyAbbrev(for program: UInt8) -> String { 64 96 switch Int(program) / 8 {
+64 -9
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 24 24 /// shrinks this when the status item can't fit, and tries to expand 25 25 /// back when there's room. Render math (pianoWidth, imageSize, hit 26 26 /// rects, slot positions) all derive from `lastMidi`. 27 - enum DisplayLayout { 28 - case full // C4..B5 — 2 octaves (default) 29 - case oneOctave // C4..B4 — 1 octave fallback 27 + enum DisplayLayout: String { 28 + case full // C4..B5 — 2 octaves, normal-width keys (default) 29 + case fullSlim // C4..B5 — 2 octaves, skinnier keys (mid-squeeze) 30 + case oneOctave // C4..B4 — 1 octave, normal-width keys 30 31 case compact // chip only, no piano keys 31 32 33 + // Shrink path tries to keep the *most notes possible* before 34 + // dropping an octave — slim 2-octave reads better than full 35 + // 1-octave when the user knows the layout, so we slim before 36 + // we truncate. 32 37 var smaller: DisplayLayout? { 33 38 switch self { 34 - case .full: return .oneOctave 39 + case .full: return .fullSlim 40 + case .fullSlim: return .oneOctave 35 41 case .oneOctave: return .compact 36 42 case .compact: return nil 37 43 } ··· 39 45 var larger: DisplayLayout? { 40 46 switch self { 41 47 case .compact: return .oneOctave 42 - case .oneOctave: return .full 48 + case .oneOctave: return .fullSlim 49 + case .fullSlim: return .full 43 50 case .full: return nil 44 51 } 45 52 } 46 53 } 47 54 static var displayLayout: DisplayLayout = .full 48 55 56 + /// When non-nil, AppDelegate's adaptive resize is a no-op and the 57 + /// renderer stays pinned at this value. Driven by the 58 + /// `forceLayout` UserDefaults key so users (and we) can verify the 59 + /// slim render without needing to actually squeeze the menubar. 60 + static var forceLayout: DisplayLayout? = nil 61 + 62 + /// Shift-held → labels render uppercase as the visual cue for 63 + /// "linger / bell-ring mode." AppDelegate flips this on .flagsChanged 64 + /// and re-issues updateIcon() so the menubar redraws. 65 + static var labelsUppercase: Bool = false 66 + 49 67 // Render area shrinks with the layout. Compact has no piano keys at 50 68 // all — `lastMidi < firstMidi` makes whiteList() empty. 51 69 static let firstMidi: Int = 60 // C4 (middle C) 52 70 static var lastMidi: Int { 53 71 switch displayLayout { 54 72 case .full: return 83 // B5 — full 2 octaves 73 + case .fullSlim: return 83 // B5 — full 2 octaves, slim keys 55 74 case .oneOctave: return 71 // B4 — single octave 56 75 case .compact: return firstMidi - 1 // empty range 57 76 } ··· 84 103 85 104 // Piano key sizes — wide whites give a generous hit area between black 86 105 // keys (the exposed-white-between-blacks zone is whiteW - blackW). 87 - static let whiteW: CGFloat = 23.0 106 + // Heights stay constant (menubar height is fixed); widths flex with 107 + // the display layout so .fullSlim can keep all 14 whites visible 108 + // when the menubar is squeezed instead of dropping an octave. 109 + static var whiteW: CGFloat { 110 + switch displayLayout { 111 + case .fullSlim: return 17.0 // ~74% of full — saves 84px on 14 whites 112 + default: return 23.0 113 + } 114 + } 88 115 static let whiteH: CGFloat = 21.0 89 - static let blackW: CGFloat = 13.5 116 + static var blackW: CGFloat { 117 + switch displayLayout { 118 + case .fullSlim: return 10.0 // proportional to slim white 119 + default: return 13.5 120 + } 121 + } 90 122 static let blackH: CGFloat = 12.0 // shorter so the white-below strip is 91 123 // tall enough to drag across easily 92 124 static let pad: CGFloat = 0.5 ··· 281 313 // legacy binary `typeMode` rendering when no closure is 282 314 // supplied (e.g., previews that don't drive animation). 283 315 if let letter = labelByMidi[m] { 316 + let display = labelsUppercase ? letter.uppercased() : letter 284 317 let a: CGFloat 285 318 if isLit { 286 319 a = 1.0 ··· 290 323 a = typeMode ? 1.0 : 0.0 291 324 } 292 325 if a > 0.01 { 293 - drawWhiteLabel(letter, in: rect, lit: isLit, alpha: a) 326 + drawWhiteLabel(display, in: rect, lit: isLit, alpha: a) 294 327 } 295 328 } 296 329 } ··· 317 350 path.lineWidth = 0.6 318 351 path.stroke() 319 352 if let letter = labelByMidi[m] { 353 + let display = labelsUppercase ? letter.uppercased() : letter 320 354 let a: CGFloat 321 355 if isLit { 322 356 a = 1.0 ··· 326 360 a = typeMode ? 1.0 : 0.0 327 361 } 328 362 if a > 0.01 { 329 - drawBlackLabel(letter, in: rect, lit: isLit, alpha: a) 363 + drawBlackLabel(display, in: rect, lit: isLit, alpha: a) 330 364 } 331 365 } 332 366 } ··· 657 691 ? baseColor.blended(withFraction: f, of: .white) ?? baseColor 658 692 : baseColor 659 693 drawTintedSymbol("music.note.list", in: iconBox, pointSize: 13.0, color: color) 694 + // Linger / bell-ring flourish — small accent tilde tucked at 695 + // the top-right of the music-note glyph whenever shift is held 696 + // or caps lock is latched. Visual cue that any key press will 697 + // ring out instead of cutting at release. Uses the system 698 + // accent color so it pops against either label-color (off) or 699 + // accent-color (MIDI-on) base glyph. 700 + if labelsUppercase { 701 + let flourishAttrs: [NSAttributedString.Key: Any] = [ 702 + .font: NSFont.systemFont(ofSize: 9.5, weight: .heavy), 703 + .foregroundColor: NSColor.controlAccentColor, 704 + ] 705 + let flourish = NSAttributedString(string: "~", attributes: flourishAttrs) 706 + let fSize = flourish.size() 707 + // Anchor the tilde just past the music-note's flag — top 708 + // right of iconBox, nudged so it overlaps the empty space 709 + // above the glyph rather than sitting on top of strokes. 710 + flourish.draw(at: NSPoint( 711 + x: iconBox.maxX - fSize.width + 1.5, 712 + y: iconBox.maxY - fSize.height + 1.0 713 + )) 714 + } 660 715 // Voice-number subscript: tiny digits in the bottom-right 661 716 // corner. The first digit sits where the single-digit case 662 717 // looked good; additional digits FLOW RIGHT from there
+168 -12
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 16 16 /// each held key. Lets keyUp remove the visually-lit cell even when 17 17 /// the audio note was octave-shifted out of the visible range. 18 18 private var heldKeyDisplayNote: [UInt16: UInt8] = [:] 19 + /// Per-key linger flag captured at keyDown. We sample shift state 20 + /// once on press so a release-shift-mid-hold still rings out the 21 + /// note that was started under shift. 22 + private var heldKeyLinger: [UInt16: Bool] = [:] 19 23 private let heldLock = NSLock() 20 24 21 25 private let midiModeKey = "notepat.midiMode" ··· 592 596 593 597 private var tapHeld: Set<UInt8> = [] // notes currently held by mouse drag (main thread) 594 598 private var tapNoteChannel: [UInt8: UInt8] = [:] // active channel per held note 595 - private var melodicVoiceCursor: UInt8 = 0 // round-robin across 8 melodic channels 599 + private var tapLinger: [UInt8: UInt8] = [:] // notes started under shift → noteOn velocity (drives doppler) 600 + // Live notes round-robin across channels 0-3; doppler retriggers 601 + // use their own 4-7 cursor. The split guarantees a live press 602 + // cannot ever land on a channel that a doppler voice is on, so 603 + // the live note's noteOff never cuts a still-ringing doppler tail 604 + // (and vice versa). 4 voices per side is enough for typical chord 605 + // play and ~7 doppler retriggers with their growing intervals. 606 + private var melodicVoiceCursor: UInt8 = 0 // round-robin 0..3 for live 607 + private var dopplerVoiceCursor: UInt8 = 4 // round-robin 4..7 for doppler retriggers 608 + 609 + /// Linger tail length — how long after key release we hold the 610 + /// note before sending the cleanup noteOff. Long enough that the 611 + /// synth's natural release envelope can decay to silence (~3-5s on 612 + /// most GM voices), short enough that an external DAW doesn't 613 + /// accumulate hung notes when the user shift-spams keys. 614 + private static let lingerTailSeconds: TimeInterval = 6.0 615 + 616 + // Doppler retrigger tuning for staccato linger. Each retrigger 617 + // fires the same midi note on a fresh channel at decaying velocity 618 + // and slowly-growing intervals. The user can keep playing live 619 + // notes over the tail because retriggers always use a rotating 620 + // channel via `nextMelodicChannel()` — they never pin the user's 621 + // active voices. 622 + private static let dopplerInitialDelay: TimeInterval = 0.30 623 + private static let dopplerIntervalGrowth: Double = 1.18 624 + private static let dopplerVelocityDecay: Double = 0.78 625 + private static let dopplerVelocityFloor: Double = 8.0 626 + private static let dopplerMaxSteps: Int = 14 627 + private static let dopplerNoteOffWindow: TimeInterval = 0.40 596 628 597 629 @inline(__always) 598 630 private func nextMelodicChannel() -> UInt8 { 599 631 let c = melodicVoiceCursor 600 - melodicVoiceCursor = (melodicVoiceCursor &+ 1) & 0x07 632 + melodicVoiceCursor = (melodicVoiceCursor &+ 1) & 0x03 633 + return c 634 + } 635 + 636 + @inline(__always) 637 + private func nextDopplerChannel() -> UInt8 { 638 + let c = dopplerVoiceCursor 639 + dopplerVoiceCursor = ((dopplerVoiceCursor &+ 1 - 4) & 0x03) + 4 601 640 return c 602 641 } 603 642 ··· 606 645 /// handler computes them from the cursor's relative position inside the 607 646 /// hovered key, giving expressive control: y closer to vertical center = 608 647 /// louder, x within key = stereo pan. 609 - func startTapNote(_ midiNote: UInt8, velocity: UInt8 = 100, pan: UInt8 = 64) { 610 - debugLog("startTapNote midi=\(midiNote) midiMode=\(midiMode)") 648 + func startTapNote(_ midiNote: UInt8, velocity: UInt8 = 100, pan: UInt8 = 64, linger: Bool = false) { 649 + debugLog("startTapNote midi=\(midiNote) midiMode=\(midiMode) linger=\(linger)") 611 650 lastPlayedNote = midiNote 612 651 if tapHeld.contains(midiNote) { return } 613 652 tapHeld.insert(midiNote) 653 + if linger { tapLinger[midiNote] = velocity } 614 654 let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) 615 655 // Synth: rotate across 8 channels so rapid same-note taps overlap 616 656 // (different channels = different voices, no stealing). MIDI: always ··· 648 688 func stopTapNote(_ midiNote: UInt8) { 649 689 guard tapHeld.contains(midiNote) else { return } 650 690 tapHeld.remove(midiNote) 691 + let lingerVelocity = tapLinger.removeValue(forKey: midiNote) 651 692 let synthCh = tapNoteChannel.removeValue(forKey: midiNote) ?? channel(for: midiNote) 652 693 let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) 653 694 let midiCh: UInt8 = isDrum ? 9 : 0 ··· 656 697 // instead of cutting each other off. The MIDI port still sends noteOff 657 698 // so external sequencers (Ableton drum racks) get a clean event pair. 658 699 // Internal synth is silent in MIDI mode anyway. 659 - if !isDrum && !midiMode { 660 - synth.noteOff(midiNote, channel: synthCh) 700 + if let v = lingerVelocity { 701 + releaseLingering(midiNote: midiNote, synthChannel: synthCh, midiChannel: midiCh, 702 + isDrum: isDrum, originalVelocity: v) 703 + } else { 704 + if !isDrum && !midiMode { 705 + synth.noteOff(midiNote, channel: synthCh) 706 + } 707 + midi.noteOff(midiNote, channel: midiCh) 661 708 } 662 - midi.noteOff(midiNote, channel: midiCh) 663 709 // Release the visual immediately on mouse-up. The earlier 664 710 // minVisibleSeconds floor read as visual lag — the user 665 711 // perceives it as the key sticking down past the click. Snap-up ··· 673 719 } 674 720 } 675 721 722 + // MARK: - Linger / bell-ring tail 723 + 724 + /// Shared release path for any note that was started under shift. 725 + /// Sustained voices keep their original noteOn alive (the synth's 726 + /// release envelope rings out) and get a cleanup noteOff scheduled 727 + /// for the end of the tail window. Staccato voices close the 728 + /// original immediately and trigger a doppler retrigger tail on 729 + /// fresh round-robin channels — this is what lets the user keep 730 + /// playing live notes over the linger without stealing voices. 731 + /// Drums always skip the synth.noteOff (existing convention) so 732 + /// the kit sample plays through. 733 + private func releaseLingering(midiNote: UInt8, 734 + synthChannel: UInt8, 735 + midiChannel: UInt8, 736 + isDrum: Bool, 737 + originalVelocity: UInt8) { 738 + if isDrum { 739 + // Drums: original noteOn already plays through; no synth 740 + // noteOff. Just delay the MIDI noteOff so external DAWs 741 + // see a clean pair without truncating the kit sample. 742 + let n = midiNote, mc = midiChannel 743 + DispatchQueue.main.asyncAfter(deadline: .now() + Self.lingerTailSeconds) { [weak self] in 744 + self?.midi.noteOff(n, channel: mc) 745 + } 746 + return 747 + } 748 + let category = GeneralMIDI.lingerCategory(for: melodicProgram) 749 + switch category { 750 + case .sustained: 751 + // Skip the immediate noteOff so the synth's release 752 + // envelope rings out. Cleanup pair lands at +tail. 753 + let n = midiNote, ch = synthChannel, mc = midiChannel 754 + DispatchQueue.main.asyncAfter(deadline: .now() + Self.lingerTailSeconds) { [weak self] in 755 + guard let self = self else { return } 756 + if !self.midiMode { self.synth.noteOff(n, channel: ch) } 757 + self.midi.noteOff(n, channel: mc) 758 + } 759 + case .staccato: 760 + // Close the original cleanly — the staccato sample is 761 + // already mostly decayed by the time the user releases 762 + // the key, so the noteOff is mostly bookkeeping. Then 763 + // schedule the doppler-style retrigger tail. 764 + if !midiMode { synth.noteOff(midiNote, channel: synthChannel) } 765 + midi.noteOff(midiNote, channel: midiChannel) 766 + scheduleStaccatoDoppler(midiNote: midiNote, 767 + midiChannel: midiChannel, 768 + startVelocity: originalVelocity) 769 + } 770 + } 771 + 772 + /// Fire the same midiNote N times on rotating melodic channels, 773 + /// each at a smaller velocity and slightly later than the last. 774 + /// Stops when velocity drops below the floor or after maxSteps. 775 + /// Each retrigger is paired with its own noteOff so MIDI consumers 776 + /// get clean event pairs and our synth voice budget recycles. 777 + private func scheduleStaccatoDoppler(midiNote: UInt8, 778 + midiChannel: UInt8, 779 + startVelocity: UInt8) { 780 + var step = 0 781 + var interval = Self.dopplerInitialDelay 782 + var velocity = Double(startVelocity) 783 + var cumulative: TimeInterval = 0 784 + while step < Self.dopplerMaxSteps { 785 + velocity *= Self.dopplerVelocityDecay 786 + if velocity < Self.dopplerVelocityFloor { break } 787 + cumulative += interval 788 + let v = UInt8(max(1, min(127, Int(velocity.rounded())))) 789 + let triggerAt = cumulative 790 + DispatchQueue.main.asyncAfter(deadline: .now() + triggerAt) { [weak self] in 791 + guard let self = self else { return } 792 + let ch = self.nextDopplerChannel() 793 + if !self.midiMode { self.synth.noteOn(midiNote, velocity: v, channel: ch) } 794 + self.midi.noteOn(midiNote, velocity: v, channel: midiChannel) 795 + DispatchQueue.main.asyncAfter(deadline: .now() + Self.dopplerNoteOffWindow) { [weak self] in 796 + guard let self = self else { return } 797 + if !self.midiMode { self.synth.noteOff(midiNote, channel: ch) } 798 + self.midi.noteOff(midiNote, channel: midiChannel) 799 + } 800 + } 801 + interval *= Self.dopplerIntervalGrowth 802 + step += 1 803 + } 804 + } 805 + 676 806 // MARK: - Permissions 677 807 678 808 @discardableResult ··· 775 905 return true 776 906 } 777 907 let hasMod = flags.contains(.maskCommand) || flags.contains(.maskControl) || flags.contains(.maskAlternate) 778 - return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod) 908 + // Linger armed when EITHER shift is currently held OR caps lock 909 + // is on. Caps lock latches the mode so the user can play a 910 + // long ambient passage without having to keep shift down. 911 + let linger = flags.contains(.maskShift) || flags.contains(.maskAlphaShift) 912 + return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod, linger: linger) 779 913 } 780 914 781 915 /// Sandbox-friendly key path: same note logic as the global tap, but ··· 786 920 @discardableResult 787 921 func handleLocalKey(keyCode: UInt16, isDown: Bool, isRepeat: Bool, flags: NSEvent.ModifierFlags) -> Bool { 788 922 let hasMod = flags.contains(.command) || flags.contains(.control) || flags.contains(.option) 789 - return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod) 923 + // Caps lock latches linger so the user can play hands-free 924 + // without holding shift; shift held still works as a momentary. 925 + let linger = flags.contains(.shift) || flags.contains(.capsLock) 926 + return playKeyEvent(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, hasModifier: hasMod, linger: linger) 790 927 } 791 928 792 929 /// Shared note logic for both the global CGEventTap path and the 793 930 /// local NSEvent panel path. Returns true if the keystroke was 794 931 /// consumed (mapped to a note); false if it should pass through. 932 + /// `linger` engages bell-ring mode: the note is held by the synth 933 + /// past key-up so it rings on its release envelope (sustained 934 + /// voices) rather than cutting on release. 795 935 @discardableResult 796 - private func playKeyEvent(keyCode: UInt16, isDown: Bool, isRepeat: Bool, hasModifier: Bool) -> Bool { 936 + private func playKeyEvent(keyCode: UInt16, isDown: Bool, isRepeat: Bool, hasModifier: Bool, linger: Bool = false) -> Bool { 797 937 // Modifier combos pass through so cmd-c, cmd-tab etc. work as usual. 798 938 if hasModifier { return false } 799 939 ··· 867 1007 heldNotes[keyCode] = note 868 1008 heldKeyChannel[keyCode] = synthCh 869 1009 heldKeyDisplayNote[keyCode] = displayNote 1010 + heldKeyLinger[keyCode] = linger 870 1011 heldLock.unlock() 871 1012 if let prevNote = prevNote { 872 1013 if !midiMode { synth.noteOff(prevNote, channel: prevCh ?? 0) } ··· 917 1058 let note = heldNotes.removeValue(forKey: keyCode) 918 1059 let synthCh = heldKeyChannel.removeValue(forKey: keyCode) ?? 0 919 1060 let displayNote = heldKeyDisplayNote.removeValue(forKey: keyCode) 1061 + let wasLinger = heldKeyLinger.removeValue(forKey: keyCode) ?? false 920 1062 heldLock.unlock() 921 1063 guard let releasedNote = note else { return true } // consume the up too 922 - if !midiMode { synth.noteOff(releasedNote, channel: synthCh) } 923 - midi.noteOff(releasedNote) 1064 + if wasLinger { 1065 + // Keyboard always plays melodic (QWERTY notes start at 1066 + // C4); originalVelocity is fixed at 100 in the keyDown 1067 + // branch. Funnel through the shared sustained/staccato 1068 + // splitter so the doppler tail engages on plucked GM 1069 + // voices automatically. 1070 + releaseLingering(midiNote: releasedNote, 1071 + synthChannel: synthCh, 1072 + midiChannel: 0, 1073 + isDrum: false, 1074 + originalVelocity: 100) 1075 + } else { 1076 + if !midiMode { synth.noteOff(releasedNote, channel: synthCh) } 1077 + midi.noteOff(releasedNote) 1078 + } 924 1079 // Extinguish the *display* lit cell, not the played pitch — 925 1080 // the played pitch may be far outside the visible range when 926 1081 // octaveShift > 0, but the lit highlight always lives in 60–83. ··· 946 1101 heldNotes.removeAll() 947 1102 heldKeyChannel.removeAll() 948 1103 heldKeyDisplayNote.removeAll() 1104 + heldKeyLinger.removeAll() 949 1105 heldLock.unlock() 950 1106 let tapSnapshot = tapHeld 951 1107 let tapChanSnapshot = tapNoteChannel
+26 -15
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 617 617 return 618 618 } 619 619 let famColor = InstrumentListView.colorForProgram(safe) 620 - self.instrumentReadout.stringValue = nameForChip 621 - self.instrumentReadout.textColor = famColor 620 + // Funnel through the shared styler — assigning `.stringValue` 621 + // here would wipe the field's attributedStringValue (font + 622 + // shadow), snapping the title back to system black mid-drag. 623 + self.applyInstrumentReadoutStyle(title: nameForChip, famColor: famColor) 622 624 // Don't retint the LED bezel during hover-drag while 623 625 // MIDI mode is on — it stays accent-colored as a status 624 626 // badge. ··· 1186 1188 let safe = max(0, min(127, Int(m.melodicProgram))) 1187 1189 let title = GeneralMIDI.programNames[safe] 1188 1190 let famColor = InstrumentListView.colorForProgram(safe) 1191 + applyInstrumentReadoutStyle(title: title, famColor: famColor) 1192 + // The visualizer is the only piece of chrome that does NOT 1193 + // track the voice color in MIDI mode — there it reads as a 1194 + // status badge ("MIDI" dot-matrix in system accent), so we 1195 + // skip the retint when MIDI is on. 1196 + if m.midiMode { 1197 + waveformView.setBaseColor(.controlAccentColor) 1198 + waveformBezel?.layer?.borderColor = NSColor.controlAccentColor 1199 + .withAlphaComponent(0.55).cgColor 1200 + } else { 1201 + waveformView.setBaseColor(famColor) 1202 + waveformBezel?.layer?.borderColor = famColor 1203 + .withAlphaComponent(0.55).cgColor 1204 + } 1205 + } 1206 + 1207 + /// Paint the title chip with YWFT Processing + Riso-misregister 1208 + /// shadow keyed to the family color. Both the committed-update path 1209 + /// (`updateInstrumentReadout`) and the hover-drag preview path 1210 + /// (`instrumentList.onHover`) funnel through here so the typeface + 1211 + /// shadow can never get wiped by a stray `.stringValue` assignment. 1212 + private func applyInstrumentReadoutStyle(title: String, famColor: NSColor) { 1189 1213 // Dark hues (Bass / Strings / Percussive) wash out against 1190 1214 // the dark popover background; light hues (Piano ivory) wash 1191 1215 // out in light mode. Adjust per-appearance so the title ··· 1234 1258 .shadow: shadow, 1235 1259 ] 1236 1260 ) 1237 - // The visualizer is the only piece of chrome that does NOT 1238 - // track the voice color in MIDI mode — there it reads as a 1239 - // status badge ("MIDI" dot-matrix in system accent), so we 1240 - // skip the retint when MIDI is on. 1241 - if m.midiMode { 1242 - waveformView.setBaseColor(.controlAccentColor) 1243 - waveformBezel?.layer?.borderColor = NSColor.controlAccentColor 1244 - .withAlphaComponent(0.55).cgColor 1245 - } else { 1246 - waveformView.setBaseColor(famColor) 1247 - waveformBezel?.layer?.borderColor = famColor 1248 - .withAlphaComponent(0.55).cgColor 1249 - } 1250 1261 } 1251 1262 1252 1263 // Appearance changes (light/dark toggle) refresh on next popover
+23 -8
slab/menuband/Sources/MenuBand/MenuBandSynth.swift
··· 265 265 let key: UInt16 = (UInt16(0x79) << 8) | UInt16(program) 266 266 let needsLoad = !loadedPrograms.contains(key) 267 267 if needsLoad { setMIDISynthPreload(au, enable: true) } 268 - sendMIDIEvent(au, status: 0xB0, data1: 0, data2: 0x79) // CC0 bank MSB 269 - sendMIDIEvent(au, status: 0xB0, data1: 32, data2: 0x00) // CC32 bank LSB 270 - sendMIDIEvent(au, status: 0xC0, data1: program) // PC program 268 + // Broadcast bank+PC to all 8 melodic channels (0-7). The 269 + // controller's round-robin voice allocator (live 0-3, doppler 270 + // 4-7) needs every channel preloaded with the chosen voice so 271 + // a noteOn lands on the user's instrument no matter which 272 + // channel it picked. Channel 9 is reserved for drums; 8 and 273 + // 10-15 are unused. Without this loop only ch0 played the 274 + // selected voice, which collapsed all "rotation" back to a 275 + // single channel and let same-note retriggers cut each other. 276 + for ch: UInt8 in 0..<8 { 277 + sendMIDIEvent(au, status: 0xB0 | ch, data1: 0, data2: 0x79) 278 + sendMIDIEvent(au, status: 0xB0 | ch, data1: 32, data2: 0x00) 279 + sendMIDIEvent(au, status: 0xC0 | ch, data1: program) 280 + } 271 281 if needsLoad { 272 282 setMIDISynthPreload(au, enable: false) 273 283 loadedPrograms.insert(key) 274 - // After preload-disable, the AU's *active* program is still 275 - // unset on this channel — re-send the PC so noteOn lands on the 284 + // After preload-disable the AU's *active* program is still 285 + // unset on each channel — re-send PC so noteOn lands on the 276 286 // freshly loaded instrument instead of silence. 277 - sendMIDIEvent(au, status: 0xC0, data1: program) 287 + for ch: UInt8 in 0..<8 { 288 + sendMIDIEvent(au, status: 0xC0 | ch, data1: program) 289 + } 278 290 } 279 291 } 280 292 ··· 509 521 return 510 522 } 511 523 if midiSynthReady, let au = midiSynth?.audioUnit { 512 - sendMIDIEvent(au, status: 0x90, data1: midi, data2: velocity) 524 + // Honor the channel argument so same-note voices on 525 + // different channels stay independent (essential for the 526 + // doppler retrigger tail to layer with a fresh live press). 527 + sendMIDIEvent(au, status: 0x90 | (channel & 0x0F), data1: midi, data2: velocity) 513 528 return 514 529 } 515 530 connectMelodicSamplerIfNeeded() ··· 533 548 return 534 549 } 535 550 if midiSynthReady, let au = midiSynth?.audioUnit { 536 - sendMIDIEvent(au, status: 0x80, data1: midi) 551 + sendMIDIEvent(au, status: 0x80 | (channel & 0x0F), data1: midi) 537 552 return 538 553 } 539 554 melodic.stopNote(midi, onChannel: 0)