Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: popover stays open on piano taps + visualizer + tighter layout

Popover behavior:
- popover.behavior = .applicationDefined (was .transient). The popover
no longer auto-dismisses on clicks "outside" — which counted clicks
on our own status-item button as outside, killing the popover every
time the user tapped a menubar piano key.
- Manual click-away monitor: a global NSEvent monitor on
leftMouseDown/rightMouseDown closes the popover when the user clicks
*another app*. In-app clicks (status item button, popover internals)
don't fire the global monitor, so piano taps + popover-internal
clicks keep the popover up. Esc local monitor still dismisses.
- closePopover() helper tears down both monitors so they don't leak.

Live audio visualizer (WaveformView):
- Layer-based: CAGradientLayer masked by a CAShapeLayer line. Each
frame the path is updated inside an actions-disabled CATransaction
so Core Animation snaps cleanly without tweening.
- Rainbow stroke whose hue rotates 0.0035/frame for shimmer; soft
cyan glow underneath via CAShapeLayer shadow + 4.5px stroke at 20%
white. Reads as "visualizer" rather than flat scope.
- 60 Hz refresh, 512 samples decimated to view width. Auto-gain so
quiet content still reads. Hidden in MIDI mode (local mixer is
silent there). 64px tall (was 36).
- New audio tap on engine.mainMixerNode → ring buffer (4096 frames)
→ snapshotWaveform(into:) read by the view each tick. Tap is
installed once at synth.start().

Layout reshuffle (popover slimmer):
- Octave moved out of its own row → top-right of the title row.
Compact stepper + monospaced "+0" label + tiny "octave" caption,
no Reset button (the stepper steps to 0 just as fast).
- About + Crash logs combined in a single side-by-side row, distri-
bution .fillEqually. Crash column hides itself when there are no
recent reports → About expands to the row width.
- Crash column copy tightened: "1 crash" / "N crashes" + "Send to
aesthetic.computer to help debug." + "Send 1" / "Send all (N)".
- AC link button shortened to "ac" (was "aesthetic.computer") to fit
the half-width column alongside notepat.com.

Click-instrument-plays fix:
- 70 ms delay between setMelodicProgram and audition note. AVAudio
UnitSampler's loadSoundBankInstrument is synchronous on the calling
thread but briefly suppresses scheduled notes on the audio render
thread while it swaps banks; without the delay the audition fell
into the gap and the user heard nothing. New auditionCurrent
Program() bypasses the midiMode gate so clicks always sound.
- debugLog stamps in mouseDown / handleInstrumentCommit /
auditionCurrentProgram for tracing.

Re-deployed:
- DMG submission 5031590b-2e87-44fe-88fa-2cfd5ccde71c, stapled.
- md5 8fbda61a540ea78eef45b9e44dcd6053. Page bumped to ?v=8fbda61.

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

