From 470ba755e6fd3c6b6a6884c527d59ebd394697c9 Mon Sep 17 00:00:00 2001
From: Daniel Joaquin Trujillo
<54636507+danieljtrujillo@users.noreply.github.com>
Date: Sun, 30 Aug 2026 08:57:37 -0700
Subject: [PATCH 1/2] Embedded build: the cockpit runs inside a host app over
http
A second build target, dist-embed/, serves the same cockpit from a host
application (theDAW's SWAY tab) while the desktop app stays untouched.
`npm run build:renderer:embed` bundles src/renderer/embed.js, which installs a
browser bridge before app.js runs; every window.swaycommand call site works
unchanged. The embed differs from dist/ in four ways only: the entry point, a
for the host's mount path, a CSP without the Electron-only
frame-src gan:, and templates/docs copied in because there is no main process
to read them off disk.
The host channel (src/renderer/host/host-channel.js) relays MIDI bytes, audio
analysis and tab visibility over postMessage. Windows lets one process hold a
MIDI input, so embedded mode never opens the hardware itself; relayed bytes go
through the same decode path as the wire. Host analysis frames enter audio.js
as the raw read and fall through the same AGC, smoothing and beat detection,
decaying back to the local analyser after 500 ms of silence.
Three boot fixes found on the way:
- requestMIDIAccess() never settles until Chromium's permission prompt is
answered. Awaiting it bare left the blast door locked with #boot-status
frozen and nothing logged. Bounded to 3 s in midi.js, 6 s per check in
runDoctor(), with a late grant still picked up by onstatechange.
- `available` reported false in relayed mode, so the splash said WebMIDI was
unavailable while relayed MIDI was audibly playing.
- The AudioContext built at boot starts suspended under autoplay policy. The
ENTER click resumes it; without that the analyser read silence and every
scene rendered flat.
Plugin postMessage now checks e.source against the gan frame's contentWindow.
Embedded, this window has a parent and siblings that can also post to it.
---
.gitignore | 3 +
package.json | 4 +-
scripts/build-renderer.js | 191 ++++++++++--
src/renderer/app.js | 52 +++-
src/renderer/embed.js | 8 +
src/renderer/engine/audio.js | 17 +-
src/renderer/host/bridge.js | 30 ++
src/renderer/host/browser-bridge.js | 455 ++++++++++++++++++++++++++++
src/renderer/host/host-channel.js | 150 +++++++++
src/renderer/midi/midi.js | 45 ++-
10 files changed, 924 insertions(+), 31 deletions(-)
create mode 100644 src/renderer/embed.js
create mode 100644 src/renderer/host/bridge.js
create mode 100644 src/renderer/host/browser-bridge.js
create mode 100644 src/renderer/host/host-channel.js
diff --git a/.gitignore b/.gitignore
index 10fde12..c4c5733 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ build/icon.png
# tooling scratch
.playwright-mcp/
+
+# Embedded build consumed by a host app (npm run build:renderer:embed)
+dist-embed/
diff --git a/package.json b/package.json
index 72603d6..5fe0452 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,9 @@
"start": "npm run build:renderer && electron .",
"dist:win": "npm run build:icon && npm run build:renderer && electron-builder --win",
"dist:mac": "npm run build:icon && npm run build:renderer && electron-builder --mac",
- "dist:linux": "npm run build:icon && npm run build:renderer && electron-builder --linux"
+ "dist:linux": "npm run build:icon && npm run build:renderer && electron-builder --linux",
+ "build:renderer:embed": "node scripts/build-renderer.js --embed --base=/sway-app/",
+ "build:all": "npm run build:renderer && npm run build:renderer:embed"
},
"devDependencies": {
"electron": "^43.4.1",
diff --git a/scripts/build-renderer.js b/scripts/build-renderer.js
index 68a68f8..6f0ef36 100644
--- a/scripts/build-renderer.js
+++ b/scripts/build-renderer.js
@@ -1,44 +1,201 @@
-// Bundle the renderer (app.js + three.js) into dist/ and copy static files.
+// Bundle the renderer into dist/ (the Electron app) or dist-embed/ (a static
+// bundle a host application serves over http) and copy the static files each
+// one needs.
+//
+// node scripts/build-renderer.js -> dist/
+// node scripts/build-renderer.js --embed --base=/sway-app/ -> dist-embed/
+//
+// The embed target exists so the same cockpit can run inside theDAW while
+// SwayCommand stays a standalone desktop application. It differs from the
+// desktop bundle in four ways and no others:
+//
+// - entry point src/renderer/embed.js, which installs the browser bridge
+// (src/renderer/host/) before app.js runs. app.js itself is untouched.
+// - a so every relative asset resolves under the host's mount
+// path rather than the host's root.
+// - a CSP without `frame-src gan:` (an Electron-only scheme) so plugin
+// surfaces served over http still frame.
+// - templates, docs and a prebuilt docs index copied in, because there is no
+// main process to read them off disk.
+//
+// dist-embed/ is deliberately NOT under dist/: electron-builder packs
+// `dist/**/*` into the asar, and a nested copy would ship the embed bundle
+// inside the desktop app for no reason.
'use strict';
const path = require('node:path');
const fs = require('node:fs');
+const { execFileSync } = require('node:child_process');
const esbuild = require('esbuild');
const root = path.join(__dirname, '..');
-const dist = path.join(root, 'dist');
+
+const argv = process.argv.slice(2);
+const EMBED = argv.includes('--embed');
+const baseArg = argv.find((a) => a.startsWith('--base='));
+const BASE = baseArg ? baseArg.slice('--base='.length) : '/';
+
+const outDir = path.join(root, EMBED ? 'dist-embed' : 'dist');
+
+/** Documents the in-app viewer offers, in order. Mirrors DOC_ORDER in main.js. */
+const DOC_ORDER = [
+ 'README.md',
+ 'docs/INDEX.md',
+ 'docs/OVERVIEW.md',
+ 'docs/INSTALLATION.md',
+ 'docs/DOCTOR.md',
+ 'docs/STUDIO.md',
+ 'docs/SYNTH.md',
+ 'docs/TROUBLESHOOTING.md',
+ 'docs/ARCHITECTURE.md',
+ 'docs/ENGINE.md',
+ 'docs/SCENE_CONTRACT.md',
+ 'docs/PROJECTS.md',
+ 'docs/MIDI.md',
+ 'docs/AUDIO.md',
+ 'docs/SWAY_INTEGRATION.md',
+ 'docs/BUILD.md',
+ 'docs/ENVIRONMENT.md',
+ 'docs/RESEARCH.md',
+];
+
+function copyDir(src, dst) {
+ if (!fs.existsSync(src)) return;
+ fs.mkdirSync(dst, { recursive: true });
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+ const from = path.join(src, entry.name);
+ const to = path.join(dst, entry.name);
+ if (entry.isDirectory()) copyDir(from, to);
+ else fs.copyFileSync(from, to);
+ }
+}
+
+/** Package identity, stamped into the bundle and into build.json. */
+function buildInfo() {
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
+ let sha = 'unknown';
+ try {
+ sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root }).toString().trim();
+ } catch {
+ /* not a git checkout */
+ }
+ return {
+ name: pkg.productName || pkg.name,
+ version: pkg.version,
+ sha,
+ builtAt: new Date().toISOString(),
+ };
+}
+
+/** The embed's index.html: base href, and a CSP without Electron-only schemes. */
+function embedHtml(html, info) {
+ let out = html;
+
+ // Every asset load is relative, so one repoints the whole document at
+ // the host's mount path. Without it the AudioWorklet and the fonts resolve
+ // against the host's root and 404.
+ out = out.replace(/
/i, `\n `);
+
+ // gan: is an Electron custom scheme; over http a plugin surface is a normal
+ // same-origin URL.
+ out = out.replace(/frame-src gan:/, "frame-src 'self'");
+
+ // The embed entry replaces the desktop bundle.
+ out = out.replace('./renderer.bundle.js', './embed.bundle.js');
+
+ // A marker for support and for the host's version display.
+ out = out.replace(
+ /<\/head>/i,
+ ` \n`,
+ );
+ return out;
+}
async function main() {
- fs.mkdirSync(dist, { recursive: true });
+ fs.rmSync(outDir, { recursive: true, force: true });
+ fs.mkdirSync(outDir, { recursive: true });
+ const info = buildInfo();
await esbuild.build({
- entryPoints: [path.join(root, 'src', 'renderer', 'app.js')],
+ entryPoints: [path.join(root, 'src', 'renderer', EMBED ? 'embed.js' : 'app.js')],
bundle: true,
format: 'iife',
platform: 'browser',
target: 'chrome140',
- outfile: path.join(dist, 'renderer.bundle.js'),
- minify: false,
+ outfile: path.join(outDir, EMBED ? 'embed.bundle.js' : 'renderer.bundle.js'),
+ minify: EMBED,
sourcemap: false,
logLevel: 'info',
+ // Keep the third-party notices esbuild would otherwise drop; the bundle
+ // carries Apache-2.0 and MIT derived work.
+ legalComments: 'eof',
+ define: {
+ // Build identity, read by the browser bridge's info(). Defined for BOTH
+ // targets: esbuild does not fail a build on a missing define, it emits
+ // the bare identifier and the page throws ReferenceError at runtime.
+ __SWAY_EMBED_BUILD__: JSON.stringify(info),
+ },
});
- for (const f of ['index.html', 'styles.css']) {
- fs.copyFileSync(path.join(root, 'src', 'renderer', f), path.join(dist, f));
- }
+ const rendererDir = path.join(root, 'src', 'renderer');
+ const html = fs.readFileSync(path.join(rendererDir, 'index.html'), 'utf8');
+ fs.writeFileSync(
+ path.join(outDir, 'index.html'),
+ EMBED ? embedHtml(html, info) : html,
+ 'utf8',
+ );
+ fs.copyFileSync(path.join(rendererDir, 'styles.css'), path.join(outDir, 'styles.css'));
+
// The AudioWorklet module loads by URL beside the bundle (CSP: self only).
- fs.copyFileSync(path.join(root, 'src', 'renderer', 'audio', 'dsp.worklet.js'), path.join(dist, 'dsp.worklet.js'));
+ fs.copyFileSync(
+ path.join(rendererDir, 'audio', 'dsp.worklet.js'),
+ path.join(outDir, 'dsp.worklet.js'),
+ );
+
// Bundled display font — the CSP has no font-src, so remote fonts cannot load.
- const fontsSrc = path.join(root, 'src', 'renderer', 'fonts');
- if (fs.existsSync(fontsSrc)) {
- const fontsDist = path.join(dist, 'fonts');
- fs.mkdirSync(fontsDist, { recursive: true });
- for (const f of fs.readdirSync(fontsSrc)) {
- fs.copyFileSync(path.join(fontsSrc, f), path.join(fontsDist, f));
+ copyDir(path.join(rendererDir, 'fonts'), path.join(outDir, 'fonts'));
+
+ if (EMBED) {
+ // No main process over http, so what it used to read off disk ships inside
+ // the bundle directory instead.
+ copyDir(path.join(root, 'projects', 'templates'), path.join(outDir, 'templates'));
+
+ const docsOut = path.join(outDir, 'docs');
+ const index = [];
+ for (const rel of DOC_ORDER) {
+ const abs = path.join(root, rel);
+ if (!fs.existsSync(abs)) continue;
+ const dest = path.join(docsOut, rel);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(abs, dest);
+ let title = path.basename(rel, '.md');
+ const head = fs.readFileSync(abs, 'utf8').slice(0, 4096);
+ const m = /^#\s+(.+)$/m.exec(head);
+ if (m) title = m[1].trim();
+ index.push({ id: rel, title });
+ }
+ fs.writeFileSync(
+ path.join(outDir, 'docs-index.json'),
+ `${JSON.stringify(index, null, 2)}\n`,
+ 'utf8',
+ );
+
+ for (const f of ['LICENSE', 'THIRD-PARTY-NOTICES.md']) {
+ const abs = path.join(root, f);
+ if (fs.existsSync(abs)) fs.copyFileSync(abs, path.join(outDir, f));
}
+
+ // Provenance, so a stale artifact in the host reads as a version rather
+ // than a mystery cockpit that is missing a scene.
+ fs.writeFileSync(
+ path.join(outDir, 'build.json'),
+ `${JSON.stringify({ ...info, base: BASE, target: 'embed' }, null, 2)}\n`,
+ 'utf8',
+ );
}
- console.log('[build] renderer bundled to dist/');
+
+ console.log(`[build] renderer bundled to ${path.relative(root, outDir)}/`);
}
main().catch((err) => {
diff --git a/src/renderer/app.js b/src/renderer/app.js
index da86071..c9b1a38 100644
--- a/src/renderer/app.js
+++ b/src/renderer/app.js
@@ -183,11 +183,44 @@ async function rendererChecks() {
return checks;
}
+// A check that throws or never settles must not lock the blast door. Measured:
+// navigator.requestMIDIAccess() inside rendererChecks() does not settle until
+// the user answers Chromium's permission prompt, so awaiting it bare left
+// #btn-enter disabled - it ships disabled in index.html - with #boot-status
+// frozen on 'Checking your system' and nothing logged. That reads as a dead
+// application. doctor.run() has the same exposure on the desktop, where it
+// shells out to pnputil / Get-PnpDevice / lsusb.
+function settleCheck(promise, fallback, label) {
+ const guard = new Promise((resolve) => {
+ setTimeout(() => resolve({ __timedOut: true }), 6000);
+ });
+ return Promise.race([
+ Promise.resolve(promise).catch((err) => {
+ console.warn(`[doctor] ${label} check failed:`, err);
+ return fallback;
+ }),
+ guard,
+ ]).then((value) => {
+ if (value && value.__timedOut) {
+ console.warn(`[doctor] ${label} check timed out after 6s`);
+ return fallback;
+ }
+ return value;
+ });
+}
+
async function runDoctor() {
$('#boot-status').textContent = 'Checking your system…';
- const [main, local] = await Promise.all([window.swaycommand.doctor.run(), rendererChecks()]);
- state.checks = [...main, ...local];
- renderChecks();
+ const [main, local] = await Promise.all([
+ settleCheck(window.swaycommand.doctor.run(), [], 'system'),
+ settleCheck(rendererChecks(), [], 'renderer'),
+ ]);
+ state.checks = [...(main || []), ...(local || [])];
+ try {
+ renderChecks();
+ } catch (err) {
+ console.warn('[doctor] renderChecks failed:', err);
+ }
const worst = state.checks.some((c) => c.status === 'fail')
? 'fail'
@@ -258,6 +291,14 @@ function enterCockpit() {
closeModal('system');
if (state.entered) return;
state.entered = true;
+ // createAudioEngine() builds its AudioContext at boot, which autoplay policy
+ // starts suspended (measured: repeated 'AudioContext was not allowed to
+ // start' warnings). This click is the one user gesture guaranteed to have
+ // happened, so resume here or the analyser reads silence and every scene
+ // renders flat.
+ if (state.audio && typeof state.audio.resume === 'function') {
+ Promise.resolve(state.audio.resume()).catch(() => {});
+ }
const door = $('#door');
door.classList.add('open');
setTimeout(() => door.classList.add('gone'), 1000);
@@ -1365,6 +1406,11 @@ function wirePlugins() {
// source (gan::[:axis]) and touches the assignment rail.
let lastTouch = '';
window.addEventListener('message', (e) => {
+ // Only the plugin surface may drive plugin routes. Embedded, this window
+ // has a parent and siblings that can also post to it, so identify the
+ // sender rather than trusting any message that looks right.
+ const ganFrame = $('#gan-frame');
+ if (!ganFrame || e.source !== ganFrame.contentWindow) return;
const d = e.data;
if (!d || d.type !== 'updateValue' || !plugins.activeId || typeof d.id !== 'string') return;
const base = `gan:${plugins.activeId}:${d.id}`;
diff --git a/src/renderer/embed.js b/src/renderer/embed.js
new file mode 100644
index 0000000..5327ed2
--- /dev/null
+++ b/src/renderer/embed.js
@@ -0,0 +1,8 @@
+// Entry point for the embedded build (dist-embed), served over http by a host
+// application instead of loaded from Electron.
+//
+// The only difference from the desktop entry is that the host bridge is
+// installed first. app.js itself is imported unchanged, and every one of its
+// window.swaycommand call sites works exactly as it does on the desktop.
+import './host/bridge.js';
+import './app.js';
diff --git a/src/renderer/engine/audio.js b/src/renderer/engine/audio.js
index cbca0e9..bdef0e9 100644
--- a/src/renderer/engine/audio.js
+++ b/src/renderer/engine/audio.js
@@ -1,4 +1,5 @@
// Audio analysis — WebAudio FFT split into smoothed bands with slow auto-gain
+import { hostAudio } from '../host/host-channel.js';
// and a bass-onset beat detector (the Lasp/Akvj role in web form).
//
// "Just works" guarantee: if no microphone/line-in is available or permission
@@ -186,11 +187,17 @@ export async function createAudioEngine() {
}
function update(dt) {
- analyser.getByteFrequencyData(freqData);
-
- const rawBass = bandAvg(RANGES.bass);
- const rawMid = bandAvg(RANGES.mid);
- const rawHigh = bandAvg(RANGES.high);
+ // Embedded with the host as the source: theDAW analyses its own master and
+ // posts bands here. Treat them as the raw read and fall through to the same
+ // AGC, smoothing and beat detection, so nothing downstream can tell the
+ // difference. A stale frame (host paused, tab hidden) decays to the local
+ // analyser rather than freezing the visuals on the last value.
+ const hostFresh = hostAudio.active && performance.now() - hostAudio.t < 500;
+ if (!hostFresh) analyser.getByteFrequencyData(freqData);
+
+ const rawBass = hostFresh ? hostAudio.bass : bandAvg(RANGES.bass);
+ const rawMid = hostFresh ? hostAudio.mid : bandAvg(RANGES.mid);
+ const rawHigh = hostFresh ? hostAudio.high : bandAvg(RANGES.high);
const rawLevel = rawBass * 0.5 + rawMid * 0.35 + rawHigh * 0.15;
// slow AGC so quiet rooms and loud rigs both land in 0..1
diff --git a/src/renderer/host/bridge.js b/src/renderer/host/bridge.js
new file mode 100644
index 0000000..30e91fc
--- /dev/null
+++ b/src/renderer/host/bridge.js
@@ -0,0 +1,30 @@
+// Decide, once and for all, which `window.swaycommand` the cockpit talks to.
+//
+// This module is imported FIRST by src/renderer/embed.js, ahead of app.js, and
+// its only job is a top-level side effect. esbuild emits imported modules in
+// dependency order and evaluates them before the importer's own body, so by the
+// time app.js's `main()` is even defined the decision is made. That matters:
+// main()'s first statement is `await window.swaycommand.info()`.
+//
+// The discriminator is the PRESENCE of window.swaycommand and nothing else.
+// In Electron, preload.js installs the real contextBridge object at
+// document-start, so this file is a no-op there and the desktop app is
+// completely untouched. Served over http -- theDAW's SWAY tab, or any plain
+// browser -- there is no preload, so the browser adapter stands in.
+//
+// Do NOT sniff `window.parent !== window`: SwayCommand may legitimately be
+// framed inside its own shell. Do NOT sniff the user agent: Electron's UA
+// contains "Chrome".
+
+import { createBrowserBridge } from './browser-bridge.js';
+import { installHostChannel } from './host-channel.js';
+
+export const NATIVE = typeof window.swaycommand !== 'undefined';
+
+if (!NATIVE) {
+ window.swaycommand = createBrowserBridge();
+ // The embedding host (theDAW) relays MIDI, audio analysis and visibility over
+ // postMessage. Installed here, before app.js runs, so no frame is missed
+ // during boot.
+ installHostChannel();
+}
diff --git a/src/renderer/host/browser-bridge.js b/src/renderer/host/browser-bridge.js
new file mode 100644
index 0000000..18fa7c8
--- /dev/null
+++ b/src/renderer/host/browser-bridge.js
@@ -0,0 +1,455 @@
+// The browser implementation of the `window.swaycommand` surface.
+//
+// In Electron, preload.js installs that object at document-start over
+// contextBridge. When the cockpit is served over http instead -- theDAW embeds
+// it in an iframe at /sway-app/ -- there is no preload and no ipcRenderer, so
+// this stands in with the same 8 namespaces and the same method signatures.
+// Not one of the ~42 call sites in the renderer changes.
+//
+// Two rules govern everything below.
+//
+// 1. NOTHING MAY REJECT. app.js ends with `main().catch(err => document.body
+// .innerHTML = )`, and main() awaits this bridge about a
+// dozen times. One rejected promise replaces the entire cockpit with a
+// stack dump inside theDAW's tab. Every method resolves, degraded if it
+// must, and reports trouble through its return shape.
+//
+// 2. Shapes are copied from src/main/*, not invented. `project.readTemplate`
+// returning `{doc, path, dir, warnings}` and `docs.list` returning
+// `[{id, title}]` are contracts the UI already destructures.
+//
+// Capabilities that genuinely need a desktop process -- USB enumeration, the
+// DFU driver installer, WASAPI loopback -- report themselves unsupported here
+// rather than pretending. The desktop app remains the place for those.
+
+const BUILD = typeof __SWAY_EMBED_BUILD__ !== 'undefined' ? __SWAY_EMBED_BUILD__ : {};
+
+const SETTINGS_KEY = 'sway:settings';
+const RECENTS_KEY = 'sway:recents';
+const PROJECTS_KEY = 'sway:projects';
+
+/** Files handed to us by a picker or a drop, addressed by a synthetic path. */
+const fileRegistry = new Map();
+let fileSeq = 0;
+
+/** theDAW's API origin. Same-origin by construction, so a bare path works. */
+const api = (path) => path;
+
+/** Fetch JSON from theDAW, resolving to null instead of throwing. */
+async function apiJson(path, init) {
+ try {
+ const res = await fetch(api(path), init);
+ if (!res.ok) return null;
+ return await res.json();
+ } catch {
+ return null;
+ }
+}
+
+function readJson(key, fallback) {
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return fallback;
+ const parsed = JSON.parse(raw);
+ return parsed == null ? fallback : parsed;
+ } catch {
+ // A private window, cleared site data, or a browser blocking storage.
+ return fallback;
+ }
+}
+
+function writeJson(key, value) {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// --- settings ---------------------------------------------------------------
+// Mirrors main.js's readSettings/writeSettings, including its shallow-merge
+// semantics: writeSettings(patch) merges and returns the whole object.
+
+function readSettings() {
+ return readJson(SETTINGS_KEY, {});
+}
+
+function writeSettings(patch) {
+ const next = { ...readSettings(), ...(patch || {}) };
+ writeJson(SETTINGS_KEY, next);
+ return next;
+}
+
+// --- templates and docs -----------------------------------------------------
+// Both ship inside the embed bundle, so they need no backend at all. The build
+// copies projects/templates/ and the DOC_ORDER markdown into dist-embed/, plus
+// a prebuilt docs-index.json so listing costs one request.
+
+async function fetchStatic(relPath) {
+ // Relative to the document base (), so this works
+ // under any mount point without knowing it.
+ const res = await fetch(`./${relPath}`);
+ if (!res.ok) throw new Error(`${relPath}: HTTP ${res.status}`);
+ return res;
+}
+
+async function templateIndex() {
+ try {
+ const res = await fetchStatic('templates/index.json');
+ const idx = await res.json();
+ return Array.isArray(idx.order) ? idx.order : [];
+ } catch {
+ return [];
+ }
+}
+
+async function listTemplates() {
+ const order = await templateIndex();
+ const out = [];
+ for (const id of order) {
+ try {
+ const res = await fetchStatic(`templates/${id}.sway`);
+ const doc = await res.json();
+ const meta = (doc.project && doc.project.meta) || {};
+ out.push({
+ id,
+ name: meta.name || id,
+ description: meta.description || '',
+ vibe: meta.vibe || '',
+ bpmHint: meta.bpmHint || 0,
+ palette: (doc.project && doc.project.palette) || [],
+ });
+ } catch (err) {
+ console.error(`[templates] failed to load ${id}:`, err.message);
+ }
+ }
+ return out;
+}
+
+async function readTemplate(id) {
+ const order = await templateIndex();
+ if (!order.includes(id)) throw new Error(`Unknown template: ${id}`);
+ const res = await fetchStatic(`templates/${id}.sway`);
+ const raw = await res.json();
+ // validateProject lives in src/shared and is bundled; the host module imports
+ // it lazily to keep this file free of a hard dependency cycle.
+ const { validateProject } = await import('../../shared/swayproject.js');
+ const { doc, warnings } = validateProject(raw);
+ return { doc, path: null, dir: null, warnings };
+}
+
+async function listDocs() {
+ try {
+ const res = await fetchStatic('docs-index.json');
+ const list = await res.json();
+ return Array.isArray(list) ? list : [];
+ } catch {
+ return [];
+ }
+}
+
+async function readDoc(id) {
+ const list = await listDocs();
+ if (!list.some((d) => d.id === id)) throw new Error(`Unknown document: ${id}`);
+ const res = await fetchStatic(`docs/${id}`);
+ return await res.text();
+}
+
+// --- files ------------------------------------------------------------------
+// A browser has no real paths. Files reach us as File objects (a picker or a
+// drop) and are addressed by a synthetic `swaydrop:` path. The EXTENSION is
+// load-bearing: app.js regexes it to decide audio vs .gan, so it is preserved.
+
+function registerFile(file) {
+ const id = ++fileSeq;
+ const path = `swaydrop:/${id}/${file.name}`;
+ fileRegistry.set(path, file);
+ return path;
+}
+
+function pickFiles({ multiple = true, accept = '' } = {}) {
+ return new Promise((resolve) => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = multiple;
+ if (accept) input.accept = accept;
+ input.style.display = 'none';
+ document.body.appendChild(input);
+ let settled = false;
+ const done = (paths) => {
+ if (settled) return;
+ settled = true;
+ input.remove();
+ resolve(paths);
+ };
+ input.addEventListener('change', () => {
+ done(Array.from(input.files || []).map(registerFile));
+ });
+ // A cancelled picker fires no 'change' in most browsers. Resolve empty on
+ // the next focus so a cancel never leaves the caller awaiting forever.
+ window.addEventListener(
+ 'focus',
+ () => setTimeout(() => done([]), 400),
+ { once: true },
+ );
+ input.click();
+ });
+}
+
+async function readAudio(filePath) {
+ const file = fileRegistry.get(filePath);
+ if (file) {
+ return new Uint8Array(await file.arrayBuffer());
+ }
+ // A path from a saved project: ask theDAW, which also transcodes formats
+ // Chromium cannot decode.
+ try {
+ const res = await fetch(api(`/api/project/clip-audio?path=${encodeURIComponent(filePath)}`));
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return new Uint8Array(await res.arrayBuffer());
+ } catch (err) {
+ throw new Error(`Cannot read ${filePath}: ${err.message}`);
+ }
+}
+
+async function statAudio(filePath) {
+ const file = fileRegistry.get(filePath);
+ if (!file) return { size: 0, sha256: '', missing: true };
+ const buf = await file.arrayBuffer();
+ const digest = await crypto.subtle.digest('SHA-256', buf);
+ const sha256 = Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
+ return { size: file.size, sha256, missing: false };
+}
+
+// --- projects ---------------------------------------------------------------
+// Saved into localStorage under a virtual path, and additionally offered as a
+// download so a project can leave the browser. Opening accepts a real file.
+
+function projectStore() {
+ return readJson(PROJECTS_KEY, {});
+}
+
+function pushRecent(path, name) {
+ const list = readJson(RECENTS_KEY, []).filter((r) => r && r.path !== path);
+ list.unshift({ path, name });
+ writeJson(RECENTS_KEY, list.slice(0, 10));
+}
+
+async function openDialog() {
+ const paths = await pickFiles({ multiple: false, accept: '.sway,application/json' });
+ return paths[0] || null;
+}
+
+async function saveDialog(name) {
+ const safe = String(name || 'project').replace(/[\\/:*?"<>|]/g, '_');
+ const withExt = safe.toLowerCase().endsWith('.sway') ? safe : `${safe}.sway`;
+ return `swayproject:/${withExt}`;
+}
+
+async function readProject(filePath) {
+ const { validateProject } = await import('../../shared/swayproject.js');
+ const file = fileRegistry.get(filePath);
+ let raw;
+ if (file) {
+ raw = JSON.parse(await file.text());
+ } else {
+ const stored = projectStore()[filePath];
+ if (!stored) throw new Error(`No such project: ${filePath}`);
+ raw = stored;
+ }
+ const { doc, warnings } = validateProject(raw);
+ pushRecent(filePath, filePath.split('/').pop() || filePath);
+ return { doc, path: filePath, dir: null, warnings };
+}
+
+async function writeProject(filePath, doc) {
+ const store = projectStore();
+ store[filePath] = doc;
+ const ok = writeJson(PROJECTS_KEY, store);
+ pushRecent(filePath, filePath.split('/').pop() || filePath);
+ // Also hand the user a real file, since browser storage is not a place to
+ // keep work that matters.
+ try {
+ const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filePath.split('/').pop() || 'project.sway';
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
+ } catch {
+ /* the localStorage copy above is the durable half */
+ }
+ return {
+ path: filePath,
+ warnings: ok ? [] : ['Browser storage is full; the downloaded copy is the only one.'],
+ };
+}
+
+// --- plugins and VST, through theDAW ----------------------------------------
+
+async function listGan() {
+ const data = await apiJson('/api/plugin/list');
+ if (!data) return [];
+ const items = Array.isArray(data) ? data : data.plugins || [];
+ return items.map((p) => ({
+ id: p.id,
+ name: p.name || p.id,
+ url: p.entry_url || p.url || '',
+ manifest: p.manifest || p,
+ }));
+}
+
+async function openGan(idOrPath) {
+ const data = await apiJson('/api/plugin/open', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(
+ typeof idOrPath === 'string' && idOrPath.includes('/')
+ ? { path: idOrPath }
+ : { id: idOrPath },
+ ),
+ });
+ if (!data) return null;
+ return {
+ id: data.id || idOrPath,
+ name: data.name || data.id || 'plugin',
+ url: data.entry_url || data.url || '',
+ manifest: data.manifest || data,
+ };
+}
+
+async function vstStatus() {
+ const data = await apiJson('/api/vst/scan');
+ if (!data) {
+ return { ok: false, detail: 'theDAW VST host is not reachable.', plugins: [] };
+ }
+ const plugins = Array.isArray(data) ? data : data.plugins || [];
+ return { ok: true, detail: 'pedalboard via theDAW', python: 'theDAW', plugins };
+}
+
+// --- the surface ------------------------------------------------------------
+
+export function createBrowserBridge() {
+ const unsupported = (what) => async () => ({
+ ok: false,
+ detail: `${what} is available in the SwayCommand desktop app.`,
+ });
+
+ return {
+ info: async () => ({
+ name: BUILD.name || 'SwayCommand',
+ version: BUILD.version || '0.0.0',
+ platform: 'browser',
+ arch: 'wasm',
+ // Lets anything that cares tell the two hosts apart without sniffing.
+ mode: 'browser',
+ host: 'theDAW',
+ build: BUILD,
+ }),
+
+ plugins: {
+ pickGan: async () => (await pickFiles({ multiple: false, accept: '.gan' }))[0] || null,
+ openGan,
+ listGan,
+ removeGan: async () => ({ ok: false }),
+ },
+
+ vst: {
+ status: vstStatus,
+ setPython: unsupported('Choosing a Python interpreter'),
+ pickPython: unsupported('Choosing a Python interpreter'),
+ scan: vstStatus,
+ params: async (pluginPath, state) =>
+ (await apiJson('/api/vst/load', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ path: pluginPath, state }),
+ })) || { ok: false, params: [] },
+ render: async () => ({
+ ok: false,
+ detail: 'Render through theDAW’s MIX chain while the cockpit is embedded.',
+ }),
+ editor: async (pluginPath) =>
+ (await apiJson('/api/vst/open-editor', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ path: pluginPath }),
+ })) || { ok: false, detail: 'theDAW VST host is not reachable.' },
+ },
+
+ doctor: {
+ // Two honest rows. The renderer's own checks (WebGL2, Web MIDI ports,
+ // audio inputs) run alongside these and answer the questions that
+ // actually matter for playing.
+ run: async () => [
+ {
+ id: 'platform',
+ label: 'Running inside theDAW',
+ status: 'ok',
+ detail: 'The cockpit is embedded. theDAW supplies MIDI and audio.',
+ },
+ {
+ id: 'desktop-only',
+ label: 'Hardware checks',
+ status: 'info',
+ detail:
+ 'USB detection, driver installation and system-audio capture run in the SwayCommand desktop app.',
+ },
+ ],
+ fix: async () => ({ ok: false, detail: 'Fixes run in the desktop app.' }),
+ onFixProgress: () => () => {},
+ },
+
+ project: {
+ openDialog,
+ saveDialog,
+ read: readProject,
+ write: writeProject,
+ recent: async () => readJson(RECENTS_KEY, []),
+ templates: listTemplates,
+ readTemplate,
+ },
+
+ docs: { list: listDocs, read: readDoc },
+
+ files: {
+ pickAudio: () => pickFiles({ multiple: true, accept: 'audio/*' }),
+ readAudio,
+ statAudio,
+ // Synchronous by contract (app.js calls it inline during a drop).
+ pathOf: (file) => {
+ try {
+ return registerFile(file);
+ } catch {
+ return '';
+ }
+ },
+ },
+
+ platform: {
+ systemAudio: async () => ({
+ supported: false,
+ detail:
+ 'Inside theDAW, choose "theDAW master" as the audio source to make the visuals follow what you are making, or pick a hardware input. System-audio capture is a desktop-app capability.',
+ }),
+ },
+
+ settings: {
+ get: async () => readSettings(),
+ set: async (patch) => writeSettings(patch),
+ },
+
+ openExternal: async (url) => {
+ try {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ return { ok: true };
+ } catch {
+ return { ok: false };
+ }
+ },
+ };
+}
diff --git a/src/renderer/host/host-channel.js b/src/renderer/host/host-channel.js
new file mode 100644
index 0000000..e915e55
--- /dev/null
+++ b/src/renderer/host/host-channel.js
@@ -0,0 +1,150 @@
+// The postMessage channel to the embedding host (theDAW's SWAY tab).
+//
+// theDAW owns the only navigator.requestMIDIAccess() in its renderer, and
+// Windows lets exactly one process hold a MIDI input, so the cockpit must not
+// open the hardware itself when embedded -- it would either steal the port from
+// theDAW or report PORT BUSY. Instead theDAW relays raw MIDI bytes here and the
+// cockpit's own decoding (factory map, learned overrides, pad channels) applies
+// to them unchanged.
+//
+// The same channel carries audio analysis (so the visuals follow whatever
+// theDAW is playing) and a visibility signal (so a hidden tab stops rendering
+// instead of burning the GPU for the rest of the session).
+//
+// Everything here is inert in the desktop app: bridge.js only installs it when
+// there is no Electron preload.
+
+const PROTOCOL = 1;
+
+/** Set by the host handshake; used to pin outbound posts. */
+let hostOrigin = null;
+
+/** Latest analysis frame from the host, consumed by engine/audio.js. */
+export const hostAudio = {
+ active: false,
+ bass: 0,
+ mid: 0,
+ high: 0,
+ level: 0,
+ t: 0,
+};
+
+/** Raw MIDI frames from the host, consumed by midi/midi.js in bridge mode. */
+const midiListeners = new Set();
+
+/** Visibility from the host, consumed by app.js to idle the render loop. */
+export const hostVisibility = { visible: true, known: false };
+const visibilityListeners = new Set();
+
+export function onHostMidi(cb) {
+ midiListeners.add(cb);
+ return () => midiListeners.delete(cb);
+}
+
+export function onHostVisibility(cb) {
+ visibilityListeners.add(cb);
+ return () => visibilityListeners.delete(cb);
+}
+
+/** True when a host is driving this cockpit (i.e. we are embedded). */
+export function hasHost() {
+ return hostOrigin !== null;
+}
+
+/**
+ * True when this document is framed by another page.
+ *
+ * Deliberately separate from hasHost(): the handshake completes a few frames
+ * after boot, but createMidi() runs DURING boot and has to decide there and
+ * then whether to open the hardware. Being framed is the synchronous, race-free
+ * signal that someone else owns the MIDI port -- and on Windows only one
+ * process may hold it.
+ */
+export function isFramed() {
+ try {
+ return window.parent !== window;
+ } catch {
+ // A cross-origin parent throws on access; that still means we are framed.
+ return true;
+ }
+}
+
+export function postToHost(payload) {
+ if (!window.parent || window.parent === window) return;
+ try {
+ window.parent.postMessage({ ...payload, v: PROTOCOL }, hostOrigin || '*');
+ } catch {
+ /* the host went away; nothing to do */
+ }
+}
+
+export function installHostChannel() {
+ window.addEventListener('message', (e) => {
+ // Only the embedder may drive this cockpit. Before the handshake we accept
+ // the parent frame alone; afterwards we also pin the origin it declared.
+ if (e.source !== window.parent) return;
+ if (hostOrigin !== null && e.origin !== hostOrigin) return;
+ const d = e.data;
+ if (!d || typeof d.type !== 'string' || !d.type.startsWith('sway/')) return;
+
+ switch (d.type) {
+ case 'sway/host-ready':
+ hostOrigin = e.origin;
+ break;
+
+ case 'sway/midi': {
+ if (!Array.isArray(d.data)) break;
+ for (const cb of midiListeners) {
+ try {
+ cb(d.data, d.t);
+ } catch (err) {
+ console.error('[host] midi listener threw:', err);
+ }
+ }
+ break;
+ }
+
+ case 'sway/analysis': {
+ hostAudio.active = true;
+ hostAudio.bass = Number(d.bass) || 0;
+ hostAudio.mid = Number(d.mid) || 0;
+ hostAudio.high = Number(d.high) || 0;
+ hostAudio.level = Number(d.volume) || 0;
+ hostAudio.t = performance.now();
+ break;
+ }
+
+ case 'sway/audio-source':
+ // 'host' = theDAW's master feeds the analysis frames above.
+ // 'input' = the cockpit opens its own input device, as it does
+ // standalone, so stop honouring stale host frames.
+ hostAudio.active = d.source === 'host';
+ break;
+
+ case 'sway/visibility': {
+ hostVisibility.visible = d.visible !== false;
+ hostVisibility.known = true;
+ for (const cb of visibilityListeners) {
+ try {
+ cb(hostVisibility.visible);
+ } catch (err) {
+ console.error('[host] visibility listener threw:', err);
+ }
+ }
+ break;
+ }
+
+ default:
+ break;
+ }
+ });
+
+ // Announce readiness. The host queues anything it wanted to send before this
+ // and flushes on receipt, so a race during boot loses nothing.
+ const announce = () => postToHost({ type: 'sway/ready', app: 'swaycommand' });
+ if (document.readyState === 'complete') announce();
+ else window.addEventListener('load', announce, { once: true });
+ // Also announce immediately: the host tolerates duplicates, and 'load' can be
+ // late behind the bundle's own work.
+ announce();
+}
diff --git a/src/renderer/midi/midi.js b/src/renderer/midi/midi.js
index 02fb5d5..a097a38 100644
--- a/src/renderer/midi/midi.js
+++ b/src/renderer/midi/midi.js
@@ -8,6 +8,7 @@
// * keeps a small monitor ring buffer for the HUD.
import { FACTORY_MAP, SWAY_PORT_NAME, createControlState } from './swaymap.js';
+import { isFramed, onHostMidi } from '../host/host-channel.js';
const MONITOR_SIZE = 14;
@@ -20,10 +21,26 @@ export async function createMidi({ onEvent } = {}) {
let learnTarget = null;
let learnResolve = null;
- const supported = typeof navigator.requestMIDIAccess === 'function';
- if (supported) {
+ // Embedded: the host owns the only MIDIAccess and relays raw bytes to us.
+ // Windows allows exactly ONE process to hold a MIDI input, so opening the
+ // port here would either take it away from the host or fail as PORT BUSY.
+ const relayed = isFramed();
+ const supported = relayed || typeof navigator.requestMIDIAccess === 'function';
+ if (!relayed && typeof navigator.requestMIDIAccess === 'function') {
try {
- access = await navigator.requestMIDIAccess({ sysex: false });
+ // requestMIDIAccess does not settle until the user answers Chromium's
+ // permission prompt - measured hanging indefinitely when it is never
+ // answered. Awaiting it bare wedges main(), leaving the blast door
+ // locked with no error anywhere. Give it a bounded wait and carry on
+ // without MIDI if it does not arrive; a late grant is picked up by the
+ // statechange handler below.
+ access = await Promise.race([
+ navigator.requestMIDIAccess({ sysex: false }),
+ new Promise((resolve) => setTimeout(() => resolve(null), 3000)),
+ ]);
+ if (!access) {
+ pushMonitor('MIDI permission not answered - continuing without it.');
+ }
} catch (err) {
console.warn('[midi] access denied/unavailable:', err.message);
}
@@ -182,7 +199,21 @@ export async function createMidi({ onEvent } = {}) {
control.portName = sway ? sway.name : targets.length ? targets.map((t) => t.name).join(', ') : null;
}
- if (access) {
+ if (relayed) {
+ // The host names the port so the link pill has something to show; every
+ // decode below is the hardware path, byte for byte.
+ control.connected = true;
+ control.isSway = true;
+ control.portName = 'theDAW (relayed)';
+ pushMonitor('Linked to the host application - MIDI is relayed.');
+ onHostMidi((data) => {
+ try {
+ handleMessage({ data }, 'theDAW');
+ } catch (err) {
+ console.error('[midi] relayed frame failed:', err);
+ }
+ });
+ } else if (access) {
rescan();
access.onstatechange = () => rescan(); // hot-plug / hot-unplug
}
@@ -192,7 +223,11 @@ export async function createMidi({ onEvent } = {}) {
monitor,
supported,
get available() {
- return !!access;
+ // Embedded (relayed) mode never opens its own MIDIAccess — the host
+ // relays raw bytes — so `access` alone made the splash report "WebMIDI
+ // unavailable" while relayed MIDI was audibly playing. The relay IS
+ // availability.
+ return relayed || !!access;
},
// Resolves when the next CC arrives; that CC becomes the binding.
learn(target) {
From 8b1fbdbdebf343d1ba90099c8440c6361f8d2cbd Mon Sep 17 00:00:00 2001
From: Daniel Joaquin Trujillo
<54636507+danieljtrujillo@users.noreply.github.com>
Date: Sun, 30 Aug 2026 09:41:37 -0700
Subject: [PATCH 2/2] README rebuilt around a rendered scene gallery, plus a
LICENSE file
The README now opens on a banner and a still from Miracle Mile, carries a
sixteen-scene gallery with a one-line mechanism note under each image, and
documents the parts that make the application an instrument rather than a
feature list: the cockpit as a line-art diagram plus a region table, the
recovered Sway factory map with its CC numbers, the target grammar every
assignable destination answers, and the timeline's track chains and sections.
Facts were re-checked against the source: sixteen scenes (five folded into the
scenes that own their subject last pass), eleven templates, 38 rack parameters
in five decks, 14 live track effect kinds, seven synth presets. The wormhole
row is gone from the controls table, since the wormhole is an element of Will I
Dream now and not a scene.
The images are generated, not captured. docs/media/gallery.plan.json is the
scene-harness plan behind every still, with the exact io snapshot each scene was
photographed under; docs/media/README.md carries the provenance table and the
regeneration command. Setup shots that only advance a scene into the state the
next shot photographs are prefixed with an underscore. Stills render at
1280x720 and are re-encoded to WebP at 480x270 through a canvas in the same
Electron runtime, which keeps the whole asset set at 472 KB.
Making that render in the in-app documentation viewer took three small changes.
markdown.js grows an image rule, placed ahead of the link rule that would
otherwise eat the bracket pair and leave a stray '!'; only the bundle's own
media directory resolves, so an external badge falls back to its alt text
rather than drawing a broken-image icon under `img-src 'self' data:`.
build-renderer.js copies docs/media/ to media/ beside index.html for both
targets, images only. Both title derivations (listDocs in main.js, the embed's
docs-index) fall back to a leading banner image's alt text, because the README
now has no H1: a wordmark image followed by the same word as a heading titles
the page twice.
LICENSE was missing while package.json, the README and the embed build all
declared MIT. It names Daniel Trujillo as the copyright holder and points at the
per-file upstream notices for the derived work.
---
LICENSE | 26 ++
README.md | 385 ++++++++++++++++++++++-------
docs/media/README.md | 51 ++++
docs/media/banner.webp | Bin 0 -> 59198 bytes
docs/media/gallery.plan.json | 347 ++++++++++++++++++++++++++
docs/media/hero.webp | Bin 0 -> 78834 bytes
docs/media/scenes/beams.webp | Bin 0 -> 13286 bytes
docs/media/scenes/chladni.webp | Bin 0 -> 18942 bytes
docs/media/scenes/cymatic.webp | Bin 0 -> 10052 bytes
docs/media/scenes/ferrofluid.webp | Bin 0 -> 8892 bytes
docs/media/scenes/lattice.webp | Bin 0 -> 26110 bytes
docs/media/scenes/mandelbulb.webp | Bin 0 -> 13772 bytes
docs/media/scenes/miraclemile.webp | Bin 0 -> 11404 bytes
docs/media/scenes/naturestomb.webp | Bin 0 -> 9914 bytes
docs/media/scenes/nebula.webp | Bin 0 -> 29924 bytes
docs/media/scenes/ribbons.webp | Bin 0 -> 5026 bytes
docs/media/scenes/spectra.webp | Bin 0 -> 5746 bytes
docs/media/scenes/swarm.webp | Bin 0 -> 33728 bytes
docs/media/scenes/valley.webp | Bin 0 -> 6894 bytes
docs/media/scenes/vjshader.webp | Bin 0 -> 21788 bytes
docs/media/scenes/voxels.webp | Bin 0 -> 9132 bytes
docs/media/scenes/willidream.webp | Bin 0 -> 52470 bytes
scripts/build-renderer.js | 21 +-
src/main/main.js | 5 +-
src/renderer/markdown.js | 20 +-
src/renderer/styles.css | 6 +
26 files changed, 767 insertions(+), 94 deletions(-)
create mode 100644 LICENSE
create mode 100644 docs/media/README.md
create mode 100644 docs/media/banner.webp
create mode 100644 docs/media/gallery.plan.json
create mode 100644 docs/media/hero.webp
create mode 100644 docs/media/scenes/beams.webp
create mode 100644 docs/media/scenes/chladni.webp
create mode 100644 docs/media/scenes/cymatic.webp
create mode 100644 docs/media/scenes/ferrofluid.webp
create mode 100644 docs/media/scenes/lattice.webp
create mode 100644 docs/media/scenes/mandelbulb.webp
create mode 100644 docs/media/scenes/miraclemile.webp
create mode 100644 docs/media/scenes/naturestomb.webp
create mode 100644 docs/media/scenes/nebula.webp
create mode 100644 docs/media/scenes/ribbons.webp
create mode 100644 docs/media/scenes/spectra.webp
create mode 100644 docs/media/scenes/swarm.webp
create mode 100644 docs/media/scenes/valley.webp
create mode 100644 docs/media/scenes/vjshader.webp
create mode 100644 docs/media/scenes/voxels.webp
create mode 100644 docs/media/scenes/willidream.webp
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f9aee0b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+MIT License
+
+Copyright (c) 2026 Daniel Trujillo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+Portions of this software are derived from third-party projects and remain
+under their own licenses. Each derived file carries the upstream notice, the
+license identifier and a statement of changes in its header. The principal
+upstream sources are listed under Credits in README.md.
diff --git a/README.md b/README.md
index 988f826..86f6be8 100644
--- a/README.md
+++ b/README.md
@@ -1,112 +1,325 @@
-# SwayCommand
-
-SwayCommand is a desktop application that renders real-time, audio-reactive visuals controlled by the Audima Labs Sway, a gesture-based MIDI controller. The application targets live performance: everything happens on one always-live cockpit page — stage, timeline, control deck, and instrument panels — it starts into a ready-to-play project, analyzes any audio input, and accepts control from the Sway, from any class-compliant MIDI controller, or from mouse and keyboard.
-
-SwayCommand is an independent project. It is not affiliated with or endorsed by Audima Labs Pty Ltd. It never redistributes Audima binaries; optional Audima components are downloaded from Audima's official CDN at the user's request. See [Legal](#legal).
-
-## Feature summary
-
-- A single-page cockpit: the stage renders from boot to quit while the scene bank, transport, timeline, assignment panel, and drawers work on top of it — no screen switching, no interrupted frames.
-- Sixteen procedural visual scenes, each driven by audio analysis and gesture input, rendered with three.js on WebGL2 — every shader GLSL3, no autonomous rotation anywhere.
-- Projects as `.sway` files: one JSON document carrying palette, engine settings, effects snapshot, synth patch, linked media, kit, timeline, and every control assignment. Save, open, and recent files live in the project menu; ten bundled templates (three tuned to pair with Audima's official Ableton demo packs — Garage, DNB, Hip Hop) provide starting points.
-- A timeline with any number of audio tracks (stems — import or drop files and each becomes a track on the grid, tempo detected on the first import; waveform clips scheduled sample-accurately on one audio clock, the loop seam scheduled ahead so everything stays in sync) and a visual lane (scene clips with per-clip cut or fade entry), plus bar / beat grid with snap, loop region, locators, and scrubbing.
-- Live music performance out of the box: every track carries an effect chain (filter, delay, reverb, distortion, bit crusher, trance gate, phaser, flanger, chorus, tremolo, auto filter, compressor, three band eq, pan); any parameter binds to a pad (a held punch), a knob (continuous) or a gesture with one BIND; Shift+drag marks a section of a track where an effect engages by itself; stems launch from pads phase-locked to the grid.
-- theDAW's plugin world: `.gan` web-plugin surfaces from the Foundry load as route sources, and VST3 plugins are hosted through pedalboard in a sidecar and rendered onto tracks with a live wet / dry mix.
-- An on-screen Sway deck — a line-art schematic of the hardware. Click any pad, knob, button, or gesture chip to assign it: pads fire samples, scene switches, a scene's own events, or momentary effect punches; knobs drive any engine, rack, synth, kit, or scene parameter with range and curve; buttons learn a hardware CC and toggle anything; the five gesture dimensions hold modulation routes. Touching a control on the hardware selects it on screen.
-- Automated scene cycling with palette-synchronized crossfades (Auto-VJ), after the pattern established by Keijiro Takahashi's Akvj.
-- A startup system check (the Doctor) that detects the Sway over USB, including firmware-update (DFU) mode, and offers one-click remediation for missing optional components.
-- Factory-map support for the Sway, MIDI-learn for any continuous control, and per-control overrides persisted in settings and in the project; any class-compliant MIDI controller drives the same controls.
-- Audio analysis from any input device or, on Windows, from system-audio loopback, with an internal fallback signal when no input exists.
-- A built-in wavetable synth covering the ground Vital does, playable from the Sway, with seven factory presets and a modulation matrix.
-- A sample kit on the deck's sixteen pads with one-shot, loop, and gate modes and choke groups; triggered samples, the synth, and timeline playback are all heard and drive the visuals.
-- A 38-parameter effects rack (mirror, kaleidoscope, glitch, anaglyph, mosaic, color, trails, ASCII, and more) applied to the composited frame when enabled; driving any rack parameter from a knob switches the rack on.
-- An in-application documentation viewer that renders the bundled Markdown documentation, including this file, without network access.
-- Full offline operation. The application contains no telemetry. Its own network access is limited to Audima endpoints: a reachability check at startup and user-initiated downloads. Links the user follows are handed to the system browser, restricted to an allowlist of hosts.
-
-## Requirements
-
-| Platform | Minimum version | Package |
+
+
+
+
+
+
+
+
+SwayCommand is a desktop VJ instrument built around the Audima Labs Sway, a
+gesture MIDI controller whose sixteen infrared sensors read hand positions in
+the air above it. Sixteen procedural scenes render on WebGL2 through three.js,
+driven by live audio analysis and by those hands. The whole rig lives on one
+page that renders from boot to quit: the scene bank, the timeline, the control
+deck and the instrument drawers all work on top of a frame that never stops.
+
+The Sway is optional at every point. Audio arrives from any input device, from
+Windows system loopback, or from stems on the built-in timeline. Control arrives
+from the Sway, from any class-compliant MIDI controller, or from the mouse and
+keyboard. With no peripherals attached at all, the analyser synthesises a
+120 BPM groove into the analyser node and every scene still plays.
+
+> SwayCommand is an independent project. It is not affiliated with or endorsed
+> by Audima Labs Pty Ltd, and it redistributes no Audima binary. Optional Audima
+> components download from Audima's own CDN on request. See [Legal](#legal).
+
+
+
+*Miracle Mile, DETONATION act. Four acts sit on one knob, a noir city sits under
+all of them, and every pad on the deck is its own re-entry vehicle.*
+
+## Scenes
+
+Sixteen modules in [src/renderer/engine/scenes/](src/renderer/engine/scenes/),
+every shader GLSL3, none of them rotating on their own. Each one reads the same
+five-colour palette, the same three audio bands and the same gesture snapshot,
+and answers a pad strike with an event of its own: a mode jump, a geometry
+advance, a re-seed. The interface they implement is
+[SCENE_CONTRACT.md](docs/SCENE_CONTRACT.md).
+
+| Beam Sixteen | Swarm | Ribbons | Voxels |
+|---|---|---|---|
+|  |  |  |  |
+| One beam per IR sensor. Knob 4 crossfades hologram, laser, electricity. | A stateless GPU cloud orbiting an attractor that chases the hand. | Lissajous trails. A strike runs a whipcrack head to tail. | A box heightfield. Bass pumps rings, each pad drops a stone. |
+
+| Nebula | Mandelbulb | Cymatic Orb | Spectra |
+|---|---|---|---|
+|  |  |  |  |
+| Three fbm layers warping each other, mirror-folded, contoured into filaments. | A raymarched solid. Strikes jump between six distance estimators. | Three spherical modes summed in the vertex shader, nodal lines lit per pixel. | A mel-history terrain scrolling under the inferno ramp. |
+
+| VJ Shader | Ferrofluid Orb | Cymatic Plate | Chrome Valley |
+|---|---|---|---|
+|  |  |  |  |
+| Five raymarch presets and eight materials, both picked from pads. | A Rosensweig spike field over a Fibonacci phyllotaxis cross-hatch. | A Chladni plate: sixteen mode pairs, adjacent modes blended. | An endless valley under a plasma sun, morphing into a spike field. |
+
+| Quantum Lattice | Will I Dream | Nature's Tomb | Miracle Mile |
+|---|---|---|---|
+|  |  |  |  |
+| 373 nodes and 756 beams morphing between four geometries. | A big bang on the downbeat, then hyperspace, wormhole, black hole. | Fifteen plates on one knob: life, its end, then weather. | Four acts on one knob: collider, fission, detonation, shockwave. |
+
+Three of the sixteen are shows rather than loops. Will I Dream opens dark on a
+singularity, ignites on the first downbeat, and flies out through thirteen
+celestial bodies and four things the flight can run into; the hand alone drives
+the hyperspace jump, and whatever waits at the far end grows in as the star
+trails come to rest. Nature's Tomb runs the order of life and then its end, from
+a B-form double helix through the cell line, the mycelium and the slime mold to
+the toxin, phagocytosis and decomposition, then out to the world: microscopy,
+ocean currents, the day, and five weather systems. Miracle Mile starts inside a
+particle detector and ends with a city under a mushroom cloud, and the wreck it
+leaves persists until a rebuild.
+
+Stills in this section are rendered by the offscreen scene harness from
+[docs/media/gallery.plan.json](docs/media/gallery.plan.json). Provenance and the
+regeneration command: [docs/media/README.md](docs/media/README.md).
+
+## The cockpit
+
+One page, always live. The stage renders behind every panel, drawer and modal,
+and nothing in the interface interrupts a frame.
+
+```
+┌──────────────────────────────────────────────────────────────────────────┐
+│ SWAYCOMMAND PROJECT PLAY STOP 01:24.6 LOOP MIRACLE MILE 58 fps │
+├────────────┬──────────────────────────────────────────┬──────────────────┤
+│ SCENES │ │ ASSIGNMENT │
+│ 16 tiles │ │ the selected │
+│ keys 1..9 │ │ pad, knob, │
+│ │ S T A G E │ button or │
+│ AUTO │ renders from boot to quit │ gesture │
+│ RUN │ ├──────────────────┤
+│ HOLD │ │ INPUT │
+│ 18..40 s │ │ source, meter, │
+│ FADE 4 s │ │ three bands │
+├────────────┴──────────────────────────────────────────┴──────────────────┤
+│ IMPORT +TRACK BPM 128 TAP SNAP beat 1 2 3 4 │
+│ SCENES │ [ voxels ][ miracle mile ][ will i dream ] │
+│ DRUMS │ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ │
+│ BASS │ ~~~~~~~~~~~~~~[ section: filter cutoff ]~~~~~~~~~~~~~~~~~~~~~~~ │
+├──────────────────────────────────────────────────────────────────────────┤
+│ K1 K2 K3 K4 ( ( ( ( sixteen IR sensors ) ) ) ) K5 K6 K7 K8 │
+│ 00 01 02 03 OLED WHEEL PRESETS 08 09 10 11 │
+│ 04 05 06 07 12 13 14 15 │
+└──────────────────────────────────────────────────────────────────────────┘
+```
+
+| Region | Contents |
+|---|---|
+| Top bar | Project menu, transport and clock, current scene, link and input pills, fps, and the SYNTH / RACK / KIT / DOCS / HELP buttons |
+| Left rail | The scene bank, with digit hints on the first nine of the active pool, and the Auto-VJ group: RUN, a hold range in seconds, a crossfade time |
+| Centre | The stage. A line-art blast door covers it while the Doctor runs, then opens once |
+| Right rail | The assignment panel for the selected control, over the input box: analysis source, level meter, band display |
+| Timeline band | Toolbar, one head per track, a bar and beat ruler with loop region and locators, a visual lane of scene clips, and one waveform lane per audio track |
+| Deck | A stroke line-art schematic of the Sway. Clicking a control on it selects that control; touching the control on the hardware does the same |
+
+Every region resizes from a grip on its inner edge, collapses to a thin strip
+from a corner chip, and restores its default on a double-click. Sizes and
+collapsed states persist in the settings file and survive solo view, which hides
+everything but the stage on `O`.
+
+## Playing it
+
+### The Sway
+
+The factory map was recovered from Audima's own artifacts (the Base Project V2
+`.swayproj`, the official Ableton Live remote scripts, the Cubase MIDI Remote
+script) rather than from published documentation. Any binding can be replaced by
+MIDI-learn, which is also what makes any other class-compliant controller work.
+
+| Surface | Factory MIDI | Role |
|---|---|---|
-| Windows | 10 (x64) | NSIS installer (`SwayCommand-Setup-.exe`) |
-| macOS | 11 | DMG |
-| Linux | glibc-based x64 distribution | AppImage |
+| Hand position over the rail | CC 50 (X), CC 38 (Y) | Continuous steering of the active scene, and two modulation routes |
+| Pulse | CC 35 | Vertical bounce energy: a brightness surge, plus any routes bound to it |
+| Press | CC 36 | Press depth: each scene's own crush or dive |
+| Sway | CC 37 | Lateral sway: morphs the active scene's generative parameters |
+| X-trigger and Y-modulation region | CC 73, CC 74 | A paired region, available as routes |
+| Knobs 1 to 8 | CC 20 to 27 | Unassigned by default. Any engine, rack, synth, kit, track or scene parameter, with range and curve |
+| Pads 0 to 15 | Notes 24 to 39 chromatic, or the B minor Theory Engine grid | A sample, a scene switch, a scene event, a momentary effect punch, or a grid-locked stem launch. Every strike is also the active scene's morph event |
+| Buttons | Unpublished CCs, learned | Toggle anything: rack, Auto-VJ, synth, transport, track mute or solo |
+| Sleep and wake | Program 37, 38 | Link pill state |
+
+### Assignment
-A WebGL2-capable GPU is required. A Sway, other MIDI hardware, and an audio input are optional; the application substitutes mouse/keyboard control and an internal analysis signal when they are absent.
+Selecting a control on the deck opens its panel on the right rail. Every
+assignable destination in the application answers one target string, so the same
+grammar covers the engine, the post chain, the instruments, the timeline and
+anything a scene chooses to publish.
+
+| Target | Reaches |
+|---|---|
+| `engine:hue`, `engine:intensity`, `engine:fadeTime` | Global palette rotation, output intensity, Auto-VJ crossfade time |
+| `fx:` | One of the 38 rack parameters. A control reaching for the rack switches the rack on, and clearing it switches the rack off again |
+| `synth:`, `sampler:` | The wavetable synth and the kit's four macro knobs |
+| `scene::` | An action or parameter the scene declares in `meta.controls`. Actions fire only while that scene is on screen; parameters apply whenever they are set |
+| `track::gain`, `:mute`, `:solo`, `:vstmix` | One timeline track |
+| `track:::` | One parameter of one effect in that track's live chain |
+| `transport:playPause`, `transport:stop` | The timeline |
+| `gan::[:x\|y\|z]` | A `.gan` plugin surface as a control **source**, driving anything above |
+
+A knob carries a range and a curve. A gesture route wins over a knob on the same
+target. Touching a control on the hardware selects it on screen, so binding is a
+matter of reaching for the thing and pressing BIND.
+
+### Timeline and tracks
+
+The timeline holds any number of audio tracks and one visual lane. Dropping
+files on the band creates one track per stem at the playhead, and tempo is
+estimated on the first import. Clips are scheduled sample-accurately on the one
+`AudioContext` clock, with the loop seam scheduled ahead so nothing drifts at
+the wrap.
+
+Each track carries a live effect chain built from 14 kinds: filter, delay,
+reverb, distortion, bit crusher, trance gate, phaser, flanger, chorus, tremolo,
+auto filter, compressor, three-band EQ and pan. Any parameter binds to a pad as
+a held punch, to a knob as a continuous control, or to a gesture. Shift-dragging
+on a track marks a section, a region where an effect engages by itself while the
+playhead is inside it. Pads can also launch stems phase-locked to the grid, at
+the next beat, bar, two bars or four.
+
+VST3 plugins run through a pedalboard sidecar, rendered offline per track and
+played back under a wet and dry mix. `.gan` surfaces from theDAW's Foundry load
+in the plugins drawer and contribute their controls as route sources.
+
+### Projects
+
+A project is one JSON document with the `.sway` extension: palette, engine
+settings, effects snapshot, synth patch, linked media, kit, timeline, linked
+plugins, every control assignment and any MIDI overrides. Eleven templates ship
+with the application, three of them tuned to pair with Audima's official Ableton
+demo packs (Garage, DNB, Hip Hop). Format and schema:
+[PROJECTS.md](docs/PROJECTS.md).
+
+### Everything else on the deck
+
+The rack is 38 post-processing parameters in five decks (Geometrics,
+Corruption, Chromatics, Timecode, ASCII) applied to the composited frame. The
+synth is a wavetable instrument with seven factory presets and a modulation
+matrix, playable from the Sway or the keyboard row. The kit puts one sample per
+pad across the sixteen pads with one-shot, loop and gate modes and choke groups.
+Auto-VJ holds a scene for a randomised interval and then crossfades to another
+from the project's pool, with the palette carried across the fade.
## Installation
-Packaged builds install per-user and require no elevation. The Windows installer is a one-click NSIS package that launches the application when installation completes. Installation from source requires Node.js 18 or later; the repository includes double-click bootstrap scripts (`Install & Launch SwayCommand.bat` on Windows, `Install & Launch SwayCommand.command` on macOS, `install-launch.sh` on Linux) that install dependencies and start the application. Details, including silent installation and uninstallation: [docs/INSTALLATION.md](docs/INSTALLATION.md).
+| Platform | Minimum | Package |
+|---|---|---|
+| Windows | 10 (x64) | NSIS installer, `SwayCommand-Setup-.exe` |
+| macOS | 11 | DMG |
+| Linux | glibc-based x64 | AppImage |
+
+A WebGL2 GPU is required. Packaged builds install per user and need no
+elevation; the Windows installer is one click and launches on completion.
+
+Installing from source needs Node.js 18 or later. The repository carries
+double-click bootstrap scripts that install dependencies and start the
+application: `Install & Launch SwayCommand.bat` on Windows,
+`Install & Launch SwayCommand.command` on macOS, `install-launch.sh` on Linux.
+Silent installation and uninstallation are covered in
+[INSTALLATION.md](docs/INSTALLATION.md).
+
+At first launch the Doctor checks the system: the Sway over USB (including
+firmware-update mode), WebGL2, WebMIDI, audio input, and the optional Audima
+components, each with a one-click fix where one exists. Details:
+[DOCTOR.md](docs/DOCTOR.md).
+
+## Keys
+
+| Key | Action |
+|---|---|
+| `1` to `9` | Select a scene from the active project's pool, disabling Auto-VJ |
+| `Space` | Crossfade to another scene from the pool |
+| `A` | Auto-VJ on or off |
+| `Z X C V B N M ,` | Pads 0 to 7 |
+| `P` / `L` / `I` | Play or pause / loop on or off / import stems |
+| `Delete`, `Left`, `Right` | Remove or nudge the selected timeline clip or section |
+| `S` / `R` / `E` / `G` | Synth, rack, kit and plugins drawers |
+| `F` / `O` | Fullscreen / solo view, stage only |
+| `H` / `D` / `K` | Controls modal, documentation, MIDI monitor (`M` is a pad key) |
+| `A W S E D F T G Y H U J K O L P ;` | Play the synth, only while the synth drawer is open |
+| `Esc` | Close the topmost layer: popover, drawer, modal, then selection |
+
+Pointer input stands in for the Sway when none is bound: position on the stage
+is XY, buttons are press, the wheel is pulse. In Will I Dream that means the
+hyperspace jump is on the pointer too, since warp is the product of X and
+closeness to the sensors, engaging past 0.55 and releasing under 0.25.
## Development
```sh
npm install
-npm start # build renderer bundle, launch Electron
-npm run dist:win # Windows installer
-npm run dist:mac # macOS DMG
-npm run dist:linux # Linux AppImage
+npm start # bundle the renderer, launch Electron
+npm run build:renderer # dist/, the desktop bundle
+npm run build:renderer:embed # dist-embed/, a static bundle a host app serves
+npm run dist:win # Windows installer
+npm run dist:mac # macOS DMG
+npm run dist:linux # Linux AppImage
```
-Build-system details: [docs/BUILD.md](docs/BUILD.md). Environment variables and file locations: [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md).
+Scenes are verified without launching the application. The offscreen harness
+compiles a scene in a hidden Electron window, drives its `update()` with a
+patched input snapshot for a set number of frames, and returns a PNG, the cost
+per frame and any shader error:
-## Controls
+```sh
+node scripts/scene-harness.js
+```
-| Input | Effect |
-|---|---|
-| Hand position over the Sway (XY) | Continuous steering of the active scene; assignable to any parameter as modulation routes |
-| Pulse / Press / Sway gestures | Pulse surges brightness; press compresses — each scene's own crush or dive; sway morphs the active scene's generative parameters (mode numbers, field strengths, warp velocity). Each dimension also holds any number of modulation routes |
-| Knobs 1–8 | Assignable. Nothing by default: no knob drives an effect, the intensity, or anything else until you assign it, and clearing a control resets the effect it drove (the rack switches off again if a control had switched it on). Templates carry their own knob tables |
-| Pads 0–15 | Assignable: sample, scene switch (cut or fade entry), a scene event the active scene declares, or momentary effect punch — numbered 0–15 as the deck shows them (top rows first, left cluster then right, then the bottom rows). Every strike is also the active scene's morph event — a geometry advance, mode jump, or re-seed |
-| Buttons 1–8 | Learned from the hardware, then toggle anything: rack, Auto-VJ, synth, transport |
-| Wormhole scene | A pad strike toggles warp (stars streak with aberration and Doppler shift); sway sets the warp velocity; a press held deep (≥ 0.7 for 0.25 s) opens the wormhole — a ~4.2 s lensed transit that exits into a different sky |
-| Mouse move / button / wheel on the stage | XY position / Press / Pulse when no Sway is bound |
-| Click a control on the deck | Selects it in the assignment panel (touching it on the hardware does the same) |
-| Panel grips / chips | Drag to resize the rails, timeline, deck, and input box; the corner chip collapses or expands a panel; double-click a grip resets it |
-| `1`–`9` | Select a scene from the active project's pool (disables Auto-VJ) |
-| `Space` | Crossfade to another scene from the project pool |
-| `A` | Toggle Auto-VJ |
-| `Z X C V B N M ,` | Pads 0–7 |
-| `P` / `L` / `I` | Play or pause the timeline / toggle the loop / import stems |
-| `G` | PLUGINS drawer (`.gan` surfaces, VST3 host) |
-| `Delete`, `←` `→` | Remove / nudge the selected timeline clip or section |
-| Timeline | Drop audio files on the band (one track per stem); click a track head to edit its effects and BIND them; Shift+drag on a track marks a section for an effect |
-| `S` / `R` / `E` | Synth / rack / kit drawer |
-| `F` | Toggle fullscreen |
-| `O` | Solo view: hide the rails and bands, stage only |
-| `H` | Controls modal (the SYSTEM check is reachable from it) |
-| `K` | MIDI monitor (`M` is a pad key) |
-| `D` | Documentation |
-| `A W S E D F T G Y H U J K O L P ;` | Play the synth — only while the synth drawer is open |
-| `Esc` | Close the topmost layer: popover, drawer, modal, then selection |
+Build system: [BUILD.md](docs/BUILD.md). Environment variables, settings file
+locations and network endpoints: [ENVIRONMENT.md](docs/ENVIRONMENT.md).
## Documentation
-The documentation viewer inside the application renders this file and every document below from the copies bundled with the build, so the text always matches the installed version. The `D` key, or the DOCS button in the top bar, opens it.
+Every document below ships inside the application and renders in the
+documentation modal, opened with `D` or the DOCS button, so the text always
+matches the installed version.
| Document | Scope |
|---|---|
-| [docs/INDEX.md](docs/INDEX.md) | Documentation map and reading order |
-| [docs/OVERVIEW.md](docs/OVERVIEW.md) | System overview, the cockpit, terminology, component map |
-| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Process model, module inventory, IPC surface, security model |
-| [docs/INSTALLATION.md](docs/INSTALLATION.md) | Packaged and from-source installation, uninstallation |
-| [docs/DOCTOR.md](docs/DOCTOR.md) | Every system check, detection method, and fix action |
-| [docs/SYNTH.md](docs/SYNTH.md) | The built-in wavetable synth, its capability against Vital, and theDAW alignment |
-| [docs/STUDIO.md](docs/STUDIO.md) | The drawers (synth, rack, kit), the sample pool, and control assignments |
-| [docs/MIDI.md](docs/MIDI.md) | Device detection, factory map, MIDI-learn, the assignment router |
-| [docs/AUDIO.md](docs/AUDIO.md) | Analysis chain, signal sources, beat detection, the timeline transport |
-| [docs/ENGINE.md](docs/ENGINE.md) | Render pipeline, crossfade compositor, effects rack, Auto-VJ, ColorMaster |
-| [docs/PROJECTS.md](docs/PROJECTS.md) | The `.sway` project format, templates, and the timeline model |
-| [docs/SCENE_CONTRACT.md](docs/SCENE_CONTRACT.md) | Scene module interface and authoring rules |
-| [docs/SWAY_INTEGRATION.md](docs/SWAY_INTEGRATION.md) | Sway USB identity, MIDI map, driver matrix, CDN interface |
-| [docs/BUILD.md](docs/BUILD.md) | Build scripts, packaging, release artifacts |
-| [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md) | Environment variables, settings file, network endpoints |
-| [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Known issues and resolutions |
-| [docs/RESEARCH.md](docs/RESEARCH.md) | Source research record with citations |
+| [INDEX.md](docs/INDEX.md) | Documentation map and reading order |
+| [OVERVIEW.md](docs/OVERVIEW.md) | System overview, the cockpit, terminology, component map |
+| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Process model, module inventory, IPC surface, security model |
+| [INSTALLATION.md](docs/INSTALLATION.md) | Packaged and from-source installation, uninstallation |
+| [DOCTOR.md](docs/DOCTOR.md) | Every system check, detection method and fix action |
+| [STUDIO.md](docs/STUDIO.md) | The drawers, the sample pool, tracks, sections and assignments |
+| [SYNTH.md](docs/SYNTH.md) | The wavetable synth, its capability against Vital, theDAW alignment |
+| [MIDI.md](docs/MIDI.md) | Device detection, factory map, MIDI-learn, the assignment router |
+| [AUDIO.md](docs/AUDIO.md) | Analysis chain, signal sources, beat detection, the transport |
+| [ENGINE.md](docs/ENGINE.md) | Render pipeline, crossfade compositor, effects rack, Auto-VJ, ColorMaster |
+| [PROJECTS.md](docs/PROJECTS.md) | The `.sway` format, templates, the timeline model |
+| [SCENE_CONTRACT.md](docs/SCENE_CONTRACT.md) | Scene module interface and authoring rules |
+| [SWAY_INTEGRATION.md](docs/SWAY_INTEGRATION.md) | Sway USB identity, MIDI map, driver matrix, CDN interface |
+| [BUILD.md](docs/BUILD.md) | Build scripts, packaging, release artifacts |
+| [ENVIRONMENT.md](docs/ENVIRONMENT.md) | Environment variables, settings file, network endpoints |
+| [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Known issues and resolutions |
+| [RESEARCH.md](docs/RESEARCH.md) | Source research record with citations |
+
+## Privacy and network use
+
+The application collects no telemetry and works offline. Its own network access
+reaches Audima hosts only: a reachability check when the Doctor runs, and
+downloads the user asks for. The companion installer is verified against
+Audima's published minisign signature before it is opened. Links followed from
+the documentation are handed to the system browser and refused unless the
+hostname is on an allowlist; renderer navigation is disabled outright.
## Credits
-SwayCommand draws on three prior projects: [theDAW](https://github.com/gantasmo/theDAW) by GANTASMO (MIT) for the web-native VJ-engine approach and local-first design; [Akvj](https://github.com/keijiro/Akvj) and [MetavidoVFX](https://github.com/keijiro/MetavidoVFX) by Keijiro Takahashi (Unlicense) for the VfxController crossfade-cycling pattern, ColorMaster palette synchronization, and the runtime effect-switcher architecture, reimplemented here in three.js.
+| Project | Author | License | Contribution |
+|---|---|---|---|
+| [theDAW](https://github.com/gantasmo/theDAW) | GANTASMO | MIT and Apache-2.0 in parts | The web-native VJ engine approach and local-first design. The cymatics work behind Ferrofluid Orb, Cymatic Plate and Chrome Valley, and the lattice engine behind Quantum Lattice |
+| VJ-9000 | GANTASMO | Author's own work | The spectrogram terrain behind Spectra, and the five raymarch presets and eight materials behind VJ Shader |
+| [Akvj](https://github.com/keijiro/Akvj), [MetavidoVFX](https://github.com/keijiro/MetavidoVFX) | Keijiro Takahashi | Unlicense | The VfxController crossfade-cycling pattern, ColorMaster palette synchronisation, and the runtime effect-switcher architecture, reimplemented in three.js |
+
+Individual scenes also carry ported CodePen work under MIT from Majid
+Manzarpour, Matthias Hurrle, Luis Alberto Martinez Riancho, amCharts and others.
+Every derived file states its upstream, its license and its changes in the file
+header.
## Legal
-SwayCommand is released under the MIT license. "Sway" and "Audima Labs" are the property of Audima Labs Pty Ltd. In accordance with Audima's terms and conditions, the application does not bundle or redistribute Audima software; the Doctor downloads Audima's official driver package and companion application directly from `cdn.audima.com.au` onto the local machine, and verifies the companion application against Audima's published minisign signature before opening the installer.
+SwayCommand is released under the [MIT license](LICENSE). "Sway" and "Audima
+Labs" are the property of Audima Labs Pty Ltd. In accordance with Audima's terms
+and conditions the application bundles no Audima software: the Doctor downloads
+the official driver package and companion application directly from
+`cdn.audima.com.au` onto the local machine, and verifies the companion
+application against Audima's published minisign signature before opening the
+installer.
diff --git a/docs/media/README.md b/docs/media/README.md
new file mode 100644
index 0000000..622ea79
--- /dev/null
+++ b/docs/media/README.md
@@ -0,0 +1,51 @@
+# Media assets
+
+Images referenced by [README.md](../../README.md). Every file here is generated
+from this repository's own code, not captured by hand.
+
+| File | Size | Source |
+|---|---|---|
+| `banner.webp` | 1280x400 | The `willidream` still under a scrim, with the wordmark in the bundled Source Code Pro |
+| `hero.webp` | 1280x720 | The `miraclemile` still, DETONATION act |
+| `scenes/.webp` | 480x270 | One still per registry scene, `` matching `meta.id` in `src/renderer/engine/scenes/` |
+
+## Regenerating the scene stills
+
+`gallery.plan.json` is the scene-harness plan the stills come from. It carries
+one entry per scene in registry order, each with the exact `io` snapshot
+(palette, bands, level, beat, knobs, gestures, strikes, transport) that the
+scene was photographed under.
+
+```sh
+node scripts/scene-harness.js docs/media/gallery.plan.json
+```
+
+The harness renders in a hidden Electron window and writes one PNG per shot to
+`%TEMP%/swaycommand-harness` unless the plan sets `out`. Shots whose name starts
+with an underscore exist to advance a scene into the state the next shot
+photographs: `_willidream_bang` runs the universe from the singularity through
+the big bang so that `willidream` can fire a hyperspace jump into a settled star
+field, and `_miraclemile_open` moves the act knob off zero so that the act
+change registers as a move rather than an initial value.
+
+Two properties of the harness affect what comes back. Scene instances are cached
+across shots, so a scene's shots must stay contiguous and in order. The clock
+`t` is shared by every shot in a run, so a scene's absolute time depends on what
+ran before it; re-running the plan reproduces the setup exactly and the frame
+approximately.
+
+The PNGs are then scaled and re-encoded to WebP at quality 0.80 (0.86 for the
+hero) through a canvas in the same Electron runtime. There is no native image
+dependency in this tree.
+
+Cold-cache warning: on a GPU shader cache that has never seen these scenes,
+`miraclemile` and `naturestomb` block the calling thread for one to two minutes
+on their first draw. That is the open compile regression recorded in
+[HANDOFF.md](../../HANDOFF.md), not a harness fault. A second run is fast.
+
+## Replacing a still with a recording
+
+The README references each scene by path, so a recording replaces a still by
+taking its name. An animated `scenes/.gif` supersedes `scenes/.webp`
+once the link in README.md points at it. Keep the 16:9 frame and a width of 480
+so the gallery grid stays even.
diff --git a/docs/media/banner.webp b/docs/media/banner.webp
new file mode 100644
index 0000000000000000000000000000000000000000..fb7b0b1f7c0fb74506708d5c854f9aba3fdf559d
GIT binary patch
literal 59198
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