From d969fc6b4d51baa60bec4edcacbcb5bcf6acf4bc Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 31 Aug 2026 08:51:20 -0400 Subject: [PATCH] the live-show pass, and the final video learns to read ahead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record loosens its grip: jit scatter 1.7x and velocity spread 1.35x across every voice (same draw count, parity held), the hands push and drag across a 32-bar breath while the kick keeps the clock, and the listener finally moves — a slow walk around the stage, stereo rotation at two incommensurate rates, breathing nearer and farther in both mixdowns. The hook grows its ending: dot dot dot dot, four every loop, and the dash pair voices itself in rotating octave shapes with a camille arpeggio ringing off the first dash. The accordion swells under the secret act and doubles the act-VII wall. viz/review-score.py gains the release-video features: a teleprompter lyric scroller (upcoming words approach a fixed now-line, light in their singer's color, sink away), a multi-voice karaoke stack so the three-people-one-pitch-28ms dash signature reads as three rows of 'dash', accordion clips in the instrument lane, and --audio/--out flags so the FINAL videos render against the release master. The final master was recut in alignment: -12.0 LUFS, -1.0 dBTP. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BoSQZ6DRscVySfjg22zyXK --- .../Oskiewar.xcodeproj/project.pbxproj | 8 + apple/oskiewar/Sources/OskiewarApp.swift | 1 + apple/oskiewar/project.yml | 1 + lith/Caddyfile | 2 +- pop/cult/bin/render10.mjs | 43 ++- pop/cult/c/cultremix.c | 48 +++- pop/cult/viz/review-score.py | 261 ++++++++++++++++-- slab/menuband-trackpad/Sources/main.swift | 62 ++++- .../Sources/MenuBand/AppDelegate.swift | 85 +++++- .../Sources/SlabMenubar/AppDelegate.swift | 20 +- xbox/live/marketing/shell.mjs | 3 +- xbox/live/render-social-preview.mjs | 3 +- xbox/live/tests/blackbox-rounds.mjs | 1 + 13 files changed, 469 insertions(+), 69 deletions(-) diff --git a/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj b/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj index b58dbf263d..3a3a887b58 100644 --- a/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj +++ b/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1B43BAF5E41E93E3D9208197 /* product-analytics.mjs in Resources */ = {isa = PBXBuildFile; fileRef = A5CDAFE515D4323680797051 /* product-analytics.mjs */; }; 21F3367D32F88CB3B2A52859 /* oskiewar-sfx.mjs in Resources */ = {isa = PBXBuildFile; fileRef = 2E725A92F239CBD5114A9A1E /* oskiewar-sfx.mjs */; }; 359F92BBF72AEE001B8BFD71 /* oskiewar-analytics.mjs in Resources */ = {isa = PBXBuildFile; fileRef = A0AEFE9FB2DD8B296D83443D /* oskiewar-analytics.mjs */; }; + 399B26763D555AAAF98440DF /* oskiewar-voice.mjs in Resources */ = {isa = PBXBuildFile; fileRef = CEF913B052D36BC3CE48BB1B /* oskiewar-voice.mjs */; }; 4D00E7B7BFD0CDAA972C6F12 /* ComicRelief-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 179EE75D06881E64B8C2C164 /* ComicRelief-Regular.ttf */; }; 5893AAD738853ADCCD1C6259 /* round-room.mjs in Resources */ = {isa = PBXBuildFile; fileRef = D5D135855C9371BD15F15377 /* round-room.mjs */; }; 6F079803CD79934E3D119B92 /* mac-test.html in Resources */ = {isa = PBXBuildFile; fileRef = 8265144AA3E23B010A51E65A /* mac-test.html */; }; @@ -21,12 +22,14 @@ C0C46FBB6805F1A1E055FED7 /* OskiewarApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43902C050551AD2D6DA0EF9 /* OskiewarApp.swift */; }; DE990881EAE44FB2BE4592FF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FCECE2875B24DB7356CB0FDC /* Assets.xcassets */; }; F10250364FF369A809057B31 /* frame-driver.mjs in Resources */ = {isa = PBXBuildFile; fileRef = 4C7C4D62E6C706C3AB30BB3B /* frame-driver.mjs */; }; + FE65C7B80AB35953E40AA8A7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 474C7E2A33BAAEDAEED939DF /* PrivacyInfo.xcprivacy */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 179EE75D06881E64B8C2C164 /* ComicRelief-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ComicRelief-Regular.ttf"; sourceTree = ""; }; 26C7F4ADC37CB30558AAE696 /* oskiewar-midi.mjs */ = {isa = PBXFileReference; path = "oskiewar-midi.mjs"; sourceTree = ""; }; 2E725A92F239CBD5114A9A1E /* oskiewar-sfx.mjs */ = {isa = PBXFileReference; path = "oskiewar-sfx.mjs"; sourceTree = ""; }; + 474C7E2A33BAAEDAEED939DF /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 4C7C4D62E6C706C3AB30BB3B /* frame-driver.mjs */ = {isa = PBXFileReference; path = "frame-driver.mjs"; sourceTree = ""; }; 59BD48D8076F347B7FF9A774 /* GlyphAtlas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlyphAtlas.swift; sourceTree = ""; }; 812FA65365747604E58B98E7 /* MetalSceneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalSceneView.swift; sourceTree = ""; }; @@ -36,6 +39,7 @@ A5CDAFE515D4323680797051 /* product-analytics.mjs */ = {isa = PBXFileReference; path = "product-analytics.mjs"; sourceTree = ""; }; AB2FCD0A93F17F907A54302C /* oskiewar.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = oskiewar.js; sourceTree = ""; }; C43902C050551AD2D6DA0EF9 /* OskiewarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OskiewarApp.swift; sourceTree = ""; }; + CEF913B052D36BC3CE48BB1B /* oskiewar-voice.mjs */ = {isa = PBXFileReference; path = "oskiewar-voice.mjs"; sourceTree = ""; }; D5D135855C9371BD15F15377 /* round-room.mjs */ = {isa = PBXFileReference; path = "round-room.mjs"; sourceTree = ""; }; F05D439B6814C2DE2C8CCBF7 /* Oskiewar.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Oskiewar.app; sourceTree = BUILT_PRODUCTS_DIR; }; FCECE2875B24DB7356CB0FDC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -84,6 +88,7 @@ isa = PBXGroup; children = ( FCECE2875B24DB7356CB0FDC /* Assets.xcassets */, + 474C7E2A33BAAEDAEED939DF /* PrivacyInfo.xcprivacy */, 5B8907180072A0F4F1552C04 /* fonts */, 9CBE8B5C340FA47D25810A30 /* lib */, E0FA0D9930BC45B45B9D078E /* live */, @@ -100,6 +105,7 @@ 8265144AA3E23B010A51E65A /* mac-test.html */, 26C7F4ADC37CB30558AAE696 /* oskiewar-midi.mjs */, 2E725A92F239CBD5114A9A1E /* oskiewar-sfx.mjs */, + CEF913B052D36BC3CE48BB1B /* oskiewar-voice.mjs */, AB2FCD0A93F17F907A54302C /* oskiewar.js */, D5D135855C9371BD15F15377 /* round-room.mjs */, ); @@ -177,11 +183,13 @@ files = ( DE990881EAE44FB2BE4592FF /* Assets.xcassets in Resources */, 4D00E7B7BFD0CDAA972C6F12 /* ComicRelief-Regular.ttf in Resources */, + FE65C7B80AB35953E40AA8A7 /* PrivacyInfo.xcprivacy in Resources */, F10250364FF369A809057B31 /* frame-driver.mjs in Resources */, 6F079803CD79934E3D119B92 /* mac-test.html in Resources */, 359F92BBF72AEE001B8BFD71 /* oskiewar-analytics.mjs in Resources */, 7729C117BEC5343C9CB8BAFC /* oskiewar-midi.mjs in Resources */, 21F3367D32F88CB3B2A52859 /* oskiewar-sfx.mjs in Resources */, + 399B26763D555AAAF98440DF /* oskiewar-voice.mjs in Resources */, 82A4DA7DFB26C3E87F9AB609 /* oskiewar.js in Resources */, 1B43BAF5E41E93E3D9208197 /* product-analytics.mjs in Resources */, 98991A7AC85B7CAE426E8D15 /* qr.mjs in Resources */, diff --git a/apple/oskiewar/Sources/OskiewarApp.swift b/apple/oskiewar/Sources/OskiewarApp.swift index 036c54a278..b2f960b5e7 100644 --- a/apple/oskiewar/Sources/OskiewarApp.swift +++ b/apple/oskiewar/Sources/OskiewarApp.swift @@ -131,6 +131,7 @@ final class BundleSchemeHandler: NSObject, WKURLSchemeHandler { "/mac-test.html": ("mac-test", "html"), "/oskiewar.js": ("oskiewar", "js"), "/oskiewar-sfx.mjs": ("oskiewar-sfx", "mjs"), + "/oskiewar-voice.mjs": ("oskiewar-voice", "mjs"), "/oskiewar-midi.mjs": ("oskiewar-midi", "mjs"), "/frame-driver.mjs": ("frame-driver", "mjs"), "/round-room.mjs": ("round-room", "mjs"), diff --git a/apple/oskiewar/project.yml b/apple/oskiewar/project.yml index 60e8ce2e5e..0a9e4cd384 100644 --- a/apple/oskiewar/project.yml +++ b/apple/oskiewar/project.yml @@ -19,6 +19,7 @@ targets: - path: ../../xbox/live/oskiewar.js buildPhase: resources - path: ../../xbox/live/oskiewar-sfx.mjs + - path: ../../xbox/live/oskiewar-voice.mjs buildPhase: resources - path: ../../xbox/live/oskiewar-midi.mjs buildPhase: resources diff --git a/lith/Caddyfile b/lith/Caddyfile index 64ec99e1b3..0155aa1994 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -930,7 +930,7 @@ oskiewar.com, midi.oskiewar.com { } # The game source and modules share the /oskiewar prefix with the live API # route below, so serve them before the proxy matcher claims them. - @oskiewarmodules path /oskiewar.js /oskiewar-sfx.mjs /oskiewar-midi.mjs + @oskiewarmodules path /oskiewar.js /oskiewar-sfx.mjs /oskiewar-voice.mjs /oskiewar-midi.mjs handle @oskiewarmodules { root * /opt/ac/xbox/live file_server diff --git a/pop/cult/bin/render10.mjs b/pop/cult/bin/render10.mjs index 79e8ff767b..4f051e5aaf 100644 --- a/pop/cult/bin/render10.mjs +++ b/pop/cult/bin/render10.mjs @@ -1241,8 +1241,10 @@ const wordsIn = (bar) => bar >= S.message[0] + 4; let seed = 20220120; // the cult post date const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296); -const jit = (ms = 6) => ((rnd() - 0.5) * 2 * ms) / 1000; -const vel = (spread = 0.20) => 1 - rnd() * spread; +// humanized (live-show pass): scatter 1.7x, velocity spread 1.35x — +// same draw count, the parity stream holds. +const jit = (ms = 6) => ((rnd() - 0.5) * 2 * ms * 1.7) / 1000; +const vel = (spread = 0.20) => 1 - rnd() * spread * 1.35; // ── (1) THE CROSSOVER WIGGLE ────────────────────────────────────────── // The depth is a function of WHERE IN THE STORY we are, because the story @@ -1301,9 +1303,14 @@ function hook(bar, { full = true } = {}) { // "that is the proper lyrical utterance rhythm there. and word choice." // dash F#4 · i wanna (1.50) · dash D4 (2.00) · i wanna (3.50) · run IT // fast (4.00, one sung phrase) · dot dot · rest. Plain sung takes. - dashStack(t + 0.00, "fs4", G, bar); + // dash-dash melody play: each loop voices its dashes in a different + // octave shape, and a quick camille arpeggio rings off the first. + const hv = (bar >> 3) % 3; + dashStack(t + 0.00, hv === 1 ? "fs3" : "fs4", G, bar); + shot(`dash-camille-${hv === 1 ? "fs4" : "fs3"}-hold`, t + 0.75, { bus: "tube", gain: 0.26 * G, pan: 0.30, side: 0.70, dly: 0.35, dur: 0.28, atk: 0.006 }); + shot("dash-camille-d4-hold", t + 1.06, { bus: "tube", gain: 0.18 * G, pan: -0.34, side: 0.75, dly: 0.45, dur: 0.22, atk: 0.006 }); sung("iwanna-a-sung", t + 1.50 + jit(4), { gain: 0.82 * G, pan: -0.18, side: 0.50, dly: 0.20 }); - dashStack(t + 2.00, "d4", G * 0.95, bar); + dashStack(t + 2.00, hv === 2 ? "b3" : hv === 1 ? "d3" : "d4", G * 0.95, bar); sung("iwanna-b-sung", t + 3.50 + jit(4), { gain: 0.82 * G, pan: 0.18, side: 0.50, dly: 0.20 }); // The long-"real" take — run · reeeeeaaaal (2.0 s) · fast. At 2.80 s it // rings on under the dot answers at +6.00, which is the point. @@ -1323,7 +1330,9 @@ function hook(bar, { full = true } = {}) { } sung("dot-b3", t + 6.00 + jit(3), { gain: 0.90 * G, pan: -0.45, side: 0.75, dly: 0.38, atk: 0.025 }); sung("dot-fs3", t + 6.50 + jit(3), { gain: 0.90 * G, pan: 0.45, side: 0.75, dly: 0.38, atk: 0.025 }); - if (full) sung("dot-d4", t + 7.00, { gain: 0.34 * G, pan: 0.0, side: 0.60, dly: 0.55 }); + // dot dot dot dot — four, ending every loop. + sung("dot-d4", t + 7.00, { gain: 0.80 * G, pan: 0.0, side: 0.60, dly: 0.45 }); + sung("dot-fs3", t + 7.50, { gain: 0.62 * G, pan: -0.20, side: 0.70, dly: 0.50 }); // length-play: every other full hook lets a daaaaash ring across the // rest bar, stretched to twice itself. if (full && ((bar >> 3) & 1)) @@ -1707,6 +1716,9 @@ for (let bar = 0; bar < BARS; bar++) { const root = bassRoot(deg); const chord = triad(deg); const four = bar % 4, eight = bar % 8; + // the hands push and drag across a 32-bar breath; the kick stays the + // clock (live-show pass) + const push = 0.0045 * Math.sin(TAU * (bar % 32) / 32 + 0.7); const D = dense(bar); // ---- kick: 4/4, and it just stays. POW per hit, never a drop. -------- @@ -1721,14 +1733,14 @@ for (let bar = 0; bar < BARS; bar++) { if (hatOn(bar)) { for (let s = 0; s < 8; s++) { const swing = s & 1 ? 0.035 : 0; - const u = t + (s * 0.5 + swing) * BEAT + jit(5); + const u = t + (s * 0.5 + swing) * BEAT + push + jit(5); if (s & 1) shot("hatC", u, { gain: 0.20 * vel(), pan: 0.20, side: 0.5, dur: 0.085 }); else if (s % 4 === 2) shot("hatC", u, { gain: 0.11 * vel(), pan: -0.18, side: 0.5, dur: 0.065 }); } if (D || eight >= 2) // Peep pass: rolling hats most bars for (let s = 0; s < 16; s++) if (s % 4 === 1 || s % 4 === 3) - shot("hatC", t + (s / 16) * BAR + jit(4), { gain: 0.055 * vel(0.5), pan: (s & 2 ? 0.35 : -0.35), side: 0.6, dur: 0.045 }); + shot("hatC", t + (s / 16) * BAR + push + jit(4), { gain: 0.055 * vel(0.5), pan: (s & 2 ? 0.35 : -0.35), side: 0.6, dur: 0.045 }); if ((D || inS(bar, "spread")) && four === 3) shot("hatO", t + 3.5 * BEAT + 0.02, { gain: 0.20, pan: -0.28, side: 0.65, dur: 0.34 }); if (four === 3 && kickOn(bar)) // Peep pass: the 32nd roll @@ -1739,9 +1751,9 @@ for (let bar = 0; bar < BARS; bar++) { // ---- the pump's second trigger: rim on 3, clap when full ------------- if (kickOn(bar)) { - snare(t + 2 * BEAT + jit(5), vel()); + snare(t + 2 * BEAT + push + jit(5), vel()); if (D) { - shot("clap", t + 2 * BEAT + jit(5), { gain: 0.22, pan: -0.12, side: 0.72 }); + shot("clap", t + 2 * BEAT + push + jit(5), { gain: 0.22, pan: -0.12, side: 0.72 }); shot("snap", t + 2 * BEAT + 0.012, { gain: 0.09, pan: 0.30, side: 0.55 }); } } @@ -2363,9 +2375,16 @@ for (let i = 0; i < N; i++) { + drumsL[i] + sideOut[i]) * fade; const r = (musicR[i] * be + voxR[i] * dv * VOXG + tubeR[i] * pe * TUBEG + sigR[i] * ds * SIGG + drumsR[i] - sideOut[i]) * fade; - L[i] = l; R[i] = r; - if (Math.abs(l) > peak) peak = Math.abs(l); - if (Math.abs(r) > peak) peak = Math.abs(r); + // the listener moves: slow stereo rotation at two incommensurate rates, + // breathing nearer and farther (live-show pass) + const lt = i / SR; + const th = 0.10 * Math.sin(TAU * 0.021 * lt) + 0.05 * Math.sin(TAU * 0.009 * lt + 1.3); + const br = 1 + 0.035 * Math.sin(TAU * 0.016 * lt + 0.5); + const lr = (l * Math.cos(th) - r * Math.sin(th)) * br; + const rr = (l * Math.sin(th) + r * Math.cos(th)) * br; + L[i] = lr; R[i] = rr; + if (Math.abs(lr) > peak) peak = Math.abs(lr); + if (Math.abs(rr) > peak) peak = Math.abs(rr); } const norm = peak > 1e-9 ? 0.92 / peak : 1; for (let i = 0; i < N; i++) { L[i] *= norm; R[i] *= norm; } diff --git a/pop/cult/c/cultremix.c b/pop/cult/c/cultremix.c index b7d5251742..f8b25af036 100644 --- a/pop/cult/c/cultremix.c +++ b/pop/cult/c/cultremix.c @@ -70,8 +70,10 @@ static inline double dmax(double a, double b) { return a > b ? a : b; } // ── the two generators, bit-exact against the Node file ──────────────── static uint32_t seed = 20220120; // the cult post date static inline double rnd(void) { seed = seed * 1664525u + 1013904223u; return (double)seed / 4294967296.0; } -static inline double jit(double ms) { return ((rnd() - 0.5) * 2.0 * ms) / 1000.0; } -static inline double vel(double spread) { return 1.0 - rnd() * spread; } +// humanized (live-show pass): scatter 1.7x, velocity spread 1.35x — +// same draw count, the parity stream holds. +static inline double jit(double ms) { return ((rnd() - 0.5) * 2.0 * ms * 1.7) / 1000.0; } +static inline double vel(double spread) { return 1.0 - rnd() * spread * 1.35; } static uint32_t nseed = 987654321; // xorshift32, MenuBandPercussion's static inline double nrnd(void) { @@ -1173,10 +1175,18 @@ static void hook_fn(int bar, int full) { // choice." So: dash F#4 · i wanna (1.50) · dash D4 (2.00) · i wanna // (3.50) · run IT fast (4.00, one sung phrase, G4-F#4-D4) · dot dot · // rest. Plain sung takes, v2 gains, dots at 0.90. - dashStack(t + 0.00, "fs4", G * F, bar); + // dash-dash melody play: each loop voices its dashes in a different + // octave shape, and a quick camille arpeggio rings off the first. + const int hv = (bar >> 3) % 3; + dashStack(t + 0.00, hv == 1 ? "fs3" : "fs4", G * F, bar); + { Shot o = SHOT_HELD(); o.gain = 0.26 * G; o.pan = 0.30; o.side = 0.70; o.dly = 0.35; o.dur = 0.28; o.atk = 0.006; + char an[64]; snprintf(an, sizeof an, "dash-camille-%s-hold", hv == 1 ? "fs4" : "fs3"); + shot(an, t + 0.75, o); } + { Shot o = SHOT_HELD(); o.gain = 0.18 * G; o.pan = -0.34; o.side = 0.75; o.dly = 0.45; o.dur = 0.22; o.atk = 0.006; + shot("dash-camille-d4-hold", t + 1.06, o); } { Shot o = SHOT_SUNG(); o.gain = 0.82 * G * F; o.pan = -0.18 * P; o.side = 0.50; o.dly = 0.20; shot("iwanna-a-sung", t + 1.50 + jit(4), o); } - dashStack(t + 2.00, "d4", G * 0.95 * F, bar); + dashStack(t + 2.00, hv == 2 ? "b3" : hv == 1 ? "d3" : "d4", G * 0.95 * F, bar); { Shot o = SHOT_SUNG(); o.gain = 0.82 * G * F; o.pan = 0.18 * P; o.side = 0.50; o.dly = 0.20; shot("iwanna-b-sung", t + 3.50 + jit(4), o); } if (wordsIn(bar)) { @@ -1195,8 +1205,11 @@ static void hook_fn(int bar, int full) { shot("dot-b3", t + 6.00 + jit(3), o); } { Shot o = SHOT_SUNG(); o.gain = 0.90 * G * F; o.pan = 0.45 * P; o.side = 0.75; o.dly = 0.38; shot("dot-fs3", t + 6.50 + jit(3), o); } - if (full) { Shot o = SHOT_SUNG(); o.gain = 0.34 * G * F; o.pan = 0.0; o.side = 0.60; o.dly = 0.55; + // dot dot dot dot — four, ending every loop. + { Shot o = SHOT_SUNG(); o.gain = 0.80 * G * F; o.pan = 0.0; o.side = 0.60; o.dly = 0.45; shot("dot-d4", t + 7.00, o); } + { Shot o = SHOT_SUNG(); o.gain = 0.62 * G * F; o.pan = -0.20 * P; o.side = 0.70; o.dly = 0.50; + shot("dot-fs3", t + 7.50, o); } // length-play: every other full hook lets a daaaaash ring across the // rest bar, stretched to twice itself. if (full && ((bar >> 3) & 1)) @@ -1413,6 +1426,9 @@ static void score(void) { const int root = bassRoot(deg); int chord[3]; triad_of(deg, 59, chord); const int four = bar % 4, eight = bar % 8; + // the hands push and drag across a 32-bar breath; the kick stays + // the clock (live-show pass) + const double push = 0.0045 * sin(TAU * (bar % 32) / 32.0 + 0.7); const int D = dense(bar); // ---- kick: 4/4, POW per hit, never a drop ---------------------- @@ -1428,14 +1444,14 @@ static void score(void) { if (hatOn(bar)) { for (int s = 0; s < 8; s++) { const double swing = (s & 1) ? 0.035 : 0; - const double u = t + (s * 0.5 + swing) * BEAT + jit(5); + const double u = t + (s * 0.5 + swing) * BEAT + push + jit(5); if (s & 1) { Shot o = SHOT_DRUM(); o.gain = 0.20 * vel(0.20); o.pan = 0.20; o.side = 0.5; o.dur = 0.085; shot("hatC", u, o); } else if (s % 4 == 2) { Shot o = SHOT_DRUM(); o.gain = 0.11 * vel(0.20); o.pan = -0.18; o.side = 0.5; o.dur = 0.065; shot("hatC", u, o); } } if (D || eight >= 2) // Peep pass: rolling hats most bars for (int s = 0; s < 16; s++) if (s % 4 == 1 || s % 4 == 3) { - const double u = t + ((double)s / 16) * BAR + jit(4); + const double u = t + ((double)s / 16) * BAR + push + jit(4); Shot o = SHOT_DRUM(); o.gain = 0.055 * vel(0.5); o.pan = (s & 2) ? 0.35 : -0.35; o.side = 0.6; o.dur = 0.045; shot("hatC", u, o); } @@ -1456,9 +1472,9 @@ static void score(void) { if (kickOn(bar)) { // (rnd order: JS evaluates args left-to-right — jit, then vel. // C leaves that unspecified, so every jit+vel pair is sequenced.) - { const double tt = t + 2 * BEAT + jit(5); snare(tt, vel(0.20)); } + { const double tt = t + 2 * BEAT + push + jit(5); snare(tt, vel(0.20)); } if (D) { - { Shot o = SHOT_DRUM(); o.gain = 0.22; o.pan = -0.12; o.side = 0.72; shot("clap", t + 2 * BEAT + jit(5), o); } + { Shot o = SHOT_DRUM(); o.gain = 0.22; o.pan = -0.12; o.side = 0.72; shot("clap", t + 2 * BEAT + push + jit(5), o); } { Shot o = SHOT_DRUM(); o.gain = 0.09; o.pan = 0.30; o.side = 0.55; shot("snap", t + 2 * BEAT + 0.012, o); } } } @@ -1986,9 +2002,17 @@ int main(int argc, char **argv) { + sigL[i] * ds * SIGG + drumsL[i] + sub + so) * fade; const double r = (musicR[i] * be * vd + voxR[i] * dv * VOXG * vg + tubeR[i] * pe * TUBEG * vd + sigR[i] * ds * SIGG + drumsR[i] + sub - so) * fade; - L[i] = (float)l; R[i] = (float)r; - if (fabs(l) > peak) peak = fabs(l); - if (fabs(r) > peak) peak = fabs(r); + // the listener moves: a slow walk around the stage (stereo + // rotation at two incommensurate rates) breathing nearer and + // farther (live-show pass) + const double lt = (double)i / SR; + const double th = 0.10 * sin(TAU * 0.021 * lt) + 0.05 * sin(TAU * 0.009 * lt + 1.3); + const double br = 1.0 + 0.035 * sin(TAU * 0.016 * lt + 0.5); + const double lr = (l * cos(th) - r * sin(th)) * br; + const double rr = (l * sin(th) + r * cos(th)) * br; + L[i] = (float)lr; R[i] = (float)rr; + if (fabs(lr) > peak) peak = fabs(lr); + if (fabs(rr) > peak) peak = fabs(rr); } } const double norm = peak > 1e-9 ? 0.92 / peak : 1; diff --git a/pop/cult/viz/review-score.py b/pop/cult/viz/review-score.py index d4d66691c3..65804fce7f 100644 --- a/pop/cult/viz/review-score.py +++ b/pop/cult/viz/review-score.py @@ -22,6 +22,20 @@ # pop/.venv/bin/python pop/cult/viz/review-score.py # club cut # pop/.venv/bin/python pop/cult/viz/review-score.py --radio # radio cut # pop/.venv/bin/python pop/cult/viz/review-score.py --light # paper theme +# --audio PATH render against a different master (e.g. the release +# master out/cult-remix-final.mp3 — same trim/timeline, +# so the receipt aligns 1:1) +# --out PATH override the output mp4 path +# +# FINAL-video additions (@jeffrey's two asks): +# · LYRICS SCROLLER — a teleprompter band along the bottom: upcoming +# words/phrases approach a fixed now-line from the right, light up in +# their singer's color while sung, then fade and sink away leftward. +# · MULTI-VOICE KARAOKE — overlapping word events with different `who` +# performers draw as a STACK of name-colored rows (camille / alex / +# jeffrey top-to-bottom by register), so the dashStack's +# three-people-one-pitch-28-ms-apart signature is visible, not +# collapsed into one word. import json, math, os, re, subprocess, sys, time import numpy as np from PIL import Image, ImageDraw, ImageFont @@ -32,13 +46,25 @@ PPS = 260 # timeline pixels per second (520 px / bar) RADIO = "--radio" in sys.argv LIGHT = "--light" in sys.argv +def argval(flag): + if flag in sys.argv: + i = sys.argv.index(flag) + if i + 1 < len(sys.argv): + return sys.argv[i + 1] + return None LANE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) OUTD = os.path.join(LANE_DIR, "out") MP3 = os.path.join(OUTD, "cult-remix-radio.mp3" if RADIO else "cult-remix.mp3") +AUDIO_OVERRIDE = argval("--audio") +if AUDIO_OVERRIDE: + MP3 = os.path.abspath(AUDIO_OVERRIDE) OUT = os.path.join(OUTD, "cult-remix-radio-review-score.mp4" if RADIO else "cult-remix-review-score.mp4") if LIGHT: OUT = OUT.replace(".mp4", "-light.mp4") +OUT_OVERRIDE = argval("--out") +if OUT_OVERRIDE: + OUT = os.path.abspath(OUT_OVERRIDE) EVENTS_JSON = os.path.join(OUTD, "cult-remix-v10.events.json") STEMS = os.path.join(OUTD, "stems") FONT_B = "/Users/jas/aesthetic-computer/slab/menuband/Sources/MenuBand/Resources/ywft-processing-bold.ttf" @@ -128,18 +154,25 @@ print(f"{len(WORDS)} word events from the receipt", flush=True) # instrument banks (new in the current engine): lane clips, NOT karaoke. # violin-secret = the act-IV violin desks; guitar-chug / guitar-wide = the # dark flanged guitars; boing-b/d/g/e = the act-VII chord-change boings; -# waterhole = the opening watery-hole hit; plus the synth "guitar" voice -# (midi notes) that shadows the reply act. +# accordion-secret = the 13.5 s musette swell under the act-IV turn, and +# accordion-b/d/g/e breathe on act VII's chord changes (drawn at their +# chord roots so the Bm·D·G·Em walk reads in-lane); waterhole = the +# opening watery-hole hit; plus the synth "guitar" voice (midi notes) +# that shadows the reply act. def instr_of(name): if re.match(r"^violin", name): return "violin" if name == "guitar-chug": return "chug" if name == "guitar-wide": return "guitar wide" if re.match(r"^boing-", name): return "boing" + if re.match(r"^accordion", name): return "accordion" if name == "waterhole": return "watery-hole" return None INSTR_MIDI = {"violin": 64, "guitar wide": 56, "chug": 50, "boing": 46, - "watery-hole": 60} + "watery-hole": 60, "accordion": 62} +CHORD_MIDI = {"b": 59, "d": 62, "g": 67, "e": 64, # accordion chord roots + "secret": 55} +BOING_MIDI = {"b": 47, "d": 50, "g": 55, "e": 52} INSTR = [] for e in EVENTS: s = e.get("sample") @@ -153,6 +186,10 @@ for e in EVENTS: continue dur = float(e.get("dur", 0.3)) or 0.3 m = INSTR_MIDI.get(lbl, 56) + if lbl == "accordion": + m = CHORD_MIDI.get(s.split("-")[-1], m) + elif lbl == "boing": + m = BOING_MIDI.get(s.split("-")[-1], m) if e["voice"] == "guitar" and e.get("midi") is not None: m = min(69, max(43, e["midi"] + 19)) # spread the low notes in-lane if INSTR and INSTR[-1]["word"] == lbl and abs(INSTR[-1]["t0"] - max(0.0, t0)) < 0.12: @@ -160,7 +197,7 @@ for e in EVENTS: INSTR.append({"t0": max(0.0, t0), "t1": max(0.0, t0) + dur, "word": lbl, "who": None, "midi": m, "voice": "instr", "prio": 0}) INSTR.sort(key=lambda e: e["t0"]) -print(f"{len(INSTR)} instrument events (violin / guitars / boings / watery-hole)", +print(f"{len(INSTR)} instrument events (violin / guitars / boings / accordion / watery-hole)", flush=True) # ---------------------------------------------------------------- lanes @@ -171,6 +208,43 @@ EV_WORDS = [e for e in WORDS if e["voice"] in ("lead", "dot", "cult", "alt", "stretch", "material")] KARAOKE = [e for e in WORDS if e["voice"] != "material"] # grains are texture EV_DASH = [e for e in WORDS if e["voice"] in ("dash", "bassdash", "sosdash")] + +# ---------------------------------------------------------------- tokens +# Karaoke events fused into display TOKENS for the scroller and the +# multi-voice stack. Two fusion rules: +# · simultaneity — same word starting within 90 ms (the dashStack's +# 28 ms offsets, the choir's 45 ms cults, jeffrey's sub octave) is ONE +# token carrying the union of whos; +# · melisma — the same word restated by the same voice while the first +# is still ringing (runrealfast-hi + its -long melisma 0.8 s later) +# extends the token instead of doubling it. +WHO_ORDER = {"camille": 0, "alex": 1, "jeffrey": 2} # register, high→low +TOKENS = [] +for e in sorted(KARAOKE, key=lambda e: e["t0"]): + fused = False + for tk in TOKENS[-10:]: + if tk["word"] != e["word"]: + continue + simul = e["t0"] - tk["first"] <= 0.09 + melis = (e["voice"] == tk["voice"] and e["t0"] < tk["t1"] + 0.05 + and e["t0"] - tk["first"] <= 1.2) + if simul or melis: + tk["t1"] = max(tk["t1"], e["t1"]) + if e["who"] and e["who"] not in tk["whos"]: + tk["whos"].append(e["who"]) + tk["prio"] = max(tk["prio"], e["prio"]) + tk["alt"] = tk["alt"] or e["voice"] == "alt" + fused = True + break + if not fused: + TOKENS.append({"word": e["word"], "t0": e["t0"], "t1": e["t1"], + "first": e["t0"], "whos": [e["who"]] if e["who"] else [], + "voice": e["voice"], "prio": e["prio"], + "alt": e["voice"] == "alt"}) +for tk in TOKENS: + tk["whos"].sort(key=lambda w: WHO_ORDER.get(w, 9)) +print(f"{len(TOKENS)} lyric tokens " + f"({sum(1 for tk in TOKENS if len(tk['whos']) > 1)} multi-voice)", flush=True) ALANES = [ # (label, signal, color, clip-gate threshold) ("bed 250+", stem("music", af="highpass=f=250"), (150, 140, 220), 0.11), ("bass <250", stem("music", af="lowpass=f=250"), (200, 120, 235), 0.11), @@ -364,8 +438,13 @@ f_mark = ImageFont.truetype(FONT_R, 20) f_bar = ImageFont.truetype(FONT_R, 20) f_act = ImageFont.truetype(FONT_B, 25) f_tc = ImageFont.truetype(FONT_B, 56) -f_kara = ImageFont.truetype(FONT_B, 96) f_who = ImageFont.truetype(FONT_B, 36) +# multi-voice stack: the row font shrinks as more voices sing at once +F_STACK = {1: ImageFont.truetype(FONT_B, 88), 2: ImageFont.truetype(FONT_B, 64), + 3: ImageFont.truetype(FONT_B, 52), 4: ImageFont.truetype(FONT_B, 42)} +f_name = ImageFont.truetype(FONT_B, 24) +f_scr = ImageFont.truetype(FONT_B, 38) # scroller: upcoming / past +f_scra = ImageFont.truetype(FONT_B, 48) # scroller: the word being sung def sx(t): # strip x for time t return PAD_L + int(round(t * PPS)) @@ -467,15 +546,20 @@ strip_np = np.array(strip) del strip, sd # ---------------------------------------------------------------- chrome -TITLE = "whistlegraph cult --- remix (v10)" + (" radio cut" if RADIO else " club cut") +TITLE = "whistlegraph cult --- remix (v10)" + ( + " radio cut" if RADIO else + " release master" if AUDIO_OVERRIDE and "final" in os.path.basename(MP3) + else " club cut") chrome = Image.new("RGB", (W, H), BG) cd = ImageDraw.Draw(chrome) cd.text((40, 18), TITLE, font=f_title, fill=INK) MM_X0, MM_X1, MM_Y0, MM_Y1 = 40, 1880, 62, 88 for i, (t0, name, col) in enumerate(ACTS): + if t0 >= DUR: # probe clips shorter than the record + continue xa = MM_X0 + int(max(0.0, t0) / DUR * (MM_X1 - MM_X0)) - xb = MM_X0 + int(ACT_END[i] / DUR * (MM_X1 - MM_X0)) + xb = MM_X0 + int(min(DUR, ACT_END[i]) / DUR * (MM_X1 - MM_X0)) cd.rectangle([xa, MM_Y0, xb, MM_Y1], fill=mm_fill(col)) cd.text((xa + 6, MM_Y0 + 5), name.split(" ")[0], font=f_tiny, fill=ink_of(col)) cd.rectangle([MM_X0, MM_Y0, MM_X1, MM_Y1], outline=MM_OUTLINE) @@ -512,14 +596,118 @@ def act_index(t): return ai MIN_SHOW = 0.35 # a dot stays readable -def words_at(t): - live = [e for e in KARAOKE - if e["t0"] <= t < max(e["t1"], e["t0"] + MIN_SHOW)] - if not live: - return None - prim = max(live, key=lambda e: (e["prio"], e["t0"])) - whos = sorted({e["who"] for e in live if e["word"] == prim["word"] and e["who"]}) - return prim, whos + +def blend(c0, c1, u): # c0 → c1 as u goes 0 → 1 + return tuple(int(a + (b - a) * u) for a, b in zip(c0, c1)) + +def tok_col(tk): # a token's base ink (single-who or neutral) + if len(tk["whos"]) == 1: + return ink_of(WHO_COL[tk["whos"][0]]) + return INK # unattributed words read in plain ink + +# ── multi-voice stack (the big karaoke, one row per live voice) ────── +# Live tokens group by word; a word with several `who` performers expands +# to one name-colored row per person (camille/alex/jeffrey, register +# order). If the moment is too crowded, multi-voice words COMPRESS to a +# single chorus row — the word drawn once per performer, offset copies in +# each color, all names listed — so nobody is ever dropped. +KAR_TOP = STRIP_BOT + 14 +def rows_at(t): + live = [tk for tk in TOKENS + if tk["t0"] <= t < max(tk["t1"], tk["t0"] + MIN_SHOW)] + live.sort(key=lambda tk: (-tk["prio"], tk["t0"])) + groups, order = {}, [] + for tk in live: + g = groups.get(tk["word"]) + if g is None: + g = groups[tk["word"]] = {"word": tk["word"], "whos": [], + "alt": False} + order.append(g) + for who in tk["whos"]: + if who not in g["whos"]: + g["whos"].append(who) + g["alt"] = g["alt"] or tk["alt"] + for g in order: + g["whos"].sort(key=lambda w: WHO_ORDER.get(w, 9)) + expanded = sum(max(1, len(g["whos"])) for g in order) + rows = [] + for g in order: + if len(g["whos"]) > 1 and expanded > 4: # crowded: chorus row + rows.append({"word": g["word"], "whos": g["whos"], "alt": False}) + elif g["whos"]: + for who in g["whos"]: + rows.append({"word": g["word"], "whos": [who], "alt": False}) + else: + rows.append({"word": g["word"], "whos": [], "alt": g["alt"]}) + return rows[:4] + +# ── lyrics scroller (teleprompter band along the bottom) ───────────── +# Time maps to x exactly like the timeline above: upcoming words approach +# the fixed now-line from the right at LPPS px/s, light up in their +# singer's color while sung, then fade and sink away to the left. +NOW_X = 350 +SCR_X0, SCR_X1 = 44, 1404 # the act card owns the right edge +SCR_Y0, SCR_Y1 = H - 142, H - 46 +SCR_ROW_Y = (H - 116, H - 68) # two rows; collisions bump to row 2 +LPPS = 150.0 +SCR_LOOK = (SCR_X1 - NOW_X) / LPPS # ≈ 7.0 s of upcoming lyric +SCR_FADE = 1.6 # seconds to fall away after t1 +SCR_SINK = 24 # px a spent word sinks as it fades + +_meas = ImageDraw.Draw(Image.new("RGB", (8, 8))) +row_end = [-1e9, -1e9] # greedy, precomputed → rows never flicker +for tk in TOKENS: + x = tk["t0"] * LPPS + w = _meas.textlength(tk["word"], font=f_scr) + r = 0 if x >= row_end[0] + 14 else 1 + if r == 1 and x < row_end[1] + 14: + r = 0 if row_end[0] <= row_end[1] else 1 # both busy: lesser evil + row_end[r] = max(row_end[r], x + w) + tk["row"] = r + tk["w"] = w + +def draw_scroller(dd, t): + dd.line([NOW_X, SCR_Y0, NOW_X, SCR_Y1], + fill=blend(BG, PLAYHEAD, 0.55), width=2) + for tk in TOKENS: + x = NOW_X + (tk["t0"] - t) * LPPS + if x > SCR_X1: + break # TOKENS is t0-sorted; the rest are further out + t1v = max(tk["t1"], tk["t0"] + MIN_SHOW) + if t > t1v + SCR_FADE: + continue + if x + tk["w"] < SCR_X0 - 20: + continue + yc = SCR_ROW_Y[tk["row"]] + active = tk["t0"] <= t < t1v + font = f_scra if active else f_scr + sink = 0 + if t < tk["t0"]: # upcoming: brighten as it nears the line + u = max(0.0, 1.0 - (tk["t0"] - t) / SCR_LOOK) + k = 0.30 + 0.45 * u + elif active: + k = 1.0 + else: # past: fade and sink away + p = min(1.0, (t - t1v) / SCR_FADE) + k = 0.75 * (1.0 - p) + sink = int(SCR_SINK * p) + # ease in from the right edge instead of popping in + k *= max(0.0, min(1.0, (SCR_X1 - x) / 130.0)) + if tk["whos"] and len(tk["whos"]) > 1: # multi-voice: a chorus + n = len(tk["whos"]) # of name-colored copies + for i, who in enumerate(tk["whos"]): + off = int((i - (n - 1) / 2) * 15) + col = blend(BG, ink_of(WHO_COL[who]), k) + dd.text((x + i * 2, yc + off + sink), tk["word"], + font=font, fill=col, anchor="lm") + else: + col = blend(BG, tok_col(tk), k) + dd.text((x, yc + sink), tk["word"], font=font, fill=col, anchor="lm") + if active: # underline in the singer's color + uc = (ink_of(WHO_COL[tk["whos"][0]]) if tk["whos"] + else ink_of(WHO_COL[None])) + ww = dd.textlength(tk["word"], font=font) + dd.rectangle([x, yc + 28, x + ww, yc + 31], fill=uc) # ---------------------------------------------------------------- frames NF = int(DUR * FPS) @@ -574,20 +762,35 @@ for f in range(NF): if xb > xa: dd.rectangle([xa, STRIP_TOP + yb0, xb, STRIP_TOP + yb1], outline=ink_of(col), width=2) - # karaoke: the word being sung, and who sings it - wa = words_at(t) - KY = STRIP_BOT + 96 - if wa is not None: - prim, whos = wa - c = WHO_COL.get(prim["who"], WHO_COL[None]) - dd.text((60, KY), prim["word"], font=f_kara, fill=INK, anchor="lm") - xw = 66 - for i, who in enumerate(whos or ([prim["who"]] if prim["who"] else [])): - dd.text((xw, KY + 84), who, font=f_who, fill=ink_of(WHO_COL[who]), anchor="lm") - xw += dd.textlength(who, font=f_who) + 26 - if not whos and prim["who"] is None and prim["voice"] == "alt": - dd.text((66, KY + 84), "another cult", font=f_who, - fill=MUTE_GRAY, anchor="lm") + # karaoke: EVERY live voice as its own name-colored row — the + # dashStack's three simultaneous people stack camille/alex/jeffrey + # top-to-bottom by register instead of collapsing into one word + rows = rows_at(t) + if rows: + font = F_STACK[len(rows)] + rh = font.size + 18 + y = KAR_TOP + rh // 2 + 4 + for row in rows: + whos = row["whos"] + if len(whos) > 1: # chorus row: every performer, one word + for i, who in enumerate(whos): + dd.text((66, y + (i - (len(whos) - 1) / 2) * 20), who, + font=f_name, fill=ink_of(WHO_COL[who]), anchor="lm") + dd.text((250 + i * 3, y + (i - (len(whos) - 1) / 2) * 10), + row["word"], font=font, + fill=ink_of(WHO_COL[who]), anchor="lm") + elif whos: + col = ink_of(WHO_COL[whos[0]]) + dd.text((66, y), whos[0], font=f_name, fill=col, anchor="lm") + dd.text((250, y), row["word"], font=font, fill=col, anchor="lm") + else: + if row["alt"]: + dd.text((66, y), "another cult", font=f_name, + fill=MUTE_GRAY, anchor="lm") + dd.text((250, y), row["word"], font=font, fill=INK, anchor="lm") + y += rh + # upcoming-lyrics teleprompter + draw_scroller(dd, t) # act card: current act lit, with a progress bar ai = act_index(t) for i, (t0, name, col) in enumerate(ACTS): diff --git a/slab/menuband-trackpad/Sources/main.swift b/slab/menuband-trackpad/Sources/main.swift index 19d4a9476c..621c4fa2e2 100644 --- a/slab/menuband-trackpad/Sources/main.swift +++ b/slab/menuband-trackpad/Sources/main.swift @@ -41,8 +41,42 @@ private final class TrackpadInteractionShield { private var commandExitArmed = false private var commandExitArmAfter: CFTimeInterval = 0 private var lastCommandTap: CFTimeInterval? + /// Set when a pointer gesture lands inside a pending Command run, which + /// voids it the way an intervening key does. + private var commandRunVoided = false private static let commandKeyCodes: Set = [54, 55] + private static let watchdogTick: CFTimeInterval = 1.0 / 60.0 + private static let commandTapWindow: CFTimeInterval = 0.75 + + /// Mouse presses that void a Command run. `hasNonCommandKeyDown` cannot + /// see these: ⌘-click (open a link in a new tab) holds no key down, so a + /// browser full of them read as a summon and relaunched Menu Band after + /// the user had quit it. Reported by @alexf, 2026-08-18. + private static let pointerCancelEvents: [CGEventType] = [ + .leftMouseDown, .rightMouseDown, .otherMouseDown, + ] + + /// True when one of those landed within the last `seconds`. The watchdog + /// polls rather than taps, so it asks the event source how long ago. + private static func pointerPressed(within seconds: CFTimeInterval) -> Bool { + pointerCancelEvents.contains { type in + CGEventSource.secondsSinceLastEventType( + .combinedSessionState, eventType: type + ) <= seconds + } + } + + /// Scrolling is handled separately from the presses above: ⌘-scroll (zoom) + /// is the gesture worth cancelling for, but momentum keeps emitting + /// scrollWheel for about a second after the fingers lift. Only a scroll + /// arriving while Command is actually held counts, which the tick below + /// samples as it happens instead of measuring backwards over the gap. + private static func scrolledThisTick() -> Bool { + CGEventSource.secondsSinceLastEventType( + .combinedSessionState, eventType: .scrollWheel + ) <= watchdogTick * 2 + } /// The permission-free watchdog only receives snapshots, not key events. /// While the first Command tap is pending, sample the keyboard so any @@ -87,6 +121,7 @@ private final class TrackpadInteractionShield { func stop() { precondition(Thread.isMainThread) lastCommandTap = nil + commandRunVoided = false commandExitArmed = false stopEventTap() panels.forEach { $0.orderOut(nil) } @@ -111,8 +146,9 @@ private final class TrackpadInteractionShield { .combinedSessionState ).contains(.maskCommand) commandExitArmed = false + commandRunVoided = false commandExitArmAfter = CACurrentMediaTime() + 0.35 - let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { + let timer = Timer(timeInterval: Self.watchdogTick, repeats: true) { [weak self] _ in guard let self else { return } let escapeDown = CGEventSource.keyState( @@ -127,19 +163,37 @@ private final class TrackpadInteractionShield { if self.lastCommandTap != nil, Self.hasNonCommandKeyDown() { self.lastCommandTap = nil } + // A ⌘-held scroll is a zoom, not a gap between taps. Catch it in + // the tick it happens so momentum after the fingers lift — ⌘ long + // released by then — cannot void an honest run. + if commandDown, self.lastCommandTap != nil, Self.scrolledThisTick() { + self.commandRunVoided = true + } if !self.commandExitArmed { if !commandDown && now >= self.commandExitArmAfter { self.commandExitArmed = true self.lastCommandTap = nil + self.commandRunVoided = false } } else if commandDown && !self.commandWasDown { if Self.hasNonCommandKeyDown() { self.lastCommandTap = nil - } else if let prior = self.lastCommandTap, now - prior <= 0.75 { - commandDoubleTapped = true - self.lastCommandTap = nil + self.commandRunVoided = false + } else if let prior = self.lastCommandTap, + now - prior <= Self.commandTapWindow { + if self.commandRunVoided + || Self.pointerPressed(within: now - prior) { + // Clicked or zoomed between the taps: this was Command + // being used, not the summon gesture. + self.lastCommandTap = nil + self.commandRunVoided = false + } else { + commandDoubleTapped = true + self.lastCommandTap = nil + } } else { self.lastCommandTap = now + self.commandRunVoided = false } } self.escapeWasDown = escapeDown diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 6a09c06ed4..171c9145c7 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -272,6 +272,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var lastQuietFocusSide: UInt16? /// Max gap between two same-side Command taps. private static let quietFocusTapWindow: CFTimeInterval = 0.50 + /// Pointer events that cancel a Command run, alongside the keyDown + /// cancel. ⌘-click and ⌘-scroll are the two ways to use Command that + /// leave no keystroke between the taps. + private static let quietFocusCancellingPointerEvents: + Set = [ + .leftMouseDown, .rightMouseDown, .otherMouseDown, .scrollWheel + ] + /// Same set on the CGEvent side (the tap is the primary route; the + /// NSEvent monitors are the fallback). The raw values are shared between + /// the two enums, which is what lets the tap callback hand the handler an + /// `NSEvent.EventType` built straight from `type.rawValue`. + private static let quietFocusCancellingCGEvents: Set = [ + .leftMouseDown, .rightMouseDown, .otherMouseDown, .scrollWheel + ] + /// What the fallback NSEvent monitors watch: the gesture's own events + /// plus the pointer events that cancel a run. + private static let quietFocusMonitorMask: NSEvent.EventTypeMask = [ + .flagsChanged, .keyDown, + .leftMouseDown, .rightMouseDown, .otherMouseDown, .scrollWheel + ] // (The visualizer's right-⌘ TRIPLE-tap gesture was removed: it had claimed // right-⌘ double-tap away from quiet focus, forcing focus onto an awkward @@ -2793,6 +2813,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // (System Events' `key code 54`) delivers a modifier's own keycode // as a keyDown, and treating that as a chord would cancel the very // run it's trying to make. + // + // A click or a scroll between the taps breaks the run for exactly + // the reason a keystroke does: it is real work in another app. + // ⌘-click (open a link in a new tab) and ⌘-scroll (zoom) are the + // ways to use Command that put no keyDown between the two edges, + // so the keyDown cancel below never sees them — two ordinary + // ⌘-clicks in a browser read as a ⌘⌘ double-tap and armed the + // keyboard mid-typing. Reported by @alexf, 2026-08-18. + if Self.quietFocusCancellingPointerEvents.contains(eventType) { + // A scroll only breaks the run while ⌘ is actually in it — + // that's the zoom gesture. Plain scrolling keeps emitting + // momentum events for about a second after the fingers lift, + // and those must not eat a ⌘⌘ made right afterwards. A click + // has no momentum tail, so it cancels unconditionally. + if eventType != .scrollWheel + || modifierFlags.contains(.command) { + self.quietFocusRunCount = 0 + } + return + } if eventType == .keyDown { let isCommandKey = keyCode == Self.leftCommandKeyCode || keyCode == Self.rightCommandKeyCode @@ -2876,7 +2916,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } else { NSLog("MenuBand: Input Monitoring not granted; Command gesture is local-only") quietFocusMonitorLocal = NSEvent.addLocalMonitorForEvents( - matching: [.flagsChanged, .keyDown] + matching: Self.quietFocusMonitorMask ) { handler($0.type, $0.keyCode, $0.modifierFlags); return $0 } } #else @@ -2888,10 +2928,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if !installQuietFocusTap() { NSLog("MenuBand: ⌘⌘ tap creation failed — falling back to NSEvent monitors") quietFocusMonitorGlobal = NSEvent.addGlobalMonitorForEvents( - matching: [.flagsChanged, .keyDown] + matching: Self.quietFocusMonitorMask ) { handler($0.type, $0.keyCode, $0.modifierFlags) } quietFocusMonitorLocal = NSEvent.addLocalMonitorForEvents( - matching: [.flagsChanged, .keyDown] + matching: Self.quietFocusMonitorMask ) { handler($0.type, $0.keyCode, $0.modifierFlags); return $0 } } #endif @@ -2901,9 +2941,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// itself when macOS disables it (timeout / user input), the failure /// that otherwise kills a tap permanently and silently. private func installQuietFocusTap() -> Bool { - let mask: CGEventMask = + // flagsChanged + keyDown drive the gesture; the pointer types are + // there only to CANCEL a run (see the handler's pointer guard). + var mask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) + for type in Self.quietFocusCancellingCGEvents { + mask |= (1 << type.rawValue) + } let callback: CGEventTapCallBack = { _, type, event, refcon in guard let refcon else { return Unmanaged.passUnretained(event) } let appDelegate = Unmanaged @@ -2914,7 +2959,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } return Unmanaged.passUnretained(event) } - guard type == .flagsChanged || type == .keyDown else { + guard type == .flagsChanged || type == .keyDown + || AppDelegate.quietFocusCancellingCGEvents.contains(type) + else { return Unmanaged.passUnretained(event) } // Deliver the way the NSEvent monitors did: as an ordinary @@ -6184,6 +6231,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { keyboardNotesHeld && !modeLatched } + /// Whether a bare Shift may open the bend gesture with no note held. + /// Shift is the "warp the still-ringing tails" move, so it only means + /// that while Menu Band holds the keyboard: quiet focus armed (either + /// half of the arming — the capture panel or the focus flag) or global + /// TYPE mode. With none of those the app is escaped out and Shift + /// belongs to whatever the user is really typing in. + static func shouldEngageShiftBend(localCaptureArmed: Bool, + keyboardPerformanceFocusActive: Bool, + typeModeActive: Bool) -> Bool { + localCaptureArmed || keyboardPerformanceFocusActive || typeModeActive + } + static func shouldLockPointerForKeyboardTrackpad( keymapShown: Bool ) -> Bool { @@ -6310,11 +6369,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // never opens the graph — still can't reactivate the bend. let inReleaseGrace = pitchBendCursorPushed && (pitchBendReleaseGraceUntil.map { $0.timeIntervalSinceNow > 0 } ?? false) + // Shift alone only counts as a performance move while Menu Band + // actually owns the keyboard. Escaped out, Shift is just Shift — and + // every other gate here needs Menu Band to already be playing, so + // this was the one door a bystander app could walk through: holding + // ⇧ to select text in Google Docs locked the pointer and raised the + // pitch pad (and, once `pitchBendCursorPushed` was set, the trackpad + // drum surface behind it). Reported by @alexf, 2026-08-18. + let shiftPerformance = shift && Self.shouldEngageShiftBend( + localCaptureArmed: localCapture.isArmed, + keyboardPerformanceFocusActive: keyboardPerformanceFocusActive, + typeModeActive: menuBand.typeMode + ) // While the spacebar tape is reverse-playing, a mouse move bends + echoes // the playback itself (the fx route onto the rewind voice's own inserts), // so engage the gesture even with no key held. - guard focusedMouseSlide || menuBand.keyboardNotesHeld || shift || inReleaseGrace - || menuBand.isRewinding else { return } + guard focusedMouseSlide || menuBand.keyboardNotesHeld || shiftPerformance + || inReleaseGrace || menuBand.isRewinding else { return } // Shift momentarily puts a continuous surface onto the original // sliding FX; Tab selects FX persistently. let momentarySurfaceFx = (trackpadPadMode == .skin diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index ad20d99776..29b89967b8 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -1757,20 +1757,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { applyAppearance(isDark ? "light" : "dark") } + /// Scheduled (sunrise/sunset) appearance switching. Clicking Light or Dark + /// in System Settings turns this off; `set dark mode` does not, and a + /// machine left with both opinions splits down the middle — apps read the + /// forced `AppleInterfaceStyle` and go dark while the menu bar keeps + /// rendering the schedule's answer (light underbelly, black glyphs) until + /// the next real sunrise or sunset. So every forced mode clears it first. + private static let autoAppearanceKey = "AppleInterfaceStyleSwitchesAutomatically" + /// Set macOS Dark Mode on this host and each reachable tailscale mac. Every /// call is dispatched async (no local shell), so the menu never blocks and /// an offline/asleep host just times out its ssh quietly. private func applyAppearance(_ mode: String) { let val = (mode == "dark") ? "true" : "false" let osa = "tell application \"System Events\" to tell appearance preferences to set dark mode to \(val)" - // Local host (neo). + // Local host (neo). The auto-switch write goes through CFPreferences + // rather than a fourth async process because it has to land *before* + // the osascript below, and `runAsync` promises no ordering. + CFPreferencesSetValue(Self.autoAppearanceKey as CFString, kCFBooleanFalse, + kCFPreferencesAnyApplication, kCFPreferencesCurrentUser, + kCFPreferencesAnyHost) + CFPreferencesAppSynchronize(kCFPreferencesAnyApplication) ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", osa]) // Remotes: pass the remote command as a single ssh arg (no local shell), // so the AppleScript's double quotes survive untouched on the far end. + // One arg also sequences the two writes on the far side for free. + let stopAuto = "defaults write -g \(Self.autoAppearanceKey) -bool false" for host in Self.appearanceHosts { ShellRunner.runAsync("/usr/bin/ssh", args: ["-o", "ConnectTimeout=4", "-o", "BatchMode=yes", - host, "osascript -e '\(osa)'"]) + host, "\(stopAuto); osascript -e '\(osa)'"]) } } diff --git a/xbox/live/marketing/shell.mjs b/xbox/live/marketing/shell.mjs index 08af15e9d0..3dea9e340f 100644 --- a/xbox/live/marketing/shell.mjs +++ b/xbox/live/marketing/shell.mjs @@ -20,7 +20,8 @@ export const repo = resolve(live, "../.."); // Hashing these tells a burner whether its output still matches the game. export const shellSources = ["oskiewar.js", "mac-test.html", "frame-driver.mjs"]; -const fromLive = ["oskiewar.js", "oskiewar-sfx.mjs", "oskiewar-midi.mjs", +const fromLive = ["oskiewar.js", "oskiewar-sfx.mjs", "oskiewar-voice.mjs", + "oskiewar-midi.mjs", "frame-driver.mjs", "round-room.mjs"]; const fromPublic = ["aesthetic.computer/dep/@akamfoad/qr/qr.mjs", "aesthetic.computer/lib/product-analytics.mjs", diff --git a/xbox/live/render-social-preview.mjs b/xbox/live/render-social-preview.mjs index 989ab28060..5a33f92cf0 100644 --- a/xbox/live/render-social-preview.mjs +++ b/xbox/live/render-social-preview.mjs @@ -42,7 +42,8 @@ function fileFor(pathname) { return join(live, "mac-test.html"); // Every module the shell imports has to be listed, or the page dies on a 404 // and the capture comes out empty rather than loudly wrong. - if (["/oskiewar.js", "/oskiewar-sfx.mjs", "/oskiewar-midi.mjs", "/frame-driver.mjs", + if (["/oskiewar.js", "/oskiewar-sfx.mjs", "/oskiewar-voice.mjs", + "/oskiewar-midi.mjs", "/frame-driver.mjs", "/round-room.mjs"].includes(pathname)) return join(live, pathname.slice(1)); if (pathname === "/aesthetic.computer/dep/@akamfoad/qr/qr.mjs") return join(repo, "system/public/aesthetic.computer/dep/@akamfoad/qr/qr.mjs"); diff --git a/xbox/live/tests/blackbox-rounds.mjs b/xbox/live/tests/blackbox-rounds.mjs index 202b26e62a..71ae03ec6f 100644 --- a/xbox/live/tests/blackbox-rounds.mjs +++ b/xbox/live/tests/blackbox-rounds.mjs @@ -22,6 +22,7 @@ const mime = new Map([ function fileFor(pathname) { if (pathname === "/oskiewar.js") return join(here, "oskiewar.js"); if (pathname === "/oskiewar-sfx.mjs") return join(here, "oskiewar-sfx.mjs"); + if (pathname === "/oskiewar-voice.mjs") return join(here, "oskiewar-voice.mjs"); if (pathname === "/frame-driver.mjs") return join(here, "frame-driver.mjs"); if (pathname === "/round-room.mjs") return join(here, "round-room.mjs"); if (pathname === "/aesthetic.computer/dep/@akamfoad/qr/qr.mjs") -- 2.51.2