From bf68487ce479c37541dcb96fbdc6e1173c06bba4 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 8 Aug 2026 12:46:49 -0700 Subject: [PATCH] Give midi mode a subdomain, a lamp, and a greeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit midi.oskiewar.com is the same site as the apex: the shell reads the host name and turns the bridge on, so scoring from a DAW no longer means remembering a query string. The host still needs its own A record at Porkbun, since that zone has no wildcard and is not on Cloudflare. The corner lamp answers every note that leaves. Pads ship empty, so "nothing happened" is ambiguous until something in the page confirms the bridge is really sending — the lamp is the cheapest possible answer, and it rides the bridge's existing onSignal hook rather than a second path that could drift from what is actually sent. Scoring is anonymous dummy play, so the account door is shut while it is on: the logout button is hidden and capabilities() carries a `midi` flag for the seam auth attaches to, rather than reaching into the sim from the host. The port now opens with a short rising greeting on real pads, so the DAW answers before any gameplay does — which also proves sounds are loaded, not merely that bytes are moving. It strikes notes directly instead of going through signal(), so it cannot drag the theme along or read as play, and panic() cancels one still in flight. The social preview keeps its burned assets: the lamp is hidden under body.social-preview, so the poster and video cannot change and only the build hash moves. --- lith/Caddyfile | 5 ++- xbox/live/mac-test.html | 50 +++++++++++++++++++++++--- xbox/live/oskiewar-midi.mjs | 39 +++++++++++++++++++- xbox/live/social/manifest.json | 2 +- xbox/live/tests/oskiewar-midi.test.mjs | 42 +++++++++++++++++++++- 5 files changed, 130 insertions(+), 8 deletions(-) diff --git a/lith/Caddyfile b/lith/Caddyfile index d58a94508c..48f9bce64b 100644 --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -851,7 +851,10 @@ # --- oskiewar.com --- # Porkbun DNS points directly at lith, so this host needs a publicly trusted # certificate rather than the Cloudflare origin certificate on the :443 block. -oskiewar.com { +# midi.oskiewar.com is the same site: the shell reads the host name and turns +# the MIDI bridge on, so scoring from a DAW needs no query string. It needs its +# own A record at Porkbun — there is no wildcard on this zone. +oskiewar.com, midi.oskiewar.com { tls { issuer acme { dir https://acme-v02.api.letsencrypt.org/directory diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html index 2ff210c0ab..621dc334a6 100644 --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -61,6 +61,21 @@ #auto-update-label { display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 400; } #auto-update { width: 16px; height: 16px; margin: 0; accent-color: #ff5070; } + /* Scoring from a DAW is anonymous dummy play, so the account door is shut + and the corner it occupied is free for the midi lamp. */ + body.midi-out #logout { display: none; } + #midi-out { position: fixed; top: max(14px, env(safe-area-inset-top)); + right: max(14px, env(safe-area-inset-right)); z-index: 6; display: none; + padding: 6px 8px; border: 2px solid #6e768d; border-radius: 8px; + background: #171b28; box-shadow: 2px 3px 0 rgba(20, 25, 38, .35); + line-height: 0; } + body.midi-out #midi-out { display: block; } + body.social-preview #midi-out { display: none; } + #midi-out .key-white { fill: #b0b8ca; transition: fill .18s ease-out; } + #midi-out .key-black { fill: #171b28; } + /* The lamp answers every note that leaves, so the corner is also the + cheapest possible confirmation that the bridge is really sending. */ + #midi-out.lit .key-white { fill: #6cf0a8; transition: none; } #touch-controls { --unit: clamp(50px, 12vmin, 72px); --gap: 6px; position: fixed; inset: 0; z-index: 3; display: none; pointer-events: none; user-select: none; -webkit-user-select: none; touch-action: none; } @@ -100,6 +115,18 @@ +
+ +
@@ -474,6 +501,9 @@ inputFamily: touchEnabled ? "touch" : hasGamepad ? "xbox" : "keyboard", colorScheme: matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark", socialPreview: new URLSearchParams(location.search).has("social-preview"), + // Scoring sessions are anonymous dummy play; the seam auth attaches to + // can read this to keep opponents and accounts out of the way. + midi: document.body.classList.contains("midi-out"), productName: mac ? "MACOS" : "WEB", version: "CROSS-PLATFORM", graphics: "CANVAS2D", audio: "WEB AUDIO" }; } @@ -747,17 +777,29 @@ // sends every signal to the IAC bus and silences the built-in voices, // `?midi=both` layers them, and the choice sticks in localStorage. The pad // layout is `oskiewar pads.adg` — note 36 upward, one per signal event. - const oskiewarMidi = createOskiewarMidi(); + // The lamp answers every note that leaves, so the corner doubles as proof + // the bridge is sending even when a pad is still silent. + const midiLamp = document.querySelector("#midi-out"); + let lampTimer = null; + const flashMidiLamp = () => { + midiLamp.classList.add("lit"); + clearTimeout(lampTimer); + lampTimer = setTimeout(() => midiLamp.classList.remove("lit"), 90); + }; + const oskiewarMidi = createOskiewarMidi({ onSignal: flashMidiLamp }); globalThis.__oskiewarMidi = oskiewarMidi; const midiParam = new URLSearchParams(location.search).get("midi"); let midiMode = midiParam; - if (midiParam === null) { - try { midiMode = localStorage.getItem("oskiewar-midi"); } catch {} - } else { + if (midiParam !== null) { try { localStorage.setItem("oskiewar-midi", midiParam || "1"); } catch {} + } else if (location.hostname.startsWith("midi.")) { + midiMode = "1"; // the subdomain is the ask; no query string to remember + } else { + try { midiMode = localStorage.getItem("oskiewar-midi"); } catch {} } if (midiMode !== null && midiMode !== "0" && midiMode !== "off") { if (await oskiewarMidi.enable()) { + document.body.classList.add("midi-out"); oskiewarMidi.connectSignals(globalThis); const layered = midiMode === "both"; if (!layered) oskiewarSfx.mute(true); diff --git a/xbox/live/oskiewar-midi.mjs b/xbox/live/oskiewar-midi.mjs index c204ec27c3..0b23114432 100644 --- a/xbox/live/oskiewar-midi.mjs +++ b/xbox/live/oskiewar-midi.mjs @@ -57,6 +57,16 @@ const THEME_CUES = Object.freeze({ // note so a filter can follow it while a pad still marks the change. const CONTINUOUS = Object.freeze({ wind: 1 }); +// Played once the port opens, so the DAW answers before any gameplay does. +// It strikes real pads rather than arbitrary notes, which makes it a check that +// sounds are actually loaded and not just that bytes are moving. +const HANDSHAKE = Object.freeze([ + { event: "hello", after: 0, value: .55 }, + { event: "countdown", after: 120, value: .7 }, + { event: "roundwin", after: 240, value: .85 }, + { event: "matchwin", after: 380, value: 1 }, +]); + const NOTE_ON = 0x90, NOTE_OFF = 0x80, CONTROL = 0xB0; export function createOskiewarMidi(options = {}) { @@ -115,6 +125,7 @@ export function createOskiewarMidi(options = {}) { } enabled = true; lastError = null; + if (options.handshake !== false) handshake(); return true; } @@ -142,6 +153,32 @@ export function createOskiewarMidi(options = {}) { return amount > 0 ? clamp(Math.round(amount * 127), 1, 127) : restVelocity; } + // Struck directly rather than through signal(), so the greeting cannot drag + // the theme along with it or count as gameplay. + function strike(note, value, when) { + const timer = setTimeout(() => { + timers.delete(timer); + const status = baseChannel - 1; + channelsUsed.add(baseChannel); + send([NOTE_ON | status, note, velocityFor(value)]); + const release = setTimeout(() => { + timers.delete(release); + send([NOTE_OFF | status, note, 0]); + }, noteMs); + timers.add(release); + }, when); + timers.add(timer); + } + + function handshake() { + if (!enabled) return false; + for (const step of HANDSHAKE) { + const note = notes[step.event]; + if (note !== undefined) strike(note, step.value, step.after); + } + return true; + } + // A theme is held rather than struck: its note stays down for as long as that // section lasts, so a sustaining instrument plays through and a gate-mode // Simpler loops. Switching sends the note-off first, so the two beds never @@ -237,7 +274,7 @@ export function createOskiewarMidi(options = {}) { } return Object.freeze({ - enable, disable, signal, cc, connectSignals, panic, chart, theme, + enable, disable, signal, cc, connectSignals, panic, chart, theme, handshake, mute(value = true) { muted = Boolean(value); }, get currentTheme() { return currentTheme; }, get enabled() { return enabled; }, diff --git a/xbox/live/social/manifest.json b/xbox/live/social/manifest.json index 846d67289a..7c5e9a523a 100644 --- a/xbox/live/social/manifest.json +++ b/xbox/live/social/manifest.json @@ -1,7 +1,7 @@ { "format": "ac.oskiewar.social-preview", "version": 2, - "build": "afff43e88a3f5a48", + "build": "7c4e0b72bbcbb16d", "theme": "light", "imageWidth": 1200, "imageHeight": 630, diff --git a/xbox/live/tests/oskiewar-midi.test.mjs b/xbox/live/tests/oskiewar-midi.test.mjs index 6bfa2f85e4..7366b28e3e 100644 --- a/xbox/live/tests/oskiewar-midi.test.mjs +++ b/xbox/live/tests/oskiewar-midi.test.mjs @@ -29,10 +29,12 @@ Object.defineProperty(globalThis, "navigator", { }, }); +// The greeting is off unless a test asks for it: its timers outlive the test +// that started them, and would otherwise rain notes into the next one's wire. async function bridge(options = {}) { const wire = []; sink = wire; - const midi = createOskiewarMidi({ noteMs: 5, ...options }); + const midi = createOskiewarMidi({ noteMs: 5, handshake: false, ...options }); assert.equal(await midi.enable(), true, midi.lastError); return { midi, wire }; } @@ -173,6 +175,44 @@ test("the chart names every pad in note order", async () => { assert.equal(chart.at(-1).pad, "result theme"); }); +test("the port opens with a greeting so the DAW answers first", async () => { + const { midi, wire } = await bridge({ handshake: true }); + await new Promise((resolve) => setTimeout(resolve, 500)); + const struck = wire.filter(([status]) => status === NOTE_ON).map(([, note]) => note); + assert.deepEqual(struck, ["hello", "countdown", "roundwin", "matchwin"] + .map((event) => OSKIEWAR_MIDI_NOTES[event])); + assert.equal(midi.currentTheme, null, "the greeting must not move the theme"); +}); + +test("the greeting can be declined", async () => { + const { wire } = await bridge({ handshake: false }); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(wire.length, 0); +}); + +test("panic cancels a greeting still in flight", async () => { + const { midi, wire } = await bridge({ handshake: true }); + midi.panic(); + wire.length = 0; + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok(!wire.some(([status]) => status === NOTE_ON), "a note escaped after panic"); +}); + +test("the subdomain turns midi on without a query string", () => { + assert.match(webShell, /location\.hostname\.startsWith\("midi\."\)/); + assert.match(caddyfile, /oskiewar\.com, midi\.oskiewar\.com \{/); +}); + +test("midi mode shuts the account door and lights the lamp", () => { + assert.match(webShell, /body\.midi-out #logout \{ display: none; \}/, + "logged-in play must be out of the way while scoring"); + assert.match(webShell, /midi: document\.body\.classList\.contains\("midi-out"\)/, + "capabilities should carry the flag the game can gate on"); + assert.match(webShell, /id="midi-out"/); + assert.match(webShell, /onSignal: flashMidiLamp/, "the lamp follows real sends"); + assert.match(webShell, /classList\.add\("midi-out"\)/); +}); + test("the web shell keeps midi opt-in so it cannot silence the bank by surprise", () => { assert.match(webShell, /import createOskiewarMidi from "\/oskiewar-midi\.mjs"/); assert.match(webShell, /localStorage\.getItem\("oskiewar-midi"\)/); -- 2.51.2