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"\)/);