Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: 0.3 — audio fix, Metal visualizer, palette collapse, click sounds

- MenuBandSynth: wrap each first-time program switch in EnablePreload so
MIDISynth actually faults the instrument's samples in. Without this the
bank URL was set but no programs loaded — noteOn was silent. Tracked in
loadedPrograms set so subsequent switches stay sub-millisecond.
- WaveformView: rewritten as MTKView with instanced unit-quad shader.
Sixteen bars, per-bar attack/release ballistics, accent-colored fill.
Driven by an explicit CVDisplayLink + display() because MTKView's
internal link doesn't fire reliably inside an NSPopover panel.
- Popover: instrument palette + label + readout collapse with an animated
popover resize when MIDI mode is on (DAW chooses instruments there, so
the local picker is misleading dead UI).
- AppDelegate + popover: Tink system click on popover open and on MIDI
switch flip — sharp tactile feedback.
- Drop the post-release audition note in handleInstrumentCommit. The
press-gated rollover already plays a preview while held, so the 70 ms
delayed retrigger was just doubling the sound.

+389 -126
+2 -2
slab/menuband/Info.plist
··· 13 13 <key>CFBundlePackageType</key> 14 14 <string>APPL</string> 15 15 <key>CFBundleVersion</key> 16 - <string>2</string> 16 + <string>3</string> 17 17 <key>CFBundleShortVersionString</key> 18 - <string>0.2</string> 18 + <string>0.3</string> 19 19 <key>CFBundleInfoDictionaryVersion</key> 20 20 <string>6.0</string> 21 21 <key>CFBundleIconFile</key>
+6
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 82 82 // for quickly toggling between the menubar piano and the picker. 83 83 let vc = MenuBandPopoverViewController() 84 84 vc.menuBand = menuBand 85 + vc.popover = popover 85 86 popoverVC = vc 86 87 popover.contentViewController = vc 87 88 // .applicationDefined: never auto-close. We manage closing manually ··· 365 366 // by default; without this you have to click into the popover 366 367 // once before its controls react. 367 368 NSApp.activate(ignoringOtherApps: true) 369 + // Sharp tactile click on open — the settings chip is the only 370 + // entry point to the popover, so this becomes the "menu opening" 371 + // sound. Tink is short and unmistakable; a richer custom sample 372 + // would need an asset bundle, and Tink ships on every macOS. 373 + NSSound(named: NSSound.Name("Tink"))?.play() 368 374 popover.show(relativeTo: anchor, of: button, preferredEdge: .minY) 369 375 DispatchQueue.main.async { 370 376 self.popover.contentViewController?.view.window?.makeKey()
+81 -14
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 57 57 /// richer feel than a plain NSMenu. 58 58 final class MenuBandPopoverViewController: NSViewController { 59 59 weak var menuBand: MenuBandController? 60 + /// Owning popover, set by AppDelegate after construction. Held weak so 61 + /// we don't extend its lifetime; used to animate `contentSize` when the 62 + /// instrument palette collapses / expands. 63 + weak var popover: NSPopover? 60 64 61 65 private var inputSegmented: HoverSegmentedControl! // legacy reference; no longer added to stack 62 66 private var modeButtons: [NSButton] = [] // vertical stack: Mouse Only / Notepat.com / Ableton MIDI Keys ··· 65 69 private var midiSelfTestLabel: NSTextField! // legacy — created but never added to stack 66 70 private var instrumentList: InstrumentListView! 67 71 private var instrumentReadout: NSTextField! 72 + private var instrumentLabel: NSTextField! 73 + private var instrumentSeparator: NSView! 68 74 private var octaveStepper: NSStepper! 69 75 private var octaveLabel: NSTextField! 70 76 private var crashStatusLabel: NSTextField! ··· 319 325 // label color in the title row instead (green = ok, red = failed). 320 326 midiSelfTestLabel = NSTextField(labelWithString: "") 321 327 322 - stack.addArrangedSubview(makeSeparator()) 328 + instrumentSeparator = makeSeparator() 329 + stack.addArrangedSubview(instrumentSeparator) 323 330 324 331 // Instrument named-list. All 128 GM programs in a scrollable list, 325 332 // family-colored stripe on the left, name on the right. Hover plays 326 333 // a preview note; click commits. Compact (~180 px window) so the 327 334 // popover stays small even though the full list is much taller. 328 - let instrumentLabel = NSTextField(labelWithString: "Instrument") 335 + // Hidden in MIDI mode — the DAW picks instruments there, so this 336 + // local picker would just be misleading dead UI. 337 + instrumentLabel = NSTextField(labelWithString: "Instrument") 329 338 instrumentLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 330 339 instrumentLabel.textColor = .labelColor 331 340 stack.addArrangedSubview(instrumentLabel) ··· 508 517 // it instead of showing a misleading dead waveform. 509 518 waveformView.isHidden = n.midiMode 510 519 waveformView.isLive = !n.midiMode 520 + // Instrument palette: only meaningful when the local synth is the 521 + // audio path. In MIDI mode the DAW chooses the instrument, so the 522 + // local picker is hidden along with its label, separator, and 523 + // selected-program readout. 524 + applyInstrumentPaletteVisibility(midiMode: n.midiMode) 511 525 // Wire up live updates so the label reflects loopback results as 512 526 // they land (test runs ~50ms after toggle-on; result settles a moment 513 527 // later). ··· 667 681 // toggles after the first per session) and other panels don't need 668 682 // to refresh. 669 683 menuBand?.toggleMIDIMode() 670 - // Waveform follows the new MIDI state directly so it appears / 671 - // disappears the moment the user flips the switch. 684 + // Tactile feedback — short system tick so the flip feels mechanical 685 + // even though it's a software switch. 686 + NSSound(named: NSSound.Name("Tink"))?.play() 687 + // Waveform + instrument palette follow the new MIDI state directly 688 + // so they appear / disappear the moment the user flips the switch. 672 689 if let m = menuBand { 673 - waveformView.isHidden = m.midiMode 690 + waveformView.animator().isHidden = m.midiMode 674 691 waveformView.isLive = !m.midiMode 692 + applyInstrumentPaletteVisibility(midiMode: m.midiMode, animated: true) 675 693 } 676 694 } 677 695 696 + private func applyInstrumentPaletteVisibility(midiMode: Bool, animated: Bool = false) { 697 + // Compute the target popover size with the palette toggled. Hide 698 + // first inside a layout pass so `fittingSize` reflects the post- 699 + // collapse stack — without this the new size would equal the old. 700 + let setHidden = { 701 + self.instrumentSeparator.isHidden = midiMode 702 + self.instrumentLabel.isHidden = midiMode 703 + self.instrumentList.isHidden = midiMode 704 + self.instrumentReadout.isHidden = midiMode 705 + } 706 + if !animated { 707 + setHidden() 708 + view.needsLayout = true 709 + view.layoutSubtreeIfNeeded() 710 + let fitting = view.fittingSize 711 + preferredContentSize = NSSize(width: fitting.width, 712 + height: fitting.height) 713 + return 714 + } 715 + // Animated path: NSStackView collapses arranged subviews when their 716 + // `isHidden` flips inside an animation group, and the popover's 717 + // backing window is an NSWindow that animates frame changes via its 718 + // animator proxy. We hide the stack rows inside the animation 719 + // context (so they fade with the resize) and slide the window's 720 + // height in lockstep. `preferredContentSize` is committed at the 721 + // end so AppKit's bookkeeping matches the final geometry. 722 + setHidden() 723 + view.needsLayout = true 724 + view.layoutSubtreeIfNeeded() 725 + let fitting = view.fittingSize 726 + let newSize = NSSize(width: fitting.width, height: fitting.height) 727 + guard let window = view.window else { 728 + preferredContentSize = newSize 729 + return 730 + } 731 + // NSPopover anchors its arrow to the menubar; on .minY edge the 732 + // popover hangs below, so its window's origin.y stays fixed at the 733 + // top while only the height changes downward. Resize from the top 734 + // by adjusting origin.y to keep the arrow attached. 735 + var frame = window.frame 736 + let dh = newSize.height - frame.height 737 + frame.origin.y -= dh 738 + frame.size.height = newSize.height 739 + frame.size.width = newSize.width 740 + NSAnimationContext.runAnimationGroup { ctx in 741 + ctx.duration = 0.18 742 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) 743 + ctx.allowsImplicitAnimation = true 744 + window.animator().setFrame(frame, display: true) 745 + } 746 + preferredContentSize = newSize 747 + } 748 + 678 749 /// 0 = Pointer, 1 = Notepat, 2 = Ableton. Matches the segmented control 679 750 /// in `loadView()`. 680 751 private func inputModeSegment(typeMode: Bool, keymap: Keymap) -> Int { ··· 705 776 instrumentList.selectedProgram = UInt8(program) 706 777 updateInstrumentReadout(program: UInt8(program)) 707 778 debugLog("instrument commit prog=\(program)") 708 - // setMelodicProgram → loadSoundBankInstrument is synchronous on the 709 - // calling thread, but AVAudioUnitSampler briefly drops scheduled 710 - // notes on the audio render thread while it swaps banks. Without 711 - // this small delay the audition note often falls into that gap and 712 - // the user hears nothing. 70 ms is enough for the swap to settle 713 - // on every Mac I've tested without feeling laggy. 714 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.07) { [weak m] in 715 - m?.auditionCurrentProgram() 716 - } 779 + // No post-release audition: the press-gated rollover already played 780 + // a preview note while the mouse was held, so retriggering on 781 + // release just doubles the sound. mouseUp paths through onHover(nil) 782 + // first which stops the preview cleanly — that's the audio-end the 783 + // user expects. 717 784 } 718 785 719 786 @objc private func octaveChanged(_ sender: NSStepper) {
+52 -12
slab/menuband/Sources/MenuBand/MenuBandSynth.swift
··· 129 129 engine.attach(avUnit) 130 130 engine.connect(avUnit, to: engine.mainMixerNode, format: nil) 131 131 132 - // 3. Set channel 0 to GM Melodic bank + the user's current program. 133 - // Channel 9 to GM Percussion + standard kit. We let MIDISynth 134 - // lazy-load programs on first use rather than preloading the 135 - // full bank — the preload-mode dance was preventing later PC 136 - // messages from actually switching the active program. Lazy 137 - // load gives a few ms of disk warmup per new program but 138 - // program switches Just Work after that. 132 + // 3. Load just the programs we need. MIDISynth requires 133 + // EnablePreload(true) → PC → EnablePreload(false) to actually 134 + // fault each program's samples in from the DLS bank; without 135 + // that, the bank URL is set but no instruments load and noteOn 136 + // is silent. The earlier full 128-program sweep used the same 137 + // mechanism but at startup cost; we now load programs lazily on 138 + // first use via setMelodicProgram. 139 139 selectMelodicProgram(au, program: currentMelodicProgram) 140 140 selectDrumKit(au) 141 141 ··· 150 150 // (Leaving the nodes attached but unrouted is safe.) 151 151 } 152 152 153 - /// Select a melodic program on channel 0 of the MIDISynth via a proper 154 - /// GM bank-select + Program Change triplet. CC0 = bank MSB (0x79 for the 155 - /// GM Melodic bank), CC32 = bank LSB (0), then PC = program. Without 156 - /// the bank-select pair, raw PC messages may end up routing into a 157 - /// different bank and produce silent/wrong-program output. 153 + /// Set of (bankMSB << 8 | program) keys we've already faulted in. The 154 + /// EnablePreload dance only needs to run once per (bank, program) — once 155 + /// loaded, subsequent program changes to the same slot are instant. 156 + private var loadedPrograms: Set<UInt16> = [] 157 + 158 + /// Select a melodic program on channel 0. First time a (bank, program) 159 + /// is touched we wrap the bank-select + PC in EnablePreload so MIDISynth 160 + /// actually faults the instrument's samples in from the DLS bank. 161 + /// Subsequent calls for an already-loaded program skip the preload 162 + /// toggle and just send the bank-select + PC for instant switching. 163 + /// CC0 = bank MSB (0x79 GM Melodic), CC32 = bank LSB (0). 158 164 private func selectMelodicProgram(_ au: AudioUnit, program: UInt8) { 165 + let key: UInt16 = (UInt16(0x79) << 8) | UInt16(program) 166 + let needsLoad = !loadedPrograms.contains(key) 167 + if needsLoad { setMIDISynthPreload(au, enable: true) } 159 168 sendMIDIEvent(au, status: 0xB0, data1: 0, data2: 0x79) // CC0 bank MSB 160 169 sendMIDIEvent(au, status: 0xB0, data1: 32, data2: 0x00) // CC32 bank LSB 161 170 sendMIDIEvent(au, status: 0xC0, data1: program) // PC program 171 + if needsLoad { 172 + setMIDISynthPreload(au, enable: false) 173 + loadedPrograms.insert(key) 174 + // After preload-disable, the AU's *active* program is still 175 + // unset on this channel — re-send the PC so noteOn lands on the 176 + // freshly loaded instrument instead of silence. 177 + sendMIDIEvent(au, status: 0xC0, data1: program) 178 + } 162 179 } 163 180 164 181 /// Select the standard GM drum kit on channel 9 (bank MSB 0x78). 165 182 private func selectDrumKit(_ au: AudioUnit) { 183 + let key: UInt16 = (UInt16(0x78) << 8) | 0 184 + let needsLoad = !loadedPrograms.contains(key) 185 + if needsLoad { setMIDISynthPreload(au, enable: true) } 166 186 sendMIDIEvent(au, status: 0xB9, data1: 0, data2: 0x78) // CC0 bank MSB 167 187 sendMIDIEvent(au, status: 0xB9, data1: 32, data2: 0x00) // CC32 bank LSB 168 188 sendMIDIEvent(au, status: 0xC9, data1: 0) // PC kit 0 189 + if needsLoad { 190 + setMIDISynthPreload(au, enable: false) 191 + loadedPrograms.insert(key) 192 + sendMIDIEvent(au, status: 0xC9, data1: 0) 193 + } 194 + } 195 + 196 + private func setMIDISynthPreload(_ au: AudioUnit, enable: Bool) { 197 + var flag: UInt32 = enable ? 1 : 0 198 + let status = AudioUnitSetProperty( 199 + au, 200 + AudioUnitPropertyID(kAUMIDISynthProperty_EnablePreload), 201 + kAudioUnitScope_Global, 202 + 0, 203 + &flag, 204 + UInt32(MemoryLayout<UInt32>.size) 205 + ) 206 + if status != noErr { 207 + NSLog("MenuBand: MIDISynth EnablePreload(\(enable)) status=\(status)") 208 + } 169 209 } 170 210 171 211 @inline(__always)
+248 -98
slab/menuband/Sources/MenuBand/WaveformView.swift
··· 1 1 import AppKit 2 + import Metal 3 + import MetalKit 4 + import simd 2 5 3 - /// Bottom-anchored audio bars synced to the display's vsync via 4 - /// CVDisplayLink — Timer-on-runloop was throttling to ~12 Hz inside the 5 - /// NSPopover window's mode. CVDisplayLink fires at the screen's refresh 6 - /// rate (60 Hz on most Macs, 120 on ProMotion) regardless of run-loop 7 - /// scheduling. Single CAShapeLayer + one combined path of 32 bar rects, 8 - /// monochrome systemTeal fill, no decay. Hidden when MIDI mode is on. 9 - final class WaveformView: NSView { 6 + /// Bottom-anchored audio bars rendered with Metal. The previous CAShapeLayer 7 + /// path swap was already vsync-driven, but Metal lets the bar geometry live 8 + /// in a vertex shader (instanced quads) instead of rebuilding a CGPath on 9 + /// the main thread every frame, and gives us a clean substrate for richer 10 + /// visualizers later (FFT, particles, gradients) without re-architecting. 11 + /// 12 + /// Sixteen instanced quads, accent-colored fill, opaque dark background, 13 + /// driven by MTKView's internal CVDisplayLink at the screen's native 14 + /// refresh. Hidden when MIDI mode is on. 15 + final class WaveformView: MTKView { 10 16 weak var menuBand: MenuBandController? 11 17 12 - private static let barCount = 32 13 - private static let barGap: CGFloat = 2 14 - private static let snapshotSize = 512 18 + private static let barCount = 16 19 + private static let barGapPts: Float = 3 // points; multiplied by contentsScale at draw time 20 + private static let snapshotSize = 256 15 21 16 22 private var samples = [Float](repeating: 0, count: snapshotSize) 17 - private let barLayer = CAShapeLayer() 23 + private var smoothedPeak: Float = 0.05 24 + private var levels = [Float](repeating: 0, count: barCount) 25 + 26 + private var commandQueue: MTLCommandQueue? 27 + private var pipelineState: MTLRenderPipelineState? 28 + private var uniforms = BarUniforms() 29 + /// Per-bar smoothed level — gives the bars visual "ballistics" so they 30 + /// don't pop instantly between frames. Decay slower than rise so attack 31 + /// transients punch but releases trail off naturally. 32 + private var displayLevels = [Float](repeating: 0, count: barCount) 33 + /// Explicit display link. NSPopover hosts content in an NSPanel whose 34 + /// runloop coalesces setNeedsDisplay-triggered redraws, and MTKView's 35 + /// internal CVDisplayLink doesn't fire reliably when the panel isn't 36 + /// `main`. We drive draws ourselves and call `display()` so the redraw 37 + /// is synchronous instead of deferred. 18 38 private var displayLink: CVDisplayLink? 19 39 20 - /// `true` while a `tick()` dispatch is queued to main but hasn't yet 21 - /// run. CVDisplayLink fires every vsync; if the main thread is briefly 22 - /// busy we MUST drop frames instead of stacking them up — a backlog of 23 - /// dispatch_main blocks turns into post-busy stutter that reads as 24 - /// "the visualizer is laggy." 25 - private var tickPending = false 26 - private let tickLock = NSLock() 27 - 28 - /// Smoothed peak across recent frames so the auto-gain doesn't strobe 29 - /// — when a transient hits, gain snaps; in silence, gain bleeds back 30 - /// over half a second so the next note pops. 31 - private var smoothedPeak: Float = 0.05 32 - 33 40 var isLive: Bool = false { 34 41 didSet { 35 - if isLive { startLink() } else { stopLink() } 42 + if isLive { 43 + startLink() 44 + } else { 45 + stopLink() 46 + for i in 0..<levels.count { levels[i] = 0 } 47 + for i in 0..<displayLevels.count { displayLevels[i] = 0 } 48 + display() // one final paint to clear bars 49 + } 36 50 } 37 51 } 38 52 39 - override init(frame frameRect: NSRect) { 40 - super.init(frame: frameRect) 41 - wantsLayer = true 42 - layer?.backgroundColor = NSColor.black.withAlphaComponent(0.92).cgColor 43 - layer?.cornerRadius = 8 44 - // Match the menubar piano's lit color: bars use the system accent. 45 - // Re-applied in `viewDidChangeEffectiveAppearance` so changing the 46 - // user's accent in System Settings updates the bars without restart. 47 - barLayer.fillColor = NSColor.controlAccentColor.cgColor 48 - barLayer.strokeColor = nil 49 - barLayer.actions = ["path": NSNull()] 50 - layer?.addSublayer(barLayer) 51 - } 52 - required init?(coder: NSCoder) { fatalError() } 53 - 54 - override func layout() { 55 - super.layout() 56 - barLayer.frame = bounds 57 - } 58 - 59 - override func viewDidChangeEffectiveAppearance() { 60 - super.viewDidChangeEffectiveAppearance() 61 - // Accent color is appearance-derived — re-pull when the user flips 62 - // light/dark or changes the system accent in Settings. 63 - barLayer.fillColor = NSColor.controlAccentColor.cgColor 64 - } 65 - 66 - deinit { stopLink() } 67 - 68 53 private func startLink() { 69 54 stopLink() 70 55 var link: CVDisplayLink? ··· 74 59 CVDisplayLinkSetOutputCallback(link, { _, _, _, _, _, ctx in 75 60 guard let ctx = ctx else { return kCVReturnSuccess } 76 61 let view = Unmanaged<WaveformView>.fromOpaque(ctx).takeUnretainedValue() 77 - // Coalesce: only queue a main-thread tick if we don't already 78 - // have one waiting. Without this, every vsync queues a tick 79 - // even when main is too busy to service them, so a brief stall 80 - // turns into a long chain of catch-up frames that reads as lag. 81 - view.tickLock.lock() 82 - let alreadyPending = view.tickPending 83 - view.tickPending = true 84 - view.tickLock.unlock() 85 - if alreadyPending { return kCVReturnSuccess } 86 - DispatchQueue.main.async { view.tick() } 62 + // display() is main-thread-only; hop over and draw synchronously 63 + // so the redraw can't be coalesced by the popover's runloop. 64 + DispatchQueue.main.async { view.display() } 87 65 return kCVReturnSuccess 88 66 }, opaque) 89 67 CVDisplayLinkStart(link) ··· 97 75 } 98 76 } 99 77 78 + deinit { stopLink() } 79 + 100 80 override func viewDidMoveToWindow() { 101 81 super.viewDidMoveToWindow() 102 82 if window == nil { stopLink() } 103 83 } 104 84 105 - private func tick() { 106 - tickLock.lock() 107 - tickPending = false 108 - tickLock.unlock() 109 - guard let m = menuBand else { return } 110 - m.synthSnapshotWaveform(into: &samples) 85 + init() { 86 + guard let device = MTLCreateSystemDefaultDevice() else { 87 + fatalError("MenuBand: no Metal device — every Mac since 2012 has one, " 88 + + "so this should not happen on macOS 11+") 89 + } 90 + super.init(frame: .zero, device: device) 91 + wantsLayer = true 92 + // Note: not using layer.cornerRadius — CAMetalLayer with 93 + // framebufferOnly = true can't be reliably clipped by a corner 94 + // mask. Square corners for the visualizer are fine; if we want 95 + // them rounded later, wrap in a clipping container view. 111 96 112 - let w = bounds.width 113 - let h = bounds.height 114 - guard w > 0, h > 0 else { return } 97 + // Opaque background — the previous 0.92 alpha was barely a tint and 98 + // making the layer translucent costs us framebufferOnly + complicates 99 + // the blend setup. Solid black reads identical at the popover scale. 100 + clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1.0) 101 + framebufferOnly = true 102 + (layer as? CAMetalLayer)?.isOpaque = true 103 + 104 + // We drive draws via our own CVDisplayLink + display() so MTKView's 105 + // internal frame timing doesn't fight the popover's runloop mode. 106 + // `enableSetNeedsDisplay = true` puts MTKView in dirty-rect mode; 107 + // `isPaused = false` so display() actually paints when called. 108 + enableSetNeedsDisplay = true 109 + isPaused = false 110 + preferredFramesPerSecond = 0 111 + 112 + commandQueue = device.makeCommandQueue() 113 + buildPipeline(device: device) 114 + delegate = self 115 + applyAccentColor() 116 + } 117 + 118 + required init(coder: NSCoder) { 119 + fatalError("WaveformView is code-only; init(coder:) is not supported") 120 + } 115 121 116 - // Per-bar peak amplitude. 122 + override var isOpaque: Bool { true } 123 + 124 + private func buildPipeline(device: MTLDevice) { 125 + do { 126 + let library = try device.makeLibrary(source: Self.shaderSource, options: nil) 127 + guard let vfn = library.makeFunction(name: "bar_vertex"), 128 + let ffn = library.makeFunction(name: "bar_fragment") else { 129 + NSLog("MenuBand: visualizer shader functions missing") 130 + return 131 + } 132 + let pd = MTLRenderPipelineDescriptor() 133 + pd.vertexFunction = vfn 134 + pd.fragmentFunction = ffn 135 + pd.colorAttachments[0].pixelFormat = colorPixelFormat 136 + pipelineState = try device.makeRenderPipelineState(descriptor: pd) 137 + } catch { 138 + NSLog("MenuBand: visualizer Metal pipeline failed: \(error)") 139 + } 140 + } 141 + 142 + override func viewDidChangeEffectiveAppearance() { 143 + super.viewDidChangeEffectiveAppearance() 144 + applyAccentColor() 145 + } 146 + 147 + private func applyAccentColor() { 148 + let c = NSColor.controlAccentColor.usingColorSpace(.sRGB) ?? NSColor.systemTeal 149 + uniforms.color = SIMD4<Float>(Float(c.redComponent), 150 + Float(c.greenComponent), 151 + Float(c.blueComponent), 152 + 1.0) 153 + } 154 + 155 + // MARK: - Per-frame audio analysis 156 + 157 + private func updateLevels() { 158 + guard let m = menuBand else { 159 + for i in 0..<levels.count { levels[i] = 0 } 160 + for i in 0..<displayLevels.count { displayLevels[i] = 0 } 161 + return 162 + } 163 + m.synthSnapshotWaveform(into: &samples) 164 + 117 165 let n = Self.barCount 118 166 let chunkSize = samples.count / n 119 167 var framePeak: Float = 0 120 - var levels = [Float](repeating: 0, count: n) 121 168 for b in 0..<n { 122 169 var peak: Float = 0 123 170 let base = b * chunkSize ··· 129 176 if peak > framePeak { framePeak = peak } 130 177 } 131 178 132 - // Auto-gain normalization. Track a smoothed peak — when current 133 - // frame is louder, jump up immediately so attack reads; when 134 - // quieter, decay over ~½ s so a sustained-quiet note still pushes 135 - // bars high. Floor at 0.05 so we never amplify the noise floor to 136 - // full scale. 179 + // Auto-gain — same envelope as the old CALayer path. Snap up on 180 + // attack, decay slowly on release so a sustained quiet note still 181 + // pushes bars up. 137 182 if framePeak > smoothedPeak { 138 183 smoothedPeak = framePeak 139 184 } else { 140 185 smoothedPeak = max(0.05, smoothedPeak * 0.92 + framePeak * 0.08) 141 186 } 142 - let gain = CGFloat(0.95) / CGFloat(smoothedPeak) 187 + let gain = 0.95 / smoothedPeak 188 + // Per-bar ballistics — fast attack (snap up on transients), slow 189 + // release (bars trail off ~100 ms instead of popping to zero). 190 + // Without this the bars look snappy/jumpy at any framerate; with it 191 + // they read as a smooth animation even at 60 Hz. 192 + let attack: Float = 0.55 193 + let release: Float = 0.18 194 + for b in 0..<n { 195 + let target = min(1.0, levels[b] * gain) 196 + let cur = displayLevels[b] 197 + let k = target > cur ? attack : release 198 + displayLevels[b] = cur + (target - cur) * k 199 + } 200 + } 201 + 202 + // MARK: - Shader source 203 + 204 + private static let shaderSource = """ 205 + #include <metal_stdlib> 206 + using namespace metal; 207 + 208 + struct Uniforms { 209 + float viewW; 210 + float viewH; 211 + float barW; 212 + float stride; 213 + float minHeight; 214 + float4 color; 215 + }; 216 + 217 + struct VertexOut { 218 + float4 position [[position]]; 219 + }; 220 + 221 + // Two triangles spanning the unit square (CCW), shared across all bar 222 + // instances. The vertex shader scales each instance into a bar rect 223 + // and converts pixel space to clip space. 224 + constant float2 unitQuad[6] = { 225 + float2(0, 0), float2(1, 0), float2(0, 1), 226 + float2(0, 1), float2(1, 0), float2(1, 1) 227 + }; 228 + 229 + vertex VertexOut bar_vertex(uint vid [[vertex_id]], 230 + uint iid [[instance_id]], 231 + constant Uniforms &u [[buffer(0)]], 232 + constant float *levels [[buffer(1)]]) 233 + { 234 + float2 local = unitQuad[vid]; 235 + float barX = float(iid) * u.stride; 236 + float h = max(u.minHeight, levels[iid] * u.viewH); 237 + float px = barX + local.x * u.barW; 238 + float py = local.y * h; 239 + // Pixel space → clip space ([-1, 1] on both axes). 240 + float clipX = (px / u.viewW) * 2.0 - 1.0; 241 + float clipY = (py / u.viewH) * 2.0 - 1.0; 242 + VertexOut out; 243 + out.position = float4(clipX, clipY, 0, 1); 244 + return out; 245 + } 246 + 247 + fragment float4 bar_fragment(VertexOut in [[stage_in]], 248 + constant Uniforms &u [[buffer(0)]]) 249 + { 250 + return u.color; 251 + } 252 + """ 253 + } 254 + 255 + private struct BarUniforms { 256 + var viewW: Float = 0 257 + var viewH: Float = 0 258 + var barW: Float = 0 259 + var stride: Float = 0 260 + var minHeight: Float = 1.5 261 + var color: SIMD4<Float> = SIMD4<Float>(0, 1, 1, 1) 262 + } 263 + 264 + extension WaveformView: MTKViewDelegate { 265 + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} 266 + 267 + func draw(in view: MTKView) { 268 + updateLevels() 143 269 144 - // Build the bar path. 145 - let barW = (w - Self.barGap * CGFloat(n - 1)) / CGFloat(n) 146 - let stride = barW + Self.barGap 147 - let path = CGMutablePath() 148 - for b in 0..<n { 149 - let normalized = Swift.min(1.0, CGFloat(levels[b]) * gain) 150 - let bh = Swift.max(1.5, normalized * h) 151 - let bx = CGFloat(b) * stride 152 - path.addRect(CGRect(x: bx, y: 0, width: barW, height: bh)) 270 + let drawableSize = view.drawableSize 271 + let viewW = Float(drawableSize.width) 272 + let viewH = Float(drawableSize.height) 273 + let n = Self.barCount 274 + // Drawable size is in pixels; gap is in points, so scale up by 275 + // contentsScale (Retina = 2). Without this the gaps look half-width 276 + // on Retina displays. 277 + let scale = Float(layer?.contentsScale ?? 2.0) 278 + let gapPx = Self.barGapPts * scale 279 + let barW = max(1, (viewW - gapPx * Float(n - 1)) / Float(n)) 280 + let stride = barW + gapPx 281 + 282 + uniforms.viewW = viewW 283 + uniforms.viewH = viewH 284 + uniforms.barW = barW 285 + uniforms.stride = stride 286 + uniforms.minHeight = 1.5 * scale 287 + 288 + guard let pipeline = pipelineState, 289 + let queue = commandQueue, 290 + let descriptor = view.currentRenderPassDescriptor, 291 + let drawable = view.currentDrawable, 292 + let cb = queue.makeCommandBuffer(), 293 + let enc = cb.makeRenderCommandEncoder(descriptor: descriptor) else { 294 + return 153 295 } 154 - // Disable implicit animations on the path swap — the layer's 155 - // `actions` dict already nullifies "path", but wrapping the 156 - // assignment in a no-action transaction is a belt-and-suspenders 157 - // guarantee that no 0.25 s default fade sneaks in. 158 - CATransaction.begin() 159 - CATransaction.setDisableActions(true) 160 - barLayer.path = path 161 - CATransaction.commit() 296 + 297 + enc.setRenderPipelineState(pipeline) 298 + enc.setVertexBytes(&uniforms, length: MemoryLayout<BarUniforms>.size, index: 0) 299 + enc.setFragmentBytes(&uniforms, length: MemoryLayout<BarUniforms>.size, index: 0) 300 + displayLevels.withUnsafeBufferPointer { ptr in 301 + enc.setVertexBytes(ptr.baseAddress!, 302 + length: MemoryLayout<Float>.size * n, 303 + index: 1) 304 + } 305 + enc.drawPrimitives(type: .triangle, 306 + vertexStart: 0, 307 + vertexCount: 6, 308 + instanceCount: n) 309 + enc.endEncoding() 310 + cb.present(drawable) 311 + cb.commit() 162 312 } 163 313 }