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