diff --git a/juke-wizard/Sources/JukeWizard/DJMixerView.swift b/juke-wizard/Sources/JukeWizard/DJMixerView.swift index 43ee54b4a5..c2ed5a4504 100644 --- a/juke-wizard/Sources/JukeWizard/DJMixerView.swift +++ b/juke-wizard/Sources/JukeWizard/DJMixerView.swift @@ -21,7 +21,10 @@ enum DJPlatterGeometry { enum DJTempoAnalyzer { static func estimate(samples: [Float], sampleRate: Double) -> Double? { guard sampleRate > 0, samples.count > Int(sampleRate * 4) else { return nil } - let hop = 512 + // Keep the onset envelope near 200 Hz. A fixed 512-frame hop made + // tempo resolution depend heavily on the source sample rate (and could + // quantize 128 BPM down near 125 BPM). + let hop = max(32, Int((sampleRate / 200).rounded())) let limit = min(samples.count, Int(sampleRate * 90)) var novelty: [Double] = [] novelty.reserveCapacity(limit / hop) @@ -41,19 +44,22 @@ enum DJTempoAnalyzer { guard novelty.count > 64 else { return nil } let stepsPerSecond = sampleRate / Double(hop) - var bestBPM = 0.0 + var bestLag = 0 var bestScore = 0.0 - for bpmStep in 700...1800 { - let bpm = Double(bpmStep) / 10 - let lag = Int((60 / bpm * stepsPerSecond).rounded()) - guard lag > 1, lag < novelty.count / 2 else { continue } + let minimumLag = max(2, Int((60 / 180 * stepsPerSecond).rounded())) + let maximumLag = min(novelty.count / 2 - 1, + Int((60 / 70 * stepsPerSecond).rounded())) + guard minimumLag <= maximumLag else { return nil } + for lag in minimumLag...maximumLag { var score = 0.0 for index in lag..= 90, bpm <= 150 { score *= 1.06 } - if score > bestScore { bestScore = score; bestBPM = bpm } + if score > bestScore { bestScore = score; bestLag = lag } } - return bestScore > 0.000_001 ? bestBPM : nil + guard bestScore > 0.000_001, bestLag > 0 else { return nil } + return 60 * stepsPerSecond / Double(bestLag) } } diff --git a/juke-wizard/Sources/JukeWizard/JukeController.swift b/juke-wizard/Sources/JukeWizard/JukeController.swift index ff50952cbc..4c23625f53 100644 --- a/juke-wizard/Sources/JukeWizard/JukeController.swift +++ b/juke-wizard/Sources/JukeWizard/JukeController.swift @@ -48,6 +48,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, var activityPollInFlight = false var watchMtimes: [String: Date] = [:] var keyMonitor: Any? + var lastDisplayedBPM: Double? // sort enum SortMode: Int, CaseIterable { @@ -737,7 +738,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, // Keep the bar disc's tempo + spin in step with playback. private func refreshMenuBar() { let bpm = djMode ? djMixer.dominantBPM - : (spotifyMode ? nil : track?.meta?.bpm.map(Double.init)) + : (spotifyMode ? nil : track?.meta?.bpm.map(Double.init) ?? wave.detectedBPM) let playing = djMode ? djMixer.isPlaying : (spotifyMode ? (spotifyState?.isPlaying ?? false) : wave.isPlaying) let title = djMode ? djMixer.dominantTitle @@ -1176,6 +1177,7 @@ final class JukeController: NSWindowController, NSWindowDelegate, commitNotes() let old = current current = i + lastDisplayedBPM = nil let t = library.tracks[i] titleLabel.stringValue = t.title titleLabel.textColor = Self.statusColor(t.meta?.status) @@ -1360,12 +1362,21 @@ final class JukeController: NSWindowController, NSWindowDelegate, // ── waveform delegate ─────────────────────────────────────────────────── func waveformDidFinish() { playButton.title = "▶"; nextTrack(); refreshMenuBar() } - func waveformTick() { updateTime() } + func waveformTick() { + updateTime() + let bpm = track?.meta?.bpm.map(Double.init) ?? wave.detectedBPM + if abs((bpm ?? 0) - (lastDisplayedBPM ?? 0)) >= 0.5 { + lastDisplayedBPM = bpm + refreshMenuBar() + } + } private func updateTime() { if let state = spotifyMode ? spotifyState : nil { ledLabel.stringValue = "\(Self.mmss(state.position)) / \(Self.mmss(state.duration))" } else { - ledLabel.stringValue = "\(Self.mmss(wave.currentTime)) / \(Self.mmss(wave.duration))" + let bpm = track?.meta?.bpm.map(Double.init) ?? wave.detectedBPM + let tempo = bpm.map { String(format: " · ≈%.1f BPM", $0) } ?? "" + ledLabel.stringValue = "\(Self.mmss(wave.currentTime)) / \(Self.mmss(wave.duration))\(tempo)" } } diff --git a/juke-wizard/Sources/JukeWizard/WaveformView.swift b/juke-wizard/Sources/JukeWizard/WaveformView.swift index b656bbe9d0..67db6856f7 100644 --- a/juke-wizard/Sources/JukeWizard/WaveformView.swift +++ b/juke-wizard/Sources/JukeWizard/WaveformView.swift @@ -18,6 +18,7 @@ final class WaveformView: NSView, AVAudioPlayerDelegate { private var player: AVAudioPlayer? private var loadedURL: URL? private var peaks: [Float] = [] + private var tempoMap: [(time: Double, bpm: Double)] = [] private var peaksToken = 0 private var timer: Timer? private var preferredVolume: Float = 0.8 @@ -26,6 +27,10 @@ final class WaveformView: NSView, AVAudioPlayerDelegate { var duration: Double { player?.duration ?? 0 } var currentTime: Double { player?.currentTime ?? 0 } var isPlaying: Bool { player?.isPlaying ?? false } + var detectedBPM: Double? { + guard !tempoMap.isEmpty else { return nil } + return tempoMap.min(by: { abs($0.time - currentTime) < abs($1.time - currentTime) })?.bpm + } var volume: Float { get { player?.volume ?? preferredVolume } set { @@ -50,6 +55,7 @@ final class WaveformView: NSView, AVAudioPlayerDelegate { stop() loadedURL = url peaks = [] + tempoMap = [] openPlayer(url: url) needsDisplay = true computePeaks(url: url) @@ -137,10 +143,32 @@ final class WaveformView: NSView, AVAudioPlayerDelegate { } let mx = out.max() ?? 1 if mx > 0 { for i in 0.. Int(sampleRate * 4) { + let mono = Array(UnsafeBufferPointer(start: ch[0] + start, count: end - start)) + if let bpm = DJTempoAnalyzer.estimate(samples: mono, sampleRate: sampleRate) { + tempos.append((time: Double(center) / sampleRate, bpm: bpm)) + } + } + center += stepFrames + } DispatchQueue.main.async { guard let self, token == self.peaksToken else { return } self.peaks = out + self.tempoMap = tempos self.needsDisplay = true + self.delegate?.waveformTick() } } } diff --git a/juke-wizard/Tests/JukeWizardTests/TempoAnalyzerTests.swift b/juke-wizard/Tests/JukeWizardTests/TempoAnalyzerTests.swift new file mode 100644 index 0000000000..2afa0e806e --- /dev/null +++ b/juke-wizard/Tests/JukeWizardTests/TempoAnalyzerTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import JukeWizard + +final class TempoAnalyzerTests: XCTestCase { + func testDetectsFourOnFloorTempo() throws { + let sampleRate = 8_000.0 + let bpm = 128.0 + let duration = 20.0 + var samples = [Float](repeating: 0, count: Int(sampleRate * duration)) + let beatFrames = Int((60 / bpm) * sampleRate) + for start in stride(from: 0, to: samples.count, by: beatFrames) { + for offset in 0../dev/null 2>&1 || true # refresh the index +# JukeWizard deliberately remains resident after its last window closes, so a +# second CLI launch would otherwise create another invisible audio engine. +# The installed copy is launchd-owned for menu-bar residency, so killing it +# here would race KeepAlive and can produce two players again. Raise the one +# resident instance and let its menu-bar/library UI remain authoritative. +JUKE_PID="$(pgrep -x JukeWizard | head -n 1 || true)" +if [ -n "$JUKE_PID" ]; then + osascript \ + -e 'on run argv' \ + -e 'tell application "System Events" to set frontmost of first process whose unix id is (item 1 of argv as integer) to true' \ + -e 'end run' "$JUKE_PID" >/dev/null 2>&1 || true + echo "JukeWizard is already running; raised the resident menu-bar instance." >&2 + exit 0 +fi + +# Close the small gap between the process check above and AppKit registering +# the new process. This matters when launchd and a CLI queue arrive together. +JUKE_LOCK="${TMPDIR:-/tmp}/computer.aesthetic.jukewizard.launching" +if ! mkdir "$JUKE_LOCK" 2>/dev/null; then + for _juke_wait in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + JUKE_PID="$(pgrep -x JukeWizard | head -n 1 || true)" + [ -n "$JUKE_PID" ] && break + sleep 0.05 + done + if [ -n "$JUKE_PID" ]; then + echo "JukeWizard is already launching; using the resident menu-bar instance." >&2 + exit 0 + fi + rmdir "$JUKE_LOCK" 2>/dev/null || true + mkdir "$JUKE_LOCK" 2>/dev/null || exit 0 +fi +trap 'rmdir "$JUKE_LOCK" 2>/dev/null || true' EXIT HUP INT TERM + # Focused queue mode: use only the following loose files/folders/playlists, # preserving their command-line order. This makes `--queue a.mp3 b.mp3` a # real two-track listening session instead of burying those tracks inside the @@ -53,12 +86,14 @@ if [ "${1:-}" = "--queue" ]; then fi FIRST="$1" case "$FIRST" in /*) SEL="$FIRST";; *) SEL="$(pwd)/$FIRST";; esac - exec "$BIN" "$@" --select "$SEL" + "$BIN" "$@" --select "$SEL" + exit $? fi # No args → just the master library. if [ "$#" -eq 0 ]; then - exec "$BIN" "$LIB" + "$BIN" "$LIB" + exit $? fi # A specific audio file → load the master library AND select/play it @@ -66,8 +101,9 @@ fi # fresh draft not in the index yet). if [ -f "$1" ]; then case "$1" in /*) SEL="$1";; *) SEL="$(pwd)/$1";; esac - exec "$BIN" "$LIB" "$@" --select "$SEL" + "$BIN" "$LIB" "$@" --select "$SEL" + exit $? fi # A folder / playlist / flags → load the master library and add them too. -exec "$BIN" "$LIB" "$@" +"$BIN" "$LIB" "$@" diff --git a/juke-wizard/bin/jukewizard-installed b/juke-wizard/bin/jukewizard-installed index 36042243a6..e30800e8a9 100755 --- a/juke-wizard/bin/jukewizard-installed +++ b/juke-wizard/bin/jukewizard-installed @@ -15,4 +15,32 @@ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "help" ]; then node "$INSTALL_ROOT/juke-cloud.mjs" --help exit 0 fi -exec "$INSTALL_ROOT/JukeWizard" "$@" + +# The app stays resident when its window closes. Reuse that menu-bar process; +# killing a launchd-owned copy here would race KeepAlive and can stack players. +JUKE_PID="$(pgrep -x JukeWizard | head -n 1 || true)" +if [ -n "$JUKE_PID" ]; then + osascript \ + -e 'on run argv' \ + -e 'tell application "System Events" to set frontmost of first process whose unix id is (item 1 of argv as integer) to true' \ + -e 'end run' "$JUKE_PID" >/dev/null 2>&1 || true + echo "JukeWizard is already running; raised the resident menu-bar instance." >&2 + exit 0 +fi + +JUKE_LOCK="${TMPDIR:-/tmp}/computer.aesthetic.jukewizard.launching" +if ! mkdir "$JUKE_LOCK" 2>/dev/null; then + for _juke_wait in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + JUKE_PID="$(pgrep -x JukeWizard | head -n 1 || true)" + [ -n "$JUKE_PID" ] && break + sleep 0.05 + done + if [ -n "$JUKE_PID" ]; then + echo "JukeWizard is already launching; using the resident menu-bar instance." >&2 + exit 0 + fi + rmdir "$JUKE_LOCK" 2>/dev/null || true + mkdir "$JUKE_LOCK" 2>/dev/null || exit 0 +fi +trap 'rmdir "$JUKE_LOCK" 2>/dev/null || true' EXIT HUP INT TERM +"$INSTALL_ROOT/JukeWizard" "$@" diff --git a/juke-wizard/install.sh b/juke-wizard/install.sh index 20dbde85cb..5b5c487e93 100755 --- a/juke-wizard/install.sh +++ b/juke-wizard/install.sh @@ -62,6 +62,9 @@ EOF uid="$(/usr/bin/id -u)" /bin/launchctl bootout "gui/$uid/$LAUNCH_LABEL" 2>/dev/null || true /usr/bin/pkill -x JukeWizard 2>/dev/null || true +# A prior deliberate Quit can leave the label disabled even after bootout; +# bootstrap then fails with launchd error 5 until the label is re-enabled. +/bin/launchctl enable "gui/$uid/$LAUNCH_LABEL" /bin/launchctl bootstrap "gui/$uid" "$LAUNCH_AGENT" echo "installed JukeWizard -> $INSTALL_ROOT/JukeWizard" diff --git a/pop/bell/c/bell.c b/pop/bell/c/bell.c index 67a99e6d7b..afc4ca019c 100644 --- a/pop/bell/c/bell.c +++ b/pop/bell/c/bell.c @@ -327,7 +327,7 @@ int bell_material_preset(BellMaterial *mat, const char *name) { {"steel", 200e9, 7850.0, 0.30, 1.2e-4}, {"aluminum", 69e9, 2700.0, 0.33, 8.0e-4}, {"silver", 83e9, 10490.0, 0.37, 5.0e-4}, - {"glass", 70e9, 2500.0, 0.22, 6.0e-5}, // long shimmering ring + {"glass", 70e9, 2500.0, 0.22, 2.5e-5}, // studio glass: very long, clear ring {"gold", 79e9, 19300.0, 0.42, 9.0e-4}, {"stone", 60e9, 2750.0, 0.25, 1.2e-3}, // granite lithophone — short, clinking ring }; @@ -390,7 +390,9 @@ int bell_geometry_preset(BellGeometry *g, const char *name) { else if (strcmp(name, "bowl") == 0) build_profile(g, "bowl", 0.11, 0.07, 1.0, 0.006, 0.006, 1.0, 2); else if (strcmp(name, "glass") == 0) - build_profile(g, "glass", 0.045, 0.12, 0.55, 0.0018, 0.0014, 1.4, 0); + // A thin, gently flared casting: fewer metallic high-order clusters and + // enough compliance for the low modes to bloom instead of clink. + build_profile(g, "glass", 0.050, 0.13, 0.58, 0.0015, 0.0011, 1.25, 0); else return -1; return 0; @@ -645,6 +647,7 @@ void bell_retune(BellModes *modes, double target_freq) { // a pure deterministic modal sum for the compare.mjs JS-parity harness. static int g_strike = 1; // add the clapper-contact noise transient static int g_norm = 1; // peak-normalize the final mix +static int g_glass_strike = 0; // softer contact for brittle glass bodies long bell_render(const BellModes *modes, double strike_vel, double sr, double dur, float *L, float *R, long nsamp) { @@ -687,12 +690,13 @@ long bell_render(const BellModes *modes, double strike_vel, double sr, if (g_strike) { uint32_t rng = 0x1234567u; double lp = 0.0; - long clk = (long)(0.006 * sr); // ~6 ms - double camp = 0.18 * strike_vel; + long clk = (long)((g_glass_strike ? 0.004 : 0.006) * sr); + double camp = (g_glass_strike ? 0.10 : 0.18) * strike_vel; for (long i = 0; i < clk && i < N; i++) { rng = rng * 1664525u + 1013904223u; double white = ((double)rng / 4294967296.0) * 2.0 - 1.0; - lp = 0.35 * white + 0.65 * lp; + double contact = g_glass_strike ? 0.22 : 0.35; + lp = contact * white + (1.0 - contact) * lp; double env = exp(-(double)i / (0.0018 * sr)); double s = camp * env * lp; L[i] += (float)s; @@ -858,6 +862,7 @@ int main(int argc, char **argv) { fprintf(stderr, "bell: unknown material '%s'\n", material); return 2; } + g_glass_strike = strcmp(material, "glass") == 0; BellModes modes; int nm = bell_solve_modes(&g, &mat, maxM, maxModes, &modes); diff --git a/pop/bell/geometries.json b/pop/bell/geometries.json index dd6c178c49..a738f4d912 100644 --- a/pop/bell/geometries.json +++ b/pop/bell/geometries.json @@ -4,5 +4,5 @@ "handbell": { "rMouth": 0.085, "height": 0.13, "rCrownFrac": 0.30, "hMouth": 0.006, "hCrown": 0.002, "flare": 1.8, "shape": 0, "about": "small handbell, bright + quick" }, "tubular": { "rMouth": 0.0159, "height": 1.40, "rCrownFrac": 1.0, "hMouth": 0.0012, "hCrown": 0.0012, "flare": 1.0, "shape": 1, "about": "orchestral tubular bell (a tuned pipe)" }, "bowl": { "rMouth": 0.11, "height": 0.07, "rCrownFrac": 1.0, "hMouth": 0.006, "hCrown": 0.006, "flare": 1.0, "shape": 2, "about": "singing bowl — long, pure, beating partials" }, - "glass": { "rMouth": 0.045, "height": 0.12, "rCrownFrac": 0.55, "hMouth": 0.0018, "hCrown": 0.0014, "flare": 1.4, "shape": 0, "about": "thin glass — pair with the glass material" } + "glass": { "rMouth": 0.050, "height": 0.13, "rCrownFrac": 0.58, "hMouth": 0.0015, "hCrown": 0.0011, "flare": 1.25, "shape": 0, "about": "thin gently flared studio glass — pair with the glass material" } } diff --git a/pop/bell/materials.json b/pop/bell/materials.json index fb6a323826..e6c3d0c537 100644 --- a/pop/bell/materials.json +++ b/pop/bell/materials.json @@ -5,6 +5,6 @@ "steel": { "E": 2.00e11, "rho": 7850, "nu": 0.30, "loss": 1.2e-4, "about": "bright, high pitch for size, long ring" }, "aluminum": { "E": 0.69e11, "rho": 2700, "nu": 0.33, "loss": 8.0e-4, "about": "light, dull, fast decay" }, "silver": { "E": 0.83e11, "rho": 10490, "nu": 0.37, "loss": 5.0e-4, "about": "dense, mellow" }, - "glass": { "E": 0.70e11, "rho": 2500, "nu": 0.22, "loss": 6.0e-5, "about": "very long shimmering ring; low nu shifts inharmonicity" }, + "glass": { "E": 0.70e11, "rho": 2500, "nu": 0.22, "loss": 2.5e-5, "about": "studio glass: exceptionally long clear ring; low nu shifts inharmonicity" }, "gold": { "E": 0.79e11, "rho": 19300, "nu": 0.42, "loss": 9.0e-4, "about": "extremely dense, low + short" } } diff --git a/pop/wattajetta/STONE.md b/pop/wattajetta/STONE.md new file mode 100644 index 0000000000..164ca981ed --- /dev/null +++ b/pop/wattajetta/STONE.md @@ -0,0 +1,34 @@ +# Wattajetta stone form + +The canonical source is the original `wattajetta.mp3` section beginning around +1:51: the bar-64 stone drop. Its identity is locked to these ingredients: + +- a continuous tempo climb from roughly 127 BPM to 138 BPM; +- sustained sub plus short offbeat sine gallops; +- dense, swung water-bloop percussion; +- granite FEM glass runs and stone bowls; +- the original 0.85 crunch and global platter accelerando; +- mostly tiny FEM strikes during the first 45 seconds, crossed by five explicit + 10–14 second glass-body blooms, then opening afterward; +- roughly half-density FEM runs and half as many bowls during that sparse + opening; kick and sine floor remain continuous; +- no inherited scratch-buffer or platter-drag gestures. +- dry, undoubled snare pops on 2 and 4 whose tone and velocity breathe over a + 12-hit cycle; +- stone glass, handbell, tubular, bowl, and one restrained church geometry. +- no white-noise splash at 0:00; the kick and sine floor introduce the track. +- one through-composed pentatonic bell line whose melodic cursor never resets + at bar boundaries; asymmetric turns prevent eight-note loop repetition. + +The two-minute form is four seamless mutations of that vocabulary. Density and +stereo placement evolve, but the four-on-floor and offbeat sine gallop never +stop. There are no midsection breakdowns and no alternate mix variants. + +```bash +node pop/wattajetta/bin/render-wattajetta.mjs --stone-study +``` + +The output is `out/wattajetta-stone-canonical.mp3`. The FEM cache in +`out/.wattajetta-bell-cache-v1/` keeps the deterministic rebuild quick; the +revised low-loss glass model uses `-fem2` cache keys so older materials remain +warm without masking the new physical response. diff --git a/pop/wattajetta/bin/render-taksmukkeklokkewattajetta.mjs b/pop/wattajetta/bin/render-taksmukkeklokkewattajetta.mjs new file mode 100644 index 0000000000..b029033e9e --- /dev/null +++ b/pop/wattajetta/bin/render-taksmukkeklokkewattajetta.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// taksmukkeklokkewattajetta — Taksmukke's wandering clock hands and shifting +// oscillator voices composed inside Wattajetta's water/material flight plan. +// This is a tempo-locked hybrid arrangement, not a concatenation of masters. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const OUT = resolve(LANE, "out"); +mkdirSync(OUT, { recursive: true }); + +const SR = 48_000; +const BPM = 138; +const BEAT = 60 / BPM; +const BAR = 4 * BEAT; +const BARS = 96; +const DUR = BARS * BAR + 5.5; +const FRAMES = Math.ceil(DUR * SR); +const BASE = resolve(OUT, "wattajetta-tinybells.mp3"); +const RAW = resolve(OUT, ".taksmukkeklokkewattajetta-melody.f32le"); +const MP3 = resolve(OUT, "taksmukkeklokkewattajetta-dance-v3.mp3"); +const RECEIPT = resolve(OUT, "taksmukkeklokkewattajetta-dance-v3.production.json"); +const audio = new Float32Array(FRAMES * 2); + +let seed = 0x74616b77; +const rnd = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296); +const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x)); +const midiHz = (m) => 440 * 2 ** ((m - 69) / 12); + +// Each breath changes harmonic weather. The borrowed bII and dominant b9 are +// deliberate adventure; common tones keep the turns loose instead of abrupt. +const harmony = [ + { name: "Em9", notes: [52, 55, 59, 66] }, + { name: "Cmaj7#11", notes: [48, 52, 59, 66] }, + { name: "Am9", notes: [45, 52, 59, 60] }, + { name: "D13sus", notes: [50, 55, 59, 64] }, + { name: "Gmaj9", notes: [43, 50, 54, 57] }, + { name: "Bbmaj7#11", notes: [46, 50, 57, 64] }, + { name: "F#m11", notes: [42, 49, 52, 59] }, + { name: "B7b9", notes: [47, 51, 57, 60] }, +]; +const timbres = ["sine", "triangle", "hollow-square", "soft-saw"]; +const events = []; +const beatEvents = []; + +function osc(kind, phase) { + const p = phase - Math.floor(phase); + if (kind === "sine") return Math.sin(2 * Math.PI * p); + // A close, clean whistle that can sit beside the FEM bells without turning + // into another pad: fundamental forward, just enough upper air to speak. + if (kind === "whistle") return 0.9 * Math.sin(2 * Math.PI * p) + + 0.075 * Math.sin(4 * Math.PI * p) + + 0.025 * Math.sin(6 * Math.PI * p); + if (kind === "triangle") return 1 - 4 * Math.abs(p - 0.5); + if (kind === "hollow-square") return Math.sin(2 * Math.PI * p) + 0.22 * Math.sin(6 * Math.PI * p); + return 0.72 * Math.sin(2 * Math.PI * p) + 0.2 * Math.sin(4 * Math.PI * p) + 0.08 * Math.sin(6 * Math.PI * p); +} + +function beatHit({ t, dur, pan = 0, gain = 1, sample }) { + const begin = Math.max(0, Math.floor(t * SR)); + const end = Math.min(FRAMES, Math.ceil((t + dur) * SR)); + const gl = Math.sqrt((1 - pan) * 0.5), gr = Math.sqrt((1 + pan) * 0.5); + for (let f = begin; f < end; f++) { + const age = f / SR - t; + const x = sample(age, dur) * gain; + audio[f * 2] += x * gl; + audio[f * 2 + 1] += x * gr; + } +} + +function kick(t, gain) { + let phase = 0; + beatHit({ t, dur: 0.34, gain, sample(age) { + const hz = 44 + 105 * Math.exp(-age * 25); + phase += hz / SR; + const body = Math.sin(2 * Math.PI * phase) * Math.exp(-age * 11); + const click = (Math.sin(2 * Math.PI * 1800 * age) * Math.exp(-age * 150)); + return body * 0.92 + click * 0.08; + }}); + beatEvents.push({ t: +t.toFixed(4), kind: "kick", gain }); +} + +function hat(t, gain, open = false) { + let noise = ((Math.floor(t * SR) ^ 0x51f15e) >>> 0) || 1; + const dur = open ? 0.16 : 0.055; + beatHit({ t, dur, pan: Math.sin(t * 2.7) * 0.32, gain, sample(age) { + noise ^= noise << 13; noise ^= noise >>> 17; noise ^= noise << 5; + const white = ((noise >>> 0) / 2147483648) - 1; + return white * Math.exp(-age * (open ? 24 : 75)) * (0.72 + 0.28 * Math.sin(2 * Math.PI * 9100 * age)); + }}); + beatEvents.push({ t: +t.toFixed(4), kind: open ? "open-hat" : "hat", gain }); +} + +function clap(t, gain) { + let noise = ((Math.floor(t * SR) ^ 0xc1a0) >>> 0) || 1; + beatHit({ t, dur: 0.12, pan: 0.08, gain, sample(age) { + noise ^= noise << 13; noise ^= noise >>> 17; noise ^= noise << 5; + const white = ((noise >>> 0) / 2147483648) - 1; + const burst = Math.max( + Math.exp(-age * 42), + age > 0.018 ? 0.72 * Math.exp(-(age - 0.018) * 48) : 0, + age > 0.036 ? 0.48 * Math.exp(-(age - 0.036) * 55) : 0); + return white * burst; + }}); + beatEvents.push({ t: +t.toFixed(4), kind: "clap", gain }); +} + +function rim(t, gain, pan) { + beatHit({ t, dur: 0.045, pan, gain, sample(age) { + const env = Math.exp(-age * 105); + return (0.72 * Math.sin(2 * Math.PI * 1680 * age) + + 0.28 * Math.sin(2 * Math.PI * 2470 * age)) * env; + }}); + beatEvents.push({ t: +t.toFixed(4), kind: "rim", gain }); +} + +// Establish the floor immediately: first four bars are a restrained heartbeat, +// then hats and claps reveal the full dance grid. Wattajetta's own rhythm still +// supplies the character; this bus makes the hybrid's opening intention clear. +for (let b = 0; b < BARS; b++) { + const intro = b < 4; + for (let q = 0; q < 4; q++) kick(b * BAR + q * BEAT, intro ? 0.105 : 0.16); + if (b >= 2) { + for (let e = 0; e < 8; e++) hat(b * BAR + (e + 0.5) * BEAT / 2, + (e % 2 ? 0.025 : 0.034) * (b < 4 ? 0.7 : 1), e === 7 && b % 4 === 3); + } + if (b >= 4) { clap(b * BAR + BEAT, 0.055); clap(b * BAR + 3 * BEAT, 0.055); } + // The first 45 seconds keep discovering the kit: dry little rim answers + // enter after the basic floor is understood, then disappear at bar 26. + if (b >= 8 && b < 26) { + rim(b * BAR + 1.5 * BEAT, 0.042, -0.42); + if (b % 2) rim(b * BAR + 3.25 * BEAT, 0.035, 0.42); + } +} + +function note({ t, dur, midi, gain, pan, voice, glideFrom = midi }) { + const a = Math.min(0.035, dur * 0.18), r = Math.min(0.24, dur * 0.42); + const begin = Math.max(0, Math.floor(t * SR)); + const end = Math.min(FRAMES, Math.ceil((t + dur) * SR)); + const gl = Math.sqrt((1 - pan) * 0.5), gr = Math.sqrt((1 + pan) * 0.5); + let phase = 0; + for (let f = begin; f < end; f++) { + const age = f / SR - t; + const env = age < a ? age / a : age > dur - r ? Math.max(0, (dur - age) / r) : 1; + const slide = Math.min(1, age / Math.min(0.13, dur * 0.35)); + const hz = midiHz(glideFrom + (midi - glideFrom) * (slide * slide * (3 - 2 * slide))); + phase += hz / SR; + const shimmer = 0.84 * osc(voice, phase) + 0.16 * Math.sin(2 * Math.PI * phase * 2.003); + const x = shimmer * env * gain; + audio[f * 2] += x * gl; + audio[f * 2 + 1] += x * gr; + } + events.push({ t: +t.toFixed(4), dur: +dur.toFixed(4), midi, voice, chord: harmony[Math.floor(t / (2 * BAR)) % harmony.length].name }); +} + +// A quiet, breathing voicing underneath every two bars. It leaves the root to +// Wattajetta's sub and choir, so extensions rather than density do the work. +for (let b = 0; b < 80; b += 2) { + const chord = harmony[(b / 2) % harmony.length]; + const voice = timbres[Math.floor(b / 8) % timbres.length]; + for (let n = 1; n < chord.notes.length; n++) + note({ t: b * BAR + 0.08 + n * 0.018, dur: 1.62 * BAR, midi: chord.notes[n] + 12, + gain: 0.026, pan: [-0.48, 0.18, 0.48][n - 1], voice }); +} + +// Bar 64 (~1:51 in the first render) is the composition's center: low sine +// voices, widest harmony, stone bells, and a hard four-on-floor. Build the +// entire song from that grammar. Every entrance is on a sixteenth/eighth grid; +// the sine calls land with the kick and the whistle answers rock on offbeats. +const hookRhythm = [0, 0.75, 1.5, 2, 2.75, 3.5]; +const hookDegrees = [0, 2, 1, 3, 2, 1]; +for (let b = 2; b < 80; b += 2) { + const chord = harmony[(b / 2) % harmony.length]; + const low = chord.notes.map((n) => clamp(n + 12, 60, 76)); + const variation = Math.floor(b / 8) % 4; + + // Early under-sine: a quiet eighth-note current beneath the audible hook. + // It shares the grid with the hats and alternates root/fifth, making the + // first 45 seconds rock even when the whistle leaves a gap. + if (b < 26) { + for (let e = 0; e < 16; e++) { + const midi = low[e % 4 === 3 ? 2 : 0]; + note({ t: b * BAR + (e + 0.5) * BEAT / 2, dur: 0.16 * BEAT, + midi, gain: e % 4 === 3 ? 0.042 : 0.032, + pan: e % 2 ? 0.12 : -0.12, voice: "sine" }); + } + } + + // Kick-side call: the lower sine-whistle body that made the stone drop work. + for (let i = 0; i < hookRhythm.length; i++) { + const midi = low[(hookDegrees[i] + variation) % low.length]; + note({ + t: b * BAR + hookRhythm[i] * BEAT, + dur: (i === 3 ? 0.68 : 0.32) * BEAT, + midi, + glideFrom: i ? low[(hookDegrees[i - 1] + variation) % low.length] : midi, + gain: b < 4 ? 0.068 : 0.092, + pan: -0.28 + (i % 2) * 0.16, + voice: "sine", + }); + } + + // Bell-side answer: one octave is deliberately avoided; this stays in the + // human whistle register and shares pitch territory with the FEM bowls. + const answerChord = harmony[(b / 2 + 1) % harmony.length]; + const answer = answerChord.notes.map((n) => clamp(n + 17, 64, 79)); + for (let i = 0; i < hookRhythm.length; i++) { + const midi = answer[(hookDegrees[hookDegrees.length - 1 - i] + variation) % answer.length]; + note({ + t: (b + 1) * BAR + (hookRhythm[i] + 0.5) * BEAT, + dur: (i === hookRhythm.length - 1 ? 0.72 : 0.3) * BEAT, + midi, + glideFrom: i ? answer[(hookDegrees[hookDegrees.length - i] + variation) % answer.length] : midi, + gain: b < 4 ? 0.075 : (b >= 64 ? 0.112 : 0.098), + pan: 0.34 - (i % 2) * 0.14, + voice: "whistle", + }); + } +} + +// In the mist, the four original oscillator identities answer one another and +// resolve the B7b9 tension back to a bare E/B fifth around the final glass bell. +for (let b = 80; b < 94; b += 2) { + const i = (b - 80) / 2; + note({ t: b * BAR + (i % 2 ? 1.25 : 0.5) * BEAT, dur: (2.1 - i * 0.14) * BEAT, + midi: [76, 79, 78, 71, 72, 66, 64][i], glideFrom: i ? [76, 76, 79, 78, 71, 72, 66][i] : 71, + gain: 0.065 - i * 0.004, pan: i % 2 ? 0.48 : -0.48, voice: timbres[i % timbres.length] }); +} + +// Gentle safety saturation on the newly composed bus only. +for (let i = 0; i < audio.length; i++) audio[i] = Math.tanh(audio[i] * 1.35) / 1.35; +writeFileSync(RAW, Buffer.from(audio.buffer)); + +const ff = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", + "-i", BASE, "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", RAW, + "-filter_complex", "[0:a]volume=0.82[water];[1:a]highpass=f=28,lowpass=f=11000,equalizer=f=2500:t=q:w=1.1:g=2.5,volume=1.08[clock];[water][clock]amix=inputs=2:duration=longest:normalize=0,acompressor=threshold=-18dB:ratio=2.2:attack=12:release=120:makeup=1.5:knee=5,alimiter=limit=0.93:attack=5:release=80[out]", + "-map", "[out]", "-ar", String(SR), "-codec:a", "libmp3lame", "-b:a", "320k", + "-metadata", "title=taksmukkeklokkewattajetta", "-metadata", "artist=aesthetic.computer", + "-metadata", "comment=Taksmukke clock hands inside Wattajetta's water-material arc", MP3], { stdio: "inherit" }); +if (ff.status !== 0) process.exit(ff.status ?? 1); + +writeFileSync(RECEIPT, JSON.stringify({ + track: "taksmukkeklokkewattajetta", renderer: "bin/render-taksmukkeklokkewattajetta.mjs", + relationship: "dance composition: early four-on-floor grid, FEM-aligned whistle hook, Wattajetta material arc", + transport: { bpm: BPM, meter: "4/4", bars: BARS, durationSec: DUR }, + sourceRhythmSection: "out/wattajetta-tinybells.mp3", output: "out/taksmukkeklokkewattajetta-dance-v3.mp3", + harmony: harmony.map((h) => h.name), oscillatorRotation: timbres, + structure: ["0-3 heartbeat: four-on-floor arrives immediately", "4-27 glass/drop: full dance grid + whistle/FEM call-and-response", "28-47 bronze/breath: low sine calls rock against offbeat whistles", "48-63 steel/drop: beat-locked register pressure", "64-79 stone/drop: widest harmonic travel", "80-95 mist: four-voice call-and-response resolution"], + melodicEventCount: events.length, beatEventCount: beatEvents.length, events, beatEvents, +}, null, 2) + "\n"); +console.log(`→ ${MP3} · ${events.length} melodic events · ${DUR.toFixed(1)}s`); diff --git a/pop/wattajetta/bin/render-wattajetta.mjs b/pop/wattajetta/bin/render-wattajetta.mjs index 85bdbeeb6f..5ad58c0d92 100644 --- a/pop/wattajetta/bin/render-wattajetta.mjs +++ b/pop/wattajetta/bin/render-wattajetta.mjs @@ -16,6 +16,8 @@ // church — one huge church-geometry toll at the coda // // node pop/wattajetta/bin/render-wattajetta.mjs → out/wattajetta.mp3 +// node pop/wattajetta/bin/render-wattajetta.mjs --tiny-bells +// → out/wattajetta-tinybells.mp3 // node pop/wattajetta/bin/render-wattajetta.mjs --score → print the engine score // // Arc (bars): @@ -32,7 +34,7 @@ // 80–95 mist stone tolls · vinyl wobble · underwater scrub · // one last glass bell closing the circle -import { writeFileSync, readFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { writeFileSync, readFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -47,8 +49,11 @@ const TAU = 2 * Math.PI; const BPM = 138; const BEAT = 60 / BPM; const BAR = BEAT * 4; -const BARS = 96; +const STONE_STUDY = process.argv.includes("--stone-study"); +const BARS = STONE_STUDY ? 72 : 96; const DUR = BARS * BAR + 5.5; // room for the last toll to settle +const TINY_BELLS = STONE_STUDY || process.argv.includes("--tiny-bells"); +const TINY_BELL_END = 45; // deterministic sprinkle — same track every bake let _s = 0xa7757e77; @@ -66,30 +71,62 @@ const ROOT_BELLS = ["E4", "G4", "A4", "D5"]; const kicks = []; // t0 f0 f1 sweep ampDb hole decay const sines = []; // t0 dur f0 f1 ampDb atk rel pan0 pan1 vibHz vibCents const noises = []; // t0 dur f0 f1 q peakDb atk rel pan +const snares = []; // { t, strength } — also keys the independent bell duck const bells = []; // { t, note, vel, pan, gain, material, geometry, dur, warp? } const bar = (n) => n * BAR; // the flight plan: each drop hardens the water a little more -const DROPS = [ +const ORIGINAL_DROPS = [ { a: 0, z: 12, mat: "glass", bowl: "silver", durs: [3.2, 3.2, 1.4], density: 0.85, gainDb: -14, bloops: false }, { a: 16, z: 28, mat: "glass", bowl: "silver", durs: [3.2, 1.4, 0.9], density: 0.9, gainDb: -13, bloops: 0.4 }, { a: 32, z: 44, mat: "bronze", bowl: "brass", durs: [1.8, 1.8, 1.0], density: 0.9, gainDb: -13, bloops: 0.6 }, { a: 48, z: 60, mat: "steel", bowl: "gold", durs: [1.2, 1.2, 0.7], density: 0.95, gainDb: -12.5, bloops: 0.6 }, { a: 64, z: 76, mat: "stone", bowl: "stone", durs: [1.6, 1.0, 0.7], density: 0.95, gainDb: -11.5, bloops: 0.75 }, ]; -const BREATHS = [12, 28, 44, 60]; // 4-bar rests between drops +// Canonicalize the original bar-64 stone drop as one continuous two-minute +// form. Four seamless 18-bar mutations change density without dropping the +// floor. Material, dense bloops, galloping sine floor, and 4/4 kick are the +// exact ingredients that made the source section work. +const STONE_DROPS = [ + { a: 0, z: 18, mat: "stone", bowl: "stone", geom: "glass", durs: [1.6, 1.0, 0.7], density: 0.92, gainDb: -11.8, bloops: 0.72 }, + { a: 18, z: 36, mat: "stone", bowl: "stone", geom: "handbell", durs: [1.6, 1.0, 0.7], density: 0.95, gainDb: -11.5, bloops: 0.78 }, + { a: 36, z: 54, mat: "stone", bowl: "stone", geom: "tubular", durs: [1.6, 1.0, 0.7], density: 0.98, gainDb: -11.2, bloops: 0.84 }, + { a: 54, z: 72, mat: "stone", bowl: "stone", geom: "glass", durs: [1.6, 1.0, 0.7], density: 1, gainDb: -10.8, bloops: 0.9 }, +]; +const DROPS = STONE_STUDY ? STONE_DROPS : ORIGINAL_DROPS; +const BREATHS = STONE_STUDY ? [] : [12, 28, 44, 60]; // ── kicks: halftime 1+3 early (the part we loved), then the flight // ramps — drop C flips to four-on-the-floor halfway, and the steel // and stone drops drive full trance 4/4 ──────────────────────────── -const kickAt = (t) => kicks.push([t, 118, 41, 0.075, -2, 0.012, 0.28]); +const kickAt = (t, db = -2) => kicks.push([t, 118, 41, 0.075, db, 0.012, 0.28]); for (const d of DROPS) for (let b = d.a; b < d.z; b++) { - const fourFloor = d.a >= 48 || (d.a === 32 && b >= 38); + const fourFloor = STONE_STUDY || d.a >= 48 || (d.a === 32 && b >= 38); if (fourFloor) for (let k = 0; k < 4; k++) kickAt(bar(b) + k * BEAT); else { kickAt(bar(b)); kickAt(bar(b) + 2 * BEAT); } } + +// Dry popping backbeat. Its brightness and velocity breathe over a 12-hit +// wave, but it never doubles: the dependable 2-and-4 silhouette stays intact. +if (STONE_STUDY) { + let snareIndex = 0; + for (let b = 4; b < BARS; b++) { + if (bar(b) < 45 && b % 2 === 1) continue; + for (const beat of [1, 3]) { + const phase = (snareIndex % 12) / 12; + const swell = 0.5 - 0.5 * Math.cos(TAU * phase); + const t = bar(b) + beat * BEAT; + const db = (bar(b) < 45 ? -15 : -13) + 3.2 * swell; + noises.push([t, 0.085 + 0.035 * swell, + 1750 + 950 * swell, 900 + 350 * swell, 0.95 + 0.4 * swell, + db, 0.002, 0.06 + 0.035 * swell, beat === 1 ? -0.08 : 0.08]); + snares.push({ t, strength: 0.68 + 0.32 * swell }); + snareIndex++; + } + } +} // coda: the downbeat only, letting the stone ring between hits for (let b = 76; b < 80; b++) kickAt(bar(b)); @@ -101,12 +138,14 @@ function subNote(t, dur, freq, db) { for (const d of DROPS) for (let b = d.a; b < d.z; b += 2) { const root = ROOTS[((b - d.a) / 2) % 4]; - if (d.a >= 48) { + if (STONE_STUDY || d.a >= 48) { // trance gallop: offbeat eighth stabs — the sidechain makes the pump for (let bb = b; bb < b + 2; bb++) for (let k = 0; k < 4; k++) - sines.push([bar(bb) + (k + 0.5) * BEAT, 0.16, root * 2, root * 2, -8, 0.005, 0.08, 0, 0, 0, 0]); - sines.push([bar(b), 2 * BAR - 0.08, root, root, -9, 0.06, 0.25, 0, 0, 0, 0]); // sub floor stays + sines.push([bar(bb) + (k + 0.5) * BEAT, 0.16, root * 2, root * 2, + -8, 0.005, 0.08, 0, 0, 0, 0]); + sines.push([bar(b), 2 * BAR - 0.08, root, root, + -9, 0.06, 0.25, 0, 0, 0, 0]); // sub floor stays } else { subNote(bar(b), 2 * BAR - 0.08, root, -7.5); } @@ -116,26 +155,64 @@ subNote(bar(76), 4 * BAR - 0.1, ROOTS[0], -9); // coda holds the root for (const b of BREATHS) sines.push([bar(b), 4 * BAR, 41.203, 82.407, -11, 1.0, 2.2, 0, 0, 0, 0]); -// ── bell runs: eighth-note pentatonic random walks; decay varies per -// strike (a few land choked), material follows the flight plan ────── +// ── bell line: one continuous pentatonic clock hand across the piece. +// The old score restarted a random walk each bar, making the runs feel +// looped. This cursor never resets: a long contour changes direction at +// asymmetric intervals and reflects at the register edges. ────────── +let bellIndex = 2; +let bellDirection = 1; +let bellStep = 0; +const BELL_MOVES = [1, 1, 0, 2, -1, 1, -2, 1, 0, 1, 2, -1, 0, -2, 1, 1, -1]; function bellRun(b, d) { - let idx = Math.floor(rnd() * DROP_NOTES.length); + const inSparseOpening = STONE_STUDY && bar(b) < 45; + const earlyDensity = inSparseOpening ? 0.48 : 1; for (let e = 0; e < 8; e++) { - idx = Math.max(0, Math.min(DROP_NOTES.length - 1, idx + (rnd() < 0.5 ? -1 : 1))); - if (rnd() < d.density) + if (bellStep > 0 && (bellStep % 11 === 0 || bellStep % 29 === 0)) + bellDirection *= -1; + const move = BELL_MOVES[(bellStep + Math.floor(b / 18) * 3) % BELL_MOVES.length]; + bellIndex += move * bellDirection; + while (bellIndex < 0 || bellIndex >= DROP_NOTES.length) { + if (bellIndex < 0) bellIndex = -bellIndex; + if (bellIndex >= DROP_NOTES.length) bellIndex = 2 * (DROP_NOTES.length - 1) - bellIndex; + bellDirection *= -1; + } + if (rnd() < d.density * earlyDensity) { + // Most opening bells remain pinpricks, but a deliberately rare FEM + // strike becomes a structural tone with its full physical decay. + const longTail = inSparseOpening && bellStep % 23 === 5; bells.push({ t: bar(b) + e * 0.5 * BEAT, - note: DROP_NOTES[idx], + note: DROP_NOTES[bellIndex], vel: 0.55 + rnd() * 0.3, pan: (rnd() * 2 - 1) * 0.7, - gain: Math.pow(10, d.gainDb / 20), - material: d.mat, geometry: "glass", - dur: d.durs[Math.floor(rnd() * d.durs.length)], + gain: Math.pow(10, (d.gainDb + (longTail ? -2.5 : 0)) / 20), + material: d.mat, geometry: d.geom || "glass", + dur: longTail ? [7, 8.5, 10][Math.floor(bellStep / 23) % 3] + : d.durs[Math.floor(rnd() * d.durs.length)], + longTail, }); + } + bellStep++; } } for (const d of DROPS) for (let b = d.a; b < d.z; b++) bellRun(b, d); +// Clearly audible opening blooms: these are not reverbs or extended chokes, +// but full 10–14 second FEM bodies in less-damped metals. Their asymmetric +// placement lets the resonances overlap organically beneath the tiny pings. +if (STONE_STUDY) { + const blooms = [ + [2, "E4", "glass", "glass", 14, -12.5, -0.42], + [7, "B4", "glass", "glass", 12, -13.0, 0.36], + [12, "G4", "glass", "glass", 14, -13.5, -0.18], + [18, "D5", "glass", "glass", 12, -14.0, 0.44], + [24, "A4", "glass", "glass", 10, -13.0, -0.32], + ]; + for (const [b, note, material, geometry, dur, db, pan] of blooms) + bells.push({ t: bar(b) + 0.25 * BEAT, note, vel: 0.82, pan, + gain: Math.pow(10, db / 20), material, geometry, dur, longTail: true }); +} + // ── bloops: actual water-drip percussion — a sine chirping UP into its // note the way a drip rings a pool, on the swung offbeats ──────────── function bloop(t, hz, db) { @@ -145,7 +222,8 @@ for (const d of DROPS) { if (!d.bloops) continue; for (let b = d.a; b < d.z; b++) for (const slot of [1.75, 3.25, 3.75]) - if (rnd() < d.bloops) bloop(bar(b) + slot * BEAT, BLOOP_HZ[Math.floor(rnd() * BLOOP_HZ.length)], -17); + if (rnd() < d.bloops) bloop(bar(b) + slot * BEAT, BLOOP_HZ[Math.floor(rnd() * BLOOP_HZ.length)], + -17); } // ── water choir: formant-shaped sine stacks — an "ooh" breathed out of @@ -193,11 +271,15 @@ for (const b of [82, 86, 91]) chimeCluster(bar(b), 3, -23); // ── bowls: a low anchor each 2-bar downbeat, material morphing too ──── for (const d of DROPS) - for (let b = d.a; b < d.z; b += 2) + for (let b = d.a; b < d.z; b += 2) { + // In canonical's first 45 seconds, bowls arrive every four bars instead + // of every two. The kick and sine floor remain; only bell traffic thins. + if (STONE_STUDY && bar(b) < 45 && ((b - d.a) / 2) % 2 === 1) continue; bells.push({ t: bar(b), note: ROOT_BELLS[((b - d.a) / 2) % 4], vel: 0.7, pan: (b / 2) % 2 === 0 ? -0.2 : 0.2, gain: Math.pow(10, -16 / 20), material: d.bowl, geometry: "bowl", dur: 4.5 }); + } // ── breaths, coda, mist: long bowls + tolls; the next material always // tolls once before its drop arrives; breath 4 bells come through @@ -235,6 +317,17 @@ for (const [b, n] of [[80, "E5"], [81, "G5"], [82.5, "A5"], [84, "E5"], [86, "D6 bells.push({ t: bar(93), note: "E6", vel: 0.45, pan: 0, gain: Math.pow(10, -19 / 20), material: "glass", geometry: "glass", dur: 3.2, warp: { depth: 0.05, hz: 0.5 } }); +if (STONE_STUDY) { + // Remove the parent arrangement's aluminum/bronze foreshadows and its + // out-of-range coda. The locked form is strictly the bar-64 stone vocabulary. + const canonicalStone = bells.filter((s) => s.t < bar(68) && s.material === "stone"); + bells.length = 0; + bells.push(...canonicalStone); + bells.push({ t: bar(63), note: "E4", vel: 0.55, pan: 0, + gain: Math.pow(10, -17 / 20), material: "stone", + geometry: "church", dur: 2.5 }); +} + // ── flybys: sine dopplers at the seams, quiet ───────────────────────── function flyby(t, dir) { const d = 2 * BAR; @@ -245,8 +338,11 @@ flyby(bar(29), -1); flyby(bar(53), 1); flyby(bar(70), -1); -// ── spray, whisper level: a steam breath at each drop, an exhale out ── -for (const d of DROPS) noises.push([bar(d.a), 2.0, 2000, 300, 0.8, -23, 0.015, 1.8, 0]); +// ── spray, whisper level: transition breaths only. Canonical deliberately +// has no bar-zero splash; it was too startling before the groove settled. +for (const d of DROPS) + if (!(STONE_STUDY && d.a === 0)) + noises.push([bar(d.a), 2.0, 2000, 300, 0.8, -23, 0.015, 1.8, 0]); noises.push([bar(80), 16 * BAR + 4, 3000, 500, 0.8, -27, 2.0, 14 * BAR, 0]); noises.push([0, DUR, 1100, 1100, 0.6, -41, 4, 4, 0]); @@ -296,6 +392,7 @@ const smooth = (p) => p * p * (3 - 2 * p); const DRIVE = 2.4; const norm = Math.tanh(DRIVE); const blendAt = (t) => { + if (STONE_STUDY) return 0.85; if (t < bar(24)) return 0; if (t < bar(64)) return 0.85 * smooth((t - bar(24)) / (bar(64) - bar(24))); if (t < bar(76)) return 0.85; @@ -318,8 +415,27 @@ const bank = new Map(); const bellFor = ({ note, material, geometry, dur }) => { const k = `${note}/${material}/${geometry}/${dur}`; if (!bank.has(k)) { - console.log(` bell ${note} (${material}/${geometry} ×${dur}s)…`); - bank.set(k, renderBell({ note, material, geometry, dur })); + const cacheDir = resolve(OUT, ".wattajetta-bell-cache-v1"); + // FEM glass v2 has lower structural loss, a thinner profile, and its own + // contact model. Preserve the useful old cache for every unaffected bell. + const modelVersion = material === "glass" ? "-fem2" : ""; + const cacheKey = `${note}-${material}-${geometry}-${dur}${modelVersion}`; + const leftPath = resolve(cacheDir, `${cacheKey}-L.f32`); + const rightPath = resolve(cacheDir, `${cacheKey}-R.f32`); + if (existsSync(leftPath) && existsSync(rightPath)) { + const left = readFileSync(leftPath), right = readFileSync(rightPath); + bank.set(k, { + L: new Float32Array(left.buffer.slice(left.byteOffset, left.byteOffset + left.byteLength)), + R: new Float32Array(right.buffer.slice(right.byteOffset, right.byteOffset + right.byteLength)), + }); + } else { + console.log(` bell ${note} (${material}/${geometry} ×${dur}s)…`); + const rendered = renderBell({ note, material, geometry, dur }); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(leftPath, Buffer.from(rendered.L.buffer, rendered.L.byteOffset, rendered.L.byteLength)); + writeFileSync(rightPath, Buffer.from(rendered.R.buffer, rendered.R.byteOffset, rendered.R.byteLength)); + bank.set(k, rendered); + } } return bank.get(k); }; @@ -344,12 +460,31 @@ for (const s of bells) { const a = (s.pan + 1) * 0.25 * Math.PI; const gl = Math.cos(a) * s.gain * s.vel; const gr = Math.sin(a) * s.gain * s.vel; - const n = Math.min(L.length, ns - at); - const fade = Math.min(Math.floor(0.08 * SR), Math.floor(n * 0.2)); // choked strikes land soft, never click + // The hybrid's opening wants the FEM materials as percussion, not wash: + // choke every bell before 0:45 to a tiny 65–105 ms ping. Later bells keep + // their composed tails so the material arc can still open up dramatically. + const tiny = TINY_BELLS && s.t < TINY_BELL_END && !s.longTail; + const chokeFrames = Math.floor((0.065 + 0.04 * Math.min(1, s.vel ?? 0.5)) * SR); + const n = Math.min(L.length, ns - at, tiny ? chokeFrames : Number.MAX_SAFE_INTEGER); + const fade = tiny + ? Math.min(Math.floor(0.028 * SR), Math.floor(n * 0.38)) + : Math.min(Math.floor(0.08 * SR), Math.floor(n * 0.2)); // always click-safe for (let i = 0; i < n; i++) { const env = i > n - fade ? (n - i) / fade : 1; - mix[2 * (at + i)] += L[i] * gl * env; - mix[2 * (at + i) + 1] += R[i] * gr * env; + const now = s.t + i / SR; + let snareDuck = 1; + for (const snare of snares) { + const since = now - snare.t; + if (since >= 0 && since < 0.19) { + const attack = 0.008; + const shape = since < attack + ? smooth(since / attack) + : 1 - smooth((since - attack) / (0.19 - attack)); + snareDuck = Math.min(snareDuck, 1 - 0.58 * snare.strength * shape); + } + } + mix[2 * (at + i)] += L[i] * gl * env * snareDuck; + mix[2 * (at + i) + 1] += R[i] * gr * env * snareDuck; } } @@ -392,23 +527,23 @@ const SUPER = [ { dur: E * 0.25, from: 0.05, to: 0.3 }, { dur: E * 0.25, from: 0.3, to: 0.1 }, { dur: E * 0.25, from: 0.1, to: 0.35 }, { dur: E * 0.25, from: 0.35, to: 0 }, ]; -// end of drop A: quick baby-scratch fill on the opening slice -scratchAt(bar(11) + 2 * BEAT, bar(0), BEAT, [ - { dur: E, from: 0, to: 0.6 }, { dur: E, from: 0.6, to: 0.1 }, - { dur: E * 0.5, from: 0.1, to: 0.5 }, { dur: E * 0.5, from: 0.5, to: 0 }, - { dur: E, from: 0, to: 1 }, -], -4); -// the SUPER scratches: each one scrubs the NEXT drop's downbeat in -scratchAt(bar(15), bar(16), BEAT, SUPER, -3); -scratchAt(bar(47), bar(48), BEAT, SUPER, -3); -// breath 2: slow half-speed scrub, barely gated -scratchAt(bar(29.5), bar(16), 2 * BEAT, [ - { dur: BEAT, from: 0, to: 0.5, gate: 0.5 }, { dur: BEAT, from: 0.5, to: 0.15, gate: 0.4 }, -], -10); -// mist: one last underwater scrub of the stone drop -scratchAt(bar(84.5), bar(64), 2 * BEAT, [ - { dur: BEAT * 1.5, from: 0, to: 0.4, gate: 0.45 }, { dur: BEAT, from: 0.4, to: 0.1, gate: 0.35 }, -], -11); +if (!STONE_STUDY) { + // The parent Wattajetta keeps its turntablist gestures. Canonical stone is a + // continuous machine: no scrubbed buffers that can read as audio glitches. + scratchAt(bar(11) + 2 * BEAT, bar(0), BEAT, [ + { dur: E, from: 0, to: 0.6 }, { dur: E, from: 0.6, to: 0.1 }, + { dur: E * 0.5, from: 0.1, to: 0.5 }, { dur: E * 0.5, from: 0.5, to: 0 }, + { dur: E, from: 0, to: 1 }, + ], -4); + scratchAt(bar(15), bar(16), BEAT, SUPER, -3); + scratchAt(bar(47), bar(48), BEAT, SUPER, -3); + scratchAt(bar(29.5), bar(16), 2 * BEAT, [ + { dur: BEAT, from: 0, to: 0.5, gate: 0.5 }, { dur: BEAT, from: 0.5, to: 0.15, gate: 0.4 }, + ], -10); + scratchAt(bar(84.5), bar(64), 2 * BEAT, [ + { dur: BEAT * 1.5, from: 0, to: 0.4, gate: 0.45 }, { dur: BEAT, from: 0.4, to: 0.1, gate: 0.35 }, + ], -11); +} // ── vocals: jeffrey-pvc drops (pop/bin/say.mjs → assets/). Mixed after // the scratches so the hand never chews them, before crunch + warp so @@ -423,7 +558,9 @@ function loadVocal(file) { const b = p.stdout; // copy into an aligned buffer — stdout offset isn't guaranteed %4 return new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.length - (b.length % 4))); } -const VOCALS = [ +const VOCALS = STONE_STUDY ? [ + { t: bar(0), file: "wattajetta.mp3", db: -9, rate: 1 }, +] : [ { t: bar(0), file: "wattajetta.mp3", db: -8, rate: 1 }, // title drop over the steam { t: bar(15) - 0.15, file: "wayer.mp3", db: -6, rate: 1 }, // riding super scratch 1 { t: bar(47) - 0.15, file: "wayer.mp3", db: -6, rate: 0.94 }, // into steel, a shade deeper @@ -461,13 +598,14 @@ for (const v of VOCALS) { // ── the kick returns: layered back on top, untouched by every hand ──── for (let i = 0; i < mix.length; i++) mix[i] += kickBus[i]; -// ── warping: the whole record is wet — a global variable-rate pass. -// The platter drags mid-breath (a hand on the vinyl), the tempo -// RAMPS ~6% faster through the back half (138 → ~146 effective BPM, -// kick and all — the rhythm stays locked because everything warps -// together), and the mist rides a warped-record wobble out ────────── +// ── canonical stone has one uninterrupted tempo story: it starts slower +// and rises gradually, with no local scratch grabs or slowdowns. ───── { const rate = (t) => { + if (STONE_STUDY) { + const progress = Math.max(0, Math.min(1, t / DUR)); + return 0.92 + 0.08 * smooth(progress); // ~127 → 138 BPM + } let r = 1; for (const c of [bar(13.5), bar(45.5)]) { const d = (t - c) / 1.4; @@ -516,11 +654,13 @@ const MASTER = [ // dead air it leaves at the tail "areverse", "silenceremove=start_periods=1:start_threshold=-70dB", "areverse", ]; -const mp3 = resolve(OUT, "wattajetta.mp3"); +const mp3 = resolve(OUT, STONE_STUDY + ? "wattajetta-stone-canonical.mp3" + : TINY_BELLS ? "wattajetta-tinybells.mp3" : "wattajetta.mp3"); const ff = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", mixedPath, "-af", MASTER.join(","), "-c:a", "libmp3lame", "-q:a", "2", - "-metadata", "title=wattajetta", "-metadata", "album=pixsies", + "-metadata", `title=${STONE_STUDY ? "wattajetta stone canonical" : TINY_BELLS ? "wattajetta tiny bells" : "wattajetta"}`, "-metadata", "album=pixsies", mp3], { stdio: "inherit" }); for (const p of [rawPath, kickPath, mixedPath]) { try { unlinkSync(p); } catch {} } if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); } diff --git a/slab/menuband/Package.swift b/slab/menuband/Package.swift index 514efbc78e..1b41180f2d 100644 --- a/slab/menuband/Package.swift +++ b/slab/menuband/Package.swift @@ -51,6 +51,7 @@ let package = Package( // exactly what wedged the MAS archive); `.metalsource` is // copied verbatim by both SwiftPM and Xcode. .process("WaveformShaders.metalsource"), + .process("TracktrampShaders.metalsource"), ], linkerSettings: [ .linkedFramework("IOKit"), diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index fbbf78e3e9..842483ceb8 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -227,7 +227,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #endif private var quietFocusRunCount = 0 private var lastQuietFocusTapAt: CFTimeInterval = 0 - /// Max gap between the two taps of the right-⌘ double. + /// Physical side of the last clean Command down edge. A qualifying pair + /// must repeat the same side; left-right remains an ordinary two-hand move. + private var lastQuietFocusSide: UInt16? + /// Max gap between two same-side Command taps. private static let quietFocusTapWindow: CFTimeInterval = 0.50 // (The visualizer's right-⌘ TRIPLE-tap gesture was removed: it had claimed @@ -370,15 +373,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// note changes / legato — for as long as this is true; it only /// springs back once the last finger lifts. private var trackpadTouchActive = false - /// The physical drum skin is the default trackpad instrument. Tab cycles - /// the open surface through skin, synthetic surface, FX, and kit; Shift - /// momentarily exposes sliding FX from either continuous surface. - private enum TrackpadPadMode { case fx, kit, skin, synth } + /// The physical drum skin is the default trackpad instrument. Tab toggles + /// between that skin and pitch-bend FX; Shift momentarily exposes sliding + /// FX from a continuous surface. + enum TrackpadPadMode { case fx, kit, skin, synth } private var trackpadPadMode: TrackpadPadMode = .skin private var trackpadPercussionState = TrackpadPercussionPad.State() private var trackpadPercussionGroups: [TrackpadPercussionPad.Voice: UInt64] = [:] private var trackpadSkinTouches: [CGPoint] = [] private var trackpadContactsByID: [Int32: CGPoint] = [:] + private var trackpadFXPrimaryContact = TrackpadPrimaryContact() private var trackpadSkinFrameTimestamp: Double = 0 /// Stabilized lengths-per-second for membrane friction. Raw per-frame /// distances inherit trackpad quantization and callback jitter, which made @@ -403,6 +407,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { clicked ? 1.55 : 1.0 } private var trackpadSurfaceEnergy = TrackpadSurfaceEnergy() + private var trackpadMembrane = TrackpadMembraneSimulation() private var trackpadEnergyTimer: Timer? private var trackpadOverlayLastDraw: Double = 0 /// Keep the last percussion surface readable after the final lift, like @@ -416,9 +421,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var trackpadRecoveredReplacements = 0 private var trackpadUntriggeredBeginFrames = 0 /// Latest set of fingers on the trackpad (normalized 0…1, origin - /// bottom-left) from the MultitouchSupport tap — drives both performance - /// surfaces and the touch dots painted on the overlay. Empty on the App - /// Store build (no tap). + /// bottom-left) from the direct-build MultitouchSupport tap or the focused + /// App Store NSTouch path. Drives the multitouch performance surfaces. private var mtTouches: [CGPoint] = [] /// True only where the private MultitouchSupport tap is compiled in. #if !MAC_APP_STORE @@ -450,15 +454,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var recordModeActive = false /// Session CGEventTap that swallows Tab WHILE a trackpad pad 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 cycles the pad - /// modes instead. Needs Accessibility (same as TYPE mode); + /// plain focus traversal can't steal the gesture — Tab toggles percussion + /// and pitch bend instead. Needs Accessibility (same as TYPE mode); /// when it can't install, the local-monitor plain-Tab path still toggles /// (it just can't suppress ⌘-Tab). `pitchBendCursorPushed` is read from the /// tap thread — a Bool set on main; a one-frame stale read is harmless. private lazy var bendTabTap: KeyEventTap = KeyEventTap { [weak self] keyCode, isDown, isRepeat, _ in guard let self = self, keyCode == 48 /* Tab */, isDown, self.pitchBendCursorPushed else { return false } - if !isRepeat { DispatchQueue.main.async { self.cycleTrackpadPadMode() } } + if !isRepeat { DispatchQueue.main.async { self.toggleTrackpadPadMode() } } return true // consume — don't let ⌘-Tab / focus traversal fire } #else @@ -474,12 +478,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Avoids spurious calls on every onLitChanged tick — only /// flips on the keyboard-held edges. private var pitchBendCursorLocked = false - /// Sticky pitch-bend MODE. Once the gesture engages (hold a key + - /// swipe), pitch stays latched: ALL subsequent trackpad movement - /// bends, with no note required, until the user hits Esc or Menu - /// Band loses focus. Releasing keys no longer ends it — only those - /// two explicit exits do. + /// Whether Menu Band currently owns the pointer/trackpad performance + /// surface. Explicit quiet focus keeps it latched until Escape/focus loss; + /// an implicit keyboard-note session releases it after the last note. private var pitchBendModeLatched = false + /// True when quiet focus (or the App Store popover) explicitly owns the + /// trackpad as an instrument. Tab may cycle this session through FX, but + /// only Escape/focus loss should tear it down. Ordinary hold-a-note pitch + /// bending remains transient and may still auto-end after release. + private var trackpadPerformanceSessionActive = false /// Floating window that draws the bend wheel above every app. /// Used in lockstep with `CGDisplayHideCursor` so the real /// system cursor is invisible during pitch bend — that's what @@ -728,13 +735,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // pitch-bend is a keyboard-mode feature: hold a // letter → cursor locks → swipe to bend → release // letter → cursor unlocks + spring rubber-band. - // Once latched, pitch-bend MODE persists regardless of which - // notes are (or aren't) held — only Esc / focus loss ends it - // (see endPitchBendSession callers). So we no longer end the - // session here on note release; releasing keys just leaves - // the bend where it is and keeps the mode live. let kbHeld = self.menuBand.keyboardNotesHeld if kbHeld { + // A menubar-key click arms local keyboard capture after the + // mouse-up, but it does not come through the explicit quiet- + // focus shortcut. Open a transient drum surface on the first + // hardware note too. Previously the code below disassociated + // the cursor without pushing/arming the surface, so subsequent + // trackpad frames were discarded and the pointer stayed frozen. + if Self.shouldOpenTransientTrackpadSurface( + keyboardNotesHeld: kbHeld, + modeLatched: self.pitchBendModeLatched + ) { + // Menubar-key capture is a real focus session, so its drum + // stays open between taps. Global TYPE-mode notes have no + // local focus owner and remain transient. + self.activateDefaultTrackpadDrum( + persistent: Self.shouldPersistKeyboardOpenedTrackpadSurface( + localCaptureArmed: self.localCapture.isArmed + ) + ) + } // A keyboard note is held — keep (or arm) the pitch-bend // graphic and cancel any pending teardown left over from a // momentary gap (fast legato note changes). @@ -752,7 +773,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // NSWindow.setFrameOrigin expects. self.pitchBendLockScreenPoint = NSEvent.mouseLocation } - } else if self.pitchBendModeLatched && self.trackpadPadMode == .fx { + } else if Self.shouldEndTrackpadSessionAfterNoteChange( + isLatched: self.pitchBendModeLatched, + performanceSessionActive: self.trackpadPerformanceSessionActive, + keyboardNotesHeld: false + ) { // No keyboard note held anymore. The pitch-shift graphic is // a hold-to-bend visual, so tear it down once the last key // lifts — after a short grace so a quick legato hand-off @@ -1219,7 +1244,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // user sees the layout dynamically appear while typing. localCapture.onKey = { [weak self] keyCode, isDown, isRepeat, flags in guard let self = self else { return false } - // PLAIN Tab cycles the open trackpad surfaces — but only + // PLAIN Tab toggles percussion and pitch bend — but only // mid-bend (the overlay is up) and only where the MultitouchSupport // tap exists. Modified Tab (⌘-Tab app switcher, ⌃-Tab, …) must pass // straight through, so require no command/option/control here. @@ -1227,7 +1252,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.pitchBendCursorPushed, !flags.contains(.command), !flags.contains(.option), !flags.contains(.control) { - if isDown && !isRepeat { self.cycleTrackpadPadMode() } + if isDown && !isRepeat { self.toggleTrackpadPadMode() } return true } // Escape disarms capture explicitly. Useful when the user @@ -1722,11 +1747,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // (see the localCapture Esc handler). Keeps the gesture a pure // "bring me into play" action that can't accidentally drop focus. guard !localCapture.isArmed else { return } - // Arm focus capture WITHOUT touching the popover — the blue glow + + // Arm focus capture WITHOUT touching the popover — the red glow + // rising bell are the "it landed" cue. keepPopoverOpen:true leaves an // already-open popover as-is rather than closing it. beginFocusCaptureFromShortcut(keepPopoverOpen: true) - FocusFlashOverlay.shared.flash(rising: true) + FocusFlashOverlay.shared.flash(rising: false) menuBand.playFocusCue(rising: true) } @@ -2187,14 +2212,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) { event in handler(event); return event } } - /// Double-tap the RIGHT ⌘ → toggle focus capture. Two bare right-⌘ down + /// Double-tap either physical ⌘ → toggle focus capture. Two bare same-side ⌘ down /// edges inside `quietFocusTapWindow` arm / disarm the menubar piano for /// typing. A single bare ⌘ does nothing, so ⌘ stays free as a normal /// modifier for the rest of the system. The toggle fires a dry click on the /// qualifying second press so the user feels the gesture land. /// - /// RIGHT ⌘ specifically, and LEFT ⌘ breaks the run rather than merely not - /// counting — otherwise ordinary two-handed ⌘ use would trip the gesture. + /// Left-left and right-right are equivalent. Switching sides restarts the + /// run so ordinary two-handed Command use cannot trip the gesture. /// /// (History worth keeping: this WAS a right-⌘ double-tap, then a right-⌘ /// TRIPLE-tap visualizer gesture took the key, so focus was moved onto an @@ -2250,6 +2275,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.commandKeysHeld = commandState let sideIsHeld = side == Self.leftCommandKeyCode ? commandState.left : commandState.right + self.menuBand.setVisualControlKey(side, isDown: sideIsHeld) #if !MAC_APP_STORE let bothHeld = commandState.left && commandState.right // Releasing either ⌘ cancels a count-in in progress. @@ -2269,18 +2295,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #endif // 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 + let sameSide = self.lastQuietFocusSide == side + let inWindow = sameSide && now - self.lastQuietFocusTapAt <= Self.quietFocusTapWindow self.quietFocusRunCount = (inWindow && self.quietFocusRunCount > 0) ? self.quietFocusRunCount + 1 : 1 self.lastQuietFocusTapAt = now + self.lastQuietFocusSide = side guard self.quietFocusRunCount >= 2 else { return } - // Right-⌘ DOUBLE = PLAY (arm the keyboard). + // Same-side ⌘ DOUBLE = PLAY (arm the keyboard). self.quietFocusRunCount = 0 self.lastQuietFocusTapAt = 0 + self.lastQuietFocusSide = nil FocusCueBeep.shared.click() self.toggleQuietFocusFromCommandTap() } @@ -3530,6 +3557,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // sensor that was reporting it is gone (the capture-panel sensor // takes over again if the user keeps playing with notes held). if !menuBand.keyboardNotesHeld { setTrackpadTouchActive(false) } + #if MAC_APP_STORE + // The sandboxed percussion surface is explicitly scoped to the open + // popover. Do not leave its warm audio/session state behind after the + // only public NSTouch responder goes away. + if pitchBendModeLatched { endPitchBendSession() } + #endif // [v1 cutoff] KidLisp TV panel removed — no child panel to tear down. if let panel = panelToFade { NSAnimationContext.runAnimationGroup({ ctx in @@ -4541,6 +4574,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if trackpadPadMode == .kit, pitchBendCursorPushed { updateTrackpadPercussion(touches, began: changes.began, callbackTime: callbackTime) + } else if trackpadPadMode == .fx, pitchBendCursorPushed { + if let primaryTouch = trackpadFXPrimaryContact.update(changes.active) { + applyAbsoluteTrackpadFX(at: primaryTouch) + } } else if shiftSlides { trackpadSkinTouches = touches trackpadSkinFrameTimestamp = timestamp @@ -4558,7 +4595,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } if pitchBendCursorPushed, !changes.began.isEmpty, trackpadTriggeredArticulations == articulationCountBefore, - !shiftSlides { + !shiftSlides, trackpadPadMode != .fx { trackpadUntriggeredBeginFrames += 1 } if pitchBendCursorPushed { @@ -4575,7 +4612,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { updateTrackpadOverlayIfDue( force: touches.count != priorTouchCount ) - } else if priorTouchCount > 0 { + } else if priorTouchCount > 0, + Self.shouldFadeTrackpadOverlayAfterLift( + performanceSessionActive: trackpadPerformanceSessionActive + ) { // Preserve the final contact/energy state long enough to read, // then ease it away on the same rhythm as the key-label ghost. pitchBendOverlay?.fadeOut( @@ -4603,6 +4643,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { trackpadSurfaceEnergy.energize( at: strike, amount: 0.90, now: CACurrentMediaTime() ) + trackpadMembrane.impulse(at: strike, amount: 0.90) updateTrackpadOverlayIfDue(force: true) } } @@ -4672,6 +4713,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { lifted explicitLifts: [CGPoint]? = nil, synthetic: Bool) { let now = CACurrentMediaTime() + trackpadMembrane.advance(to: now, touches: touches) let lifts = explicitLifts ?? TrackpadDrumSkinPad.liftedTouches( previous: trackpadSkinTouches, current: touches) if !lifts.isEmpty { menuBand.markTrackpadInput(at: callbackTime) } @@ -4705,6 +4747,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { amount: 0.22 + Double(velocity) / 127.0 * 0.34, now: now ) + trackpadMembrane.impulse( + at: strike, amount: 0.28 + Double(velocity) / 127.0 * 0.54 + ) } let contactChanged = !strikes.isEmpty || !lifts.isEmpty || touches.count != trackpadSkinTouches.count @@ -4932,7 +4977,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func startTrackpadEnergyDisplay() { trackpadEnergyTimer?.invalidate() - let timer = Timer(timeInterval: 1.0 / 30.0, repeats: true) { + let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in guard let self, self.pitchBendModeLatched else { timer.invalidate() @@ -4940,6 +4985,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } self.trackpadSurfaceEnergy.decay(to: CACurrentMediaTime()) if self.trackpadPadMode == .skin || self.trackpadPadMode == .synth { + self.trackpadMembrane.advance( + to: CACurrentMediaTime(), touches: self.mtTouches + ) self.updateTrackpadOverlayIfDue() } } @@ -4951,13 +4999,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Make the physical skin immediately playable when Menu Band's quiet /// focus is summoned. The cursor lock is the boundary of the global drum: /// Escape/focus loss restores the ordinary pointer and ends percussion. - private func activateDefaultTrackpadDrum() { + private func activateDefaultTrackpadDrum(persistent: Bool = true) { guard trackpadFxAvailable else { return } #if !MAC_APP_STORE setTrackpadFighterSuppressed(true) #endif trackpadPadMode = .skin + trackpadFXPrimaryContact.reset() trackpadSurfaceEnergy.reset(at: CACurrentMediaTime()) + trackpadMembrane.reset(at: CACurrentMediaTime()) trackpadScratchSpeed = nil startTrackpadEnergyDisplay() menuBand.setTrackpadPerformanceActive(true) @@ -4982,6 +5032,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } #endif pitchBendModeLatched = true + // Shortcut/popover focus owns the surface until Escape/focus loss. + // A surface opened implicitly by a hardware note is transient and + // closes after the final note's short release grace. + trackpadPerformanceSessionActive = persistent if !pitchBendCursorPushed { #if !MAC_APP_STORE PitchBendCursor.neutral.push() @@ -5003,17 +5057,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #endif } - /// Cycle an open trackpad through physical skin, synthetic surface, FX, - /// and the quadrant kit. - /// Entering percussion neutralizes the FX so the drum kit is not silently - /// pitch-shifted; returning to FX requires a fresh movement or note. - private func cycleTrackpadPadMode() { - switch trackpadPadMode { - case .fx: trackpadPadMode = .kit - case .kit: trackpadPadMode = .skin - case .skin: trackpadPadMode = .synth - case .synth: trackpadPadMode = .fx - } + /// Toggle an open trackpad between physical percussion and pitch bend. + /// Returning to percussion neutralizes FX so the skin is never silently + /// pitch-shifted. + private func toggleTrackpadPadMode() { + trackpadFXPrimaryContact.reset() + trackpadPadMode = Self.trackpadPadModeAfterTab(trackpadPadMode) if trackpadPadMode != .fx { pitchBendEndTimer?.invalidate() pitchBendEndTimer = nil @@ -5049,12 +5098,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #if !MAC_APP_STORE trackpadPercussionGestureTap.stop() #endif - if !menuBand.keyboardNotesHeld { scheduleGraphicEndIfNoKeyHeld() } + if Self.shouldAutoEndTrackpadFX( + performanceSessionActive: trackpadPerformanceSessionActive, + keyboardNotesHeld: menuBand.keyboardNotesHeld + ) { + scheduleGraphicEndIfNoKeyHeld() + } } updatePitchBendOverlayImage() debugLog("trackpad pad mode = \(trackpadPadMode)") } + static func trackpadPadModeAfterTab(_ mode: TrackpadPadMode) -> TrackpadPadMode { + mode == .fx ? .skin : .fx + } + func setTrackpadTouchActive(_ active: Bool) { trackpadTouchActive = active guard !active else { return } @@ -5065,11 +5123,88 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // bend/space/echo where they set them so pitch + ambience don't // drift on their own. The next finger touch (or note release) // is the right time to change them. - if !menuBand.keyboardNotesHeld && trackpadPadMode == .fx { + if trackpadPadMode == .fx && Self.shouldAutoEndTrackpadFX( + performanceSessionActive: trackpadPerformanceSessionActive, + keyboardNotesHeld: menuBand.keyboardNotesHeld + ) { endPitchBendSession() } } + static func shouldAutoEndTrackpadFX(performanceSessionActive: Bool, + keyboardNotesHeld: Bool) -> Bool { + !performanceSessionActive && !keyboardNotesHeld + } + + static func shouldOpenTransientTrackpadSurface(keyboardNotesHeld: Bool, + modeLatched: Bool) -> Bool { + keyboardNotesHeld && !modeLatched + } + + static func shouldPersistKeyboardOpenedTrackpadSurface( + localCaptureArmed: Bool + ) -> Bool { + localCaptureArmed + } + + static func shouldFadeTrackpadOverlayAfterLift( + performanceSessionActive: Bool + ) -> Bool { + !performanceSessionActive + } + + static func shouldEndTrackpadSessionAfterNoteChange( + isLatched: Bool, + performanceSessionActive: Bool, + keyboardNotesHeld: Bool + ) -> Bool { + isLatched && shouldAutoEndTrackpadFX( + performanceSessionActive: performanceSessionActive, + keyboardNotesHeld: keyboardNotesHeld + ) + } + + static func absoluteTrackpadFXValues(at point: CGPoint, + bendRange: Float, + echoEnabled: Bool) + -> (bend: Float, fxX: Float, space: Float, echo: Float) { + let normalizedX = max(Float(-1), min(Float(1), + (Float(point.x) - 0.5) * 2 + )) + let fxX = echoEnabled ? normalizedX : min(Float(0), normalizedX) + let bend = max(-bendRange, min(bendRange, + (Float(point.y) - 0.5) * 2 * bendRange + )) + return ( + bend: bend, + fxX: fxX, + space: max(Float(0), -fxX), + echo: echoEnabled ? max(Float(0), fxX) : 0 + ) + } + + /// The Tab-selected FX page is an absolute trackpad surface: vertical + /// position controls pitch and the two horizontal halves select space or + /// echo. MultitouchSupport and the App Store NSTouch path both arrive here + /// through `handleTrackpadFrame`, so neither build depends on mouse deltas. + private func applyAbsoluteTrackpadFX(at point: CGPoint) { + let values = Self.absoluteTrackpadFXValues( + at: point, + bendRange: Self.bendRange, + echoEnabled: Self.fxEchoEnabled + ) + fxX = values.fxX + spaceAmount = values.space + echoAmount = values.echo + bendGestureTarget = values.bend + bendEaseAllChannels = false + cancelFxRelease() + startBendEase() + menuBand.setSpace(amount: spaceAmount) + menuBand.setEcho(amount: echoAmount) + pushStaffPitchShift() + } + private func handlePitchBendCursorMove(event: NSEvent) { // Single-finger trackpad gesture: normally active while the // user is holding a KEYBOARD note. Mouse-tapped piano notes @@ -5113,8 +5248,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // so engage the gesture even with no key held. guard menuBand.keyboardNotesHeld || shift || inReleaseGrace || menuBand.isRewinding else { return } - // Shift momentarily puts either continuous surface onto the original - // sliding FX. Kit remains exclusive; Tab can select FX persistently. + // Shift momentarily puts a continuous surface onto the original + // sliding FX; Tab selects FX persistently. let momentarySurfaceFx = (trackpadPadMode == .skin || trackpadPadMode == .synth) && shift if trackpadPadMode != .fx && !momentarySurfaceFx { return } @@ -5270,22 +5405,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return TrackpadPercussionPad.image(touches: mtTouches, state: trackpadPercussionState) } else if trackpadPadMode == .skin && !momentarySurfaceFx { - return TrackpadDrumSkinPad.image(touches: mtTouches, energy: energy) + return TrackpadDrumSkinPad.image( + touches: mtTouches, energy: energy, + membrane: trackpadMembrane.snapshot() + ) } else if trackpadPadMode == .synth && !momentarySurfaceFx { return TrackpadSynthPad.image(touches: mtTouches, energy: energy) } return PitchBendCursor.image(forBend: bendAmount / Self.bendRange, echo: fxX, - keyDown: menuBand.keyboardNotesHeld, - touches: mtTouches) + keyDown: menuBand.keyboardNotesHeld) + } + + private var showingTracktrampSkin: Bool { + trackpadPadMode == .skin + && !NSEvent.modifierFlags.contains(.shift) } private func showPitchBendOverlay() { if (trackpadPadMode == .skin || trackpadPadMode == .synth), trackpadEnergyTimer == nil { trackpadSurfaceEnergy.reset(at: CACurrentMediaTime()) + trackpadMembrane.reset(at: CACurrentMediaTime()) startTrackpadEnergyDisplay() } let overlay = ensurePitchBendOverlay() + if showingTracktrampSkin { + overlay.showTracktramp( + trackpadMembrane.snapshot(), + touches: mtTouches, + atScreenPoint: trackpadOverlayAnchor( + imageSize: TracktrampMetalView.logicalSize, + fallback: pitchBendLockScreenPoint + ) + ) + return + } let image = currentFxCursorImage() overlay.show(image: image, atScreenPoint: trackpadOverlayAnchor( @@ -5321,6 +5475,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func updatePitchBendOverlayImage() { guard let overlay = pitchBendOverlay, overlay.isVisible else { return } trackpadOverlayLastDraw = CACurrentMediaTime() + if showingTracktrampSkin { + overlay.updateTracktramp( + trackpadMembrane.snapshot(), + touches: mtTouches, + atScreenPoint: trackpadOverlayAnchor( + imageSize: TracktrampMetalView.logicalSize, + fallback: pitchBendLockScreenPoint + ) + ) + return + } let image = currentFxCursorImage() overlay.update(image: image, atScreenPoint: trackpadOverlayAnchor( @@ -5332,6 +5497,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// from delaying the next input callback by a complete 8 ms touch frame. private func updateTrackpadOverlayIfDue(force: Bool = false) { let now = CACurrentMediaTime() + // Touch and sound stay at the hardware's 125 Hz cadence, but this + // decorative thumbnail must not compete with notation/UI animation. + // The Metal skin submits at most once per display frame and reuses its + // cached texture; input and sound continue independently at 125 Hz. guard force || now - trackpadOverlayLastDraw >= 1.0 / 60.0 else { return } updatePitchBendOverlayImage() } @@ -5464,6 +5633,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { pitchBendEndTimer?.invalidate() pitchBendEndTimer = nil pitchBendReleaseGraceUntil = nil + trackpadFXPrimaryContact.reset() // Stop the bend easer so it can't keep nudging pitch after teardown; // the fx spring-back (startFxRelease below) owns `bendAmount` now. stopBendEase() @@ -5471,6 +5641,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Esc / focus-loss always fully exits, even if the lock flag was // somehow already cleared. pitchBendModeLatched = false + trackpadPerformanceSessionActive = false #if !MAC_APP_STORE setTrackpadFighterSuppressed(false) #endif diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 690e62df7f..8f32bad8a8 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -270,20 +270,36 @@ final class MenuBandController { /// a live keyboard. The key codes are the keymap's own inverse — whichever /// physical key would have sounded that note. `spaceHeld` lights the space /// bar too, for the reverse-playback demo. - func captureHold(notes: Set, spaceHeld: Bool = false) { + func captureHold(notes: Set, spaceHeld: Bool = false, + extraKeyCodes: Set = []) { litNotes = notes var codes = Set() for keyCode in UInt16(0)..<128 { + // Score-driven promo captures teach the canonical keyboard at + // zero shift; they must not inherit this Mac's persisted octave + // preference or the corresponding QWERTY cap can disappear. if let midi = MenuBandLayout.midiNote(forKeyCode: keyCode, - octaveShift: octaveShift, + octaveShift: 0, keymap: keymap), notes.contains(midi) { codes.insert(keyCode) } } if spaceHeld { codes.insert(49) } // kVK_Space + codes.formUnion(extraKeyCodes) captureHeldKeyCodes = codes } + /// Feed physical non-note state into the same live key snapshot used by + /// QwertyLayoutView. Command arrives through flagsChanged rather than the + /// note handler, so AppDelegate calls this explicitly for keycodes 54/55. + func setVisualControlKey(_ keyCode: UInt16, isDown: Bool) { + heldLock.lock() + if isDown { heldControlKeys.insert(keyCode) } + else { heldControlKeys.remove(keyCode) } + heldLock.unlock() + onChange?() + } + /// Pose the spacebar reverse-replay state for a headless render — the scope /// draws its frozen columns with an orange playhead when `isRewinding`. func captureReverse(_ active: Bool) { isRewinding = active } diff --git a/slab/menuband/Sources/MenuBand/MultitouchTrackpad.swift b/slab/menuband/Sources/MenuBand/MultitouchTrackpad.swift index 602c5b7373..a85fcc72af 100644 --- a/slab/menuband/Sources/MenuBand/MultitouchTrackpad.swift +++ b/slab/menuband/Sources/MenuBand/MultitouchTrackpad.swift @@ -41,6 +41,31 @@ struct TrackpadContactChanges { } } +/// Keeps an absolute-control surface tied to the finger that started it. +/// Additional contacts are ignored, and an already-resting contact cannot +/// take over when the primary lifts; all fingers must lift before re-arming. +struct TrackpadPrimaryContact { + private(set) var identifier: Int32? + + mutating func update(_ contacts: [TrackpadContact]) -> CGPoint? { + let active = contacts.filter(\.isActive) + if let identifier { + if let primary = active.first(where: { $0.identifier == identifier }) { + return primary.point + } + if active.isEmpty { self.identifier = nil } + return nil + } + guard let primary = active.first else { return nil } + identifier = primary.identifier + return primary.point + } + + mutating func reset() { + identifier = nil + } +} + // MultitouchTrackpad — global trackpad-finger tap via Apple's PRIVATE // MultitouchSupport.framework. Unlike NSTouch (which only reaches the // frontmost app's first responder), this receives every finger on every diff --git a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift index fced197a25..085539ba1c 100644 --- a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift +++ b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift @@ -1,4 +1,6 @@ import AppKit +import Metal +import MetalKit /// Sparse inertial energy field shared by the physical and electronic /// surfaces. Charges persist, merge locally, and decay; audio and drawing read @@ -94,6 +96,125 @@ struct TrackpadSurfaceEnergy { } } +/// A damped, rim-fixed membrane advanced from the same touch clock as the +/// instrument. Every cell exchanges velocity with its neighbors, so a strike +/// bends and rings the whole sheet instead of moving an isolated decoration. +struct TrackpadMembraneSimulation { + struct Snapshot { + let columns: Int + let rows: Int + let heights: [Double] + + var isFlat: Bool { + !heights.contains { abs($0) > 0.000_5 } + } + + func height(at point: CGPoint) -> CGFloat { + let x = max(0, min(1, point.x)) * CGFloat(columns - 1) + let y = max(0, min(1, point.y)) * CGFloat(rows - 1) + let x0 = min(columns - 1, Int(floor(x))) + let y0 = min(rows - 1, Int(floor(y))) + let x1 = min(columns - 1, x0 + 1) + let y1 = min(rows - 1, y0 + 1) + let tx = Double(x - CGFloat(x0)) + let ty = Double(y - CGFloat(y0)) + func sample(_ column: Int, _ row: Int) -> Double { + heights[row * columns + column] + } + let bottom = sample(x0, y0) * (1 - tx) + sample(x1, y0) * tx + let top = sample(x0, y1) * (1 - tx) + sample(x1, y1) * tx + return CGFloat(bottom * (1 - ty) + top * ty) + } + } + + private let columns = 23 + private let rows = 15 + private var heights: [Double] = Array(repeating: 0, count: 23 * 15) + private var velocities: [Double] = Array(repeating: 0, count: 23 * 15) + private var lastTime: Double = 0 + + mutating func reset(at now: Double) { + heights = Array(repeating: 0, count: columns * rows) + velocities = Array(repeating: 0, count: columns * rows) + lastTime = now + } + + mutating func impulse(at point: CGPoint, amount: Double) { + for row in 1..<(rows - 1) { + for column in 1..<(columns - 1) { + let normalized = CGPoint( + x: CGFloat(column) / CGFloat(columns - 1), + y: CGFloat(row) / CGFloat(rows - 1) + ) + let dx = Double(normalized.x - point.x) * 1.64 + let dy = Double(normalized.y - point.y) + let falloff = exp(-(dx * dx + dy * dy) / (2 * 0.105 * 0.105)) + // Taut fabric takes a compact shove instead of a broad fluid + // splash. The stronger damping in `step` turns this into one + // readable rebound rather than a long train of ripples. + velocities[row * columns + column] += max(0, amount) * falloff * 6.2 + } + } + } + + mutating func advance(to now: Double, touches: [CGPoint]) { + if lastTime == 0 { lastTime = now; return } + let elapsed = max(0, min(0.080, now - lastTime)) + lastTime = now + guard elapsed > 0 else { return } + let stepCount = max(1, Int(ceil(elapsed / (1.0 / 240.0)))) + let dt = elapsed / Double(stepCount) + for _ in 0.. Snapshot { + Snapshot(columns: columns, rows: rows, heights: heights) + } +} + private enum TrackpadEnergyVisual { static func draw(_ charges: [TrackpadSurfaceEnergy.Charge], in chart: NSRect, accent: NSColor) { @@ -119,6 +240,71 @@ private enum TrackpadEnergyVisual { } } +/// Lights the original drum chart as a height field. Geometry never slides: +/// local slope produces a moving highlight and shadow over the existing ink. +private enum TrackpadMembraneLighting { + static func draw(_ membrane: TrackpadMembraneSimulation.Snapshot?, + in chart: NSRect) { + guard let membrane, !membrane.isFlat else { return } + // Render in the simulation's normalized coordinate grid and let + // AppKit scale the tiny texture. The old code rasterized every logical + // chart point on every frame (and Retina scaled it once more), doing + // ~10,000 bilinear samples and allocating ~42 KB at 60 Hz. A 2× grid + // retains smooth slopes while keeping this decoration out of the + // notation surface's frame budget. + let width = max(2, membrane.columns * 2 - 1) + let height = max(2, membrane.rows * 2 - 1) + guard let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, + bitmapFormat: [], bytesPerRow: 0, bitsPerPixel: 0 + ), let data = bitmap.bitmapData else { return } + let dx = 1 / CGFloat(width - 1) + let dy = 1 / CGFloat(height - 1) + for row in 0..= 0 ? alpha : 0 + data[offset] = channel + data[offset + 1] = channel + data[offset + 2] = channel + data[offset + 3] = alpha + } + } + let lighting = NSImage(size: NSSize(width: width, height: height)) + lighting.addRepresentation(bitmap) + let context = NSGraphicsContext.current + let priorInterpolation = context?.imageInterpolation + context?.imageInterpolation = .high + lighting.draw(in: chart, + from: NSRect(x: 0, y: 0, width: width, height: height), + operation: .sourceOver, fraction: 1) + if let priorInterpolation { context?.imageInterpolation = priorInterpolation } + } +} + /// Finger count chooses articulation; vertical position chooses the kit half. /// Compound two-finger gestures enter a cooldown until that half is clear, so /// lifting one finger never fires an unintended single-finger hit. @@ -406,26 +592,35 @@ enum TrackpadDrumSkinPad { } static func image(touches: [CGPoint], - energy: [TrackpadSurfaceEnergy.Charge] = []) -> NSImage { + energy: [TrackpadSurfaceEnergy.Charge] = [], + membrane: TrackpadMembraneSimulation.Snapshot? = nil, + appearance: NSAppearance? = nil) -> NSImage { // 1.64:1, matching the physical trackpad rather than the square FX // cursor. Large enough to read the four inset material contours. let size = NSSize(width: 140, height: 88) - let appearance = NSApp.effectiveAppearance + // Overlay panels can live on a screen whose appearance differs from + // NSApp's cached effective appearance. Resolve from the presenting + // view when supplied so light mode never receives a dark skin texture. + let appearance = appearance ?? NSApp.effectiveAppearance let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua return NSImage(size: size, flipped: false) { rect in if #available(macOS 11.0, *) { appearance.performAsCurrentDrawingAppearance { - draw(in: rect, touches: touches, energy: energy, isDark: isDark) + draw(in: rect, touches: touches, energy: energy, + membrane: membrane, isDark: isDark) } } else { - draw(in: rect, touches: touches, energy: energy, isDark: isDark) + draw(in: rect, touches: touches, energy: energy, + membrane: membrane, isDark: isDark) } return true } } private static func draw(in rect: NSRect, touches: [CGPoint], - energy: [TrackpadSurfaceEnergy.Charge], isDark: Bool) { + energy: [TrackpadSurfaceEnergy.Charge], + membrane: TrackpadMembraneSimulation.Snapshot?, + isDark: Bool) { // Match the physical trackpad's wide rounded rectangle instead of // depicting the synthesis surface as a circular drum. let chart = rect.insetBy(dx: 4, dy: 4) @@ -447,21 +642,23 @@ enum TrackpadDrumSkinPad { let rimZone = insetZone(14.4) let snareZone = insetZone(21.6) let kickZone = insetZone(28) + // Preserve the original natural material palette. System accent is a + // performance signal on the rim and contacts, not the skin's pigment. let clickColor = isDark - ? NSColor(white: 0.82, alpha: 1) - : NSColor(srgbRed: 0.98, green: 0.95, blue: 0.82, alpha: 1) + ? NSColor(srgbRed: 0.48, green: 0.43, blue: 0.34, alpha: 1) + : NSColor(srgbRed: 0.97, green: 0.91, blue: 0.77, alpha: 1) // cream let hatColor = isDark - ? NSColor(white: 0.29, alpha: 1) - : NSColor(srgbRed: 0.66, green: 0.68, blue: 0.65, alpha: 1) + ? NSColor(srgbRed: 0.34, green: 0.39, blue: 0.29, alpha: 1) + : NSColor(srgbRed: 0.72, green: 0.77, blue: 0.62, alpha: 1) // sage let rimColor = isDark - ? NSColor(srgbRed: 0.58, green: 0.31, blue: 0.16, alpha: 1) - : NSColor(srgbRed: 0.70, green: 0.38, blue: 0.18, alpha: 1) + ? NSColor(srgbRed: 0.43, green: 0.34, blue: 0.20, alpha: 1) + : NSColor(srgbRed: 0.84, green: 0.68, blue: 0.40, alpha: 1) // ochre let snareColor = isDark - ? NSColor(srgbRed: 0.38, green: 0.22, blue: 0.15, alpha: 1) - : NSColor(srgbRed: 0.80, green: 0.58, blue: 0.38, alpha: 1) + ? NSColor(srgbRed: 0.43, green: 0.25, blue: 0.17, alpha: 1) + : NSColor(srgbRed: 0.76, green: 0.45, blue: 0.30, alpha: 1) // terracotta let kickColor = isDark - ? NSColor(srgbRed: 0.075, green: 0.055, blue: 0.045, alpha: 1) - : NSColor(srgbRed: 0.34, green: 0.20, blue: 0.13, alpha: 1) + ? NSColor(srgbRed: 0.24, green: 0.15, blue: 0.10, alpha: 1) + : NSColor(srgbRed: 0.48, green: 0.29, blue: 0.19, alpha: 1) // umber NSGraphicsContext.saveGraphicsState() body.addClip() clickColor.setFill(); body.fill() @@ -517,8 +714,9 @@ enum TrackpadDrumSkinPad { clickRail.stroke() TrackpadEnergyVisual.draw(energy, in: chart, accent: accent) + TrackpadMembraneLighting.draw(membrane, in: chart) let boundaryColor = (isDark ? NSColor.white : NSColor.black) - .withAlphaComponent(0.32) + .withAlphaComponent(isDark ? 0.32 : 0.50) boundaryColor.setStroke() for (zone, width) in [(hatZone, 0.9), (rimZone, 2.0), (snareZone, 0.9), (kickZone, 1.3)] { @@ -709,10 +907,9 @@ enum PitchBendCursor { buildImage(bend: CGFloat(amount), echo: 0, keyDown: false) } - static func image(forBend bend: Float, echo: Float, keyDown: Bool = false, - touches: [CGPoint] = []) -> NSImage { - buildImage(bend: CGFloat(bend), echo: CGFloat(echo), keyDown: keyDown, - touches: touches) + static func image(forBend bend: Float, echo: Float, + keyDown: Bool = false) -> NSImage { + buildImage(bend: CGFloat(bend), echo: CGFloat(echo), keyDown: keyDown) } static func cursor(forBend amount: Float) -> NSCursor { @@ -723,8 +920,8 @@ enum PitchBendCursor { NSCursor(image: image(forBend: bend, echo: echo), hotSpot: hotSpot) } - private static func buildImage(bend: CGFloat, echo: CGFloat, keyDown: Bool, - touches: [CGPoint] = []) -> NSImage { + private static func buildImage(bend: CGFloat, echo: CGFloat, + keyDown: Bool) -> NSImage { let bendC = max(-1, min(1, bend)) // `echo` is the bipolar fx-X driver in [-1, +1]: positive // (right) is echo, negative (left) is space/reverb. We keep @@ -742,19 +939,18 @@ enum PitchBendCursor { if #available(macOS 11.0, *) { appearance.performAsCurrentDrawingAppearance { drawChart(in: rect, bend: bendC, echo: xC, isDark: isDark, - keyDown: keyDown, touches: touches) + keyDown: keyDown) } } else { drawChart(in: rect, bend: bendC, echo: xC, isDark: isDark, - keyDown: keyDown, touches: touches) + keyDown: keyDown) } return true } } private static func drawChart(in rect: NSRect, bend: CGFloat, echo: CGFloat, - isDark: Bool, keyDown: Bool, - touches: [CGPoint] = []) { + isDark: Bool, keyDown: Bool) { // A Menu Band keycap plate, cleanly divided into four quadrants by a // single thin cross (no axis labels, no arrowheads, no end-caps). The // puck carries the live bend (Y) / echo (X) and lights up with the @@ -831,24 +1027,6 @@ enum PitchBendCursor { knob.lineWidth = keyDown ? 1.0 : 0.8 knob.stroke() - // Live trackpad touches (private MultitouchSupport tap). Each finger's - // absolute normalized position is mapped straight into the chart, so - // the pad reads as a tiny mirror of the trackpad. - if !touches.isEmpty { - let dotR: CGFloat = 3 - for t in touches { - let px = chart.minX + max(0, min(1, CGFloat(t.x))) * chart.width - let py = chart.minY + max(0, min(1, CGFloat(t.y))) * chart.height - let dot = NSBezierPath(ovalIn: NSRect(x: px - dotR, y: py - dotR, - width: dotR * 2, height: dotR * 2)) - accent.withAlphaComponent(0.30).setFill() - dot.fill() - accent.withAlphaComponent(0.6).setStroke() - dot.lineWidth = 0.8 - dot.stroke() - } - } - // Keycap outline last so the whole pad is framed like a key. let edge = isDark ? NSColor.black.withAlphaComponent(0.85) @@ -864,6 +1042,7 @@ enum PitchBendCursor { /// cursor so the chart visibly replaces it. final class PitchBendCursorOverlayWindow: NSPanel { private let imageView = NSImageView() + private let tracktrampView = TracktrampMetalView() private var anchorScreenPoint = NSPoint.zero private var fadeTimer: Timer? @@ -889,6 +1068,9 @@ final class PitchBendCursorOverlayWindow: NSPanel { imageView.frame = frame imageView.imageScaling = .scaleNone contentView?.addSubview(imageView) + tracktrampView.frame = frame + tracktrampView.isHidden = true + contentView?.addSubview(tracktrampView) } /// `screenPoint` is the absolute center chosen by AppDelegate, normally @@ -897,6 +1079,8 @@ final class PitchBendCursorOverlayWindow: NSPanel { fadeTimer?.invalidate() fadeTimer = nil anchorScreenPoint = screenPoint + tracktrampView.isHidden = true + imageView.isHidden = false apply(image: image) alphaValue = 1 if !isVisible { orderFrontRegardless() } @@ -905,14 +1089,52 @@ final class PitchBendCursorOverlayWindow: NSPanel { /// Update only the chart image (puck position changes); window /// position stays put unless the caller supplies a refreshed anchor. func update(image: NSImage) { + tracktrampView.isHidden = true + imageView.isHidden = false apply(image: image) } func update(image: NSImage, atScreenPoint screenPoint: NSPoint) { anchorScreenPoint = screenPoint + tracktrampView.isHidden = true + imageView.isHidden = false apply(image: image) } + /// Present the physical skin through the cached Metal texture. Only the + /// small height field changes between frames; no NSImage is constructed. + func showTracktramp(_ membrane: TrackpadMembraneSimulation.Snapshot, + touches: [CGPoint], + atScreenPoint screenPoint: NSPoint) { + fadeTimer?.invalidate() + fadeTimer = nil + anchorScreenPoint = screenPoint + applyTracktrampFrame() + imageView.isHidden = true + tracktrampView.isHidden = false + tracktrampView.update(membrane, touches: touches) + alphaValue = 1 + if !isVisible { orderFrontRegardless() } + } + + func updateTracktramp(_ membrane: TrackpadMembraneSimulation.Snapshot, + touches: [CGPoint], + atScreenPoint screenPoint: NSPoint) { + anchorScreenPoint = screenPoint + applyTracktrampFrame() + imageView.isHidden = true + tracktrampView.isHidden = false + tracktrampView.update(membrane, touches: touches) + } + + private func applyTracktrampFrame() { + let size = TracktrampMetalView.logicalSize + setFrame(NSRect(x: anchorScreenPoint.x - size.width / 2, + y: anchorScreenPoint.y - size.height / 2, + width: size.width, height: size.height), display: false) + tracktrampView.frame = NSRect(origin: .zero, size: size) + } + /// A new hardware contact reclaims a surface that is still visible but /// partway through its idle fade. Image updates alone must not inherit the /// old alpha or allow the old timer to keep dissolving the new gesture. @@ -966,10 +1188,240 @@ final class PitchBendCursorOverlayWindow: NSPanel { fadeTimer?.invalidate() fadeTimer = nil alphaValue = 1 + tracktrampView.pause() orderOut(nil) } } +/// GPU presentation for the physical drum skin. The AppKit artwork is cached +/// as a texture and a single fragment pass backward-warps its pixels from the +/// membrane slope. This view has no clock of its own: the existing 60 Hz +/// presentation timer supplies snapshots, while touch/audio callbacks remain +/// entirely outside the renderer. +final class TracktrampMetalView: MTKView, MTKViewDelegate { + static let logicalSize = NSSize(width: 140, height: 88) + + private struct Uniforms { + var columns: UInt32 + var rows: UInt32 + // A restrained fabric pull, not a lens/refraction effect. + var warp: Float = 0.046 + var lighting: Float = 0.14 + var touchCount: UInt32 = 0 + var _padding: UInt32 = 0 + var accent = SIMD4(0.2, 0.48, 1, 1) + // kick, tom, snare, hat; click follows separately for 16-byte layout. + var zoneLevels = SIMD4(repeating: 0) + var clickLevel: Float = 0 + var _zonePadding = SIMD3(repeating: 0) + } + + private var queue: MTLCommandQueue? + private var pipeline: MTLRenderPipelineState? + private var baseTexture: MTLTexture? + private var heightBuffer: MTLBuffer? + private var touchBuffer: MTLBuffer? + private var uniforms = Uniforms(columns: 1, rows: 1) + private var cachedScale: CGFloat = 0 + private var drewFlatFrame = false + private var zoneLevels = [Float](repeating: 0, count: 5) + private var lastZoneUpdate = CACurrentMediaTime() + + init() { + let device = MTLCreateSystemDefaultDevice() + super.init(frame: NSRect(origin: .zero, size: Self.logicalSize), + device: device) + wantsLayer = true + // MTKView's CAMetalLayer defaults to opaque even when the render-pass + // clear color has zero alpha. That exposes the whole drawable as a + // dark square around our rounded shader mask. + layer?.isOpaque = false + layer?.backgroundColor = NSColor.clear.cgColor + clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) + framebufferOnly = true + enableSetNeedsDisplay = true + isPaused = true + preferredFramesPerSecond = 60 + delegate = self + queue = device?.makeCommandQueue() + if let device { buildPipeline(device) } + } + + required init(coder: NSCoder) { + fatalError("TracktrampMetalView is code-only") + } + + override var isOpaque: Bool { false } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + rebuildTextureIfNeeded(force: true) + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + rebuildTextureIfNeeded(force: true) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + rebuildTextureIfNeeded(force: true) + } + + func update(_ snapshot: TrackpadMembraneSimulation.Snapshot, + touches: [CGPoint]) { + guard !isHidden, window?.isVisible == true, let device else { return } + rebuildTextureIfNeeded(force: false) + let byteCount = snapshot.heights.count * MemoryLayout.stride + if heightBuffer == nil || heightBuffer!.length < byteCount { + heightBuffer = device.makeBuffer(length: byteCount, options: .storageModeShared) + } + guard let heightBuffer else { return } + let destination = heightBuffer.contents().bindMemory(to: Float.self, + capacity: snapshot.heights.count) + for index in snapshot.heights.indices { + destination[index] = Float(snapshot.heights[index]) + } + uniforms.columns = UInt32(snapshot.columns) + uniforms.rows = UInt32(snapshot.rows) + let visibleTouches = touches.prefix(10) + let touchBytes = max(1, visibleTouches.count) * MemoryLayout>.stride + if touchBuffer == nil || touchBuffer!.length < touchBytes { + touchBuffer = device.makeBuffer(length: touchBytes, options: .storageModeShared) + } + if let touchBuffer { + let destination = touchBuffer.contents().bindMemory( + to: SIMD2.self, capacity: max(1, visibleTouches.count)) + for (index, touch) in visibleTouches.enumerated() { + destination[index] = SIMD2(Float(touch.x), Float(touch.y)) + } + } + uniforms.touchCount = UInt32(visibleTouches.count) + let now = CACurrentMediaTime() + let elapsed = min(0.1, max(0, now - lastZoneUpdate)) + lastZoneUpdate = now + let decay = Float(exp(-elapsed / 0.24)) + for index in zoneLevels.indices { zoneLevels[index] *= decay } + for touch in visibleTouches { + let index: Int + switch MenuBandPercussion.drumSkinZone(at: touch) { + case .kick: index = 0 + case .tom: index = 1 + case .snare: index = 2 + case .hat: index = 3 + case .click: index = 4 + } + zoneLevels[index] = 1 + } + uniforms.zoneLevels = SIMD4(zoneLevels[0], zoneLevels[1], + zoneLevels[2], zoneLevels[3]) + uniforms.clickLevel = zoneLevels[4] + let accent = KeyboardIconRenderer.accent.usingColorSpace(.sRGB) + ?? NSColor.controlAccentColor + uniforms.accent = SIMD4(Float(accent.redComponent), Float(accent.greenComponent), + Float(accent.blueComponent), Float(accent.alphaComponent)) + // Paint one final undistorted frame, then stop submitting GPU work. + if snapshot.isFlat && visibleTouches.isEmpty + && (zoneLevels.max() ?? 0) < 0.01 { + guard !drewFlatFrame else { return } + drewFlatFrame = true + } else { + drewFlatFrame = false + } + // MTKView is paused and dirty-driven; draw exactly when the existing + // capped presentation path hands us a fresh visual snapshot. + draw() + } + + func pause() { drewFlatFrame = false } + + private func buildPipeline(_ device: MTLDevice) { + do { + let library: MTLLibrary + if let url = Bundle.appResources.url(forResource: "TracktrampShaders", + withExtension: "metalsource"), + let source = try? String(contentsOf: url, encoding: .utf8) { + library = try device.makeLibrary(source: source, options: nil) + } else { + library = try device.makeDefaultLibrary(bundle: .module) + } + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "tracktramp_vertex") + descriptor.fragmentFunction = library.makeFunction(name: "tracktramp_fragment") + descriptor.colorAttachments[0].pixelFormat = colorPixelFormat + descriptor.colorAttachments[0].isBlendingEnabled = true + descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one + descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + pipeline = try device.makeRenderPipelineState(descriptor: descriptor) + } catch { + NSLog("MenuBand: tracktramp Metal pipeline failed: \(error)") + } + } + + private func rebuildTextureIfNeeded(force: Bool) { + guard let device else { return } + let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + guard force || baseTexture == nil || scale != cachedScale else { return } + cachedScale = scale + let image = TrackpadDrumSkinPad.image(touches: [], energy: [], membrane: nil, + appearance: effectiveAppearance) + let pixelWidth = max(1, Int(ceil(Self.logicalSize.width * scale))) + let pixelHeight = max(1, Int(ceil(Self.logicalSize.height * scale))) + guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: pixelWidth, + pixelsHigh: pixelHeight, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + // Keep the cached artwork in the + // standard RGBA layout expected by + // MTKTextureLoader. `.alphaFirst` + // was being sampled as color data, + // turning the tan skin dark blue. + bitmapFormat: [], + bytesPerRow: 0, + bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: bitmap) else { return } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.scaleBy(x: scale, y: scale) + image.draw(in: NSRect(origin: .zero, size: Self.logicalSize), + from: .zero, operation: .copy, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + guard let cgImage = bitmap.cgImage else { return } + baseTexture = try? MTKTextureLoader(device: device).newTexture( + cgImage: cgImage, + options: [.SRGB: true, .origin: MTKTextureLoader.Origin.topLeft] + ) + drewFlatFrame = false + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} + + func draw(in view: MTKView) { + guard let drawable = currentDrawable, + let pass = currentRenderPassDescriptor, + let queue, let pipeline, let baseTexture, let heightBuffer, + let touchBuffer, + let command = queue.makeCommandBuffer(), + let encoder = command.makeRenderCommandEncoder(descriptor: pass) else { return } + encoder.setRenderPipelineState(pipeline) + encoder.setFragmentTexture(baseTexture, index: 0) + encoder.setFragmentBuffer(heightBuffer, offset: 0, index: 0) + encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, + index: 1) + encoder.setFragmentBuffer(touchBuffer, offset: 0, index: 2) + encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) + encoder.endEncoding() + command.present(drawable) + command.commit() + } +} + extension NSCursor { /// Convenience to push the neutral pitch-bend cursor onto the /// stack. Mirrors the original `PitchBendCursor.shared.push()` diff --git a/slab/menuband/Sources/MenuBand/TracktrampShaders.metalsource b/slab/menuband/Sources/MenuBand/TracktrampShaders.metalsource new file mode 100644 index 0000000000..eb74964f89 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/TracktrampShaders.metalsource @@ -0,0 +1,157 @@ +#include +using namespace metal; + +struct TracktrampVertexOut { + float4 position [[position]]; + float2 uv; +}; + +struct TracktrampUniforms { + uint columns; + uint rows; + float warp; + float lighting; + uint touchCount; + uint padding; + float4 accent; + float4 zoneLevels; + float clickLevel; + float3 zonePadding; +}; + +vertex TracktrampVertexOut tracktramp_vertex(uint id [[vertex_id]]) { + const float2 positions[4] = { + float2(-1.0, -1.0), float2(1.0, -1.0), + float2(-1.0, 1.0), float2(1.0, 1.0) + }; + TracktrampVertexOut out; + out.position = float4(positions[id], 0.0, 1.0); + out.uv = positions[id] * 0.5 + 0.5; // normalized, bottom-left + return out; +} + +static float membraneHeight(device const float *heights, uint columns, + uint rows, float2 uv) { + float2 grid = clamp(uv, 0.0, 1.0) * float2(columns - 1, rows - 1); + uint2 a = uint2(floor(grid)); + uint2 b = min(a + 1, uint2(columns - 1, rows - 1)); + float2 f = fract(grid); + float bottom = mix(heights[a.y * columns + a.x], + heights[a.y * columns + b.x], f.x); + float top = mix(heights[b.y * columns + a.x], + heights[b.y * columns + b.x], f.x); + return mix(bottom, top, f.y); +} + +fragment float4 tracktramp_fragment( + TracktrampVertexOut in [[stage_in]], + texture2d base [[texture(0)]], + device const float *heights [[buffer(0)]], + constant TracktrampUniforms &u [[buffer(1)]], + device const float2 *touches [[buffer(2)]]) { + constexpr sampler linearSampler(coord::normalized, address::clamp_to_edge, + filter::linear); + float2 cell = 1.0 / float2(u.columns - 1, u.rows - 1); + float left = membraneHeight(heights, u.columns, u.rows, + in.uv - float2(cell.x, 0)); + float right = membraneHeight(heights, u.columns, u.rows, + in.uv + float2(cell.x, 0)); + float below = membraneHeight(heights, u.columns, u.rows, + in.uv - float2(0, cell.y)); + float above = membraneHeight(heights, u.columns, u.rows, + in.uv + float2(0, cell.y)); + float centerHeight = membraneHeight(heights, u.columns, u.rows, in.uv); + float2 gradient = float2(right - left, above - below); + + // The fixed rim smoothly kills displacement across the outer 12%. + float rimDistance = min(min(in.uv.x, 1.0 - in.uv.x), + min(in.uv.y, 1.0 - in.uv.y)); + float freeSkin = smoothstep(0.0, 0.12, rimDistance); + float2 warped = clamp(in.uv - gradient * u.warp * freeSkin, 0.0, 1.0); + + // AppKit/CG textures have a top-left first row. This is the sole Y flip; + // simulation, touches, gradients, and shader UVs remain bottom-left. + float4 color = base.sample(linearSampler, float2(warped.x, 1.0 - warped.y)); + + // Enforce the 132×80pt chart's 8pt rounded silhouette in the final Metal + // pass. Texture filtering and alpha-first bitmap conversion must never be + // able to turn the transparent outer corners into a square panel. + float2 point = in.uv * float2(140.0, 88.0); + float2 center = float2(70.0, 44.0); + float2 halfSize = float2(66.0, 40.0); + float radius = 8.0; + float2 rounded = abs(point - center) - (halfSize - radius); + float signedDistance = length(max(rounded, 0.0)) + + min(max(rounded.x, rounded.y), 0.0) - radius; + float roundedMask = 1.0 - smoothstep(-0.65, 0.65, signedDistance); + color *= roundedMask; + float light = dot(gradient, normalize(float2(-0.55, 0.84))) + * u.lighting * freeSkin; + // Accent follows symmetric membrane energy, not the directional lighting + // slope. This keeps the colored impact centered beneath the physical XY + // cursor instead of appearing offset toward the virtual light source. + float membraneEnergy = abs(centerHeight) * 0.42 + length(gradient) * 1.65; + float accentWave = clamp(membraneEnergy, 0.0, 0.44) + * freeSkin * color.a; + color.rgb = mix(color.rgb, u.accent.rgb, accentWave); + color.rgb *= 1.0 + min(light, 0.0) * 2.2; + color.rgb = clamp(color.rgb, 0.0, 1.0); + + // Illuminate the complete instrument band that was struck. These five + // levels decay on the CPU, so even a very quick tap leaves a readable + // accent afterglow over its nested rounded region. + float2 surface = (in.uv - 0.5) * 2.0; + const float halfWidth = 0.82; + const float halfHeight = 0.50; + const float corner = 0.055; + float2 roundedPoint = abs(surface) * float2(halfWidth, halfHeight); + float2 zoneQ = roundedPoint - float2(halfWidth - corner, + halfHeight - corner); + float zoneOutside = length(max(zoneQ, 0.0)); + float zoneInside = min(max(zoneQ.x, zoneQ.y), 0.0); + float zoneSignedDistance = zoneOutside + zoneInside - corner; + float zoneDistance = 1.0 - clamp(max(0.0, -zoneSignedDistance) + / halfHeight, 0.0, 1.0); + float zoneLevel = zoneDistance < 0.30 ? u.zoneLevels.x + : zoneDistance < 0.46 ? u.zoneLevels.y + : zoneDistance < 0.64 ? u.zoneLevels.z + : zoneDistance < 0.88 ? u.zoneLevels.w + : u.clickLevel; + color.rgb = mix(color.rgb, u.accent.rgb, + zoneLevel * 0.28 * roundedMask * color.a); + + // Tie the chassis to the same accent used by Menu Band's active keys. + // Restrict tinting to a narrow band of already-opaque rim pixels so the + // transparent rounded corners remain clean. + float accentRim = (1.0 - smoothstep(0.045, 0.075, rimDistance)) * color.a; + color.rgb = mix(color.rgb, u.accent.rgb, accentRim * 0.22); + + // Crisp cursor-like contacts stay in physical bottom-left XY space; they + // identify the finger, while the ink underneath shows the deformation. + float contactOutline = 0.0; + float contactWhite = 0.0; + float contactCore = 0.0; + for (uint i = 0; i < u.touchCount; ++i) { + // Preserve the true normalized touch for membrane physics, but inset + // the cursor's visual center by its outer radius. Edge contacts remain + // fully visible instead of losing half their high-contrast ring beyond + // the drawable/rounded silhouette. + const float cursorRadius = 6.8; + float2 cursorInset = cursorRadius / float2(140.0, 88.0); + float2 cursorTouch = clamp(touches[i], cursorInset, 1.0 - cursorInset); + float2 pixelDelta = (in.uv - cursorTouch) * float2(140.0, 88.0); + float distance = length(pixelDelta); + contactOutline = max(contactOutline, + 1.0 - smoothstep(5.9, 6.45, distance)); + contactWhite = max(contactWhite, + 1.0 - smoothstep(4.35, 4.85, distance)); + contactCore = max(contactCore, + 1.0 - smoothstep(2.0, 2.5, distance)); + } + color.rgb = mix(color.rgb, float3(0.025), contactOutline); + float3 contactTint = mix(float3(1.0), u.accent.rgb, 0.24); + color.rgb = mix(color.rgb, contactTint, contactWhite); + color.rgb = mix(color.rgb, u.accent.rgb, contactCore); + color.a = max(color.a, contactOutline); + return color; +} diff --git a/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift b/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift index acd7dacb36..5e76db103d 100644 --- a/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift +++ b/slab/menuband/Tests/MenuBandTests/ShapedownTests.swift @@ -129,9 +129,20 @@ final class ShapedownTests: XCTestCase { func testDrumSkinChartRendersAtPhysicalAspectRatio() throws { _ = NSApplication.shared + var membrane = TrackpadMembraneSimulation() + membrane.reset(at: 1) + membrane.impulse(at: CGPoint(x: 0.50, y: 0.50), amount: 0.9) + membrane.advance(to: 1.085, + touches: [CGPoint(x: 0.50, y: 0.50), + CGPoint(x: 0.94, y: 0.52)]) let image = TrackpadDrumSkinPad.image( touches: [CGPoint(x: 0.50, y: 0.50), - CGPoint(x: 0.94, y: 0.52)] + CGPoint(x: 0.94, y: 0.52)], + energy: [ + .init(point: CGPoint(x: 0.50, y: 0.50), level: 0.88), + .init(point: CGPoint(x: 0.24, y: 0.72), level: 0.34), + ], + membrane: membrane.snapshot() ) XCTAssertEqual(image.size.width / image.size.height, 140.0 / 88.0, accuracy: 0.000_001) @@ -174,6 +185,39 @@ final class ShapedownTests: XCTestCase { XCTAssertLessThan(field.energy(at: point, now: 3), built) } + func testMembraneSimulationPinsRimAndPropagatesAcrossSurface() { + var membrane = TrackpadMembraneSimulation() + membrane.reset(at: 1) + membrane.impulse(at: CGPoint(x: 0.5, y: 0.5), amount: 1) + membrane.advance(to: 1.12, touches: []) + let snapshot = membrane.snapshot() + XCTAssertEqual(snapshot.height(at: CGPoint(x: 0, y: 0.5)), 0, + accuracy: 0.000_001) + XCTAssertGreaterThan(abs(snapshot.height(at: CGPoint(x: 0.5, y: 0.5))), + 0.01) + XCTAssertGreaterThan(abs(snapshot.height(at: CGPoint(x: 0.65, y: 0.5))), + 0.000_1) + } + + func testReleasedMembraneRingsDownWithoutInstability() { + var membrane = TrackpadMembraneSimulation() + membrane.reset(at: 1) + membrane.impulse(at: CGPoint(x: 0.42, y: 0.58), amount: 1) + var now = 1.0 + for _ in 0..<18 { + now += 1.0 / 60.0 + membrane.advance(to: now, touches: []) + } + let earlyPeak = membrane.snapshot().heights.map(abs).max() ?? 0 + for _ in 0..<180 { + now += 1.0 / 60.0 + membrane.advance(to: now, touches: []) + } + let late = membrane.snapshot().heights + XCTAssertTrue(late.allSatisfy(\.isFinite)) + XCTAssertLessThan(late.map(abs).max() ?? 0, earlyPeak) + } + func testFullestShapeSurvivesStaggeredFourFingerLift() { var memory = ShapedownGestureMemory() let quad = points(4) @@ -286,6 +330,124 @@ final class ShapedownTests: XCTestCase { clicked: true), 1.55, accuracy: 0.000_001) } + func testTabSelectedPerformanceFXDoesNotAutoEndWithoutHeldNote() { + XCTAssertFalse(AppDelegate.shouldAutoEndTrackpadFX( + performanceSessionActive: true, + keyboardNotesHeld: false + )) + } + + func testTabOnlyTogglesPhysicalSkinAndPitchBend() { + XCTAssertEqual(AppDelegate.trackpadPadModeAfterTab(.skin), .fx) + XCTAssertEqual(AppDelegate.trackpadPadModeAfterTab(.fx), .skin) + XCTAssertEqual(AppDelegate.trackpadPadModeAfterTab(.synth), .fx) + XCTAssertEqual(AppDelegate.trackpadPadModeAfterTab(.kit), .fx) + } + + func testOrdinaryPitchBendFXStillEndsAfterRelease() { + XCTAssertTrue(AppDelegate.shouldAutoEndTrackpadFX( + performanceSessionActive: false, + keyboardNotesHeld: false + )) + XCTAssertFalse(AppDelegate.shouldAutoEndTrackpadFX( + performanceSessionActive: false, + keyboardNotesHeld: true + )) + } + + func testTransientKeyboardDrumSurfaceEndsAfterRelease() { + XCTAssertTrue(AppDelegate.shouldOpenTransientTrackpadSurface( + keyboardNotesHeld: true, + modeLatched: false + )) + XCTAssertTrue(AppDelegate.shouldPersistKeyboardOpenedTrackpadSurface( + localCaptureArmed: true + ), "menubar-key focus must keep the drum alive between taps") + XCTAssertFalse(AppDelegate.shouldPersistKeyboardOpenedTrackpadSurface( + localCaptureArmed: false + ), "a global TYPE-mode note should remain transient") + XCTAssertTrue(AppDelegate.shouldEndTrackpadSessionAfterNoteChange( + isLatched: true, + performanceSessionActive: false, + keyboardNotesHeld: false + ), "a note-opened drum surface must restore the system cursor") + XCTAssertFalse(AppDelegate.shouldEndTrackpadSessionAfterNoteChange( + isLatched: true, + performanceSessionActive: true, + keyboardNotesHeld: false + ), "explicit quiet focus owns the drum surface until focus exits") + XCTAssertFalse(AppDelegate.shouldFadeTrackpadOverlayAfterLift( + performanceSessionActive: true + ), "a focused drum chart should remain visible between taps") + XCTAssertTrue(AppDelegate.shouldFadeTrackpadOverlayAfterLift( + performanceSessionActive: false + )) + } + + func testAbsoluteTrackpadFXMapsCenterToNeutral() { + let values = AppDelegate.absoluteTrackpadFXValues( + at: CGPoint(x: 0.5, y: 0.5), bendRange: 2, echoEnabled: true + ) + XCTAssertEqual(values.bend, 0, accuracy: 0.000_001) + XCTAssertEqual(values.fxX, 0, accuracy: 0.000_001) + XCTAssertEqual(values.space, 0, accuracy: 0.000_001) + XCTAssertEqual(values.echo, 0, accuracy: 0.000_001) + } + + func testAbsoluteTrackpadFXMapsCornersToPitchAndEffects() { + let upperRight = AppDelegate.absoluteTrackpadFXValues( + at: CGPoint(x: 1, y: 1), bendRange: 2, echoEnabled: true + ) + XCTAssertEqual(upperRight.bend, 2, accuracy: 0.000_001) + XCTAssertEqual(upperRight.echo, 1, accuracy: 0.000_001) + XCTAssertEqual(upperRight.space, 0, accuracy: 0.000_001) + + let lowerLeft = AppDelegate.absoluteTrackpadFXValues( + at: CGPoint(x: 0, y: 0), bendRange: 2, echoEnabled: true + ) + XCTAssertEqual(lowerLeft.bend, -2, accuracy: 0.000_001) + XCTAssertEqual(lowerLeft.echo, 0, accuracy: 0.000_001) + XCTAssertEqual(lowerLeft.space, 1, accuracy: 0.000_001) + } + + func testAbsoluteTrackpadFXKeepsDisabledEchoHalfNeutral() { + let values = AppDelegate.absoluteTrackpadFXValues( + at: CGPoint(x: 1, y: 0.5), bendRange: 2, echoEnabled: false + ) + XCTAssertEqual(values.fxX, 0, accuracy: 0.000_001) + XCTAssertEqual(values.echo, 0, accuracy: 0.000_001) + XCTAssertEqual(values.space, 0, accuracy: 0.000_001) + } + + func testPitchBendKeepsOnePrimaryTrackpadContact() { + var primary = TrackpadPrimaryContact() + let first = TrackpadContact( + identifier: 41, point: CGPoint(x: 0.2, y: 0.3), state: 3 + ) + let extra = TrackpadContact( + identifier: 42, point: CGPoint(x: 0.8, y: 0.9), state: 3 + ) + XCTAssertEqual(primary.update([first]), first.point) + XCTAssertEqual(primary.update([extra, first]), first.point) + XCTAssertEqual(primary.identifier, first.identifier) + } + + func testPitchBendDoesNotHandOffUntilEveryFingerLifts() { + var primary = TrackpadPrimaryContact() + let first = TrackpadContact( + identifier: 51, point: CGPoint(x: 0.2, y: 0.3), state: 3 + ) + let extra = TrackpadContact( + identifier: 52, point: CGPoint(x: 0.8, y: 0.9), state: 4 + ) + _ = primary.update([first]) + XCTAssertNil(primary.update([extra])) + XCTAssertEqual(primary.identifier, first.identifier) + XCTAssertNil(primary.update([])) + XCTAssertNil(primary.identifier) + XCTAssertEqual(primary.update([extra]), extra.point) + } + func testSamePadReplacementRetriggersKitVoice() { let first = CGPoint(x: 0.30, y: 0.80) let down = TrackpadPercussionPad.transition( diff --git a/slab/menuband/project.yml b/slab/menuband/project.yml index 05b60d4946..f8ac4cf99e 100644 --- a/slab/menuband/project.yml +++ b/slab/menuband/project.yml @@ -63,6 +63,7 @@ targets: - "KidLisp/README.md" - "Resources/**" - "WaveformShaders.metalsource" + - "TracktrampShaders.metalsource" # Resource files → Copy Bundle Resources (flattened). verovio/sheet.html # + verovio-toolkit-wasm.js flatten into the same Resources root, so the @@ -78,6 +79,8 @@ targets: # `.metalsource` is copied verbatim, so no build-time Metal at all. - path: Sources/MenuBand/WaveformShaders.metalsource buildPhase: resources + - path: Sources/MenuBand/TracktrampShaders.metalsource + buildPhase: resources # App icon. Info.plist already declares CFBundleIconFile = AppIcon, so # copying AppIcon.icns into Resources/ is all that's needed (no asset