diff --git a/slab/menuband/Info.plist b/slab/menuband/Info.plist index 1537fdd5a..89c1d2e6e 100644 --- a/slab/menuband/Info.plist +++ b/slab/menuband/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleVersion - 4 + 5 CFBundleShortVersionString - 0.4 + 0.5 CFBundleInfoDictionaryVersion 6.0 CFBundleIconFile diff --git a/slab/menuband/Sources/MenuBand/GarageBandLibrary.swift b/slab/menuband/Sources/MenuBand/GarageBandLibrary.swift new file mode 100644 index 000000000..54278126e --- /dev/null +++ b/slab/menuband/Sources/MenuBand/GarageBandLibrary.swift @@ -0,0 +1,99 @@ +import Foundation +import AVFoundation + +/// Scans the GarageBand sample library on disk and returns the subset of +/// `.exs` instruments that `AVAudioUnitSampler` can actually load. Many +/// of GB's bundled stubs reference samples from packs that haven't been +/// downloaded — those error out at load time with `-43` (file not found) +/// or `-10868` (format unsupported). We pre-flight every patch through a +/// throwaway sampler and only surface the ones that pass. +/// +/// Library is *empty* when GarageBand isn't installed or its Sound +/// Library hasn't been downloaded; callers should treat the GarageBand +/// backend as unavailable in that case (hide the toggle, fall back to GM). +enum GarageBandLibrary { + struct Patch: Hashable { + let family: String // "Church Organ", "iOS Instruments", etc. + let displayName: String // file basename minus .exs + let url: URL + } + + /// Top-level directories where GarageBand drops sampler patches. The + /// first path is the system one populated by the Sound Library + /// downloader; the second is the per-user one (rare for GB but Logic + /// Pro shares this convention). + private static let roots: [String] = [ + "/Library/Application Support/GarageBand/Instrument Library/Sampler/Sampler Instruments", + "\(NSHomeDirectory())/Music/Audio Music Apps/Sampler Instruments", + ] + + /// Cached scan result. The scan is *expensive* (~4 s for 50 files + /// across 3 families because each pre-flight load touches disk-backed + /// sample data) so we do it once at app startup and reuse the result + /// until the next launch. Users who download additional packs while + /// the app is running won't see them until restart — acceptable + /// tradeoff vs. either caching nothing or scanning live every time + /// the popover opens. + private(set) static var cache: [Patch] = [] + + /// True iff at least one loadable patch was found. Drives whether the + /// popover offers the GM/GarageBand toggle at all. + static var isAvailable: Bool { !cache.isEmpty } + + /// Patches grouped by family, families sorted alphabetically, patches + /// inside each family sorted by display name. Stable order so the + /// popover list doesn't shuffle between launches. + static var groupedByFamily: [(family: String, patches: [Patch])] { + let groups = Dictionary(grouping: cache, by: { $0.family }) + return groups.keys.sorted().map { family in + let sorted = groups[family]!.sorted { $0.displayName < $1.displayName } + return (family, sorted) + } + } + + /// Scan + pre-flight every `.exs` under `roots`. Fills `cache`. + /// Called once during `MenuBandController.bootstrap` on a background + /// queue so we don't block app launch on the load-test pass. + static func scan() { + // Disposable engine + sampler: we never play through it, just + // probe whether each EXS loads. Released as soon as the scan is + // done so we don't keep a second audio graph alive forever. + let engine = AVAudioEngine() + let sampler = AVAudioUnitSampler() + engine.attach(sampler) + engine.connect(sampler, to: engine.mainMixerNode, format: nil) + do { try engine.start() } catch { + NSLog("MenuBand: GB library scan engine start failed: \(error)") + return + } + defer { engine.stop() } + + var found: [Patch] = [] + for root in roots { + let rootURL = URL(fileURLWithPath: root) + guard FileManager.default.fileExists(atPath: rootURL.path) else { continue } + guard let enumerator = FileManager.default.enumerator( + at: rootURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { continue } + for case let url as URL in enumerator { + guard url.pathExtension.lowercased() == "exs" else { continue } + do { + try sampler.loadInstrument(at: url) + } catch { + continue // patch isn't loadable; skip silently + } + let parent = url.deletingLastPathComponent().lastPathComponent + // Patches living directly under "Sampler Instruments" + // get a friendlier family label. Nested folders (Church + // Organ, iOS Instruments) keep their actual folder name. + let family = (parent == "Sampler Instruments") ? "Sampler Instruments" : parent + let name = url.deletingPathExtension().lastPathComponent + found.append(Patch(family: family, displayName: name, url: url)) + } + } + cache = found + NSLog("MenuBand: GB library scan — \(found.count) loadable patches across \(Set(found.map(\.family)).count) families") + } +} diff --git a/slab/menuband/Sources/MenuBand/GarageBandPatchView.swift b/slab/menuband/Sources/MenuBand/GarageBandPatchView.swift new file mode 100644 index 000000000..8aee59afc --- /dev/null +++ b/slab/menuband/Sources/MenuBand/GarageBandPatchView.swift @@ -0,0 +1,223 @@ +import AppKit + +/// Family-grouped scrollable list of GarageBand sampler patches. Mirrors +/// the API of `InstrumentListView` — `onCommit`, `onHover`, and the +/// `selectedPatchURL` highlight — so the popover can swap between the +/// two views in the same physical rectangle without re-architecting the +/// surrounding UI. +/// +/// Layout: a vertical `NSStackView` of section blocks. Each block is a +/// "FAMILY NAME" header label followed by one row per patch. Rows are +/// click + hover targets; the active patch is rendered with the system +/// accent fill, hovered rows tint lightly. +final class GarageBandPatchView: NSView { + /// Click commit. Receives the URL of the picked patch. + var onCommit: ((URL) -> Void)? + /// Hover preview. Same press-gated semantics as `InstrumentListView` + /// — only fires while the user is dragging with the mouse held. + var onHover: ((URL?) -> Void)? + + /// Highlight the row matching this URL. Set by the controller after + /// `onCommit` fires (and on initial show from saved state). nil = + /// nothing selected. + var selectedPatchURL: URL? { + didSet { needsDisplay = true; refreshRowHighlights() } + } + + /// Match the GM grid's footprint so the popover doesn't reflow when + /// switching backends. + static let preferredWidth: CGFloat = InstrumentListView.preferredWidth + static let preferredHeight: CGFloat = InstrumentListView.preferredHeight + + private let scrollView = NSScrollView() + private let documentView = NSView() + private var rows: [PatchRow] = [] + private var dragging = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.cornerRadius = 4 + layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + layer?.borderColor = NSColor.separatorColor.cgColor + layer?.borderWidth = 0.5 + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true + scrollView.scrollerStyle = .overlay + scrollView.drawsBackground = false + scrollView.contentView.drawsBackground = false + scrollView.documentView = documentView + addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + rebuild() + } + required init?(coder: NSCoder) { fatalError() } + + override var intrinsicContentSize: NSSize { + NSSize(width: Self.preferredWidth, height: Self.preferredHeight) + } + + /// Rebuild the row list from `GarageBandLibrary.cache`. Called once + /// on init; can be re-called if we ever support live re-scanning. + private func rebuild() { + documentView.subviews.forEach { $0.removeFromSuperview() } + rows.removeAll() + let groups = GarageBandLibrary.groupedByFamily + let stack = NSStackView() + stack.translatesAutoresizingMaskIntoConstraints = false + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 0 + stack.edgeInsets = NSEdgeInsets(top: 4, left: 0, bottom: 6, right: 0) + for group in groups { + let header = NSTextField(labelWithString: group.family.uppercased()) + header.font = NSFont.systemFont(ofSize: 9, weight: .bold) + header.textColor = .secondaryLabelColor + let headerWrap = NSView() + headerWrap.translatesAutoresizingMaskIntoConstraints = false + headerWrap.addSubview(header) + header.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + header.leadingAnchor.constraint(equalTo: headerWrap.leadingAnchor, constant: 8), + header.topAnchor.constraint(equalTo: headerWrap.topAnchor, constant: 6), + header.bottomAnchor.constraint(equalTo: headerWrap.bottomAnchor, constant: -2), + headerWrap.widthAnchor.constraint(equalToConstant: Self.preferredWidth), + ]) + stack.addArrangedSubview(headerWrap) + + for patch in group.patches { + let row = PatchRow(patch: patch, parent: self) + rows.append(row) + stack.addArrangedSubview(row) + row.widthAnchor.constraint(equalToConstant: Self.preferredWidth).isActive = true + } + } + documentView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: documentView.topAnchor), + stack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: documentView.bottomAnchor), + documentView.widthAnchor.constraint(equalToConstant: Self.preferredWidth), + ]) + } + + fileprivate func refreshRowHighlights() { + for row in rows { row.needsDisplay = true } + } + + // MARK: - Press-gated mouse handling + // + // Same model as InstrumentMapView: passive hover does nothing; + // mouseDown arms drag-browse, mouseDragged updates hover/preview, + // mouseUp commits. The PatchRow forwards events up to here so the + // gesture state lives in one place. + + fileprivate func didPressDown(at point: NSPoint) { + dragging = true + forwardHover(at: point) + } + + fileprivate func didDrag(to point: NSPoint) { + guard dragging else { return } + forwardHover(at: point) + } + + fileprivate func didMouseUp(at point: NSPoint) { + guard dragging else { return } + dragging = false + onHover?(nil) + if let row = rowAt(point: point) { + onCommit?(row.patch.url) + selectedPatchURL = row.patch.url + } else { + // Unhighlight all + for r in rows { r.isHovered = false } + } + } + + private func forwardHover(at point: NSPoint) { + let hit = rowAt(point: point) + for r in rows { r.isHovered = (r === hit) } + onHover?(hit?.patch.url) + } + + private func rowAt(point: NSPoint) -> PatchRow? { + // `point` is in our coordinate space; rows live inside the + // scroll's documentView, so convert through. + let docPt = documentView.convert(point, from: self) + return rows.first { $0.frame.contains(docPt) } + } +} + +/// A single row in the patch list. Draws its own background so we don't +/// have to rebuild the whole stack just to update one highlight. +private final class PatchRow: NSView { + let patch: GarageBandLibrary.Patch + weak var parent: GarageBandPatchView? + var isHovered: Bool = false { + didSet { if oldValue != isHovered { needsDisplay = true } } + } + + private let nameLabel = NSTextField(labelWithString: "") + + init(patch: GarageBandLibrary.Patch, parent: GarageBandPatchView) { + self.patch = patch + self.parent = parent + super.init(frame: NSRect(x: 0, y: 0, width: 224, height: 22)) + translatesAutoresizingMaskIntoConstraints = false + wantsLayer = true + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.stringValue = patch.displayName + nameLabel.font = NSFont.systemFont(ofSize: 11) + nameLabel.textColor = .labelColor + nameLabel.lineBreakMode = .byTruncatingTail + addSubview(nameLabel) + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 22), + nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + required init?(coder: NSCoder) { fatalError() } + + override func draw(_ dirtyRect: NSRect) { + let isSelected = parent?.selectedPatchURL == patch.url + if isSelected { + NSColor.controlAccentColor.withAlphaComponent(0.85).setFill() + bounds.fill() + nameLabel.textColor = .white + } else if isHovered { + NSColor.controlAccentColor.withAlphaComponent(0.20).setFill() + bounds.fill() + nameLabel.textColor = .labelColor + } else { + nameLabel.textColor = .labelColor + } + } + + override func mouseDown(with event: NSEvent) { + let pt = (parent ?? self).convert(event.locationInWindow, from: nil) + parent?.didPressDown(at: parent?.convert(pt, from: parent) ?? pt) + // Translate the press into a hit on this row directly — that's + // the simpler path; parent.didPressDown rehits via geometry. + } + override func mouseDragged(with event: NSEvent) { + guard let parent = parent else { return } + let pt = parent.convert(event.locationInWindow, from: nil) + parent.didDrag(to: pt) + } + override func mouseUp(with event: NSEvent) { + guard let parent = parent else { return } + let pt = parent.convert(event.locationInWindow, from: nil) + parent.didMouseUp(at: pt) + } +} diff --git a/system/public/menuband/index.html b/system/public/menuband/index.html index 0e4ed9bc8..af82fa86e 100644 --- a/system/public/menuband/index.html +++ b/system/public/menuband/index.html @@ -571,9 +571,9 @@

Taking macOS' standard instruments out of the 🎸 Garage and kickin' it on the curb!

- + Download - 0.3 · Apple Silicon · 1.2 MB + 0.5 · Apple Silicon · 2.1 MB

view source · by aesthetic.computer

@@ -598,8 +598,10 @@
-

What's new 0.3

-

Audio actually fires (lazy-loaded GM bank). Visualizer rendered in Metal with per-bar ballistics. Instrument palette collapses with the popover in MIDI mode. Click sounds on popover open + MIDI toggle.

+

What's new 0.5

+

Snappier. Visualizer pauses when the popover is hidden, and the Metal layer no longer waits for vsync — the popover opens instantly and bars track audio with less latency. Big thanks to Esteban Uribe for both performance patches.

+

No Accessibility prompt. Click the menubar piano and the keys flash their letters; type to play. No system-wide keystroke capture, no permission dialog. (The Notepat / Ableton modes still use global capture for play-while-using-other-apps; that's an opt-in toggle.)

+

Plus: a mute toggle, an accent-tinted Finder icon that follows your system color, and the popover keeps the same width when MIDI flips on.