From 4941d0be2e328e94c9a92319ca65fd96ede15e19 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Wed, 15 Jul 2026 07:18:53 +0000 Subject: [PATCH] menuband: record mode → per-take DMG releases + count-in + shapedown polish Record gesture: hold BOTH ⌘ → a charging count-in (jeffrey-voiced 3·2·1 via /api/say + rising white-noise hush + brightening red glow) → tape rolls → play (keys light red) → Escape dumps the take. Right ⌘⌘ still = play/focus, left ⌘⌘ = Shapedown. Each take is wrapped into its own DMG 'record release' on the Desktop: our AppIcon as the volume icon, the take's WAV inside, and generative album-art (bold length, random hue per take) on the .dmg file. Audio is the full processed stereo mix (panning/dynamics/space/echo), dead-air trimmed both ends, peak-normalized to ~-1 dBFS. Synth-only — no mic prompt. Fixes: sample-rate converter went terminal after one buffer (endOfStream) so only ~65ms captured at 96kHz — now uses .noDataNow to keep filter state continuous (no dropout, no boundary pops). 4-channel WAV format needs an explicit discrete channel layout (the channels: initializer returns nil). Adds .mbscore auto-perform test harness (--mbscore ) + tape-test.mbscore so the whole record→trim→normalize→art→DMG pipeline runs hands-free. Also: shapedown corner-wrap around vertex dots, single-weight stroke, evaporate-on-lift + click-to-commit + close bloom, one-group compositing. --- slab/menuband/Info.plist | 4 ++-- slab/menuband/Sources/MenuBand/AppDelegate.swift | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- slab/menuband/Sources/MenuBand/CountInVoice.swift | 25 +++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/CountInWhoosh.swift | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/FocusFlashOverlay.swift | 49 ++++++++++++++++++++++++++++++++++++++++++------- slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift | 7 +++++++ slab/menuband/Sources/MenuBand/MBScore.swift | 39 +++++++++++++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/MenuBandController.swift | 44 ++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/MenuBandTape.swift | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- slab/menuband/Sources/MenuBand/Resources/countin-1.mp3 | 0 slab/menuband/Sources/MenuBand/Resources/countin-2.mp3 | 0 slab/menuband/Sources/MenuBand/Resources/countin-3.mp3 | 0 slab/menuband/Sources/MenuBand/SettingsWindow.swift | 28 ++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/Shapedown.swift | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------ slab/menuband/Sources/MenuBand/TakeDMG.swift | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menuband/Sources/MenuBand/TapeCoverArt.swift | 478 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- slab/menuband/tape-test.mbscore | 15 +++++++++++++++ 17 file(s) changed, 992 insertion(s)(+), 503 deletion(s)(-) diff --git a/slab/menuband/Info.plist b/slab/menuband/Info.plist --- a/slab/menuband/Info.plist +++ b/slab/menuband/Info.plist @@ -36,9 +36,9 @@ Menu Band CFBundlePackageType APPL CFBundleShortVersionString - 1.5.4 + 1.6.2 CFBundleVersion - 155 + 162 ITSAppUsesNonExemptEncryption LSApplicationCategoryType diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -368,6 +368,18 @@ /// Double-tap left ⌘ → fullscreen trackpad-as-screen shape wall. Shares the /// MultitouchSupport frames; while its wall is up, `handleTrackpadFrame` /// routes fingers here instead of into the pitch-bend fx pad. private let shapedown = Shapedown() + /// Record gesture (folded into the quiet-focus ⌘ monitor): a LEFT-⌘ tap + /// immediately before the RIGHT-⌘ double means "record", not "focus". This + /// is when that left-⌘ prefix landed. + /// Physically-held ⌘ keycodes (toggled per flagsChanged edge) — used to + /// detect the both-⌘ hold that starts the record count-in. + private var heldCmdKeys: Set = [] + /// Scheduled count-in beats + the final record-start, so releasing a ⌘ + /// mid-hold can cancel them. + private var countInWork: [DispatchWorkItem] = [] + /// True from the moment recording starts until it's dumped — drives the red + /// keys. + private var recordModeActive = false /// Session CGEventTap that swallows Tab WHILE a bend is live, so the macOS /// ⌘-Tab app switcher (owned by the Dock, above any normal key handler) and /// plain focus traversal can't steal the gesture — Tab drives the @@ -518,6 +530,8 @@ } MultitouchTrackpad.shared.start() // Arm the global double-tap-⌘ listener for the Shapedown wall. shapedown.start() + // `--mbscore ` → auto-perform a take (record-pipeline test). + runMBScoreIfRequested() // Try to install the Tab-suppression tap up front (retried per-bend in // case Accessibility is granted after launch). _ = bendTabTap.start() @@ -1126,6 +1140,13 @@ // Escape disarms capture explicitly. Useful when the user // wants to release focus without clicking another app. Also // the explicit exit for latched pitch-bend mode. if isDown && keyCode == 53 /* kVK_Escape */ { + // In record mode, Escape DUMPS the take to the Desktop and + // leaves capture armed — so the keyboard keeps playing after. + // Only unfocus when NOT in record mode. + if self.recordModeActive { + self.stopRecordingAndSave() + return true + } if self.pitchBendModeLatched { self.endPitchBendSession() } // Esc is now the UNFOCUS gesture — it shows the same red // flash + falling-bell cue the right-⌘ double-tap used to @@ -2076,6 +2097,14 @@ // key cancels: a modifier's own keycode can arrive as a keyDown from // synthesized input (System Events' `key code 54`), and treating // that as a chord would cancel the very run it's trying to make. if event.type == .keyDown { + if event.keyCode == 53 /* Escape */ { + // Escape cancels a count-in, or dumps a rolling take. + if !self.countInWork.isEmpty { self.cancelCountIn(); return } + if self.menuBand.isTapeRecording { + self.stopRecordingAndSave() + return + } + } let isModifierKey = event.keyCode == Self.leftCommandKeyCode || event.keyCode == Self.rightCommandKeyCode if event.modifierFlags.contains(.command), !isModifierKey { @@ -2087,26 +2116,27 @@ guard event.type == .flagsChanged else { return } let side = event.keyCode guard side == Self.leftCommandKeyCode || side == Self.rightCommandKeyCode else { return } - // .flagsChanged fires on press AND release for the same physical - // key — `.command` is set on the down edge, cleared on the up edge. - // We only count down edges. - guard event.modifierFlags.contains(.command) else { return } - // Bare ⌘ only. If anything else is held (⇧/⌥/⌃/capsLock, or a chord - // like ⌘⇧), this isn't a tap candidate — reset the run so a chord - // can't pair into a future double-tap. + + // Track which ⌘ keys are physically held by toggling per keycode + // edge (each press/release is one flagsChanged for that keycode). + let wasHeld = self.heldCmdKeys.contains(side) + if wasHeld { self.heldCmdKeys.remove(side) } else { self.heldCmdKeys.insert(side) } + let bothHeld = self.heldCmdKeys.contains(Self.leftCommandKeyCode) + && self.heldCmdKeys.contains(Self.rightCommandKeyCode) + // Releasing either ⌘ cancels a count-in in progress. + if !bothHeld { self.cancelCountIn() } + guard !wasHeld else { return } // up edge — done + + // Both ⌘ held together (bare) → start the 3s record count-in. let mask = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard mask == .command else { - self.quietFocusRunCount = 0 - return - } - // Left ⌘ breaks the run. Not just "doesn't count" — if it merely - // failed to count, a left tap between two right taps would still - // leave them adjacent in time and the gesture would fire on ordinary - // two-handed ⌘ use. - guard side == Self.rightCommandKeyCode else { - self.quietFocusRunCount = 0 + if bothHeld, mask == .command { + self.beginCountIn() return } + // Otherwise: bare-⌘ double-tap tracking for PLAY. + guard mask == .command else { self.quietFocusRunCount = 0; return } + // Left ⌘ breaks the run (leaves left-⌘⌘ free for Shapedown). + guard side == Self.rightCommandKeyCode else { self.quietFocusRunCount = 0; return } let now = CACurrentMediaTime() let inWindow = now - self.lastQuietFocusTapAt <= Self.quietFocusTapWindow self.quietFocusRunCount = (inWindow && self.quietFocusRunCount > 0) @@ -2114,8 +2144,7 @@ ? self.quietFocusRunCount + 1 : 1 self.lastQuietFocusTapAt = now guard self.quietFocusRunCount >= 2 else { return } - // Double completed — consume it so a third tap starts a fresh run - // instead of chaining toggles. + // Right-⌘ DOUBLE = PLAY (arm the keyboard). self.quietFocusRunCount = 0 self.lastQuietFocusTapAt = 0 FocusCueBeep.shared.click() @@ -2445,7 +2474,10 @@ KeyboardIconRenderer.playingActive = !menuBand.litNotes.isEmpty // Sample-voice recording state — the renderer reads this to // tint the chip + VU bars red while the user is holding the // record key. - KeyboardIconRenderer.recordingActive = menuBand.sampleRecordingActive + // Red keys for sample-voice recording, a rolling tape take, OR an armed + // record mode waiting on the first keypress. + KeyboardIconRenderer.recordingActive = + menuBand.sampleRecordingActive || menuBand.isTapeRecording || recordModeActive // Tape state — refreshed every paint so the cassette REC dot, // mic LED, fill bar and playhead track the controller in real // time. Position fractions are computed off the tape's own @@ -4192,6 +4224,103 @@ startBendEase() menuBand.setSpace(amount: spaceAmount) menuBand.setEcho(amount: echoAmount) pushStaffPitchShift() + } + + /// `--mbscore ` test hook: auto-perform the score into a take so the + /// whole record → trim → normalize → album-art → DMG pipeline can be + /// exercised without a human playing. Skips the count-in; drives the synth + /// directly on the note timeline, then stops + saves. + private func runMBScoreIfRequested() { + let args = CommandLine.arguments + guard let i = args.firstIndex(of: "--mbscore"), i + 1 < args.count, + let score = MBScore.load(URL(fileURLWithPath: args[i + 1])) else { return } + NSLog("MenuBand mbscore: running '\(score.name)' — \(score.notes.count) notes, \(score.duration)s") + // Let the audio engine warm up, then perform into a recording. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + self.recordModeActive = true + self.menuBand.startSynthOnlyRecording() + self.updateIcon() + for n in score.notes { + DispatchQueue.main.asyncAfter(deadline: .now() + n.start) { + self.menuBand.playTestNote(midi: n.midi, velocity: n.velocity ?? 100, duration: n.dur) + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + score.duration) { + self.stopRecordingAndSave() + NSLog("MenuBand mbscore: take complete") + } + } + } + + /// Both ⌘ held together → a ~3s count-in that "charges up" (a white-noise + /// suck swelling under four ticks, the red flash brightening each beat), + /// then rolls tape. Holding the whole time is the commit; releasing a ⌘ or + /// hitting Escape cancels. + private func beginCountIn() { + NSLog("MenuBand: beginCountIn (recordModeActive=\(recordModeActive) pending=\(countInWork.count) tapeRec=\(menuBand.isTapeRecording))") + guard !recordModeActive, countInWork.isEmpty, !menuBand.isTapeRecording else { return } + let numbers = ["3", "2", "1"] + let interval = 0.7 + let total = interval * Double(numbers.count) // 2.1 s + // Soft rising hush + a red glow that charges UP over the whole count. + CountInWhoosh.shared.play(duration: total) + FocusFlashOverlay.shared.beginCharge(duration: total) + for (i, n) in numbers.enumerated() { + let w = DispatchWorkItem { CountInVoice.shared.play(n) } // jeffrey: 3… 2… 1… + countInWork.append(w) + DispatchQueue.main.asyncAfter(deadline: .now() + interval * Double(i), execute: w) + } + let go = DispatchWorkItem { [weak self] in + self?.countInWork.removeAll() + self?.startRecordingMode() + } + countInWork.append(go) + DispatchQueue.main.asyncAfter(deadline: .now() + total, execute: go) + } + + /// Abort a count-in (a ⌘ released mid-hold, or Escape). + private func cancelCountIn() { + guard !countInWork.isEmpty else { return } + NSLog("MenuBand: cancelCountIn (count-in aborted)") + countInWork.forEach { $0.cancel() } + countInWork.removeAll() + CountInWhoosh.shared.stop() + FocusFlashOverlay.shared.endCharge(fadeOut: true) + } + + /// Roll tape (audio-only, no mic). Arms the keyboard so keys play + record, + /// starts the take IMMEDIATELY on the main thread (a background-thread start + /// only captured a few buffers), with the hot-red flash + Ping. Leading + /// silence up to the first note is trimmed at eject. + private func startRecordingMode() { + guard !menuBand.isTapeRecording, !recordModeActive else { return } + if !localCapture.isArmed { + beginFocusCaptureFromShortcut(keepPopoverOpen: true) + } + recordModeActive = true + menuBand.startSynthOnlyRecording() + FocusFlashOverlay.shared.flashRecord() + NSSound(named: "Ping")?.play() + updateIcon() + NSLog("MenuBand: RECORDING started") + } + + /// Escape while rolling → stop and dump the WAV onto the Desktop, with a + /// distinct "saved" chime + red stop flash. + private func stopRecordingAndSave() { + // INSTANT feedback: stop the transport + fire the cues right away. + menuBand.stopTapeNow() + recordModeActive = false + FocusFlashOverlay.shared.flash(rising: false) + NSSound(named: "Glass")?.play() + updateIcon() // keys/chip back to normal color + // The WAV render + album-art icon + Desktop copy are heavy — do them + // off the main thread so Escape never blocks. + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let saved = self?.menuBand.saveStoppedTakeToDesktop() + NSLog("MenuBand: recording dumped → \(saved?.path ?? "nothing captured")") + } } /// Toggle the absolute trackpad fx mode (Tab, mid-bend). Snaps onto the diff --git a/slab/menuband/Sources/MenuBand/CountInVoice.swift b/slab/menuband/Sources/MenuBand/CountInVoice.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/CountInVoice.swift @@ -0,0 +1,25 @@ +import AVFoundation +import AppKit + +/// Plays @jeffrey's voiced count-in numbers ("three / two / one") for the +/// record count-in. Clips are bundled mp3s (generated via /api/say in the +/// jeffrey PVC voice). Falls back to a soft system tick if a clip is missing. +final class CountInVoice { + static let shared = CountInVoice() + private var players: [String: AVAudioPlayer] = [:] + + /// `n` is "3" / "2" / "1". + func play(_ n: String) { + if let p = loadPlayer(n) { p.currentTime = 0; p.volume = 0.4; p.play() } + else { NSSound(named: "Tink")?.play() } + } + + private func loadPlayer(_ n: String) -> AVAudioPlayer? { + if let p = players[n] { return p } + guard let url = Bundle.appResources.url(forResource: "countin-\(n)", withExtension: "mp3"), + let p = try? AVAudioPlayer(contentsOf: url) else { return nil } + p.prepareToPlay() + players[n] = p + return p + } +} diff --git a/slab/menuband/Sources/MenuBand/CountInWhoosh.swift b/slab/menuband/Sources/MenuBand/CountInWhoosh.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/CountInWhoosh.swift @@ -0,0 +1,57 @@ +import AVFoundation + +/// A rising white-noise "suck" for the record count-in — a lowpass that opens +/// as an amplitude swell builds, so it reads as charging up into the downbeat. +/// Runs on its own tiny engine so it never touches the instrument's audio graph. +final class CountInWhoosh { + static let shared = CountInWhoosh() + + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let sampleRate = 44_100.0 + private var started = false + + private func ensureStarted() { + guard !started else { return } + engine.attach(player) + guard let fmt = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 2) else { return } + engine.connect(player, to: engine.mainMixerNode, format: fmt) + do { try engine.start(); started = true } + catch { NSLog("CountInWhoosh: engine start failed — \(error)") } + } + + /// Play a `duration`-second charging swell: white noise through a filter + /// that opens over time, under an amplitude envelope that ramps up. + func play(duration: Double) { + ensureStarted() + guard started, + let fmt = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 2), + let buf = AVAudioPCMBuffer( + pcmFormat: fmt, + frameCapacity: AVAudioFrameCount(sampleRate * duration)), + let left = buf.floatChannelData?[0], + let right = buf.floatChannelData?[1] + else { return } + + let n = Int(sampleRate * duration) + buf.frameLength = AVAudioFrameCount(n) + var lpL: Float = 0, lpR: Float = 0 + for i in 0.. NSColor = { m in + // Recording: every pressed key lights RED so the keyboard + // itself reads as "REC ON", not the usual accent/ROYGBIV. + if KeyboardIconRenderer.recordingActive { + return isDark + ? NSColor(srgbRed: 230/255, green: 60/255, blue: 60/255, alpha: 1) + : NSColor(srgbRed: 220/255, green: 35/255, blue: 35/255, alpha: 1) + } guard KeyboardIconRenderer.perKeyAccent, let hue = Self.chromaticColorByPitchClass[((m % 12) + 12) % 12] else { return accentLit } diff --git a/slab/menuband/Sources/MenuBand/MBScore.swift b/slab/menuband/Sources/MenuBand/MBScore.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/MBScore.swift @@ -0,0 +1,39 @@ +import Foundation + +/// `.mbscore` — a tiny JSON score Menu Band can auto-perform, for testing the +/// record → DMG pipeline without a human at the keyboard. Point the app at one +/// with `--mbscore ` and it arms record mode, plays the notes on time, +/// then hits stop so a take DMG lands on the Desktop. +/// +/// { +/// "name": "tape-test", +/// "notes": [ +/// { "midi": 60, "start": 0.0, "dur": 0.4 }, +/// { "midi": 64, "start": 0.4, "dur": 0.4 } +/// ] +/// } +/// +/// `start`/`dur` are seconds. The take ends `tailSeconds` after the last note. +struct MBScore: Decodable { + struct Note: Decodable { + let midi: UInt8 + let start: Double + let dur: Double + let velocity: UInt8? + } + let name: String + let notes: [Note] + var tailSeconds: Double = 0.5 + + private enum CodingKeys: String, CodingKey { case name, notes, tailSeconds } + + static func load(_ url: URL) -> MBScore? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(MBScore.self, from: data) + } + + /// Total performance length: last note-end plus the tail. + var duration: Double { + (notes.map { $0.start + $0.dur }.max() ?? 0) + tailSeconds + } +} diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -1405,6 +1405,50 @@ /// pasteboard so dropping the cassette onto the Desktop yields a /// single multi-track WAV that DAWs can split into stems. func ejectTape() -> URL? { tape.eject()?.file } + /// True while a tape recording is rolling — the global record shortcut + /// keys off this so it can't stack recordings. + var isTapeRecording: Bool { tape.state == .recording } + + /// Audio-only recording for the global record gesture: pin ONLY the synth + /// waveform tap, never the mic — so it captures the instrument with no + /// microphone permission prompt. The mic stem stays silent in the WAV. + func startSynthOnlyRecording() { + guard tape.state != .recording else { return } + synth.addWaveformTapPin(tapeWaveformPinReason) + tape.record() + } + + /// Instant transport stop — just flips the tape state. The heavy WAV + /// render happens later in `saveStoppedTakeToDesktop()` off the main + /// thread, so Escape feels immediate. + func stopTapeNow() { + if tape.state == .recording { tape.stop() } + } + + /// Play one melodic note for `duration` seconds — drives the synth + /// directly (bypassing the keyboard) so an `.mbscore` can auto-perform a + /// take for testing the record → DMG pipeline. + func playTestNote(midi: UInt8, velocity: UInt8 = 100, duration: TimeInterval) { + synth.noteOn(midi, velocity: velocity, channel: 0) + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + self?.synth.noteOff(midi, channel: 0) + } + } + + /// Render the stopped take and wrap it into a per-take **DMG "record + /// release"** on the Desktop: our logo as the volume icon, the take's WAV + /// (with its generative album-art icon) inside, and that album art on the + /// .dmg file itself. HEAVY (audio conversion + drawing + hdiutil) — call + /// OFF the main thread. Returns the .dmg URL, or nil if empty. + @discardableResult + func saveStoppedTakeToDesktop() -> URL? { + guard let src = tape.eject()?.file else { return nil } + // The album-art icon eject() stamped on the WAV → the .dmg file icon. + let cover = NSWorkspace.shared.icon(forFile: src.path) + let name = src.deletingPathExtension().lastPathComponent + return TakeDMG.build(wav: src, name: name, coverIcon: cover) + } + /// State-change observer. Drops the pins whenever the tape goes /// back to idle so the synth engine can suspend on inactivity. private func handleTapeChange() { diff --git a/slab/menuband/Sources/MenuBand/MenuBandTape.swift b/slab/menuband/Sources/MenuBand/MenuBandTape.swift --- a/slab/menuband/Sources/MenuBand/MenuBandTape.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandTape.swift @@ -1,6 +1,7 @@ import Foundation import AVFoundation import AppKit +import Accelerate extension Notification.Name { /// Fired (on main) whenever tape state, position, or buffer changes @@ -294,6 +295,61 @@ /// The mix is reconstructable from the file's channels (sum 1+3 /// for left, 2+4 for right). We don't pre-bake a mix track here; /// a single 4-channel WAV is the cleanest "tape" artifact. @discardableResult + /// The audible span of the take: first and last frames above a silence + /// threshold across both stems, so leading dead air (count-in → first note) + /// AND a trailing tail before Escape are both trimmed. Small pads keep the + /// attack and release intact. Returns (0,0) when nothing is audible. + private func trimmedRange(total: Int) -> (start: Int, end: Int) { + guard total > 0 else { return (0, 0) } + let threshold: Float = 0.0008 + var first = total, last = 0 + bufferLock.lock() + if let l = synthBuffer.floatChannelData?[0], + let r = synthBuffer.floatChannelData?[1] { + var i = 0 + while i < total { if abs(l[i]) > threshold || abs(r[i]) > threshold { first = i; break }; i += 1 } + var j = total - 1 + while j >= 0 { if abs(l[j]) > threshold || abs(r[j]) > threshold { last = j + 1; break }; j -= 1 } + } + if let m = micBuffer.floatChannelData?[0] { + var i = 0 + while i < first { if abs(m[i]) > threshold { first = i; break }; i += 1 } + var j = total - 1 + while j >= last { if abs(m[j]) > threshold { last = j + 1; break }; j -= 1 } + } + bufferLock.unlock() + guard last > first else { return (0, 0) } + let start = max(0, first - Int(Self.sampleRate * 0.03)) // 30 ms pre-roll + let end = min(total, last + Int(Self.sampleRate * 0.15)) // 150 ms tail + return (start, end) + } + + /// Peak-normalization gain to master the take up to ~-1 dBFS. The signal is + /// already through the compressor + limiter (mastered tone); this just lifts + /// the ceiling so quiet takes aren't quiet files. Same gain on both stems so + /// their balance is preserved. Boost is capped so a near-silent take doesn't + /// roar its noise floor. + private func normalizationGain(from offset: Int, count: Int) -> Float { + guard count > 0 else { return 1 } + var peak: Float = 0 + bufferLock.lock() + if let sl = synthBuffer.floatChannelData?[0], + let sr = synthBuffer.floatChannelData?[1] { + var p0: Float = 0, p1: Float = 0 + vDSP_maxmgv(sl.advanced(by: offset), 1, &p0, vDSP_Length(count)) + vDSP_maxmgv(sr.advanced(by: offset), 1, &p1, vDSP_Length(count)) + peak = max(peak, p0, p1) + } + if let mc = micBuffer.floatChannelData?[0] { + var pm: Float = 0 + vDSP_maxmgv(mc.advanced(by: offset), 1, &pm, vDSP_Length(count)) + peak = max(peak, pm) + } + bufferLock.unlock() + guard peak > 1e-5 else { return 1 } + return min(0.89 / peak, 24) // -1 dBFS target, capped boost + } + func eject() -> EjectResult? { guard hasRecording else { return nil } // Reuse the cached result if the on-disk file is still @@ -303,10 +359,17 @@ if let cached = cachedEject, FileManager.default.fileExists(atPath: cached.file.path) { return cached } - let frames = bufferLock.withLock { max(synthWriteFrame, micWriteFrame) } + let rawFrames = bufferLock.withLock { max(synthWriteFrame, micWriteFrame) } + // Trim dead air off BOTH ends — the take starts at the first note and + // ends at the last, regardless of the count-in lead or the pause before + // Escape. `offset` is where the trimmed audio begins in the buffer. + let (offset, endFrame) = trimmedRange(total: rawFrames) + let frames = endFrame - offset + NSLog("MenuBandTape: eject raw=\(rawFrames) offset=\(offset) end=\(endFrame) frames=\(frames)") guard frames > 0 else { return nil } let duration = Double(frames) / Self.sampleRate let date = recordStartDate + let normGain = normalizationGain(from: offset, count: frames) // master to ~-1 dBFS // Pick a friendly filename + dodge collisions in /tmp. let baseName = Self.makeCuteName(date: date) @@ -322,19 +385,24 @@ // 4-channel signed-16 WAV (the lingua franca for DAW import). // 32-bit float would preserve internal headroom but some // legacy DAW versions choke on float WAV; 16-bit is the // safest format that every audio app on macOS can open. - guard let outFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, - sampleRate: Self.sampleRate, - channels: 4, - interleaved: true) - else { return nil } + // 4 channels need an EXPLICIT layout — the channels/interleaved + // convenience initializer only knows the standard mono/stereo layouts + // and returns nil for 4. Discrete-in-order = four independent tracks, + // exactly the multitrack "tape" artifact we want. + guard let quadLayout = AVAudioChannelLayout( + layoutTag: kAudioChannelLayoutTag_DiscreteInOrder | 4) + else { NSLog("MenuBandTape: eject nil — quadLayout"); return nil } + let outFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: Self.sampleRate, + interleaved: true, + channelLayout: quadLayout) // Float storage format for the in-memory 4-channel buffer // we hand to the converter. Non-interleaved so we can fill // each channel independently. - guard let quadFloat = AVAudioFormat(commonFormat: .pcmFormatFloat32, - sampleRate: Self.sampleRate, - channels: 4, - interleaved: false) - else { return nil } + let quadFloat = AVAudioFormat(commonFormat: .pcmFormatFloat32, + sampleRate: Self.sampleRate, + interleaved: false, + channelLayout: quadLayout) let file: AVAudioFile do { @@ -352,14 +420,14 @@ // Stream the recording out in 8192-frame chunks so the work // buffer stays small. Each chunk: copy synth L/R + mic into // a 4-channel float buffer, convert to int16, write. guard let converter = AVAudioConverter(from: quadFloat, to: outFormat) - else { return nil } + else { NSLog("MenuBandTape: eject nil — converter"); return nil } let chunkFrames = 8192 var cursor = 0 while cursor < frames { let take = min(chunkFrames, frames - cursor) guard let src = AVAudioPCMBuffer(pcmFormat: quadFloat, frameCapacity: AVAudioFrameCount(take)) - else { return nil } + else { NSLog("MenuBandTape: eject nil — src buffer"); return nil } src.frameLength = AVAudioFrameCount(take) bufferLock.lock() if let sl = synthBuffer.floatChannelData?[0], @@ -369,18 +437,27 @@ let d0 = src.floatChannelData?[0], let d1 = src.floatChannelData?[1], let d2 = src.floatChannelData?[2], let d3 = src.floatChannelData?[3] { - memcpy(d0, sl.advanced(by: cursor), take * MemoryLayout.size) - memcpy(d1, sr.advanced(by: cursor), take * MemoryLayout.size) + let read = offset + cursor + memcpy(d0, sl.advanced(by: read), take * MemoryLayout.size) + memcpy(d1, sr.advanced(by: read), take * MemoryLayout.size) // Mic is mono — duplicate into ch3 + ch4 so DAWs // that auto-pair-stereo see a vocal stereo track. - memcpy(d2, mc.advanced(by: cursor), take * MemoryLayout.size) - memcpy(d3, mc.advanced(by: cursor), take * MemoryLayout.size) + memcpy(d2, mc.advanced(by: read), take * MemoryLayout.size) + memcpy(d3, mc.advanced(by: read), take * MemoryLayout.size) + // Master gain — normalize the whole take to ~-1 dBFS. + if normGain != 1 { + var g = normGain + vDSP_vsmul(d0, 1, &g, d0, 1, vDSP_Length(take)) + vDSP_vsmul(d1, 1, &g, d1, 1, vDSP_Length(take)) + vDSP_vsmul(d2, 1, &g, d2, 1, vDSP_Length(take)) + vDSP_vsmul(d3, 1, &g, d3, 1, vDSP_Length(take)) + } } bufferLock.unlock() guard let dst = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: AVAudioFrameCount(take)) - else { return nil } + else { NSLog("MenuBandTape: eject nil — dst buffer"); return nil } var supplied = false var error: NSError? let status = converter.convert(to: dst, error: &error) { _, outStatus in @@ -405,13 +482,9 @@ } cursor += take } - // Cover art on the .wav file. Use the mix-derived waveform - // for the icon — visually represents what the user will hear - // by default when scrubbing the file. - let waveform = mixDownsampleRMS(frames: frames, buckets: 512) - let icon = TapeCoverArt.makeIcon(date: date, - duration: duration, - waveform: waveform) + // Album-art cover for the .wav — a bold generative icon with the length + // set large. (No waveform; the icon renderer ignores it.) + let icon = TapeCoverArt.makeIcon(date: date, duration: duration) NSWorkspace.shared.setIcon(icon, forFile: url.path, options: []) NSLog("MenuBandTape: ejected \(baseName).wav (\(duration) s, 4ch) → \(url.path)") @@ -448,12 +521,19 @@ Int(Double(frames) * (Self.sampleRate / inFmt.sampleRate)) + 16)) else { return } var supplied = false var err: NSError? + // .noDataNow (NOT .endOfStream) once this buffer is consumed: the + // converter produces what it can and RETAINS its resampling filter + // state for the next buffer. endOfStream would flush + terminate it + // (dropping the rest of the take), and reset()-per-buffer would clear + // the filter each time (audible pops at every buffer boundary). This + // keeps one continuous, glitch-free stream. let status = conv.convert(to: scratch, error: &err) { _, outStatus in - if supplied { outStatus.pointee = .endOfStream; return nil } + if supplied { outStatus.pointee = .noDataNow; return nil } supplied = true; outStatus.pointee = .haveData return buffer } guard status != .error, + scratch.frameLength > 0, let l = scratch.floatChannelData?[0], let r = scratch.floatChannelData?[1] else { return } writeSynthFrames(left: l, right: r, frames: Int(scratch.frameLength)) @@ -499,11 +579,12 @@ else { return } var supplied = false var err: NSError? let status = conv.convert(to: scratch, error: &err) { _, outStatus in - if supplied { outStatus.pointee = .endOfStream; return nil } + if supplied { outStatus.pointee = .noDataNow; return nil } // keep filter state (see ingestSynth) supplied = true; outStatus.pointee = .haveData return buffer } guard status != .error, + scratch.frameLength > 0, let mono = scratch.floatChannelData?[0] else { return } writeMicFrames(mono: mono, frames: Int(scratch.frameLength)) } diff --git a/slab/menuband/Sources/MenuBand/Resources/countin-1.mp3 b/slab/menuband/Sources/MenuBand/Resources/countin-1.mp3 new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/Resources/countin-1.mp3 diff --git a/slab/menuband/Sources/MenuBand/Resources/countin-2.mp3 b/slab/menuband/Sources/MenuBand/Resources/countin-2.mp3 new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/Resources/countin-2.mp3 diff --git a/slab/menuband/Sources/MenuBand/Resources/countin-3.mp3 b/slab/menuband/Sources/MenuBand/Resources/countin-3.mp3 new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/Resources/countin-3.mp3 diff --git a/slab/menuband/Sources/MenuBand/SettingsWindow.swift b/slab/menuband/Sources/MenuBand/SettingsWindow.swift --- a/slab/menuband/Sources/MenuBand/SettingsWindow.swift +++ b/slab/menuband/Sources/MenuBand/SettingsWindow.swift @@ -120,6 +120,24 @@ note.textColor = .tertiaryLabelColor stack.addArrangedSubview(note) } + #if !MAC_APP_STORE + // Shapedown (double-tap left ⌘) feedback cues — the same flash + bell + // the right-⌘ focus gesture uses, switchable independently. + let sdFlash = NSButton(checkboxWithTitle: "Shapedown flashes", + target: self, + action: #selector(toggleShapedownFlash(_:))) + sdFlash.state = Shapedown.flashesEnabled ? .on : .off + sdFlash.toolTip = "Full-screen flash when the Shapedown wall opens, closes, or stamps." + stack.addArrangedSubview(sdFlash) + + let sdSound = NSButton(checkboxWithTitle: "Shapedown sounds", + target: self, + action: #selector(toggleShapedownSound(_:))) + sdSound.state = Shapedown.soundsEnabled ? .on : .off + sdSound.toolTip = "Bell and click sounds for the Shapedown wall." + stack.addArrangedSubview(sdSound) + #endif + // Crashes — conditional. A diagnostics link only earns a row if there // is something to diagnose; on a healthy install Settings shouldn't // advertise a crash viewer at all. @@ -154,6 +172,16 @@ @objc private func toggleHaptics(_ sender: NSButton) { menuBand?.hapticsEnabled = (sender.state == .on) } + + #if !MAC_APP_STORE + @objc private func toggleShapedownFlash(_ sender: NSButton) { + Shapedown.flashesEnabled = (sender.state == .on) + } + + @objc private func toggleShapedownSound(_ sender: NSButton) { + Shapedown.soundsEnabled = (sender.state == .on) + } + #endif @objc private func viewCrashLogs(_ sender: Any?) { let logs = CrashLogReader.recentLogs() diff --git a/slab/menuband/Sources/MenuBand/Shapedown.swift b/slab/menuband/Sources/MenuBand/Shapedown.swift --- a/slab/menuband/Sources/MenuBand/Shapedown.swift +++ b/slab/menuband/Sources/MenuBand/Shapedown.swift @@ -54,6 +54,40 @@ /// True while the wall is showing — AppDelegate checks this to route /// trackpad frames here instead of into the pitch-bend fx pad. var isActive: Bool { overlay?.isVisible == true } + // MARK: Feedback cues — the same full-screen flash and bell/click the + // right-⌘ focus gesture uses, each independently switchable in Settings. + private static let flashKey = "ShapedownFlash" + private static let soundKey = "ShapedownSound" + /// Both default ON (absent key == on), like Haptics. + static var flashesEnabled: Bool { + get { UserDefaults.standard.object(forKey: flashKey) == nil + ? true : UserDefaults.standard.bool(forKey: flashKey) } + set { UserDefaults.standard.set(newValue, forKey: flashKey) } + } + static var soundsEnabled: Bool { + get { UserDefaults.standard.object(forKey: soundKey) == nil + ? true : UserDefaults.standard.bool(forKey: soundKey) } + set { UserDefaults.standard.set(newValue, forKey: soundKey) } + } + private func flashCue(rising: Bool) { + if Self.flashesEnabled { FocusFlashOverlay.shared.flash(rising: rising) } + } + private func bellCue(rising: Bool) { + if Self.soundsEnabled { FocusCueBeep.shared.play(rising: rising) } + } + private func clickCue() { + if Self.soundsEnabled { FocusCueBeep.shared.click() } + } + + /// Physical trackpad click (pushing the pad in) → pin the current shape so + /// it stays put until the wall closes, with a click + flash to confirm. + private func stampPermanently() { + guard isActive else { return } + canvas?.pinCurrent() + clickCue() + flashCue(rising: true) + } + /// Arm the global ⌘ listener. Idempotent. Needs Accessibility (same grant /// TYPE mode already asks for); silently inert until it's given. func start() { @@ -109,6 +143,10 @@ } private func toggle() { isActive ? hide() : show() } + /// Close the wall without cues — used when the ⌘⌘↩ record gesture starts + /// and a left-⌘⌘ had just opened it as a side effect. + func dismissIfActive() { if isActive { hide(cues: false) } } + private func show() { guard overlay == nil else { return } guard let screen = NSScreen.main ?? NSScreen.screens.first else { return } @@ -139,35 +177,54 @@ panel.alphaValue = 0 self.overlay = panel self.canvas = canvas - // Hide the pointer for the whole session. CGDisplayHideCursor is - // focus-independent (NSCursor.hide only works while frontmost + over - // our window), which a non-activating click-through panel never is. + // Hide the pointer for the whole session, from a BACKGROUND app. + // CGDisplayHideCursor alone is ignored unless the caller is frontmost, + // which this menubar panel never is — so first flip the private + // `SetsCursorInBackground` connection property, which lifts that + // restriction. Then decouple the mouse so a single finger stops + // dragging the (now hidden) pointer. All three are undone in hide(). if !cursorHidden { + Self.setBackgroundCursorHiding(true) + CGAssociateMouseAndMouseCursorPosition(0) CGDisplayHideCursor(CGMainDisplayID()) cursorHidden = true } - _ = gestureTap.start() // block system gestures for the session + gestureTap.onClick = { [weak self] in self?.stampPermanently() } + _ = gestureTap.start() // block system gestures + catch clicks panel.orderFrontRegardless() NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.16 panel.animator().alphaValue = 1 } + flashCue(rising: true) // "wall on" cue, matching the ⌘ gesture + bellCue(rising: true) } - private func hide() { + private func hide(cues: Bool = true) { guard let panel = overlay else { return } + if cues { + flashCue(rising: false) // "wall off" cue + bellCue(rising: false) + } overlay = nil - canvas?.stop() - canvas = nil + // Cursor + gestures return to the system immediately. if cursorHidden { + CGAssociateMouseAndMouseCursorPosition(1) // mouse drives the cursor again CGDisplayShowCursor(CGMainDisplayID()) + Self.setBackgroundCursorHiding(false) cursorHidden = false } gestureTap.stop() // gestures return to the system + // Bloom every committed shape out together, and fade the wall over the + // same beat, so the whole thing leaves as one gesture. + let dyingCanvas = canvas + canvas = nil + let dur = dyingCanvas?.beginClose() ?? 0.2 NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = 0.2 + ctx.duration = max(0.2, dur) panel.animator().alphaValue = 0 }, completionHandler: { + dyingCanvas?.stop() panel.orderOut(nil) }) } @@ -177,6 +234,25 @@ /// wall. Called from AppDelegate's trackpad frame handler while active. func ingest(_ touches: [CGPoint]) { canvas?.ingest(touches) } + + /// Toggle the private CGS `SetsCursorInBackground` connection property so + /// CGDisplayHideCursor works from a non-frontmost app. The two symbols live + /// in the already-loaded CoreGraphics image; we dlsym them (RTLD_DEFAULT) + /// rather than link, so a future SDK that drops them just no-ops instead of + /// failing to build. Private API — fine here (this whole file is out of the + /// MAS build alongside the MultitouchSupport tap). + private static func setBackgroundCursorHiding(_ on: Bool) { + typealias MainConnFn = @convention(c) () -> Int32 + typealias SetPropFn = @convention(c) (Int32, Int32, CFString, CFTypeRef) -> Int32 + let dflt = UnsafeMutableRawPointer(bitPattern: -2) // RTLD_DEFAULT + guard let connSym = dlsym(dflt, "CGSMainConnectionID"), + let setSym = dlsym(dflt, "CGSSetConnectionProperty") else { return } + let mainConn = unsafeBitCast(connSym, to: MainConnFn.self) + let setProp = unsafeBitCast(setSym, to: SetPropFn.self) + let cid = mainConn() + _ = setProp(cid, cid, "SetsCursorInBackground" as CFString, + (on ? kCFBooleanTrue : kCFBooleanFalse)) + } } /// A CGEventTap, live ONLY while the wall is up, that consumes scroll and the @@ -196,13 +272,19 @@ private var source: CFRunLoopSource? private var thread: Thread? private var runLoop: CFRunLoop? + /// Fired (on main) when the trackpad is physically clicked in — the wall + /// uses it to pin the current shape permanently. + var onClick: (() -> Void)? + @discardableResult func start() -> Bool { guard tap == nil else { return true } - // Raw event-type numbers the tap sees: 22 = scrollWheel, and the - // NSEvent gesture family 18/19/20 (rotate/begin/end), 29 gesture, - // 30 magnify, 31 swipe, 32 smartMagnify, 33 quickLook, 34 pressure. - let types: [UInt32] = [18, 19, 20, 22, 29, 30, 31, 32, 33, 34] + // Raw event-type numbers the tap sees: 1/2 left mouse down/up (the + // physical trackpad click), 3/4 right, 25/26 other; 22 = scrollWheel; + // and the NSEvent gesture family 18/19/20 (rotate/begin/end), 29 + // gesture, 30 magnify, 31 swipe, 32 smartMagnify, 33 quickLook, 34 + // pressure. All consumed so the wall owns the trackpad completely. + let types: [UInt32] = [1, 2, 3, 4, 18, 19, 20, 22, 25, 26, 29, 30, 31, 32, 33, 34] var mask: CGEventMask = 0 for t in types { mask |= (CGEventMask(1) << CGEventMask(t)) } @@ -213,6 +295,9 @@ let me = Unmanaged.fromOpaque(refcon).takeUnretainedValue() if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { if let tap = me.tap { CGEvent.tapEnable(tap: tap, enable: true) } return Unmanaged.passUnretained(event) + } + if type == .leftMouseDown { + DispatchQueue.main.async { me.onClick?() } } return nil // consume — the wall owns the trackpad } @@ -270,32 +355,40 @@ /// shape until it "sets", then stamps + fades it. Bottom-left origin so the /// MultitouchSupport normalized space (also bottom-left) maps straight across /// without a Y flip. final class ShapedownCanvas: NSView { - /// A committed shape, sustaining then fading on the wall. + /// A committed shape, held crisp on the wall until close. private struct Stamp { var points: [CGPoint] // view space var hue: CGFloat + var born: TimeInterval // for the pop-in entrance + } + /// A transient fade — a lifted sketch evaporating, or a committed shape + /// blooming away as the wall closes. One mechanism for every fade. + private struct Ghost { + var points: [CGPoint] + var hue: CGFloat var born: TimeInterval + var dur: TimeInterval + var bloom: CGFloat // outward swell across the fade (0 = none) } private var live: [CGPoint] = [] // fingers right now, view space - private var stamps: [Stamp] = [] - private var stampCount = 0 // ever — drives hue cycling - - // Hold-to-set: a shape "sets" once its centroid has sat within `stillSlop` - // for `holdThreshold`. armedPoints is the frozen shape we'll stamp on lift. - private var heldSince: TimeInterval = 0 - private var heldCentroid: CGPoint = .zero - private var heldCount = 0 - private var armed = false - private var armedPoints: [CGPoint] = [] + private var pinned: [Stamp] = [] // committed shapes — held until close + private var ghosts: [Ghost] = [] // transient fades (evaporate / close bloom) + private var recent: [(t: TimeInterval, pts: [CGPoint])] = [] // short frame history + private var stampCount = 0 // the pen-color index; advances per commit + private var committedThisTouch = false // don't evaporate a touch that got committed + private var closing = false private var timer: Timer? // Tuning. - private let holdThreshold: TimeInterval = 0.4 // stillness before a shape sets - private let stillSlop: CGFloat = 22 // px of centroid drift still counts as "held" - private let sustain: TimeInterval = 1.6 // full-strength hold after stamping - private let fadeDur: TimeInterval = 2.6 // fade to gone + private let evaporateDur: TimeInterval = 0.5 // lift-without-commit: quick dissolve + private let evaporateBloom: CGFloat = 0.06 // barely swells — it just goes + private let popDur: TimeInterval = 0.22 // commit entrance settle + private let closeBloomDur: TimeInterval = 0.75 // all commits bloom out together on close + private let closeBloom: CGFloat = 0.28 + private let nodeRadius: CGFloat = 13 // ONE thickness: dot / line / corner-wrap + private let commitLookback: TimeInterval = 0.25 // peak-finger window a click commits from override var isFlipped: Bool { false } override var wantsDefaultClipping: Bool { false } @@ -315,61 +408,98 @@ return CGPoint(x: sx / n, y: sy / n) } func ingest(_ normalized: [CGPoint]) { + guard !closing else { return } let w = bounds.width, h = bounds.height let pts = normalized.map { CGPoint(x: $0.x * w, y: $0.y * h) } - let t = now if pts.isEmpty { - // All fingers lifted — stamp the shape iff it had set. - if armed, !armedPoints.isEmpty { - stamps.append(Stamp(points: armedPoints, hue: hue(for: stampCount), born: t)) - stampCount += 1 + // Fingers lifted. If this touch was never committed with a click, + // the sketch just evaporates — using the peak shape so a press that + // collapsed the last frame to one finger doesn't shrink it. + if !live.isEmpty, !committedThisTouch { + ghosts.append(Ghost(points: committedShape(), hue: hue(for: stampCount), + born: now, dur: evaporateDur, bloom: evaporateBloom)) } live = [] - armed = false - armedPoints = [] - heldSince = 0 - heldCount = 0 + recent.removeAll() + committedThisTouch = false } else { + if live.isEmpty { committedThisTouch = false } // a fresh touch begins live = pts - let c = centroid(pts) - let stillHolding = pts.count == heldCount && hypot(c.x - heldCentroid.x, c.y - heldCentroid.y) < stillSlop - if stillHolding { - heldCentroid = c // track slowly with the hand - if t - heldSince >= holdThreshold { - armed = true - armedPoints = pts // freeze the latest steady pose - } - } else { - heldCentroid = c - heldCount = pts.count - heldSince = t - armed = false - } + let t = now + recent.append((t, pts)) + recent.removeAll { t - $0.t > commitLookback } } ensureTimer() needsDisplay = true } - /// ~60fps while anything is on screen (live fingers or fading stamps); - /// stops itself once the wall is empty so it costs nothing at rest. + /// The shape a click should commit: the frame with the MOST fingers seen in + /// the last `commitLookback` (ties → most recent). Pressing the pad to click + /// briefly collapses the contacts to the one finger doing the pressing, so + /// sampling `live` at the click instant loses the line/tri/quad; the peak + /// over the recent window recovers what the hand was actually holding. + private func committedShape() -> [CGPoint] { + let cutoff = now - commitLookback + let best = recent.filter { $0.t >= cutoff } + .max { $0.pts.count < $1.pts.count }?.pts + return (best?.isEmpty == false) ? best! : live + } + + /// ~60fps while anything is animating — fingers down, a fade in flight, a + /// commit still popping, or the closing bloom. Idles to nothing at rest + /// (committed shapes are static, so they just stay drawn). private func ensureTimer() { guard timer == nil else { return } timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in guard let self else { return } self.needsDisplay = true - if self.live.isEmpty && self.stamps.isEmpty { + let popping = self.pinned.contains { self.now - $0.born < self.popDur } + if self.live.isEmpty, self.ghosts.isEmpty, !self.closing, !popping { self.timer?.invalidate() self.timer = nil } } } + /// Commit the shape currently under the fingers (physical trackpad click): + /// it pops in crisp and stays until the wall closes. Advances the pen color. + func pinCurrent() { + let shape = committedShape() + guard !shape.isEmpty else { return } + pinned.append(Stamp(points: shape, hue: hue(for: stampCount), born: now)) + stampCount += 1 + committedThisTouch = true + needsDisplay = true + } + + /// Begin the closing exit: turn every committed shape into a bloom-fade so + /// they all leave together. Returns the duration so the overlay can match + /// its own fade to it. Further input is ignored once closing. + @discardableResult + func beginClose() -> TimeInterval { + closing = true + let t = now + for p in pinned { + ghosts.append(Ghost(points: p.points, hue: p.hue, born: t, + dur: closeBloomDur, bloom: closeBloom)) + } + pinned = [] + live = [] + recent.removeAll() + ensureTimer() + needsDisplay = true + return closeBloomDur + } + func stop() { timer?.invalidate() timer = nil live = [] - stamps = [] + pinned = [] + ghosts = [] + recent.removeAll() + closing = false } override func draw(_ dirtyRect: NSRect) { @@ -380,20 +510,38 @@ // Faint dim so bright shapes read against a busy desktop. NSColor(white: 0, alpha: 0.16).setFill() bounds.fill() - // Stamps: oldest first, sustaining then fading. - stamps.removeAll { t - $0.born > sustain + fadeDur } - for s in stamps { - let age = t - s.born - let a: CGFloat = age <= sustain ? 1 : CGFloat(1 - (age - sustain) / fadeDur) - drawShape(s.points, hue: s.hue, alpha: max(0, a) * 0.9, glow: true, ctx: ctx) + // Transient fades: evaporating sketches and the closing bloom, one path. + ghosts.removeAll { t - $0.born > $0.dur } + for g in ghosts { + let f = CGFloat(min(max((t - g.born) / g.dur, 0), 1)) + let eased = f * f * (3 - 2 * f) // smoothstep + let alpha = (1 - eased) * 0.9 + let scale = 1 + g.bloom * eased + let pts = scale == 1 ? g.points : scaled(g.points, by: scale) + drawShape(pts, hue: g.hue, alpha: alpha, glow: true, ctx: ctx) } - // The shape under your fingers, on top. Pulses once it has set. + // Committed shapes: crisp and permanent, with a little pop as they land. + for p in pinned { + let age = t - p.born + let alpha: CGFloat + let scale: CGFloat + if age < popDur { + let f = CGFloat(age / popDur) + alpha = f + scale = 1 + 0.10 * (1 - f) + } else { + alpha = 1 + scale = 1 + } + let pts = scale == 1 ? p.points : scaled(p.points, by: scale) + drawShape(pts, hue: p.hue, alpha: alpha * 0.95, glow: true, ctx: ctx) + } + + // The wet sketch under your fingers, on top — dim, and in the NEXT pen + // color, so it reads as provisional until you click to commit it. if !live.isEmpty { - let h = hue(for: stampCount) - let alpha: CGFloat = armed ? (0.7 + 0.3 * CGFloat(abs(sin(t * 6)))) : 0.55 - drawShape(live, hue: h, alpha: alpha, glow: armed, ctx: ctx) - for p in live { drawVertex(p, hue: h, ctx: ctx) } + drawShape(live, hue: hue(for: stampCount), alpha: 0.5, glow: false, ctx: ctx) } } @@ -404,50 +552,66 @@ let c = centroid(pts) return pts.sorted { atan2($0.y - c.y, $0.x - c.x) < atan2($1.y - c.y, $1.x - c.x) } } + /// Scale a point set out from its own centroid — used for the fade bloom. + private func scaled(_ pts: [CGPoint], by k: CGFloat) -> [CGPoint] { + let c = centroid(pts) + return pts.map { CGPoint(x: c.x + ($0.x - c.x) * k, y: c.y + ($0.y - c.y) * k) } + } + private func drawShape(_ points: [CGPoint], hue: CGFloat, alpha: CGFloat, glow: Bool, ctx: CGContext) { guard !points.isEmpty, alpha > 0.001 else { return } - let fill = NSColor(hue: hue, saturation: 0.85, brightness: 1, alpha: alpha) + // Composite the whole shape as ONE group: draw fill + stroke fully + // OPAQUE inside a transparency layer, then let the layer composite at + // `alpha`. Opaque-over-opaque unions to flat coverage, so the stroke + // and fill can't double-blend into a darker seam — the bug that showed + // as a doubled outline at low alpha. Glow + alpha apply to the layer + // as a whole, so the glow is drawn once around the union, not per-op. + let color = NSColor(hue: hue, saturation: 0.85, brightness: 1, alpha: 1) + let r = nodeRadius ctx.saveGState() if glow { ctx.setShadow(offset: .zero, blur: 24, - color: NSColor(hue: hue, saturation: 0.9, brightness: 1, alpha: alpha).cgColor) + color: NSColor(hue: hue, saturation: 0.9, brightness: 1, alpha: 1).cgColor) } + ctx.setAlpha(alpha) + ctx.beginTransparencyLayer(auxiliaryInfo: nil) + color.setFill() + color.setStroke() switch points.count { case 1: - let p = points[0], r: CGFloat = 44 - fill.setFill() + // A dot exactly nodeRadius in radius. + let p = points[0] ctx.fillEllipse(in: CGRect(x: p.x - r, y: p.y - r, width: r * 2, height: r * 2)) case 2: + // A capsule the same thickness as the dot — each rounded end IS a dot. let path = NSBezierPath() - path.lineWidth = 26 - path.lineCapStyle = .round path.move(to: points[0]) path.line(to: points[1]) - fill.setStroke() + path.lineWidth = r * 2 + path.lineCapStyle = .round + path.lineJoinStyle = .round path.stroke() default: + // Fill the polygon, then stroke its outline with a round-joined pen + // of the same nodeRadius. The stroke bulges OUTWARD around every + // vertex, so each corner wraps around its dot instead of cutting + // across it — and inside the transparency layer the two union into + // one solid weight, no seam. let poly = hull(points) let path = NSBezierPath() path.move(to: poly[0]) for p in poly.dropFirst() { path.line(to: p) } path.close() - fill.setFill() - path.fill() - NSColor(hue: hue, saturation: 0.4, brightness: 1, alpha: alpha).setStroke() - path.lineWidth = 3 + path.lineWidth = r * 2 + path.lineCapStyle = .round + path.lineJoinStyle = .round path.stroke() + path.fill() } + ctx.endTransparencyLayer() ctx.restoreGState() - } - - /// A little white core at every fingertip so the vertices are always - /// legible even inside a big filled shape. - private func drawVertex(_ p: CGPoint, hue: CGFloat, ctx: CGContext) { - let r: CGFloat = 7 - NSColor(white: 1, alpha: 0.9).setFill() - ctx.fillEllipse(in: CGRect(x: p.x - r, y: p.y - r, width: r * 2, height: r * 2)) } } diff --git a/slab/menuband/Sources/MenuBand/TakeDMG.swift b/slab/menuband/Sources/MenuBand/TakeDMG.swift new file mode 100644 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/TakeDMG.swift @@ -0,0 +1,113 @@ +import AppKit + +/// Wraps a finished take into its own little **DMG "record release"**: a +/// compressed disk image whose VOLUME icon is our Menu Band logo, containing +/// the take's WAV (which already wears its generative album-art icon). The +/// .dmg FILE on the Desktop wears the same colorful album art so a folder of +/// takes reads as a shelf of records; mounting one shows the branded logo. +/// +/// All via `hdiutil` (always present) + `NSWorkspace.setIcon` for the custom +/// icon bit — no Xcode command-line tools, so it works on any tester's Mac. +enum TakeDMG { + /// Build `.dmg` next to where `wav` should live (the Desktop). The + /// WAV is consumed INTO the image (not left loose). Returns the .dmg URL, + /// or nil on failure (caller can fall back to the bare WAV). + static func build(wav: URL, name: String, coverIcon: NSImage?) -> URL? { + let fm = FileManager.default + let desktop = fm.urls(for: .desktopDirectory, in: .userDomainMask).first + ?? fm.homeDirectoryForCurrentUser.appendingPathComponent("Desktop") + let out = uniqueURL(desktop.appendingPathComponent("\(name).dmg")) + + let stage = fm.temporaryDirectory.appendingPathComponent("mbtake-\(UUID().uuidString)") + let rw = fm.temporaryDirectory.appendingPathComponent("mbtake-rw-\(UUID().uuidString).dmg") + defer { try? fm.removeItem(at: stage); try? fm.removeItem(at: rw) } + + do { + try fm.createDirectory(at: stage, withIntermediateDirectories: true) + try fm.copyItem(at: wav, to: stage.appendingPathComponent(wav.lastPathComponent)) + // .VolumeIcon.icns at the volume root is the fallback the OS reads + // when the custom-icon bit is set (we set it via NSWorkspace below). + if let icns = Bundle.appResources.url(forResource: "AppIcon", withExtension: "icns") { + try? fm.copyItem(at: icns, to: stage.appendingPathComponent(".VolumeIcon.icns")) + } + + // 1) Read-write image from the staging folder. + guard run("/usr/bin/hdiutil", + ["create", "-srcfolder", stage.path, + "-volname", name, "-fs", "HFS+", + "-format", "UDRW", "-ov", rw.path]) else { return nil } + + // 2) Mount it, grab the mount point, brand the VOLUME with our logo. + guard let mount = attach(rw) else { return nil } + if let logo = appLogo() { + NSWorkspace.shared.setIcon(logo, forFile: mount, options: []) + } + _ = run("/usr/bin/hdiutil", ["detach", mount, "-quiet"]) + + // 3) Compress to the final read-only .dmg on the Desktop. + try? fm.removeItem(at: out) + guard run("/usr/bin/hdiutil", + ["convert", rw.path, "-format", "UDZO", "-o", out.path]) else { return nil } + + // 4) The colorful album art on the .dmg FILE itself (Desktop icon). + if let cover = coverIcon { + NSWorkspace.shared.setIcon(cover, forFile: out.path, options: []) + } + NSLog("MenuBand TakeDMG: \(out.path)") + return out + } catch { + NSLog("MenuBand TakeDMG failed: \(error)") + return nil + } + } + + private static func appLogo() -> NSImage? { + if let icns = Bundle.appResources.url(forResource: "AppIcon", withExtension: "icns") { + return NSImage(contentsOf: icns) + } + return NSImage(named: NSImage.applicationIconName) + } + + /// Attach an image and return its mount point (parses hdiutil's plist-free + /// tabular output — the last field of the line naming a /Volumes path). + private static func attach(_ image: URL) -> String? { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + p.arguments = ["attach", image.path, "-nobrowse", "-noautoopen"] + let pipe = Pipe() + p.standardOutput = pipe + do { try p.run(); p.waitUntilExit() } catch { return nil } + guard p.terminationStatus == 0 else { return nil } + let out = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + for line in out.split(separator: "\n") { + if let r = line.range(of: "/Volumes/") { + return String(line[r.lowerBound...]).trimmingCharacters(in: .whitespaces) + } + } + return nil + } + + @discardableResult + private static func run(_ tool: String, _ args: [String]) -> Bool { + let p = Process() + p.executableURL = URL(fileURLWithPath: tool) + p.arguments = args + p.standardOutput = FileHandle.nullDevice + p.standardError = FileHandle.nullDevice + do { try p.run(); p.waitUntilExit() } catch { return false } + return p.terminationStatus == 0 + } + + private static func uniqueURL(_ url: URL) -> URL { + let fm = FileManager.default + guard fm.fileExists(atPath: url.path) else { return url } + let base = url.deletingPathExtension().lastPathComponent + let dir = url.deletingLastPathComponent() + var i = 2 + while true { + let candidate = dir.appendingPathComponent("\(base)-\(i).dmg") + if !fm.fileExists(atPath: candidate.path) { return candidate } + i += 1 + } + } +} diff --git a/slab/menuband/Sources/MenuBand/TapeCoverArt.swift b/slab/menuband/Sources/MenuBand/TapeCoverArt.swift --- a/slab/menuband/Sources/MenuBand/TapeCoverArt.swift +++ b/slab/menuband/Sources/MenuBand/TapeCoverArt.swift @@ -1,409 +1,161 @@ import AppKit -/// Per-tape Finder cover art. Renders a 512×512 cassette icon with the -/// recording's downsampled waveform plotted across the label card and a -/// date/duration stamp on the J-card spine — so when the user drops a -/// tape onto the Desktop, the file's icon shows what's on it rather -/// than the generic audio file glyph. +/// Per-recording **album art** Finder icon. Each take gets a bold generative +/// cover — a vivid gradient field with a few big soft shapes and the take's +/// LENGTH set large across the middle. Every recording draws its own random +/// hue + composition, so a Desktop full of takes looks like a stack of records. +/// No waveform, no cassette. /// -/// Attached via `NSWorkspace.shared.setIcon(_:forFile:options:)` after -/// `AVAudioFile.write` completes. The icon is stored on the file's -/// resource fork (extended attribute on APFS); it travels with the -/// file across Finder copies and survives a re-import into a DAW. +/// Attached via `NSWorkspace.shared.setIcon(_:forFile:options:)` after the WAV +/// is written; the icon rides the file's resource fork across Finder copies. enum TapeCoverArt { static let canvasSize = CGSize(width: 512, height: 512) - /// Build a custom Finder icon for a freshly-rendered tape. - /// - /// - Parameters: - /// - date: time of recording — colors the body palette (a - /// time-of-day tint that distinguishes morning vs evening - /// tapes at a glance in a folder full of them). - /// - duration: recorded length in seconds. Drawn on the label - /// spine; also caps the waveform render to its actual span. - /// - waveform: downsampled RMS buckets, one per horizontal pixel - /// of the label width. Empty → label stays plain. Values are - /// in 0…1 normalized to the recording's peak. + /// `waveform` is accepted for call-site compatibility but no longer drawn. + /// Renders into an offscreen bitmap context (NOT `lockFocus`) so it's safe + /// to build off the main thread — the take is saved on a background queue. static func makeIcon(date: Date, duration: TimeInterval, - waveform: [Float]) -> NSImage { - let img = NSImage(size: canvasSize) - img.lockFocus() - defer { img.unlockFocus() } - draw(date: date, duration: duration, waveform: waveform, + waveform: [Float] = []) -> NSImage { + let w = Int(canvasSize.width), h = Int(canvasSize.height) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: w, pixelsHigh: h, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bytesPerRow: 0, bitsPerPixel: 0), + let ctx = NSGraphicsContext(bitmapImageRep: rep) else { + return NSImage(size: canvasSize) + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = ctx + draw(date: date, duration: duration, rect: NSRect(origin: .zero, size: canvasSize)) + ctx.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + let img = NSImage(size: canvasSize) + img.addRepresentation(rep) return img } - private static func draw(date: Date, - duration: TimeInterval, - waveform: [Float], - rect: NSRect) { - // Clear background so the icon has transparent corners - // (rounded square + free space around the cassette). + private static func draw(date: Date, duration: TimeInterval, rect: NSRect) { NSColor.clear.setFill() rect.fill() - // Body color shifts with time of day — same palette logic the - // recap photo pipeline uses, narrowed to four bands so the - // tape labels read as "morning tape" / "afternoon tape" / - // "evening tape" / "night tape" without us having to think - // about a per-minute gradient. Inspired by the 70s AGFA bold- - // color blank cassettes in the Core77 archive. - let palette = palette(for: date) - - // Cassette body — landscape-oriented inside the 512×512 - // canvas. Generous margin so the corners can host the date - // stamp without crowding the cassette. - let bodyMargin: CGFloat = 56 - let bodyAspect: CGFloat = 11.0 / 7.0 // matches a real cassette - let maxW = rect.width - bodyMargin * 2 - var bodyW = maxW - var bodyH = bodyW / bodyAspect - if bodyH > rect.height - bodyMargin * 2 { - bodyH = rect.height - bodyMargin * 2 - bodyW = bodyH * bodyAspect - } - let bodyRect = NSRect(x: rect.midX - bodyW / 2, - y: rect.midY - bodyH / 2, - width: bodyW, - height: bodyH) + // Rounded-square cover. + let cover = rect.insetBy(dx: 26, dy: 26) + let clip = NSBezierPath(roundedRect: cover, xRadius: 44, yRadius: 44) - // Drop shadow — sits the cassette above the canvas. Stops the - // icon from looking like a flat sticker against a Finder - // window background. + // Drop shadow so the cover sits above the Finder background. + NSGraphicsContext.saveGraphicsState() let shadow = NSShadow() shadow.shadowColor = NSColor.black.withAlphaComponent(0.35) shadow.shadowOffset = NSSize(width: 0, height: -8) shadow.shadowBlurRadius = 22 - NSGraphicsContext.saveGraphicsState() shadow.set() - - let bodyPath = NSBezierPath(roundedRect: bodyRect, - xRadius: 14, yRadius: 14) - NSGradient(colors: [palette.bodyHi, palette.bodyLo], - atLocations: [0.0, 1.0], - colorSpace: .sRGB)?.draw(in: bodyPath, angle: -90) + NSColor.black.setFill() + clip.fill() NSGraphicsContext.restoreGraphicsState() - NSColor.white.withAlphaComponent(0.25).setStroke() - bodyPath.lineWidth = 1.5 - bodyPath.stroke() + NSGraphicsContext.saveGraphicsState() + clip.addClip() - // Label card — across the top half of the cassette body. This - // is where the waveform goes. Real cassette J-cards used a - // cream + thin colored stripe; we match the idiom but trade - // the typography for an audio-visualization payload. - let labelInset: CGFloat = 22 - let labelH = bodyRect.height * 0.40 - let labelRect = NSRect(x: bodyRect.minX + labelInset, - y: bodyRect.maxY - labelH - 16, - width: bodyRect.width - labelInset * 2, - height: labelH) - let labelPath = NSBezierPath(roundedRect: labelRect, - xRadius: 5, yRadius: 5) - NSGradient(colors: [palette.labelHi, palette.labelLo], - atLocations: [0.0, 1.0], - colorSpace: .sRGB)?.draw(in: labelPath, angle: -90) - NSColor(srgbRed: 30/255, green: 30/255, blue: 40/255, - alpha: 0.20).setStroke() - labelPath.lineWidth = 0.8 - labelPath.stroke() - - // TDK-gold stripe — bottom edge of the label card. - let stripeColor = palette.stripe - let stripeRect = NSRect(x: labelRect.minX, - y: labelRect.minY, - width: labelRect.width, - height: 5) - stripeColor.setFill() - NSBezierPath(rect: stripeRect).fill() - - // Waveform — the actual "cover art" of this tape. RMS buckets - // plotted as vertical bars across the label card, centered on - // the label's mid-Y so it reads symmetrically (above and - // below the waveform axis), mimicking a stereo audio editor - // VU meter. - drawWaveform(in: labelRect.insetBy(dx: 12, dy: 18), - buckets: waveform, - color: palette.waveform) - - // Two big hexagonal-hub reels below the label. Same geometry - // as the inline + popover cassettes; scale gives plenty of - // detail room here. - let reelAreaY = bodyRect.minY + 20 - let reelAreaH = labelRect.minY - reelAreaY - 12 - let reelR = min(reelAreaH * 0.46, bodyRect.width * 0.13) - let reelCY = reelAreaY + reelAreaH / 2 - let leftCX = bodyRect.midX - reelR * 2.2 - let rightCX = bodyRect.midX + reelR * 2.2 - drawReel(at: NSPoint(x: leftCX, y: reelCY), - radius: reelR, - rotation: 0, - fillFraction: 0.85, - spoolColor: palette.spool) - drawReel(at: NSPoint(x: rightCX, y: reelCY), - radius: reelR, - rotation: .pi / 6, - fillFraction: 0.15, - spoolColor: palette.spool) - - // Drive holes between the reels — small white circles that - // every cassette has for the deck's pinch wheels. - let driveR: CGFloat = 5 - let driveColor = NSColor.black.withAlphaComponent(0.55) - for i in 0..<3 { - let x = bodyRect.midX + CGFloat(i - 1) * 22 - driveColor.setFill() - NSBezierPath(ovalIn: NSRect(x: x - driveR, - y: reelCY - driveR, - width: driveR * 2, - height: driveR * 2)).fill() + // One random base hue defines the whole cover. + let base = CGFloat.random(in: 0..<1) + func hue(_ shift: CGFloat, _ s: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> NSColor { + NSColor(hue: (base + shift).truncatingRemainder(dividingBy: 1), + saturation: s, brightness: b, alpha: a) } - // Date stamp on the label — small monospaced caption inside - // the label card, lower left. - drawDateAndDuration(date: date, - duration: duration, - rect: labelRect.insetBy(dx: 14, dy: 6)) - - // Wordmark — "MENU BAND" along the bottom of the cassette - // body, in tiny letters like a real cassette manufacturer - // signature. - let wordmark = "MENU BAND" - let wordmarkAttrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 13, weight: .heavy), - .foregroundColor: NSColor.white.withAlphaComponent(0.7), - .kern: 3.0, - ] - let wmSize = (wordmark as NSString).size(withAttributes: wordmarkAttrs) - let wmRect = NSRect(x: bodyRect.midX - wmSize.width / 2, - y: bodyRect.minY + 8, - width: wmSize.width, - height: wmSize.height) - (wordmark as NSString).draw(in: wmRect, withAttributes: wordmarkAttrs) - } - - // MARK: - Waveform plot - - private static func drawWaveform(in rect: NSRect, - buckets: [Float], - color: NSColor) { - guard !buckets.isEmpty, rect.width > 1 else { return } - - // Resample to the rect's pixel width so we get one bar per - // horizontal pixel, regardless of how many buckets came in. - let columns = Int(rect.width) - let resampled = resample(buckets, to: columns) - - // Auto-gain so quiet recordings still produce visible bars. - var peak: Float = 0.0001 - for v in resampled { peak = max(peak, abs(v)) } - let gain = min(8.0, 0.95 / peak) - - let midY = rect.midY - let halfH = rect.height / 2 - let barW: CGFloat = 1 - color.setFill() - for (i, value) in resampled.enumerated() { - let amp = CGFloat(min(1.0, value * gain)) - let h = amp * halfH - let x = rect.minX + CGFloat(i) * barW - // Symmetric VU rendering — looks like a stereo audio - // editor display, reads as "this is sound." - let bar = NSRect(x: x, y: midY - h, width: barW, height: h * 2) - NSBezierPath(rect: bar).fill() - } - - // Center line — thin axis so the waveform reads as silence - // (no bars) vs sound (bars rising symmetrically). - color.withAlphaComponent(0.35).setStroke() - let axis = NSBezierPath() - axis.move(to: NSPoint(x: rect.minX, y: midY)) - axis.line(to: NSPoint(x: rect.maxX, y: midY)) - axis.lineWidth = 0.5 - axis.stroke() - } + // Gradient field. + NSGradient(colors: [hue(0.00, 0.70, 0.88), hue(0.09, 0.88, 0.44)])? + .draw(in: cover, angle: CGFloat.random(in: 15...75)) - private static func resample(_ buckets: [Float], to count: Int) -> [Float] { - guard count > 0 else { return [] } - if buckets.count == count { return buckets } - var out = [Float](repeating: 0, count: count) - let ratio = Double(buckets.count) / Double(count) - for i in 0.. NSFont { + var size = start + while size > 44 { + let f = NSFont.systemFont(ofSize: size, weight: .black) + if (s as NSString).size(withAttributes: [.font: f]).width <= maxWidth { return f } + size -= 6 } - spokes.stroke() - - // Drive pin. - let pinR: CGFloat = 3 - let pinRect = NSRect(x: center.x - pinR, y: center.y - pinR, - width: pinR * 2, height: pinR * 2) - NSColor.black.withAlphaComponent(0.85).setFill() - NSBezierPath(ovalIn: pinRect).fill() + return NSFont.systemFont(ofSize: 44, weight: .black) } - // MARK: - Palette (time-of-day color tint) - - private struct Palette { - let bodyHi: NSColor - let bodyLo: NSColor - let labelHi: NSColor - let labelLo: NSColor - let stripe: NSColor - let waveform: NSColor - let spool: NSColor - } - - /// Body color shifts with the hour the tape was recorded. Four - /// distinct moods so a folder of tapes self-organizes visually: - /// - /// • morning (06–12) — warm pink/peach - /// • afternoon (12–18) — classic Walkman steel-blue - /// • evening (18–22) — sunset orange/red - /// • night (22–06) — deep navy/violet - private static func palette(for date: Date) -> Palette { - let cal = Calendar.current - let hour = cal.component(.hour, from: date) - switch hour { - case 6..<12: - return Palette( - bodyHi: NSColor(srgbRed: 255/255, green: 175/255, blue: 145/255, alpha: 1), - bodyLo: NSColor(srgbRed: 210/255, green: 110/255, blue: 90/255, alpha: 1), - labelHi: NSColor(srgbRed: 252/255, green: 240/255, blue: 215/255, alpha: 1), - labelLo: NSColor(srgbRed: 232/255, green: 215/255, blue: 180/255, alpha: 1), - stripe: NSColor(srgbRed: 235/255, green: 100/255, blue: 70/255, alpha: 1), - waveform: NSColor(srgbRed: 160/255, green: 60/255, blue: 40/255, alpha: 0.92), - spool: NSColor(srgbRed: 85/255, green: 50/255, blue: 40/255, alpha: 1)) - case 12..<18: - return Palette( - bodyHi: NSColor(srgbRed: 110/255, green: 135/255, blue: 175/255, alpha: 1), - bodyLo: NSColor(srgbRed: 52/255, green: 68/255, blue: 105/255, alpha: 1), - labelHi: NSColor(srgbRed: 252/255, green: 246/255, blue: 224/255, alpha: 1), - labelLo: NSColor(srgbRed: 230/255, green: 220/255, blue: 192/255, alpha: 1), - stripe: NSColor(srgbRed: 215/255, green: 175/255, blue: 70/255, alpha: 1), - waveform: NSColor(srgbRed: 40/255, green: 55/255, blue: 90/255, alpha: 0.92), - spool: NSColor(srgbRed: 70/255, green: 55/255, blue: 45/255, alpha: 1)) - case 18..<22: - return Palette( - bodyHi: NSColor(srgbRed: 235/255, green: 115/255, blue: 75/255, alpha: 1), - bodyLo: NSColor(srgbRed: 160/255, green: 50/255, blue: 60/255, alpha: 1), - labelHi: NSColor(srgbRed: 250/255, green: 235/255, blue: 200/255, alpha: 1), - labelLo: NSColor(srgbRed: 225/255, green: 200/255, blue: 165/255, alpha: 1), - stripe: NSColor(srgbRed: 240/255, green: 90/255, blue: 60/255, alpha: 1), - waveform: NSColor(srgbRed: 130/255, green: 30/255, blue: 40/255, alpha: 0.92), - spool: NSColor(srgbRed: 90/255, green: 45/255, blue: 35/255, alpha: 1)) - default: // 22..6 - return Palette( - bodyHi: NSColor(srgbRed: 78/255, green: 70/255, blue: 120/255, alpha: 1), - bodyLo: NSColor(srgbRed: 32/255, green: 28/255, blue: 60/255, alpha: 1), - labelHi: NSColor(srgbRed: 230/255, green: 220/255, blue: 235/255, alpha: 1), - labelLo: NSColor(srgbRed: 200/255, green: 190/255, blue: 215/255, alpha: 1), - stripe: NSColor(srgbRed: 175/255, green: 100/255, blue: 220/255, alpha: 1), - waveform: NSColor(srgbRed: 50/255, green: 40/255, blue: 85/255, alpha: 0.92), - spool: NSColor(srgbRed: 60/255, green: 50/255, blue: 75/255, alpha: 1)) - } + private static let footFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM d" + return f + }() + private static func footDate(_ d: Date) -> String { + footFormatter.string(from: d).uppercased() } } diff --git a/slab/menuband/tape-test.mbscore b/slab/menuband/tape-test.mbscore new file mode 100644 --- /dev/null +++ b/slab/menuband/tape-test.mbscore @@ -0,0 +1,15 @@ +{ + "name": "tape-test", + "tailSeconds": 0.6, + "notes": [ + { "midi": 60, "start": 0.0, "dur": 0.35 }, + { "midi": 64, "start": 0.35, "dur": 0.35 }, + { "midi": 67, "start": 0.70, "dur": 0.35 }, + { "midi": 72, "start": 1.05, "dur": 0.5 }, + { "midi": 67, "start": 1.55, "dur": 0.3 }, + { "midi": 64, "start": 1.85, "dur": 0.3 }, + { "midi": 60, "start": 2.15, "dur": 0.9 }, + { "midi": 64, "start": 2.15, "dur": 0.9 }, + { "midi": 67, "start": 2.15, "dur": 0.9 } + ] +} -- tangled.sh