+812 -89
+49 -2
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 20 20 /// so we don't spam the user every check. 21 21 private var hasAlertedNoSpace = false 22 22 23 + /// Click-away monitor active while the popover is shown. Catches clicks 24 + /// on OTHER apps and dismisses the popover. Clicks on our status-item 25 + /// button stay in-app and route through `statusClicked`, which only 26 + /// closes the popover when the user clicks the settings chip — piano 27 + /// taps keep the popover open. 28 + private var clickAwayMonitor: Any? 29 + private var popoverEscMonitor: Any? 30 + 23 31 func applicationDidFinishLaunching(_ notification: Notification) { 24 32 debugLog("applicationDidFinishLaunching pid=\(ProcessInfo.processInfo.processIdentifier)") 25 33 Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in ··· 76 84 vc.menuBand = menuBand 77 85 popoverVC = vc 78 86 popover.contentViewController = vc 79 - popover.behavior = .transient 87 + // .applicationDefined: never auto-close. We manage closing manually 88 + // so clicking a menubar piano key (which would normally count as 89 + // "outside" the popover under .transient) doesn't dismiss the 90 + // popover while the user is playing. 91 + popover.behavior = .applicationDefined 80 92 popover.animates = false 81 93 _ = vc.view 82 94 ··· 312 324 313 325 // MARK: - Popover 314 326 327 + /// Close the popover and tear down the click-away monitor. Called from 328 + /// `statusClicked` (settings-chip toggle) and from the click-away 329 + /// monitor itself when the user clicks anywhere outside our app. 330 + private func closePopover() { 331 + if popover.isShown { 332 + popover.performClose(nil) 333 + } 334 + if let m = clickAwayMonitor { NSEvent.removeMonitor(m); clickAwayMonitor = nil } 335 + if let m = popoverEscMonitor { NSEvent.removeMonitor(m); popoverEscMonitor = nil } 336 + } 337 + 315 338 private func showPopover() { 316 339 guard let button = statusItem.button else { return } 317 340 // popoverVC is pre-built in applicationDidFinishLaunching so the first 318 341 // open is instant — no lazy view inflation here. 319 342 popoverVC?.syncFromController() 320 343 if popover.isShown { 321 - popover.performClose(nil) 344 + closePopover() 322 345 } else { 323 346 let imgSize = KeyboardIconRenderer.imageSize 324 347 let bb = button.bounds ··· 339 362 popover.show(relativeTo: anchor, of: button, preferredEdge: .minY) 340 363 DispatchQueue.main.async { 341 364 self.popover.contentViewController?.view.window?.makeKey() 365 + } 366 + // Click-away monitor: clicks on OTHER apps close the popover. 367 + // In-app clicks (status item button, the popover itself) don't 368 + // fire global monitors and therefore don't dismiss — that's how 369 + // we keep the popover open while the user taps menubar piano 370 + // keys. 371 + if clickAwayMonitor == nil { 372 + clickAwayMonitor = NSEvent.addGlobalMonitorForEvents( 373 + matching: [.leftMouseDown, .rightMouseDown] 374 + ) { [weak self] _ in 375 + self?.closePopover() 376 + } 377 + } 378 + // Esc closes the popover when it has key focus. 379 + if popoverEscMonitor == nil { 380 + popoverEscMonitor = NSEvent.addLocalMonitorForEvents( 381 + matching: [.keyDown] 382 + ) { [weak self] event in 383 + if event.keyCode == 53 /* kVK_Escape */ { 384 + self?.closePopover() 385 + return nil 386 + } 387 + return event 388 + } 342 389 } 343 390 } 344 391 }
+4 -1
slab/menuband/Sources/MenuBand/InstrumentMapView.swift
··· 261 261 } 262 262 263 263 override func mouseDown(with event: NSEvent) { 264 - if let p = program(at: convert(event.locationInWindow, from: nil)) { 264 + let pt = convert(event.locationInWindow, from: nil) 265 + let p = program(at: pt) 266 + debugLog("InstrumentListView.mouseDown pt=(\(pt.x),\(pt.y)) program=\(String(describing: p))") 267 + if let p = p { 265 268 onCommit?(p) 266 269 } 267 270 }
+11 -3
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 35 35 /// synth, regardless of MIDI mode. Used by the instrument-list click 36 36 /// handler so the user *always* hears their instrument pick, even when 37 37 /// MIDI is on (which normally silences the local synth and routes to 38 - /// the DAW). Plays middle-C for ~600 ms then releases. 38 + /// the DAW). Plays middle-C at velocity 100 for ~700 ms then releases. 39 + /// Forward the synth's tap-ring snapshot to callers (the WaveformView). 40 + /// Routing through the controller keeps MenuBandSynth private to the 41 + /// rest of the app while still letting the popover wire up live audio. 42 + func synthSnapshotWaveform(into dest: inout [Float]) { 43 + synth.snapshotWaveform(into: &dest) 44 + } 45 + 39 46 func auditionCurrentProgram() { 40 47 let note: UInt8 = 60 41 - synth.noteOn(note, velocity: 90, channel: 0) 42 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in 48 + debugLog("audition: synth.noteOn 60 (program \(melodicProgram))") 49 + synth.noteOn(note, velocity: 100, channel: 0) 50 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in 43 51 self?.synth.noteOff(note, channel: 0) 44 52 } 45 53 }
+132 -82
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 69 69 private var crashSendButton: NSButton! 70 70 private var updateBanner: NSView! 71 71 private var updateLabel: NSTextField! 72 + private var waveformView: WaveformView! 72 73 73 74 override func loadView() { 74 75 // Plain solid-color background — no NSVisualEffectView. The visual ··· 89 90 stack.translatesAutoresizingMaskIntoConstraints = false 90 91 root.addSubview(stack) 91 92 92 - // Title row. 93 + // Title row: app name on the left, octave control hugging the right. 94 + // Octave gets its own row in the popover via this top-anchored 95 + // arrangement so we don't need a dedicated "Octave" panel below. 96 + let titleRow = NSStackView() 97 + titleRow.orientation = .horizontal 98 + titleRow.alignment = .centerY 99 + titleRow.distribution = .fill 100 + titleRow.spacing = 8 101 + 102 + let titleStack = NSStackView() 103 + titleStack.orientation = .vertical 104 + titleStack.alignment = .leading 105 + titleStack.spacing = 0 93 106 let title = NSTextField(labelWithString: "Menu Band") 94 107 title.font = NSFont.systemFont(ofSize: 13, weight: .bold) 95 108 title.textColor = .labelColor 96 - stack.addArrangedSubview(title) 97 - 98 109 let subtitle = NSTextField(labelWithString: "Built-in macOS instruments, in the menu bar.") 99 110 subtitle.font = NSFont.systemFont(ofSize: 10.5) 100 111 subtitle.textColor = .secondaryLabelColor 101 - stack.addArrangedSubview(subtitle) 112 + titleStack.addArrangedSubview(title) 113 + titleStack.addArrangedSubview(subtitle) 114 + titleRow.addArrangedSubview(titleStack) 115 + 116 + let titleSpacer = NSView() 117 + titleSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) 118 + titleRow.addArrangedSubview(titleSpacer) 119 + 120 + // Compact octave: stepper + monospaced label, no "Octave" caption 121 + // (small enough that the stepper itself reads). Reset is dropped 122 + // — the stepper can walk back to 0 just as fast. 123 + octaveLabel = NSTextField(labelWithString: "+0") 124 + octaveLabel.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .bold) 125 + octaveLabel.textColor = .secondaryLabelColor 126 + octaveLabel.alignment = .right 127 + octaveLabel.widthAnchor.constraint(equalToConstant: 24).isActive = true 128 + octaveStepper = NSStepper() 129 + octaveStepper.minValue = -4 130 + octaveStepper.maxValue = 4 131 + octaveStepper.increment = 1 132 + octaveStepper.valueWraps = false 133 + octaveStepper.target = self 134 + octaveStepper.action = #selector(octaveChanged(_:)) 135 + let octaveCaption = NSTextField(labelWithString: "octave") 136 + octaveCaption.font = NSFont.systemFont(ofSize: 10) 137 + octaveCaption.textColor = .tertiaryLabelColor 138 + titleRow.addArrangedSubview(octaveCaption) 139 + titleRow.addArrangedSubview(octaveLabel) 140 + titleRow.addArrangedSubview(octaveStepper) 141 + stack.addArrangedSubview(titleRow) 142 + titleRow.widthAnchor.constraint(equalTo: stack.widthAnchor, 143 + constant: -32).isActive = true 102 144 103 145 // Update banner — hidden until UpdateChecker reports a newer 104 146 // release. Tinted accent so the user notices it without it feeling ··· 166 208 inputHint.textColor = .secondaryLabelColor 167 209 stack.addArrangedSubview(inputHint) 168 210 211 + // Live waveform of the local synth output. Hidden in MIDI mode 212 + // (DAW handles audio there; our local mixer is silent so the line 213 + // would just sit flat). Single antialiased path, ~60 Hz redraw. 214 + waveformView = WaveformView() 215 + waveformView.menuBand = menuBand 216 + waveformView.translatesAutoresizingMaskIntoConstraints = false 217 + stack.addArrangedSubview(waveformView) 218 + waveformView.widthAnchor.constraint(equalToConstant: InstrumentListView.preferredWidth).isActive = true 219 + waveformView.heightAnchor.constraint(equalToConstant: 64).isActive = true 220 + 169 221 stack.addArrangedSubview(makeSeparator()) 170 222 171 223 // MIDI switch row. ··· 216 268 217 269 stack.addArrangedSubview(makeSeparator()) 218 270 219 - // Octave row. 220 - let octaveRow = NSStackView() 221 - octaveRow.orientation = .horizontal 222 - octaveRow.alignment = .centerY 223 - octaveRow.spacing = 8 224 - let octaveTitle = NSTextField(labelWithString: "Octave") 225 - octaveTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 226 - octaveTitle.textColor = .labelColor 227 - octaveLabel = NSTextField(labelWithString: "+0") 228 - octaveLabel.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .bold) 229 - octaveLabel.textColor = .labelColor 230 - octaveLabel.alignment = .center 231 - octaveLabel.widthAnchor.constraint(equalToConstant: 28).isActive = true 232 - octaveStepper = NSStepper() 233 - octaveStepper.minValue = -4 234 - octaveStepper.maxValue = 4 235 - octaveStepper.increment = 1 236 - octaveStepper.valueWraps = false 237 - octaveStepper.target = self 238 - octaveStepper.action = #selector(octaveChanged(_:)) 239 - let octaveReset = NSButton(title: "Reset", target: self, action: #selector(resetOctave)) 240 - octaveReset.bezelStyle = .recessed 241 - octaveReset.controlSize = .small 242 - octaveRow.addArrangedSubview(octaveTitle) 243 - octaveRow.addArrangedSubview(octaveLabel) 244 - octaveRow.addArrangedSubview(octaveStepper) 245 - octaveRow.addArrangedSubview(octaveReset) 246 - stack.addArrangedSubview(octaveRow) 247 - 248 - stack.addArrangedSubview(makeSeparator()) 249 - 250 - // Crash logs — count + opt-in send. Read from 251 - // ~/Library/Logs/DiagnosticReports/. macOS deposits MenuBand-*.ips 252 - // there automatically when we crash. We never auto-send; this 253 - // surfaces the count and lets the user click to ship them up. 254 - crashStatusLabel = NSTextField(labelWithString: "") 255 - crashStatusLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 256 - crashStatusLabel.textColor = .labelColor 257 - stack.addArrangedSubview(crashStatusLabel) 258 - 259 - crashHintLabel = NSTextField(wrappingLabelWithString: "") 260 - crashHintLabel.font = NSFont.systemFont(ofSize: 10) 261 - crashHintLabel.textColor = .secondaryLabelColor 262 - crashHintLabel.maximumNumberOfLines = 0 263 - crashHintLabel.preferredMaxLayoutWidth = 248 264 - stack.addArrangedSubview(crashHintLabel) 265 - 266 - crashSendButton = NSButton(title: "Send crash reports", 267 - target: self, 268 - action: #selector(sendCrashLogs(_:))) 269 - crashSendButton.bezelStyle = .recessed 270 - crashSendButton.controlSize = .small 271 - stack.addArrangedSubview(crashSendButton) 271 + // About + Crash logs in a side-by-side row to save vertical space. 272 + // Each takes half the popover width. Crash column is hidden when 273 + // there are no recent reports, leaving About full-width. 274 + let aboutCrashRow = NSStackView() 275 + aboutCrashRow.orientation = .horizontal 276 + aboutCrashRow.alignment = .top 277 + aboutCrashRow.distribution = .fillEqually 278 + aboutCrashRow.spacing = 12 272 279 273 - stack.addArrangedSubview(makeSeparator()) 274 - 275 - // About — inline rather than a separate dialog. 280 + let aboutCol = NSStackView() 281 + aboutCol.orientation = .vertical 282 + aboutCol.alignment = .leading 283 + aboutCol.spacing = 4 276 284 let aboutTitle = NSTextField(labelWithString: "About") 277 285 aboutTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 278 286 aboutTitle.textColor = .labelColor 279 - stack.addArrangedSubview(aboutTitle) 280 - 281 287 let aboutBody = NSTextField(wrappingLabelWithString: 282 288 "A political project to bring the built-in macOS instruments — " + 283 - "the ones GarageBand uses — into the menu bar. Accessible " + 284 - "music-making is as essential as time, network connectivity, " + 285 - "and battery life.") 286 - aboutBody.font = NSFont.systemFont(ofSize: 10.5) 289 + "the ones GarageBand uses — into the menu bar. Free + open source.") 290 + aboutBody.font = NSFont.systemFont(ofSize: 10) 287 291 aboutBody.textColor = .secondaryLabelColor 288 292 aboutBody.maximumNumberOfLines = 0 289 - aboutBody.preferredMaxLayoutWidth = 248 290 - stack.addArrangedSubview(aboutBody) 291 - 293 + aboutBody.preferredMaxLayoutWidth = 200 294 + aboutCol.addArrangedSubview(aboutTitle) 295 + aboutCol.addArrangedSubview(aboutBody) 292 296 let linksRow = NSStackView() 293 297 linksRow.orientation = .horizontal 294 298 linksRow.alignment = .centerY 295 - linksRow.spacing = 8 296 - let acLink = NSButton(title: "aesthetic.computer", 299 + linksRow.spacing = 6 300 + let acLink = NSButton(title: "ac", 297 301 target: self, action: #selector(openAesthetic)) 298 302 acLink.bezelStyle = .recessed 299 303 acLink.controlSize = .small ··· 303 307 npLink.controlSize = .small 304 308 linksRow.addArrangedSubview(acLink) 305 309 linksRow.addArrangedSubview(npLink) 306 - stack.addArrangedSubview(linksRow) 310 + aboutCol.addArrangedSubview(linksRow) 311 + 312 + let crashCol = NSStackView() 313 + crashCol.orientation = .vertical 314 + crashCol.alignment = .leading 315 + crashCol.spacing = 4 316 + crashStatusLabel = NSTextField(labelWithString: "") 317 + crashStatusLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 318 + crashStatusLabel.textColor = .labelColor 319 + crashHintLabel = NSTextField(wrappingLabelWithString: "") 320 + crashHintLabel.font = NSFont.systemFont(ofSize: 10) 321 + crashHintLabel.textColor = .secondaryLabelColor 322 + crashHintLabel.maximumNumberOfLines = 0 323 + crashHintLabel.preferredMaxLayoutWidth = 200 324 + crashSendButton = NSButton(title: "Send crash reports", 325 + target: self, 326 + action: #selector(sendCrashLogs(_:))) 327 + crashSendButton.bezelStyle = .recessed 328 + crashSendButton.controlSize = .small 329 + crashCol.addArrangedSubview(crashStatusLabel) 330 + crashCol.addArrangedSubview(crashHintLabel) 331 + crashCol.addArrangedSubview(crashSendButton) 332 + 333 + aboutCrashRow.addArrangedSubview(aboutCol) 334 + aboutCrashRow.addArrangedSubview(crashCol) 335 + stack.addArrangedSubview(aboutCrashRow) 307 336 308 337 stack.addArrangedSubview(makeSeparator()) 309 338 ··· 361 390 updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) 362 391 refreshCrashStatus() 363 392 refreshUpdateBanner() 393 + // Waveform: live only when local synth is the audible path. MIDI 394 + // mode silences the local mixer, so the line would be flat — hide 395 + // it instead of showing a misleading dead waveform. 396 + waveformView.isHidden = n.midiMode 397 + waveformView.isLive = !n.midiMode 364 398 // Wire up live updates so the label reflects loopback results as 365 399 // they land (test runs ~50ms after toggle-on; result settles a moment 366 400 // later). ··· 431 465 } 432 466 433 467 /// Update the crash-log status row from disk. Called on every popover 434 - /// open so the count is current. 468 + /// open so the count is current. When there are zero crashes, the 469 + /// whole crash column hides — About sits side-by-side normally; when 470 + /// crashes are present, About narrows to share the row. 435 471 private func refreshCrashStatus() { 436 472 let logs = CrashLogReader.recentLogs() 437 473 let n = logs.count 474 + // Walk up to the parent crash column to hide/show the whole panel. 475 + let crashCol = crashStatusLabel?.superview 438 476 if n == 0 { 439 - crashStatusLabel.stringValue = "Crash logs" 440 - crashHintLabel.stringValue = "No recent crashes — Menu Band's been stable since macOS last cleaned the diagnostic reports folder." 477 + crashCol?.isHidden = true 441 478 crashSendButton.isHidden = true 442 479 } else { 443 - crashStatusLabel.stringValue = n == 1 ? "1 recent crash" : "\(n) recent crashes" 444 - crashHintLabel.stringValue = "Send the report to aesthetic.computer? It helps debug the bug. Nothing personal goes — just the macOS crash log." 480 + crashCol?.isHidden = false 481 + crashStatusLabel.stringValue = n == 1 ? "1 crash" : "\(n) crashes" 482 + crashHintLabel.stringValue = "Send to aesthetic.computer to help debug." 445 483 crashSendButton.isHidden = false 446 - crashSendButton.title = n == 1 ? "Send 1 report" : "Send all (\(n))" 484 + crashSendButton.title = n == 1 ? "Send 1" : "Send all (\(n))" 447 485 crashSendButton.isEnabled = true 448 486 } 449 487 } ··· 512 550 // toggles after the first per session) and other panels don't need 513 551 // to refresh. 514 552 menuBand?.toggleMIDIMode() 553 + // Waveform follows the new MIDI state directly so it appears / 554 + // disappears the moment the user flips the switch. 555 + if let m = menuBand { 556 + waveformView.isHidden = m.midiMode 557 + waveformView.isLive = !m.midiMode 558 + } 515 559 } 516 560 517 561 /// 0 = Pointer, 1 = Notepat, 2 = Ableton. Matches the segmented control ··· 542 586 guard let m = menuBand else { return } 543 587 m.setMelodicProgram(UInt8(program)) 544 588 instrumentList.selectedProgram = UInt8(program) 545 - // Always audition through the local synth — even when MIDI mode is 546 - // on (which normally silences the synth). The user wants to hear 547 - // the instrument they picked, period. 548 - m.auditionCurrentProgram() 589 + debugLog("instrument commit prog=\(program)") 590 + // setMelodicProgram → loadSoundBankInstrument is synchronous on the 591 + // calling thread, but AVAudioUnitSampler briefly drops scheduled 592 + // notes on the audio render thread while it swaps banks. Without 593 + // this small delay the audition note often falls into that gap and 594 + // the user hears nothing. 70 ms is enough for the swap to settle 595 + // on every Mac I've tested without feeling laggy. 596 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.07) { [weak m] in 597 + m?.auditionCurrentProgram() 598 + } 549 599 } 550 600 551 601 @objc private func octaveChanged(_ sender: NSStepper) {
+53
slab/menuband/Sources/MenuBand/MenuBandSynth.swift
··· 15 15 private let drums = AVAudioUnitSampler() 16 16 private var started = false 17 17 18 + // Tap-driven ring buffer for the popover's live waveform display. The 19 + // tap fires on the audio render thread and writes here; the main thread 20 + // reads via `snapshotWaveform(into:)` whenever the WaveformView wants 21 + // a fresh frame. 22 + private static let waveformRingSize = 4096 23 + private var waveformRing = [Float](repeating: 0, count: waveformRingSize) 24 + private var waveformWriteIdx: Int = 0 25 + private let waveformLock = NSLock() 26 + 18 27 // Apple's DLS bank — present on every macOS install since 10.x. 19 28 private static let bankURL = URL( 20 29 fileURLWithPath: "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls" ··· 36 45 } 37 46 loadDefaultPatches() 38 47 primeForLowLatency() 48 + installWaveformTap() 49 + } 50 + 51 + /// Tap the engine's main mixer so the WaveformView gets a live picture 52 + /// of whatever the synth is producing — both melodic and drum hits go 53 + /// through the mainMixer. Buffer size 512 frames ≈ 11 ms at 44.1 kHz, 54 + /// small enough that the waveform feels live. 55 + private func installWaveformTap() { 56 + let mixer = engine.mainMixerNode 57 + let format = mixer.outputFormat(forBus: 0) 58 + mixer.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in 59 + self?.ingestWaveformBuffer(buffer) 60 + } 61 + } 62 + 63 + private func ingestWaveformBuffer(_ buffer: AVAudioPCMBuffer) { 64 + guard let data = buffer.floatChannelData?[0] else { return } 65 + let frames = Int(buffer.frameLength) 66 + waveformLock.lock() 67 + let ringSize = Self.waveformRingSize 68 + var idx = waveformWriteIdx 69 + for i in 0..<frames { 70 + waveformRing[idx] = data[i] 71 + idx += 1 72 + if idx >= ringSize { idx = 0 } 73 + } 74 + waveformWriteIdx = idx 75 + waveformLock.unlock() 76 + } 77 + 78 + /// Copy the most recent `dest.count` samples from the tap ring (in 79 + /// chronological order) into `dest`. Older samples first, newest last. 80 + /// Cheap; safe to call from main thread on every screen frame. 81 + func snapshotWaveform(into dest: inout [Float]) { 82 + let count = Swift.min(dest.count, Self.waveformRingSize) 83 + waveformLock.lock() 84 + let ringSize = Self.waveformRingSize 85 + var readIdx = (waveformWriteIdx - count + ringSize) % ringSize 86 + for i in 0..<count { 87 + dest[i] = waveformRing[readIdx] 88 + readIdx += 1 89 + if readIdx >= ringSize { readIdx = 0 } 90 + } 91 + waveformLock.unlock() 39 92 } 40 93 41 94 /// Plays inaudible velocity-1 notes through both samplers immediately
+155
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 1 + import AppKit 2 + 3 + /// Live audio visualizer for the local synth output. Single antialiased 4 + /// line, stroked with a horizontal rainbow gradient (CAGradientLayer + 5 + /// CAShapeLayer mask) so it reads as a "visualizer" rather than a flat 6 + /// scope. Hidden when MIDI mode is on (DAW handles audio there; the 7 + /// local mixer is silent). 8 + /// 9 + /// Layer-based draw path: the path is updated on a 60 Hz timer with 10 + /// implicit animations off, so each frame snaps cleanly to the new 11 + /// shape with no tweening. Cheap enough to keep the popover snappy. 12 + final class WaveformView: NSView { 13 + weak var menuBand: MenuBandController? 14 + 15 + private static let sampleCount = 512 16 + private var samples = [Float](repeating: 0, count: sampleCount) 17 + 18 + private let gradientLayer = CAGradientLayer() 19 + private let lineMask = CAShapeLayer() 20 + private let glowLayer = CAShapeLayer() 21 + 22 + private var refreshTimer: Timer? 23 + private var hue: CGFloat = 0 // slowly rotated each frame for shimmer 24 + 25 + var isLive: Bool = false { 26 + didSet { 27 + if isLive { startTimer() } else { stopTimer() } 28 + } 29 + } 30 + 31 + override init(frame frameRect: NSRect) { 32 + super.init(frame: frameRect) 33 + wantsLayer = true 34 + layer?.backgroundColor = NSColor.black.withAlphaComponent(0.92).cgColor 35 + layer?.cornerRadius = 8 36 + layer?.borderWidth = 0.5 37 + layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor 38 + 39 + // A soft glow under the line — lower opacity, wider stroke. 40 + glowLayer.fillColor = nil 41 + glowLayer.lineWidth = 4.5 42 + glowLayer.lineJoin = .round 43 + glowLayer.lineCap = .round 44 + glowLayer.strokeColor = NSColor.white.withAlphaComponent(0.20).cgColor 45 + glowLayer.shadowColor = NSColor.systemTeal.cgColor 46 + glowLayer.shadowOpacity = 0.55 47 + glowLayer.shadowRadius = 6 48 + glowLayer.shadowOffset = .zero 49 + layer?.addSublayer(glowLayer) 50 + 51 + // Sharp foreground line, masked by the rainbow gradient. 52 + lineMask.fillColor = nil 53 + lineMask.lineWidth = 1.5 54 + lineMask.lineJoin = .round 55 + lineMask.lineCap = .round 56 + lineMask.strokeColor = NSColor.black.cgColor 57 + 58 + gradientLayer.colors = [ 59 + NSColor.systemPink.cgColor, 60 + NSColor.systemOrange.cgColor, 61 + NSColor.systemYellow.cgColor, 62 + NSColor.systemGreen.cgColor, 63 + NSColor.systemTeal.cgColor, 64 + NSColor.systemPurple.cgColor, 65 + ] 66 + gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) 67 + gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5) 68 + gradientLayer.mask = lineMask 69 + layer?.addSublayer(gradientLayer) 70 + } 71 + required init?(coder: NSCoder) { fatalError() } 72 + 73 + override var wantsUpdateLayer: Bool { true } 74 + 75 + override func layout() { 76 + super.layout() 77 + gradientLayer.frame = bounds 78 + glowLayer.frame = bounds 79 + lineMask.frame = bounds 80 + } 81 + 82 + private func startTimer() { 83 + stopTimer() 84 + // 60 Hz. Path update is GPU-accelerated; CPU cost is the sample 85 + // walk + path build (512 line segments) — negligible. 86 + refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in 87 + self?.tick() 88 + } 89 + if let t = refreshTimer { RunLoop.main.add(t, forMode: .common) } 90 + } 91 + 92 + private func stopTimer() { 93 + refreshTimer?.invalidate() 94 + refreshTimer = nil 95 + } 96 + 97 + override func viewDidMoveToWindow() { 98 + super.viewDidMoveToWindow() 99 + if window == nil { stopTimer() } 100 + } 101 + 102 + private func tick() { 103 + guard let m = menuBand else { return } 104 + m.synthSnapshotWaveform(into: &samples) 105 + 106 + let w = bounds.width 107 + let h = bounds.height 108 + guard w > 0, h > 0 else { return } 109 + let midY = h * 0.5 110 + 111 + // Auto-gain: scale to ~85% of half-height by peak. Floor the peak 112 + // so silence doesn't blow up to look like noise. 113 + var peak: Float = 0.05 114 + for s in samples { 115 + let a = abs(s) 116 + if a > peak { peak = a } 117 + } 118 + let scale = CGFloat(0.88) / CGFloat(min(max(peak, 0.05), 1.0)) 119 + 120 + let path = CGMutablePath() 121 + let n = samples.count 122 + guard n > 1 else { return } 123 + let step = w / CGFloat(n - 1) 124 + for i in 0..<n { 125 + let x = CGFloat(i) * step 126 + let y = midY - CGFloat(samples[i]) * midY * scale 127 + if i == 0 { 128 + path.move(to: CGPoint(x: x, y: y)) 129 + } else { 130 + path.addLine(to: CGPoint(x: x, y: y)) 131 + } 132 + } 133 + 134 + // Rotate the gradient hue slowly so quiet content still looks alive. 135 + hue += 0.0035 136 + if hue > 1.0 { hue -= 1.0 } 137 + let h0 = hue 138 + let h1 = (hue + 0.16).truncatingRemainder(dividingBy: 1.0) 139 + let h2 = (hue + 0.33).truncatingRemainder(dividingBy: 1.0) 140 + let h3 = (hue + 0.50).truncatingRemainder(dividingBy: 1.0) 141 + let h4 = (hue + 0.66).truncatingRemainder(dividingBy: 1.0) 142 + let h5 = (hue + 0.83).truncatingRemainder(dividingBy: 1.0) 143 + let cgcolor = { (hue: CGFloat) -> CGColor in 144 + NSColor(hue: hue, saturation: 0.85, brightness: 1.0, alpha: 1.0).cgColor 145 + } 146 + 147 + CATransaction.begin() 148 + CATransaction.setDisableActions(true) 149 + gradientLayer.colors = [cgcolor(h0), cgcolor(h1), cgcolor(h2), 150 + cgcolor(h3), cgcolor(h4), cgcolor(h5)] 151 + lineMask.path = path 152 + glowLayer.path = path 153 + CATransaction.commit() 154 + } 155 + }
+408 -1
system/public/menuband/index.html
··· 84 84 linear-gradient(to bottom, var(--aqua-sky-top) 0%, var(--aqua-sky-bot) 100%) fixed; 85 85 background-attachment: fixed; 86 86 min-height: 100vh; 87 - padding: 36px 16px 64px; 87 + padding: 56px 16px 64px; /* extra top room for the faux menu bar */ 88 88 position: relative; 89 89 } 90 + 91 + /* ── Faux macOS menu bar ──────────────────────────────────────────────── */ 92 + 93 + .macbar { 94 + position: fixed; 95 + top: 0; left: 0; right: 0; 96 + height: 24px; 97 + background: rgba(245, 247, 250, 0.72); 98 + backdrop-filter: blur(28px) saturate(180%); 99 + -webkit-backdrop-filter: blur(28px) saturate(180%); 100 + border-bottom: 0.5px solid rgba(0, 0, 0, 0.12); 101 + display: flex; 102 + align-items: center; 103 + justify-content: space-between; 104 + padding: 0 10px; 105 + font-family: var(--display-stack); 106 + font-size: 13px; 107 + color: #1d1d1f; 108 + z-index: 50; 109 + user-select: none; 110 + cursor: default; 111 + } 112 + .macbar-left, .macbar-right { 113 + display: flex; 114 + align-items: center; 115 + gap: 14px; 116 + } 117 + .macbar .apple-logo { 118 + width: 13px; height: 16px; 119 + fill: #1d1d1f; 120 + opacity: 0.9; 121 + } 122 + .macbar .menu-app { font-weight: 600; } 123 + .macbar .menu-item { font-weight: 400; } 124 + .macbar .menu-item:hover, .macbar .menu-app:hover { 125 + background: rgba(0, 0, 0, 0.08); 126 + border-radius: 4px; 127 + padding: 1px 4px; 128 + margin: -1px -4px; 129 + } 130 + 131 + /* The actual graphic — a working Menu Band piano in the menu bar. 132 + One full octave, jellybean-keyed and clickable. */ 133 + .dock-piano { 134 + position: relative; 135 + display: inline-flex; 136 + align-items: flex-start; 137 + height: 17px; 138 + padding: 1px; 139 + background: #131315; 140 + border-radius: 3px; 141 + box-shadow: 142 + 0 0 0 0.5px rgba(0, 0, 0, 0.5), 143 + inset 0 1px 0 rgba(255, 255, 255, 0.12), 144 + inset 0 -1px 0 rgba(0, 0, 0, 0.4); 145 + cursor: pointer; 146 + transition: transform 220ms var(--ease-apple), box-shadow 220ms var(--ease-apple); 147 + } 148 + .dock-piano:hover { transform: translateY(0.5px); } 149 + .dock-piano.glow { 150 + box-shadow: 151 + 0 0 0 0.5px rgba(0, 0, 0, 0.5), 152 + inset 0 1px 0 rgba(255, 255, 255, 0.12), 153 + inset 0 -1px 0 rgba(0, 0, 0, 0.4), 154 + 0 0 12px 2px rgba(10, 132, 255, 0.55); 155 + } 156 + .dock-piano .key { 157 + display: inline-block; 158 + border-radius: 0 0 1.5px 1.5px; 159 + transition: background 80ms linear; 160 + } 161 + .dock-piano .key.w { 162 + width: 5px; height: 15px; 163 + background: linear-gradient(to bottom, #fafafa 0%, #e2e2e2 80%, #cbcbcb 100%); 164 + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.18); 165 + margin-right: 1px; 166 + } 167 + .dock-piano .key.w:last-child { margin-right: 0; } 168 + .dock-piano .key.b { 169 + width: 4px; height: 9px; 170 + background: linear-gradient(to bottom, #2a2a2c, #050505); 171 + margin: 0 -2.5px 0 -2.5px; 172 + position: relative; 173 + z-index: 2; 174 + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.15); 175 + } 176 + .dock-piano .key:active, 177 + .dock-piano .key.lit.w { background: linear-gradient(to bottom, #cfe6ff, #8ec4ff); } 178 + .dock-piano .key.lit.b { background: linear-gradient(to bottom, #4a8fd1, #1f5aa8); } 179 + 180 + .macbar-right .menu-extra { 181 + font-size: 11px; 182 + opacity: 0.85; 183 + font-family: "Berkeley Mono Variable", "SF Mono", Menlo, monospace; 184 + letter-spacing: 0.02em; 185 + } 186 + .macbar-right .menu-extra.clock { font-variant-numeric: tabular-nums; } 187 + .macbar-right svg { fill: currentColor; opacity: 0.85; } 90 188 91 189 /* Subtle musical pattern tiled over the sky — quarter, eighth, beam, 92 190 flat. Tinted Aqua-edge so it reads like a watermark. Pans diagonally ··· 530 628 body::before { animation: none; } 531 629 .footer-mark { animation: none; } 532 630 .aqua, a, .titlebar h1 a { transition-duration: 1ms; } 631 + .app-icon.genie-up, .app-icon.genie-down { animation-duration: 1ms !important; } 632 + } 633 + 634 + /* ── Genie effect ───────────────────────────────────────────────────────── 635 + Click the hero icon and it funnels UP into the menu-bar piano (the same 636 + metaphor the app embodies). The leading edge — the TOP — narrows toward 637 + a point, while the bottom corners stay anchored, then the whole shape 638 + translates and scales toward the destination. Click again to bring it 639 + back. Pure CSS keyframes; JS only sets --gx/--gy and toggles a class. */ 640 + 641 + .icon-stage img.app-icon { 642 + cursor: pointer; 643 + transition: filter 220ms var(--ease-apple); 644 + } 645 + .icon-stage img.app-icon:hover { filter: brightness(1.04) drop-shadow(0 4px 12px rgba(10, 132, 255, 0.25)); } 646 + 647 + .icon-stage img.app-icon.genie-up { 648 + animation: genie-up 720ms cubic-bezier(0.55, 0, 0.2, 1) forwards; 649 + pointer-events: none; 650 + } 651 + .icon-stage img.app-icon.genie-down { 652 + animation: genie-down 720ms cubic-bezier(0.8, 0, 0.45, 1) forwards; 653 + pointer-events: none; 654 + } 655 + /* During the trip, drop the reflection so it doesn't trail. */ 656 + .icon-stage.genied img.reflection { opacity: 0; transition: opacity 260ms linear; } 657 + 658 + @keyframes genie-up { 659 + 0% { 660 + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); 661 + transform: translate(0, 0) scale(1); 662 + opacity: 1; 663 + filter: blur(0); 664 + } 665 + 25% { 666 + clip-path: polygon(15% 0%, 85% 0%, 100% 100%, 0% 100%); 667 + transform: translate(calc(var(--gx, 0px) * 0.22), calc(var(--gy, 0px) * 0.22)) scale(0.78); 668 + } 669 + 55% { 670 + clip-path: polygon(35% 0%, 65% 0%, 100% 100%, 0% 100%); 671 + transform: translate(calc(var(--gx, 0px) * 0.62), calc(var(--gy, 0px) * 0.62)) scale(0.42); 672 + filter: blur(0.3px); 673 + } 674 + 85% { 675 + clip-path: polygon(46% 0%, 54% 0%, 100% 100%, 0% 100%); 676 + transform: translate(calc(var(--gx, 0px) * 0.92), calc(var(--gy, 0px) * 0.92)) scale(0.12); 677 + filter: blur(0.7px); 678 + } 679 + 100% { 680 + clip-path: polygon(50% 0%, 50% 0%, 100% 100%, 0% 100%); 681 + transform: translate(var(--gx, 0px), var(--gy, 0px)) scale(0.04); 682 + opacity: 0; 683 + filter: blur(1.4px); 684 + } 685 + } 686 + @keyframes genie-down { 687 + 0% { 688 + clip-path: polygon(50% 0%, 50% 0%, 100% 100%, 0% 100%); 689 + transform: translate(var(--gx, 0px), var(--gy, 0px)) scale(0.04); 690 + opacity: 0; 691 + filter: blur(1.4px); 692 + } 693 + 20% { 694 + clip-path: polygon(46% 0%, 54% 0%, 100% 100%, 0% 100%); 695 + transform: translate(calc(var(--gx, 0px) * 0.92), calc(var(--gy, 0px) * 0.92)) scale(0.12); 696 + opacity: 1; 697 + filter: blur(0.7px); 698 + } 699 + 50% { 700 + clip-path: polygon(35% 0%, 65% 0%, 100% 100%, 0% 100%); 701 + transform: translate(calc(var(--gx, 0px) * 0.62), calc(var(--gy, 0px) * 0.62)) scale(0.42); 702 + filter: blur(0.3px); 703 + } 704 + 80% { 705 + clip-path: polygon(15% 0%, 85% 0%, 100% 100%, 0% 100%); 706 + transform: translate(calc(var(--gx, 0px) * 0.22), calc(var(--gy, 0px) * 0.22)) scale(0.78); 707 + filter: blur(0); 708 + } 709 + 100% { 710 + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); 711 + transform: translate(0, 0) scale(1); 712 + opacity: 1; 713 + filter: blur(0); 714 + } 715 + } 716 + 717 + /* ── Dark mode ────────────────────────────────────────────────────────── */ 718 + @media (prefers-color-scheme: dark) { 719 + :root { 720 + --aqua-sky-top: #0e1a2e; 721 + --aqua-sky-bot: #04070d; 722 + --aqua-blue: #4a93ff; 723 + --aqua-blue-hi: #79b1ff; 724 + --aqua-blue-edge: #1f5aa8; 725 + --aqua-blue-tint: #2a4a7a; 726 + --tahoe-blue: #4a93ff; 727 + --tahoe-indigo: #7d7afe; 728 + --panel-bg: #1c1c1e; 729 + --panel-stripe: #232325; 730 + --panel-edge: #2c2c2e; 731 + --titlebar-top: #2c2c2e; 732 + --titlebar-bot: #1f1f21; 733 + --ink-body: #ebebf0; 734 + --ink-head: #ffffff; 735 + --ink-soft: #98989d; 736 + --shadow-card: 737 + 0 1px 2px rgba(0, 0, 0, 0.35), 738 + 0 4px 12px rgba(0, 0, 0, 0.4), 739 + 0 24px 48px -12px rgba(0, 0, 0, 0.6); 740 + } 741 + body { 742 + color: var(--ink-body); 743 + } 744 + /* Re-tint the music-drift glyphs so they still read on the dark sky. */ 745 + body::before { 746 + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='150' viewBox='0 0 200 150'><g fill='%2379b1ff' opacity='0.08' font-family='Georgia,serif'><text x='15' y='38' font-size='30'>♪</text><text x='100' y='32' font-size='34'>♫</text><text x='55' y='90' font-size='28'>♩</text><text x='140' y='105' font-size='32'>♬</text><text x='20' y='130' font-size='22'>♭</text><text x='160' y='55' font-size='20'>♯</text></g></svg>"); 747 + } 748 + .window { 749 + background: #1c1c1e; 750 + box-shadow: 751 + 0 1px 0 rgba(255, 255, 255, 0.06) inset, 752 + var(--shadow-card); 753 + } 754 + .titlebar { 755 + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset; 756 + } 757 + .titlebar h1 a, .titlebar h1 a:visited { 758 + color: #d0d0d2; 759 + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.5); 760 + } 761 + .light { 762 + box-shadow: 763 + 0 1px 0 rgba(255, 255, 255, 0.25) inset, 764 + 0 0 0 0.5px rgba(0, 0, 0, 0.5); 765 + } 766 + .frosted { 767 + background: rgba(28, 28, 30, 0.65); 768 + border-bottom-color: rgba(255, 255, 255, 0.06); 769 + color: var(--ink-soft); 770 + } 771 + .icon-stage::before { 772 + background: rgba(40, 50, 70, 0.45); 773 + border: 1px solid rgba(255, 255, 255, 0.08); 774 + box-shadow: 775 + inset 0 1px 0 rgba(255, 255, 255, 0.12), 776 + inset 0 -1px 0 rgba(0, 0, 0, 0.25), 777 + 0 8px 24px -8px rgba(0, 0, 0, 0.5); 778 + } 779 + section.panel { 780 + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset; 781 + } 782 + .aqua.alt { 783 + color: #f5f5f7; 784 + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.5); 785 + background: linear-gradient(to bottom, #4a4a4d 0%, #2c2c2e 100%); 786 + border-color: #1a1a1c; 787 + box-shadow: 788 + 0 1px 0 rgba(255, 255, 255, 0.18) inset, 789 + 0 0 0 0.5px rgba(255, 255, 255, 0.12) inset, 790 + 0 8px 20px -8px rgba(0, 0, 0, 0.55), 791 + 0 1px 2px rgba(0, 0, 0, 0.4); 792 + } 793 + .aqua.alt::after { 794 + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0)); 795 + } 796 + code { 797 + background: #2c2c2e; 798 + border-color: #3a3a3c; 799 + color: var(--ink-body); 800 + } 801 + section.panel.testimonial { 802 + background: linear-gradient(to bottom, rgba(74, 147, 255, 0.07), rgba(74, 147, 255, 0.02)); 803 + } 804 + .badge { 805 + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.25) inset; 806 + } 807 + /* Menu bar — dark-mode vibrancy. */ 808 + .macbar { 809 + background: rgba(20, 20, 22, 0.7); 810 + color: #f5f5f7; 811 + border-bottom-color: rgba(255, 255, 255, 0.08); 812 + } 813 + .macbar .apple-logo { fill: #f5f5f7; } 814 + .macbar .menu-item:hover, .macbar .menu-app:hover { 815 + background: rgba(255, 255, 255, 0.10); 816 + } 533 817 } 534 818 </style> 535 819 </head> 536 820 <body> 537 821 822 + <!-- Faux macOS menu bar with a real, working Menu Band piano on the right. 823 + Click the hero icon below and it genie's UP into this piano. --> 824 + <div class="macbar" role="presentation"> 825 + <div class="macbar-left"> 826 + <svg class="apple-logo" viewBox="0 0 14 17" aria-hidden="true"> 827 + <path d="M9.78 9.27c.02-1.81 1.49-2.67 1.55-2.71-.85-1.24-2.17-1.4-2.64-1.42-1.12-.11-2.19.66-2.76.66-.58 0-1.45-.65-2.39-.63C2.32 5.18 1.22 5.89.62 6.94c-1.31 2.27-.34 5.64.94 7.49.62.91 1.36 1.92 2.32 1.88.93-.04 1.29-.6 2.41-.6 1.12 0 1.45.6 2.43.59 1-.02 1.64-.92 2.25-1.83.71-1.06 1-2.09 1.01-2.14-.03-.01-1.94-.74-1.96-2.96zM7.92 3.85c.51-.62.86-1.49.77-2.35-.74.04-1.64.5-2.17 1.11-.49.55-.91 1.42-.79 2.27.83.06 1.68-.42 2.19-1.03z"/> 828 + </svg> 829 + <span class="menu-app">Menu Band</span> 830 + <span class="menu-item">File</span> 831 + <span class="menu-item">Edit</span> 832 + <span class="menu-item">View</span> 833 + <span class="menu-item">Window</span> 834 + <span class="menu-item">Help</span> 835 + </div> 836 + <div class="macbar-right"> 837 + <span class="menu-extra" aria-hidden="true">⌘</span> 838 + <span class="menu-extra" aria-hidden="true">⏏</span> 839 + <span class="menu-extra" aria-hidden="true">◐</span> 840 + <span class="menu-extra" aria-hidden="true">▮▮▮</span> 841 + <span class="menu-extra" aria-hidden="true">📶</span> 842 + <!-- The actual Menu Band piano graphic — one playable octave. --> 843 + <div class="dock-piano" id="dock-piano" aria-label="Menu Band piano" title="Menu Band"> 844 + <span class="key w" data-note="C"></span> 845 + <span class="key b" data-note="C#"></span> 846 + <span class="key w" data-note="D"></span> 847 + <span class="key b" data-note="D#"></span> 848 + <span class="key w" data-note="E"></span> 849 + <span class="key w" data-note="F"></span> 850 + <span class="key b" data-note="F#"></span> 851 + <span class="key w" data-note="G"></span> 852 + <span class="key b" data-note="G#"></span> 853 + <span class="key w" data-note="A"></span> 854 + <span class="key b" data-note="A#"></span> 855 + <span class="key w" data-note="B"></span> 856 + </div> 857 + <span class="menu-extra clock" id="macbar-clock" aria-hidden="true">Wed 2:48 PM</span> 858 + </div> 859 + </div> 860 + 538 861 <article class="window" aria-label="Menu Band"> 539 862 <header class="titlebar" aria-hidden="true"> 540 863 <div class="lights"> ··· 618 941 <a href="mailto:mail@aesthetic.computer">mail@aesthetic.computer</a> 619 942 </div> 620 943 </footer> 944 + 945 + <script> 946 + // ── Live menu-bar clock — same format as macOS. ──────────────────────── 947 + (function clock() { 948 + const el = document.getElementById('macbar-clock'); 949 + if (!el) return; 950 + const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; 951 + function tick() { 952 + const d = new Date(); 953 + let h = d.getHours(); 954 + const m = d.getMinutes(); 955 + const ampm = h >= 12 ? 'PM' : 'AM'; 956 + h = h % 12 || 12; 957 + el.textContent = `${days[d.getDay()]} ${h}:${String(m).padStart(2,'0')} ${ampm}`; 958 + } 959 + tick(); 960 + setInterval(tick, 15000); 961 + })(); 962 + 963 + // ── Genie effect — click the hero icon to fly it into the menu-bar piano, 964 + // click it again to fly it back. The destination is computed live so 965 + // it survives layout shifts and works on narrow viewports. ─────────── 966 + (function genie() { 967 + const stage = document.querySelector('.icon-stage'); 968 + const icon = document.querySelector('.icon-stage img.app-icon'); 969 + const piano = document.getElementById('dock-piano'); 970 + if (!stage || !icon || !piano) return; 971 + 972 + let docked = false; 973 + let busy = false; 974 + 975 + function flyTo(dest) { 976 + const ir = icon.getBoundingClientRect(); 977 + const pr = piano.getBoundingClientRect(); 978 + const targetX = (pr.left + pr.width / 2) - (ir.left + ir.width / 2); 979 + const targetY = (pr.top + pr.height / 2) - (ir.top + ir.height / 2); 980 + icon.style.setProperty('--gx', targetX + 'px'); 981 + icon.style.setProperty('--gy', targetY + 'px'); 982 + icon.classList.remove('genie-up', 'genie-down'); 983 + // Force reflow so a re-trigger restarts the animation cleanly. 984 + void icon.offsetWidth; 985 + icon.classList.add(dest === 'up' ? 'genie-up' : 'genie-down'); 986 + } 987 + 988 + function pulsePiano() { 989 + piano.classList.add('glow'); 990 + setTimeout(() => piano.classList.remove('glow'), 600); 991 + } 992 + 993 + icon.addEventListener('click', () => { 994 + if (busy) return; 995 + busy = true; 996 + flyTo(docked ? 'down' : 'up'); 997 + if (!docked) setTimeout(pulsePiano, 560); 998 + docked = !docked; 999 + setTimeout(() => { busy = false; }, 740); 1000 + }); 1001 + 1002 + // Click the menu-bar piano to bring the icon back, too. 1003 + piano.addEventListener('click', (e) => { 1004 + // Don't intercept individual key plays from bubbling up if the user 1005 + // wired keys to play notes — this listener only fires for the piano 1006 + // body itself when the icon is currently docked. 1007 + if (!docked || busy) return; 1008 + busy = true; 1009 + flyTo('down'); 1010 + docked = false; 1011 + setTimeout(() => { busy = false; }, 740); 1012 + }); 1013 + 1014 + // Tiny "play" highlight on key click — purely visual. stopPropagation 1015 + // on both mousedown AND click so pressing a key while the icon is 1016 + // docked doesn't also fire the piano's "bring back" handler. 1017 + piano.querySelectorAll('.key').forEach(k => { 1018 + const swallow = (ev) => ev.stopPropagation(); 1019 + k.addEventListener('click', swallow); 1020 + k.addEventListener('mousedown', (ev) => { 1021 + ev.stopPropagation(); 1022 + k.classList.add('lit'); 1023 + setTimeout(() => k.classList.remove('lit'), 140); 1024 + }); 1025 + }); 1026 + })(); 1027 + </script> 621 1028 622 1029 </body> 623 1030 </html>