From 56620c672b2ae291798050ce844b7a4f775fb724 Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:25:23 -0700 Subject: [PATCH 01/26] Add .sway v1 project format and template conversion New shared module src/shared/swayproject.js: document schema with defaults, non-throwing validation, legacy preset conversion, and the control-target grammar (engine:/fx:/synth:/sampler:/transport:). scripts/convert-projects.js converts the 8 legacy presets into committed projects/templates/*.sway. Co-Authored-By: Claude Fable 5 --- projects/templates/beam-sixteen.sway | 219 ++++++++++++++ projects/templates/chrysanthemum.sway | 225 ++++++++++++++ projects/templates/dnb-tunnel.sway | 221 ++++++++++++++ projects/templates/first-flight.sway | 226 ++++++++++++++ projects/templates/garage-neon.sway | 222 ++++++++++++++ projects/templates/hiphop-voxels.sway | 220 ++++++++++++++ projects/templates/hyperspace.sway | 224 ++++++++++++++ projects/templates/index.json | 12 + projects/templates/nebula-drift.sway | 222 ++++++++++++++ scripts/convert-projects.js | 35 +++ src/shared/swayproject.js | 419 ++++++++++++++++++++++++++ 11 files changed, 2245 insertions(+) create mode 100644 projects/templates/beam-sixteen.sway create mode 100644 projects/templates/chrysanthemum.sway create mode 100644 projects/templates/dnb-tunnel.sway create mode 100644 projects/templates/first-flight.sway create mode 100644 projects/templates/garage-neon.sway create mode 100644 projects/templates/hiphop-voxels.sway create mode 100644 projects/templates/hyperspace.sway create mode 100644 projects/templates/index.json create mode 100644 projects/templates/nebula-drift.sway create mode 100644 scripts/convert-projects.js create mode 100644 src/shared/swayproject.js diff --git a/projects/templates/beam-sixteen.sway b/projects/templates/beam-sixteen.sway new file mode 100644 index 0000000..02da201 --- /dev/null +++ b/projects/templates/beam-sixteen.sway @@ -0,0 +1,219 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "beam-sixteen", + "name": "Beam Sixteen", + "description": "A love letter to the hardware: sixteen light columns, one per Sway IR sensor. Sweep your hand along the surface and watch the row answer; strike pads to detonate single beams.", + "author": "", + "vibe": "anthemic", + "bpmHint": 0, + "pairsWith": null, + "template": true + }, + "palette": [ + "#dbe9ff", + "#9fc5ff", + "#5e8fce", + "#2de1fc", + "#f4f9ff" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": false, + "pool": [ + "beams", + "nebula" + ], + "minHold": 30, + "maxHold": 60, + "fadeTime": 6 + }, + "start": { + "scene": "beams" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + { + "target": "engine:hue", + "min": 0, + "max": 1, + "curve": "detent" + }, + { + "target": "engine:fadeTime", + "min": 1, + "max": 8, + "curve": "linear" + }, + { + "target": "engine:intensity", + "min": 0, + "max": 1, + "curve": "linear" + }, + null, + { + "target": "sampler:master", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:cutoff", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:rate", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:send", + "min": 0, + "max": 1, + "curve": "linear" + } + ], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ], + "gestures": [] + }, + "midiOverrides": {} + } +} diff --git a/projects/templates/chrysanthemum.sway b/projects/templates/chrysanthemum.sway new file mode 100644 index 0000000..fc78eb4 --- /dev/null +++ b/projects/templates/chrysanthemum.sway @@ -0,0 +1,225 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "chrysanthemum", + "name": "Chrysanthemum", + "description": "The full geometry load. Mandelbulb solids that re-knit on the bass, a resonating orb throwing Chladni figures, nebulae that lurch on every beat. Press to morph the fractal from Mandelbulb into Julia and watch the solid transmute.", + "author": "", + "vibe": "hyperreal", + "bpmHint": 0, + "pairsWith": null, + "template": true + }, + "palette": [ + "#39ff14", + "#ff10f0", + "#00fff7", + "#ffea00", + "#ff5400" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": true, + "pool": [ + "mandelbulb", + "vjshader", + "ferrofluid", + "chladni", + "lattice", + "nebula", + "warp", + "spectra" + ], + "minHold": 26, + "maxHold": 52, + "fadeTime": 6 + }, + "start": { + "scene": "mandelbulb" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + { + "target": "engine:hue", + "min": 0, + "max": 1, + "curve": "detent" + }, + { + "target": "engine:fadeTime", + "min": 1, + "max": 8, + "curve": "linear" + }, + { + "target": "engine:intensity", + "min": 0, + "max": 1, + "curve": "linear" + }, + null, + { + "target": "sampler:master", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:cutoff", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:rate", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:send", + "min": 0, + "max": 1, + "curve": "linear" + } + ], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ], + "gestures": [] + }, + "midiOverrides": {} + } +} diff --git a/projects/templates/dnb-tunnel.sway b/projects/templates/dnb-tunnel.sway new file mode 100644 index 0000000..852792c --- /dev/null +++ b/projects/templates/dnb-tunnel.sway @@ -0,0 +1,221 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "dnb-tunnel", + "name": "DNB Tunnel", + "description": "Pairs with Audima's official Ableton DNB demo pack. Relentless forward motion: the wormhole eats the bassline, acid greens strobe on the highs, press to choke the throat tight — and strike a pad to jump.", + "author": "", + "vibe": "relentless", + "bpmHint": 174, + "pairsWith": "Audima Ableton DNB demo pack", + "template": true + }, + "palette": [ + "#39ff14", + "#0affef", + "#ff3131", + "#cfff04", + "#7df9ff" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": true, + "pool": [ + "warp", + "swarm", + "mandelbulb", + "beams" + ], + "minHold": 12, + "maxHold": 24, + "fadeTime": 2 + }, + "start": { + "scene": "warp" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + { + "target": "engine:hue", + "min": 0, + "max": 1, + "curve": "detent" + }, + { + "target": "engine:fadeTime", + "min": 1, + "max": 8, + "curve": "linear" + }, + { + "target": "engine:intensity", + "min": 0, + "max": 1, + "curve": "linear" + }, + null, + { + "target": "sampler:master", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:cutoff", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:rate", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:send", + "min": 0, + "max": 1, + "curve": "linear" + } + ], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ], + "gestures": [] + }, + "midiOverrides": {} + } +} diff --git a/projects/templates/first-flight.sway b/projects/templates/first-flight.sway new file mode 100644 index 0000000..55c35a0 --- /dev/null +++ b/projects/templates/first-flight.sway @@ -0,0 +1,226 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "first-flight", + "name": "First Flight", + "description": "The out-of-the-box show. Every scene in rotation, forgiving fades, a palette that flatters any music. Wave a hand over the Sway — or just move the mouse — and you are performing.", + "author": "", + "vibe": "welcoming", + "bpmHint": 0, + "pairsWith": null, + "template": true + }, + "palette": [ + "#ff2d95", + "#7a0bc0", + "#2de1fc", + "#f9f871", + "#ff6b35" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": true, + "pool": [ + "beams", + "swarm", + "ribbons", + "voxels", + "warp", + "nebula", + "mandelbulb", + "cymatic", + "vjshader" + ], + "minHold": 20, + "maxHold": 45, + "fadeTime": 5 + }, + "start": { + "scene": "beams" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + { + "target": "engine:hue", + "min": 0, + "max": 1, + "curve": "detent" + }, + { + "target": "engine:fadeTime", + "min": 1, + "max": 8, + "curve": "linear" + }, + { + "target": "engine:intensity", + "min": 0, + "max": 1, + "curve": "linear" + }, + null, + { + "target": "sampler:master", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:cutoff", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:rate", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:send", + "min": 0, + "max": 1, + "curve": "linear" + } + ], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ], + "gestures": [] + }, + "midiOverrides": {} + } +} diff --git a/projects/templates/garage-neon.sway b/projects/templates/garage-neon.sway new file mode 100644 index 0000000..8a64e24 --- /dev/null +++ b/projects/templates/garage-neon.sway @@ -0,0 +1,222 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "garage-neon", + "name": "Garage Neon", + "description": "Built to run alongside Audima's official Ableton Garage demo pack. Skippy two-step energy: ribbons chase your hand, beams answer the kick, hot pink on deep violet.", + "author": "", + "vibe": "club", + "bpmHint": 132, + "pairsWith": "Audima Ableton Garage demo pack", + "template": true + }, + "palette": [ + "#ff2d95", + "#7a0bc0", + "#2de1fc", + "#ff6b35", + "#f9f871" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": true, + "pool": [ + "ribbons", + "valley", + "lattice", + "beams", + "swarm" + ], + "minHold": 16, + "maxHold": 32, + "fadeTime": 3 + }, + "start": { + "scene": "ribbons" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + { + "target": "engine:hue", + "min": 0, + "max": 1, + "curve": "detent" + }, + { + "target": "engine:fadeTime", + "min": 1, + "max": 8, + "curve": "linear" + }, + { + "target": "engine:intensity", + "min": 0, + "max": 1, + "curve": "linear" + }, + null, + { + "target": "sampler:master", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:cutoff", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:rate", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:send", + "min": 0, + "max": 1, + "curve": "linear" + } + ], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ], + "gestures": [] + }, + "midiOverrides": {} + } +} diff --git a/projects/templates/hiphop-voxels.sway b/projects/templates/hiphop-voxels.sway new file mode 100644 index 0000000..84b79f6 --- /dev/null +++ b/projects/templates/hiphop-voxels.sway @@ -0,0 +1,220 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "hiphop-voxels", + "name": "Hip Hop Voxels", + "description": "Pairs with Audima's official Ableton Hip Hop demo pack. Head-nod tempo, heavyweight blocks: the voxel field rides the 808, pads drop ripples across the grid, golds over deep browns.", + "author": "", + "vibe": "heavyweight", + "bpmHint": 88, + "pairsWith": "Audima Ableton Hip Hop demo pack", + "template": true + }, + "palette": [ + "#ffb300", + "#ff6f00", + "#8d5524", + "#fff3c4", + "#e63946" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": true, + "pool": [ + "voxels", + "ribbons", + "nebula" + ], + "minHold": 24, + "maxHold": 48, + "fadeTime": 5 + }, + "start": { + "scene": "voxels" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + { + "target": "engine:hue", + "min": 0, + "max": 1, + "curve": "detent" + }, + { + "target": "engine:fadeTime", + "min": 1, + "max": 8, + "curve": "linear" + }, + { + "target": "engine:intensity", + "min": 0, + "max": 1, + "curve": "linear" + }, + null, + { + "target": "sampler:master", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:cutoff", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:rate", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:send", + "min": 0, + "max": 1, + "curve": "linear" + } + ], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ], + "gestures": [] + }, + "midiOverrides": {} + } +} diff --git a/projects/templates/hyperspace.sway b/projects/templates/hyperspace.sway new file mode 100644 index 0000000..44b550c --- /dev/null +++ b/projects/templates/hyperspace.sway @@ -0,0 +1,224 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "hyperspace", + "name": "Hyperspace", + "description": "Deep space at the throat of a wormhole. Fall toward the aperture, bank with a hand, and strike a pad to punch the jump — charge, launch, cruise, settle. Star-streaked, chrome-cold, built for the drop.", + "author": "", + "vibe": "transluminal", + "bpmHint": 0, + "pairsWith": null, + "template": true + }, + "palette": [ + "#ffffff", + "#9bf6ff", + "#4361ee", + "#7209b7", + "#f72585" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": true, + "pool": [ + "warp", + "vjshader", + "mandelbulb", + "lattice", + "valley", + "spectra", + "swarm" + ], + "minHold": 22, + "maxHold": 46, + "fadeTime": 3 + }, + "start": { + "scene": "warp" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + { + "target": "engine:hue", + "min": 0, + "max": 1, + "curve": "detent" + }, + { + "target": "engine:fadeTime", + "min": 1, + "max": 8, + "curve": "linear" + }, + { + "target": "engine:intensity", + "min": 0, + "max": 1, + "curve": "linear" + }, + null, + { + "target": "sampler:master", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:cutoff", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:rate", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:send", + "min": 0, + "max": 1, + "curve": "linear" + } + ], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ], + "gestures": [] + }, + "midiOverrides": {} + } +} diff --git a/projects/templates/index.json b/projects/templates/index.json new file mode 100644 index 0000000..b49f800 --- /dev/null +++ b/projects/templates/index.json @@ -0,0 +1,12 @@ +{ + "order": [ + "first-flight", + "hyperspace", + "chrysanthemum", + "beam-sixteen", + "garage-neon", + "dnb-tunnel", + "hiphop-voxels", + "nebula-drift" + ] +} diff --git a/projects/templates/nebula-drift.sway b/projects/templates/nebula-drift.sway new file mode 100644 index 0000000..8d53ac2 --- /dev/null +++ b/projects/templates/nebula-drift.sway @@ -0,0 +1,222 @@ +{ + "format": "sway", + "format_version": 1, + "app_version": "", + "audio_mode": "linked", + "created_at": null, + "modified_at": null, + "project": { + "meta": { + "id": "nebula-drift", + "name": "Nebula Drift", + "description": "Interstellar gas with a pulse. Glide a hand for parallax through the folds, press to condense them, strike pads to detonate shockwaves through the cloud. Atmospheric, but it moves.", + "author": "", + "vibe": "ambient", + "bpmHint": 0, + "pairsWith": null, + "template": true + }, + "palette": [ + "#0f4c5c", + "#5bc0be", + "#9bf6ff", + "#3a506b", + "#e0fbfc" + ], + "engine": { + "quality": "med", + "fxEnabled": false, + "autoVJ": { + "enabled": true, + "pool": [ + "nebula", + "ferrofluid", + "chladni", + "cymatic", + "swarm" + ], + "minHold": 40, + "maxHold": 80, + "fadeTime": 10 + }, + "start": { + "scene": "nebula" + } + }, + "fx": { + "params": {} + }, + "synth": { + "enabled": true, + "preset": "Init", + "patch": null + }, + "media": [], + "sampler": { + "kit": { + "version": 1, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "knobs": { + "master": 0.5, + "cutoff": 0.5, + "rate": 0.5, + "send": 0.5 + } + }, + "timeline": { + "loop": { + "enabled": false, + "start": 0, + "end": 0 + }, + "locators": [], + "tracks": [ + { + "id": "audio-1", + "type": "audio", + "name": "Audio", + "gain": 1, + "muted": false, + "clips": [] + }, + { + "id": "visual-1", + "type": "visual", + "name": "Scenes", + "clips": [] + } + ] + }, + "assignments": { + "version": 1, + "noteRouting": { + "synth": "unassigned" + }, + "pads": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "knobs": [ + { + "target": "engine:hue", + "min": 0, + "max": 1, + "curve": "detent" + }, + { + "target": "engine:fadeTime", + "min": 1, + "max": 8, + "curve": "linear" + }, + { + "target": "engine:intensity", + "min": 0, + "max": 1, + "curve": "linear" + }, + null, + { + "target": "sampler:master", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:cutoff", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:rate", + "min": 0, + "max": 1, + "curve": "linear" + }, + { + "target": "sampler:send", + "min": 0, + "max": 1, + "curve": "linear" + } + ], + "buttons": [ + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + }, + { + "cc": null, + "channel": null, + "action": null + } + ], + "gestures": [] + }, + "midiOverrides": {} + } +} diff --git a/scripts/convert-projects.js b/scripts/convert-projects.js new file mode 100644 index 0000000..37dbbef --- /dev/null +++ b/scripts/convert-projects.js @@ -0,0 +1,35 @@ +// One-shot: convert the legacy projects/*.json presets into bundled .sway +// templates under projects/templates/. Output is committed; safe to re-run. + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { legacyToSway } = require('../src/shared/swayproject'); + +const root = path.join(__dirname, '..'); +const legacyDir = path.join(root, 'projects'); +const outDir = path.join(legacyDir, 'templates'); + +const index = JSON.parse(fs.readFileSync(path.join(legacyDir, 'index.json'), 'utf8')); +fs.mkdirSync(outDir, { recursive: true }); + +const order = []; +for (const id of index.order) { + const legacyPath = path.join(legacyDir, `${id}.json`); + let legacy; + try { + legacy = JSON.parse(fs.readFileSync(legacyPath, 'utf8')); + } catch (err) { + console.error(`[convert] skipped ${id}: ${err.message}`); + continue; + } + const doc = legacyToSway(legacy); + const outPath = path.join(outDir, `${id}.sway`); + fs.writeFileSync(outPath, JSON.stringify(doc, null, 2) + '\n'); + order.push(id); + console.log(`[convert] ${id} -> ${path.relative(root, outPath)}`); +} + +fs.writeFileSync(path.join(outDir, 'index.json'), JSON.stringify({ order }, null, 2) + '\n'); +console.log(`[convert] wrote templates/index.json (${order.length} templates)`); diff --git a/src/shared/swayproject.js b/src/shared/swayproject.js new file mode 100644 index 0000000..c39c009 --- /dev/null +++ b/src/shared/swayproject.js @@ -0,0 +1,419 @@ +// The .sway project document — schema, defaults, validation, legacy conversion. +// Shared between the main process (CommonJS require) and the renderer bundle +// (esbuild interops the require form), so this file must stay dependency-free +// and must not touch fs, DOM, or Electron. +// +// A .sway file is one pretty-printed JSON document: header keys that mirror a +// .tasmo manifest (format / format_version / audio_mode / dates) plus a single +// `project` object. A future v2 can wrap the same two parts in a ZIP +// (manifest.json + project.json + audio/*) without renaming a key. Media is +// linked, never embedded, in v1: `project.media[]` is the only place file +// paths live, and pads and timeline clips reference media by id. + +'use strict'; + +const FORMAT = 'sway'; +const FORMAT_VERSION = 1; + +const PALETTE_FALLBACK = ['#ff2d95', '#7a0bc0', '#2de1fc', '#f9f871', '#ff6b35']; +const GESTURE_SOURCES = ['xy:x', 'xy:y', 'gesture:pulse', 'gesture:press', 'gesture:sway']; +const TARGET_NAMESPACES = ['engine', 'fx', 'synth', 'sampler', 'transport']; +const PAD_ACTION_TYPES = ['sample', 'scene', 'fxPunch']; +const CURVES = ['linear', 'detent']; +const NOTE_ROUTING = ['always', 'unassigned', 'off']; +const SAMPLER_KNOB_TARGETS = ['sampler:master', 'sampler:cutoff', 'sampler:rate', 'sampler:send']; + +const HEX_COLOR = /^#[0-9a-f]{6}$/i; + +let uidCounter = 0; +function uid(prefix) { + uidCounter = (uidCounter + 1) % 0xffff; + return `${prefix}-${Date.now().toString(36)}${uidCounter.toString(36)}`; +} + +// Knob defaults reproduce the behavior the engine used to hardwire (hue on 0, +// fade length on 1, intensity on 2) and give the sampler knobs their factory +// slots on the right cluster. +function defaultKnobs() { + return [ + { target: 'engine:hue', min: 0, max: 1, curve: 'detent' }, + { target: 'engine:fadeTime', min: 1, max: 8, curve: 'linear' }, + { target: 'engine:intensity', min: 0, max: 1, curve: 'linear' }, + null, + { target: 'sampler:master', min: 0, max: 1, curve: 'linear' }, + { target: 'sampler:cutoff', min: 0, max: 1, curve: 'linear' }, + { target: 'sampler:rate', min: 0, max: 1, curve: 'linear' }, + { target: 'sampler:send', min: 0, max: 1, curve: 'linear' }, + ]; +} + +function defaultAssignments() { + return { + version: 1, + noteRouting: { synth: 'unassigned' }, + pads: new Array(16).fill(null), + knobs: defaultKnobs(), + // Slot = physical button index; the Sway's button CC numbers are not + // published, so cc stays null until LEARN fills it. + buttons: Array.from({ length: 8 }, () => ({ cc: null, channel: null, action: null })), + gestures: [], + }; +} + +function defaultProject() { + return { + format: FORMAT, + format_version: FORMAT_VERSION, + app_version: '', + audio_mode: 'linked', + created_at: null, + modified_at: null, + project: { + meta: { + id: 'untitled', + name: 'Untitled', + description: '', + author: '', + vibe: '', + bpmHint: 0, + pairsWith: null, + template: false, + }, + palette: PALETTE_FALLBACK.slice(), + engine: { + quality: 'med', + fxEnabled: false, + autoVJ: { enabled: true, pool: [], minHold: 18, maxHold: 40, fadeTime: 4 }, + start: { scene: null }, + }, + fx: { params: {} }, + synth: { enabled: true, preset: 'Init', patch: null }, + media: [], + sampler: { + kit: { version: 1, pads: new Array(16).fill(null) }, + knobs: { master: 0.5, cutoff: 0.5, rate: 0.5, send: 0.5 }, + }, + timeline: { + loop: { enabled: false, start: 0, end: 0 }, + locators: [], + tracks: [ + { id: 'audio-1', type: 'audio', name: 'Audio', gain: 1, muted: false, clips: [] }, + { id: 'visual-1', type: 'visual', name: 'Scenes', clips: [] }, + ], + }, + assignments: defaultAssignments(), + midiOverrides: {}, + }, + }; +} + +function parseTarget(str) { + if (typeof str !== 'string') return null; + const at = str.indexOf(':'); + if (at < 1) return null; + const ns = str.slice(0, at); + const key = str.slice(at + 1); + if (!TARGET_NAMESPACES.includes(ns) || !key) return null; + return { ns, key }; +} + +function applyCurve(curve, v) { + // Curves map a 0..1 control position before the min/max range is applied. + // 'detent' preserves the old knob-0 hue semantics: center = exactly zero. + if (curve === 'detent') return Math.abs(v - 0.5) < 0.004 ? 0 : v; + return v; +} + +// --- validation -------------------------------------------------------------- + +function isObj(v) { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +function num(v, fallback, lo, hi) { + const n = Number(v); + if (!Number.isFinite(n)) return fallback; + if (lo !== undefined && n < lo) return lo; + if (hi !== undefined && n > hi) return hi; + return n; +} + +function str(v, fallback) { + return typeof v === 'string' ? v : fallback; +} + +function bool(v, fallback) { + return typeof v === 'boolean' ? v : fallback; +} + +// Fills every missing key with its default, clamps what it understands, and +// keeps keys it does not recognize so a newer minor version's data survives a +// round-trip. Never throws on content: problems land in `warnings`, matching +// the sampler.setKit posture. Structural rejection (wrong format, newer +// format_version) is the caller's job — see projectfile.readProject. +function validateProject(input) { + const warnings = []; + const doc = isObj(input) ? input : {}; + const out = { ...defaultProject(), ...doc }; + out.format = FORMAT; + out.format_version = FORMAT_VERSION; + out.audio_mode = 'linked'; + + const dp = defaultProject().project; + const p = (out.project = { ...dp, ...(isObj(doc.project) ? doc.project : {}) }); + + p.meta = { ...dp.meta, ...(isObj(p.meta) ? p.meta : {}) }; + p.meta.name = str(p.meta.name, 'Untitled') || 'Untitled'; + p.meta.id = str(p.meta.id, 'untitled') || 'untitled'; + p.meta.bpmHint = num(p.meta.bpmHint, 0, 0, 999); + p.meta.template = bool(p.meta.template, false); + + // Palette: exactly five hex entries; short arrays repeat cyclically the way + // colormaster accepts them, junk falls back per slot. + const rawPal = Array.isArray(p.palette) && p.palette.length ? p.palette : PALETTE_FALLBACK; + p.palette = Array.from({ length: 5 }, (_, i) => { + const c = rawPal[i % rawPal.length]; + if (typeof c === 'string' && HEX_COLOR.test(c)) return c.toLowerCase(); + warnings.push(`palette[${i}] is not a #rrggbb color; default used`); + return PALETTE_FALLBACK[i]; + }); + + p.engine = { ...dp.engine, ...(isObj(p.engine) ? p.engine : {}) }; + if (!['low', 'med', 'high'].includes(p.engine.quality)) p.engine.quality = 'med'; + p.engine.fxEnabled = bool(p.engine.fxEnabled, false); + const av = (p.engine.autoVJ = { ...dp.engine.autoVJ, ...(isObj(p.engine.autoVJ) ? p.engine.autoVJ : {}) }); + av.enabled = bool(av.enabled, true); + av.pool = (Array.isArray(av.pool) ? av.pool : []).filter((s) => typeof s === 'string'); + av.minHold = num(av.minHold, 18, 1, 3600); + av.maxHold = num(av.maxHold, 40, av.minHold, 7200); + av.fadeTime = num(av.fadeTime, 4, 0, 60); + p.engine.start = { scene: str(isObj(p.engine.start) ? p.engine.start.scene : null, null) }; + + p.fx = { params: isObj(p.fx) && isObj(p.fx.params) ? p.fx.params : {} }; + + p.synth = { ...dp.synth, ...(isObj(p.synth) ? p.synth : {}) }; + p.synth.enabled = bool(p.synth.enabled, true); + p.synth.preset = str(p.synth.preset, null); + if (!isObj(p.synth.patch)) p.synth.patch = null; + + // Media table — the only path-bearing structure in the document. + const mediaIds = new Set(); + p.media = (Array.isArray(p.media) ? p.media : []).filter((m) => { + if (!isObj(m) || typeof m.id !== 'string' || !m.id || typeof m.path !== 'string' || !m.path) { + warnings.push('media entry without id/path dropped'); + return false; + } + if (mediaIds.has(m.id)) { + warnings.push(`duplicate media id ${m.id} dropped`); + return false; + } + mediaIds.add(m.id); + m.name = str(m.name, m.path.split(/[\\/]/).pop()); + m.sha256 = str(m.sha256, null); + m.bytes = m.bytes == null ? null : num(m.bytes, null, 0); + m.duration = m.duration == null ? null : num(m.duration, null, 0); + return true; + }); + + p.sampler = { ...dp.sampler, ...(isObj(p.sampler) ? p.sampler : {}) }; + const kit = (p.sampler.kit = { version: 1, pads: [], ...(isObj(p.sampler.kit) ? p.sampler.kit : {}) }); + const rawPads = Array.isArray(kit.pads) ? kit.pads : []; + kit.pads = Array.from({ length: 16 }, (_, i) => { + const pad = rawPads[i]; + if (!isObj(pad)) return null; + if (typeof pad.id !== 'string' || !mediaIds.has(pad.id)) { + warnings.push(`kit pad ${i} references unknown media; cleared`); + return null; + } + return { + id: pad.id, + gain: num(pad.gain, 1, 0, 4), + pitch: num(pad.pitch, 1, 0.25, 4), + loop: bool(pad.loop, false), + chokeGroup: pad.chokeGroup == null ? null : String(pad.chokeGroup), + mode: ['oneshot', 'loop', 'gate'].includes(pad.mode) ? pad.mode : 'oneshot', + }; + }); + const kn = (p.sampler.knobs = { ...dp.sampler.knobs, ...(isObj(p.sampler.knobs) ? p.sampler.knobs : {}) }); + for (const k of ['master', 'cutoff', 'rate', 'send']) kn[k] = num(kn[k], 0.5, 0, 1); + + // Timeline: exactly one audio track and one visual track in v1. + const tl = (p.timeline = { ...dp.timeline, ...(isObj(p.timeline) ? p.timeline : {}) }); + const loop = (tl.loop = { ...dp.timeline.loop, ...(isObj(tl.loop) ? tl.loop : {}) }); + loop.enabled = bool(loop.enabled, false); + loop.start = num(loop.start, 0, 0); + loop.end = num(loop.end, 0, 0); + if (loop.end <= loop.start) loop.enabled = false; + tl.locators = (Array.isArray(tl.locators) ? tl.locators : []) + .filter((l) => isObj(l) && Number.isFinite(Number(l.time))) + .map((l) => ({ id: str(l.id, uid('loc')), name: str(l.name, ''), time: num(l.time, 0, 0), color: str(l.color, null) })); + + const tracks = Array.isArray(tl.tracks) ? tl.tracks : []; + const audioIn = tracks.find((t) => isObj(t) && t.type === 'audio') || dp.timeline.tracks[0]; + const visualIn = tracks.find((t) => isObj(t) && t.type === 'visual') || dp.timeline.tracks[1]; + const audioTrack = { + id: str(audioIn.id, 'audio-1'), + type: 'audio', + name: str(audioIn.name, 'Audio'), + gain: num(audioIn.gain, 1, 0, 2), + muted: bool(audioIn.muted, false), + clips: (Array.isArray(audioIn.clips) ? audioIn.clips : []) + .filter((c) => { + if (!isObj(c)) return false; + if (typeof c.media !== 'string' || !mediaIds.has(c.media)) { + warnings.push('audio clip references unknown media; dropped'); + return false; + } + return num(c.end, 0, 0) > num(c.start, 0, 0); + }) + .map((c) => ({ + id: str(c.id, uid('c')), + name: str(c.name, ''), + media: c.media, + start: num(c.start, 0, 0), + end: num(c.end, 0, 0), + offset: num(c.offset, 0, 0), + gain: num(c.gain, 1, 0, 4), + fadeIn: num(c.fadeIn, 0, 0, 60), + fadeOut: num(c.fadeOut, 0, 0, 60), + })) + .sort((a, b) => a.start - b.start), + }; + const visualTrack = { + id: str(visualIn.id, 'visual-1'), + type: 'visual', + name: str(visualIn.name, 'Scenes'), + clips: (Array.isArray(visualIn.clips) ? visualIn.clips : []) + .filter((c) => isObj(c) && typeof c.scene === 'string' && num(c.end, 0, 0) > num(c.start, 0, 0)) + .map((c) => { + const tr = isObj(c.transition) ? c.transition : {}; + return { + id: str(c.id, uid('v')), + scene: c.scene, + start: num(c.start, 0, 0), + end: num(c.end, 0, 0), + transition: { + type: tr.type === 'crossfade' ? 'crossfade' : 'cut', + duration: num(tr.duration, 0, 0, 60), + }, + }; + }) + .sort((a, b) => a.start - b.start), + }; + tl.tracks = [audioTrack, visualTrack]; + + // Assignments. + const da = defaultAssignments(); + const asg = (p.assignments = { ...da, ...(isObj(p.assignments) ? p.assignments : {}) }); + asg.version = 1; + const nr = isObj(asg.noteRouting) ? asg.noteRouting : {}; + asg.noteRouting = { synth: NOTE_ROUTING.includes(nr.synth) ? nr.synth : 'unassigned' }; + + const rawPadA = Array.isArray(asg.pads) ? asg.pads : []; + asg.pads = Array.from({ length: 16 }, (_, i) => { + const a = rawPadA[i]; + if (!isObj(a) || !PAD_ACTION_TYPES.includes(a.type)) return null; + if (a.type === 'sample') return { type: 'sample', pad: num(a.pad, i, 0, 15) }; + if (a.type === 'scene') { + if (typeof a.scene !== 'string' || !a.scene) return null; + const tr = isObj(a.transition) ? a.transition : {}; + return { + type: 'scene', + scene: a.scene, + transition: { type: tr.type === 'crossfade' ? 'crossfade' : 'cut', duration: num(tr.duration, 0, 0, 60) }, + }; + } + // fxPunch + if (typeof a.param !== 'string' || !a.param) return null; + return { type: 'fxPunch', param: a.param, value: num(a.value, 1) }; + }); + + const rawKnobA = Array.isArray(asg.knobs) ? asg.knobs : []; + asg.knobs = Array.from({ length: 8 }, (_, i) => { + const a = rawKnobA[i]; + if (a === undefined) return da.knobs[i]; + if (!isObj(a)) return null; + if (!parseTarget(a.target)) { + warnings.push(`knob ${i} has an invalid target; cleared`); + return null; + } + return { + target: a.target, + min: num(a.min, 0), + max: num(a.max, 1), + curve: CURVES.includes(a.curve) ? a.curve : 'linear', + }; + }); + + const rawBtnA = Array.isArray(asg.buttons) ? asg.buttons : []; + asg.buttons = Array.from({ length: 8 }, (_, i) => { + const b = rawBtnA[i]; + if (!isObj(b)) return { cc: null, channel: null, action: null }; + let action = null; + if (isObj(b.action) && b.action.type === 'toggle' && parseTarget(b.action.target)) { + action = { type: 'toggle', target: b.action.target }; + } + return { + cc: b.cc == null ? null : num(b.cc, null, 0, 127), + channel: b.channel == null ? null : num(b.channel, null, 0, 15), + action, + }; + }); + + asg.gestures = (Array.isArray(asg.gestures) ? asg.gestures : []).filter((g) => { + if (!isObj(g) || !GESTURE_SOURCES.includes(g.source) || !parseTarget(g.target)) { + warnings.push('gesture route with invalid source/target dropped'); + return false; + } + g.min = num(g.min, 0); + g.max = num(g.max, 1); + g.curve = CURVES.includes(g.curve) ? g.curve : 'linear'; + g.enabled = bool(g.enabled, true); + return true; + }); + + p.midiOverrides = isObj(p.midiOverrides) ? p.midiOverrides : {}; + + return { doc: out, warnings }; +} + +// --- legacy conversion ------------------------------------------------------- + +// Maps a legacy projects/.json preset onto a full v1 document. Everything +// the legacy shape does not cover stays at defaults; the result is a template. +function legacyToSway(legacy) { + const base = defaultProject(); + const p = base.project; + p.meta.id = str(legacy.id, 'untitled'); + p.meta.name = str(legacy.name, p.meta.id); + p.meta.description = str(legacy.description, ''); + p.meta.vibe = str(legacy.vibe, ''); + p.meta.bpmHint = num(legacy.bpmHint, 0, 0, 999); + p.meta.pairsWith = str(legacy.pairsWith, null); + p.meta.template = true; + if (Array.isArray(legacy.palette) && legacy.palette.length) p.palette = legacy.palette; + if (Array.isArray(legacy.scenes)) p.engine.autoVJ.pool = legacy.scenes.slice(); + const av = isObj(legacy.autoVJ) ? legacy.autoVJ : null; + if (av) { + p.engine.autoVJ.enabled = !!av.enabled; + p.engine.autoVJ.minHold = num(av.minHold, 18, 1, 3600); + p.engine.autoVJ.maxHold = num(av.maxHold, 40, 1, 7200); + p.engine.autoVJ.fadeTime = num(av.fadeTime, 4, 0, 60); + } + p.engine.start.scene = isObj(legacy.start) ? str(legacy.start.scene, null) : null; + return validateProject(base).doc; +} + +module.exports = { + FORMAT, + FORMAT_VERSION, + GESTURE_SOURCES, + SAMPLER_KNOB_TARGETS, + defaultProject, + defaultAssignments, + validateProject, + legacyToSway, + parseTarget, + applyCurve, + uid, +}; From dabe8a25642b75afbcf4f0b491649e354b6ba526 Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:27:13 -0700 Subject: [PATCH 02/26] Add .sway project IPC surface New src/main/projectfile.js: validated read/write at user-chosen paths (atomic write, app-dir refusal, 8 MB cap), id-gated template listing, recents pruning, streamed sha256 for media. Registers project:openDialog/saveDialog/read/write/recent/templates/readTemplate and files:statAudio; preload exposes them. HUD now names WASAPI loopback (SYSTEM AUDIO) instead of falling through to NO AUDIO. Co-Authored-By: Claude Fable 5 --- src/main/main.js | 67 +++++++++++++++ src/main/projectfile.js | 180 ++++++++++++++++++++++++++++++++++++++++ src/preload/preload.js | 11 +++ src/renderer/app.js | 3 +- 4 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/main/projectfile.js diff --git a/src/main/main.js b/src/main/main.js index 1e69bb4..34f61f3 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -9,6 +9,7 @@ const { app, BrowserWindow, desktopCapturer, dialog, ipcMain, session, shell } = const doctor = require('./doctor'); const audima = require('./audima'); const driver = require('./driver-install'); +const projectfile = require('./projectfile'); const { APP } = require('../shared/constants'); let win = null; @@ -293,6 +294,72 @@ app.whenReady().then(() => { }); ipcMain.handle('projects:list', () => listProjects()); + + // --- .sway project files --- + ipcMain.handle('project:openDialog', async () => { + const settings = readSettings(); + const result = await dialog.showOpenDialog(win, { + title: 'Open project', + defaultPath: settings.lastProjectDir || projectfile.defaultProjectsDir(), + properties: ['openFile'], + filters: [{ name: 'Sway Project', extensions: ['sway'] }], + }); + if (result.canceled || !result.filePaths.length) return null; + writeSettings({ lastProjectDir: path.dirname(result.filePaths[0]) }); + return { path: result.filePaths[0] }; + }); + + ipcMain.handle('project:saveDialog', async (_e, suggestedName) => { + const settings = readSettings(); + const dir = settings.lastProjectDir || projectfile.defaultProjectsDir(); + fs.mkdirSync(dir, { recursive: true }); + const base = String(suggestedName || 'Untitled').replace(/[<>:"/\\|?*]/g, ''); + const result = await dialog.showSaveDialog(win, { + title: 'Save project', + defaultPath: path.join(dir, `${base}.sway`), + filters: [{ name: 'Sway Project', extensions: ['sway'] }], + }); + if (result.canceled || !result.filePath) return null; + const filePath = result.filePath.toLowerCase().endsWith('.sway') ? result.filePath : `${result.filePath}.sway`; + writeSettings({ lastProjectDir: path.dirname(filePath) }); + return { path: filePath }; + }); + + ipcMain.handle('project:read', (_e, filePath) => { + const result = projectfile.readProject(filePath); + const settings = readSettings(); + writeSettings({ + recentProjects: projectfile.pushRecent(settings.recentProjects, { + path: result.path, + name: result.doc.project.meta.name, + }), + }); + return result; + }); + + ipcMain.handle('project:write', (_e, filePath, doc) => { + const result = projectfile.writeProject(filePath, doc); + const settings = readSettings(); + writeSettings({ + recentProjects: projectfile.pushRecent(settings.recentProjects, { + path: result.path, + name: (doc && doc.project && doc.project.meta && doc.project.meta.name) || path.basename(result.path, '.sway'), + }), + }); + return result; + }); + + ipcMain.handle('project:recent', () => { + const settings = readSettings(); + const pruned = projectfile.pruneRecents(settings.recentProjects); + if ((settings.recentProjects || []).length !== pruned.length) writeSettings({ recentProjects: pruned }); + return pruned; + }); + + ipcMain.handle('project:templates', () => projectfile.listTemplates()); + ipcMain.handle('project:readTemplate', (_e, id) => projectfile.readTemplate(id)); + ipcMain.handle('files:statAudio', (_e, filePath) => projectfile.statAudio(filePath, MAX_AUDIO_BYTES)); + ipcMain.handle('docs:list', () => listDocs()); ipcMain.handle('docs:read', (_e, id) => readDoc(id)); ipcMain.handle('files:pickAudio', () => pickAudioFiles()); diff --git a/src/main/projectfile.js b/src/main/projectfile.js new file mode 100644 index 0000000..be7ac94 --- /dev/null +++ b/src/main/projectfile.js @@ -0,0 +1,180 @@ +// .sway project file I/O — the application's only write channel. Reads and +// writes validated documents at user-chosen paths; bundled templates are +// id-gated the same way DOC_ORDER gates docs:read, so no caller-supplied path +// reaches the filesystem through the template surface. + +'use strict'; + +const path = require('node:path'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const { app } = require('electron'); + +const { FORMAT, FORMAT_VERSION, validateProject } = require('../shared/swayproject'); + +// A v1 document is kilobytes of JSON; the cap exists to keep the read channel +// from being pointed at anything else. +const MAX_PROJECT_BYTES = 8 * 1024 * 1024; + +function defaultProjectsDir() { + return path.join(app.getPath('documents'), 'SwayCommand Projects'); +} + +function templatesDir() { + // package root in dev; resources/app.asar root when packaged + return path.join(__dirname, '..', '..', 'projects', 'templates'); +} + +function templateOrder() { + try { + const index = JSON.parse(fs.readFileSync(path.join(templatesDir(), 'index.json'), 'utf8')); + return Array.isArray(index.order) ? index.order : []; + } catch { + return []; + } +} + +function assertSwayPath(filePath) { + if (typeof filePath !== 'string' || path.extname(filePath).toLowerCase() !== '.sway') { + throw new Error('Project files use the .sway extension'); + } +} + +function readProject(filePath) { + assertSwayPath(filePath); + const stat = fs.statSync(filePath); + if (stat.size > MAX_PROJECT_BYTES) { + throw new Error(`${path.basename(filePath)} is not a project file (too large)`); + } + const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (raw.format !== FORMAT) throw new Error(`${path.basename(filePath)} is not a .sway project`); + const fv = Number(raw.format_version); + if (fv > FORMAT_VERSION) { + throw new Error(`This project uses format v${fv}; this build supports up to v${FORMAT_VERSION} — update AKSWAYJ to open it.`); + } + const { doc, warnings } = validateProject(raw); + const dir = path.dirname(filePath); + for (const m of doc.project.media) { + m.resolvedPath = path.isAbsolute(m.path) ? m.path : path.resolve(dir, m.path); + } + return { doc, path: filePath, dir, warnings }; +} + +function writeProject(filePath, input) { + assertSwayPath(filePath); + const appRoot = app.getAppPath(); + const resolved = path.resolve(filePath); + if (resolved === appRoot || resolved.startsWith(appRoot + path.sep)) { + throw new Error('Projects cannot be saved inside the application directory'); + } + const { doc, warnings } = validateProject(input); + const now = new Date().toISOString(); + if (!doc.created_at) doc.created_at = now; + doc.modified_at = now; + doc.app_version = app.getVersion(); + const dir = path.dirname(resolved); + for (const m of doc.project.media) { + // Prefer the freshest absolute path the renderer knows, then store it + // project-relative when the file sits under the project directory. + const abs = m.resolvedPath && path.isAbsolute(m.resolvedPath) + ? m.resolvedPath + : path.isAbsolute(m.path) ? m.path : path.resolve(dir, m.path); + delete m.resolvedPath; + const rel = path.relative(dir, abs); + m.path = rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : abs; + } + fs.mkdirSync(dir, { recursive: true }); + const tmp = resolved + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(doc, null, 2) + '\n'); + fs.renameSync(tmp, resolved); + return { ok: true, path: resolved, warnings }; +} + +function listTemplates() { + const dir = templatesDir(); + return templateOrder() + .map((id) => { + try { + const doc = JSON.parse(fs.readFileSync(path.join(dir, `${id}.sway`), 'utf8')); + const meta = (doc.project && doc.project.meta) || {}; + return { + 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 null; + } + }) + .filter(Boolean); +} + +function readTemplate(id) { + // Only ids the bundled index enumerates are readable. + if (!templateOrder().includes(id)) throw new Error(`Unknown template: ${id}`); + const raw = JSON.parse(fs.readFileSync(path.join(templatesDir(), `${id}.sway`), 'utf8')); + const { doc, warnings } = validateProject(raw); + return { doc, path: null, dir: null, warnings }; +} + +// --- recents (stored under settings.recentProjects) -------------------------- + +const MAX_RECENTS = 10; + +function pruneRecents(list) { + const seen = new Set(); + const out = []; + for (const r of Array.isArray(list) ? list : []) { + if (!r || typeof r.path !== 'string' || seen.has(r.path)) continue; + seen.add(r.path); + try { + fs.accessSync(r.path); + } catch { + continue; + } + out.push({ path: r.path, name: typeof r.name === 'string' ? r.name : path.basename(r.path, '.sway'), openedAt: r.openedAt || null }); + if (out.length >= MAX_RECENTS) break; + } + return out; +} + +function pushRecent(list, entry) { + return pruneRecents([{ ...entry, openedAt: new Date().toISOString() }, ...(Array.isArray(list) ? list : [])]); +} + +// --- media hashing ----------------------------------------------------------- + +function statAudio(filePath, maxBytes) { + return new Promise((resolve, reject) => { + let stat; + try { + stat = fs.statSync(filePath); + } catch (err) { + return reject(err); + } + if (maxBytes && stat.size > maxBytes) { + return reject(new Error(`${path.basename(filePath)} is ${Math.round(stat.size / 1e6)} MB; the limit is ${maxBytes / 1e6} MB`)); + } + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve({ bytes: stat.size, sha256: hash.digest('hex') })); + }); +} + +module.exports = { + MAX_PROJECT_BYTES, + defaultProjectsDir, + readProject, + writeProject, + listTemplates, + readTemplate, + pruneRecents, + pushRecent, + statAudio, +}; diff --git a/src/preload/preload.js b/src/preload/preload.js index 71d7d33..084a39e 100644 --- a/src/preload/preload.js +++ b/src/preload/preload.js @@ -21,6 +21,16 @@ contextBridge.exposeInMainWorld('akswayj', { list: () => ipcRenderer.invoke('projects:list'), }, + project: { + openDialog: () => ipcRenderer.invoke('project:openDialog'), + saveDialog: (name) => ipcRenderer.invoke('project:saveDialog', name), + read: (filePath) => ipcRenderer.invoke('project:read', filePath), + write: (filePath, doc) => ipcRenderer.invoke('project:write', filePath, doc), + recent: () => ipcRenderer.invoke('project:recent'), + templates: () => ipcRenderer.invoke('project:templates'), + readTemplate: (id) => ipcRenderer.invoke('project:readTemplate', id), + }, + docs: { list: () => ipcRenderer.invoke('docs:list'), read: (id) => ipcRenderer.invoke('docs:read', id), @@ -29,6 +39,7 @@ contextBridge.exposeInMainWorld('akswayj', { files: { pickAudio: () => ipcRenderer.invoke('files:pickAudio'), readAudio: (filePath) => ipcRenderer.invoke('files:readAudio', filePath), + statAudio: (filePath) => ipcRenderer.invoke('files:statAudio', filePath), }, platform: { diff --git a/src/renderer/app.js b/src/renderer/app.js index b88b1d6..e9481c7 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -860,7 +860,8 @@ function updateHud() { midiPill.classList.toggle('pill-on', c.isSway || c.connected); const a = state.audio.state; - $('#hud-audio').textContent = a.source === 'input' ? 'LIVE AUDIO' : a.source === 'internal' ? 'INT. GROOVE' : 'NO AUDIO'; + $('#hud-audio').textContent = + a.source === 'input' ? 'LIVE AUDIO' : a.source === 'system' ? 'SYSTEM AUDIO' : a.source === 'internal' ? 'INT. GROOVE' : 'NO AUDIO'; if (state.monitorVisible) { $('#midi-monitor').textContent = state.midi.monitor.join('\n') || '(waiting for MIDI…)'; From b42f1b714519f0a05c089712728f5fefbd85d5f2 Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:29:32 -0700 Subject: [PATCH 03/26] Engine: instant cut, prewarm, ResizeObserver, frame hook, .sway apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cutTo(id) swaps slots without the 0.1 s fade floor; crossfadeTo routes sub-0.12 s requests through it. prewarm() compiles one cold scene per frame after a project apply. A ResizeObserver on the canvas makes the stage safe in a grid cell. Knobs 0-2 are no longer hardwired in the frame loop — engine.params {hue, intensity} takes their place and autoVJ.fadeTime stops being overwritten every frame (app.js carries a change-driven shim until the assignment router lands). setFrameHook() reserves the router/transport slot. applyProject() loads the .sway shape, replaying fx params through the rack's clamping. fxrack now exports DECKS beside RANGES (drops the three phantom keys, surfaces audioReactive and asciiAccent). Co-Authored-By: Claude Fable 5 --- src/renderer/app.js | 41 ++++++++++++----- src/renderer/engine/engine.js | 84 +++++++++++++++++++++++++++++++++-- src/renderer/engine/fxrack.js | 13 ++++++ 3 files changed, 122 insertions(+), 16 deletions(-) diff --git a/src/renderer/app.js b/src/renderer/app.js index e9481c7..5ee1851 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -11,7 +11,7 @@ import { createMidi } from './midi/midi.js'; import { renderMarkdown, slugify } from './markdown.js'; import { createSampler } from './audio/sampler.js'; import { createSynth, PRESET_NAMES, TABLE_NAMES, MOD_SOURCES, MOD_DESTS } from './audio/synth.js'; -import { RANGES as fxRanges } from './engine/fxrack.js'; +import { RANGES as fxRanges, DECKS as fxDecks } from './engine/fxrack.js'; const $ = (sel) => document.querySelector(sel); @@ -584,22 +584,14 @@ function studioKeyUp(e) { } // --- effects rack ------------------------------------------------------------ -// Controls are generated from the rack's own RANGES table, so the panel cannot -// drift out of step with what setParam actually accepts. - -const FX_DECKS = [ - { name: 'Geometrics', keys: ['mirrorX', 'mirrorY', 'kaleidoscope', 'softEdges', 'tiling', 'radialSpokes', 'feedback', 'zoomPunch'] }, - { name: 'Corruption', keys: ['glitch', 'rgbGhost', 'rgbSplit', 'chromaAb', 'chromaAbRadial', 'waveWarp', 'pixelate', 'backskip'] }, - { name: 'Chromatics', keys: ['hue', 'saturation', 'contrast', 'brightness', 'invert', 'edgeDetect', 'sepia', 'grayscale', 'blur', 'scanlines', 'crt', 'vignette'] }, - { name: 'Timecode', keys: ['echoTrails', 'strobe', 'posterizeTime', 'slitScan', 'timeDisplace'] }, - { name: 'ASCII', keys: ['ascii', 'asciiCols', 'asciiMono', 'asciiPalette'] }, -]; +// Controls are generated from the rack's own RANGES table and DECKS grouping, +// so the panel cannot drift out of step with what setParam actually accepts. function renderFxPanel() { const rack = state.engine.fx; const ranges = fxRanges; const html = []; - for (const deck of FX_DECKS) { + for (const deck of fxDecks) { const rows = []; for (const key of deck.keys) { const spec = ranges[key]; @@ -837,7 +829,32 @@ function startPerformance(project) { bumpHelpBar(); } +// Interim, replaced by the assignment router: mirror hardware knobs 0-2 onto +// the engine params they used to be hardwired to. Change-driven so an idle +// knob never stomps a project's fadeTime or a UI edit. +const knobShim = { last: null }; +function applyKnobShim() { + const k = state.midi.control.knobs; + if (!knobShim.last) { + knobShim.last = [k[0], k[1], k[2]]; + return; + } + if (k[0] !== knobShim.last[0]) { + knobShim.last[0] = k[0]; + state.engine.params.hue = k[0] === 0.5 ? 0 : k[0]; + } + if (k[1] !== knobShim.last[1]) { + knobShim.last[1] = k[1]; + state.engine.autoVJ.fadeTime = 1 + k[1] * 7; + } + if (k[2] !== knobShim.last[2]) { + knobShim.last[2] = k[2]; + state.engine.params.intensity = k[2]; + } +} + function updateHud() { + applyKnobShim(); if (state.screen === 'studio') { // The analyser is shared, so the meter reflects whatever source is live. state.audio.update(1 / 60); diff --git a/src/renderer/engine/engine.js b/src/renderer/engine/engine.js index 276fe2c..9dedb77 100644 --- a/src/renderer/engine/engine.js +++ b/src/renderer/engine/engine.js @@ -92,7 +92,16 @@ export function createEngine({ canvas, quality = 'med' }) { let fading = false; let fadeTime = 4; + function cutTo(id) { + if (!creators[id]) return; + slotA = id; + slotB = null; + mix = 0; + fading = false; + } + function crossfadeTo(id, seconds) { + if (seconds <= 0.12) return cutTo(id); if (id === slotA && !fading) return; if (fading) { // settle the current fade instantly, then start the new one @@ -104,6 +113,18 @@ export function createEngine({ canvas, quality = 'med' }) { fadeTime = Math.max(0.1, seconds); } + // Cold scene instancing builds geometry and compiles shaders mid-frame, so + // a project load queues its scenes here and the loop warms one per frame. + const warmQueue = []; + let warmResolvers = []; + function prewarm(ids = autoVJ.pool) { + for (const id of ids) { + if (creators[id] && !instances.has(id) && !warmQueue.includes(id)) warmQueue.push(id); + } + if (!warmQueue.length) return Promise.resolve(); + return new Promise((resolve) => warmResolvers.push(resolve)); + } + // --- Auto-VJ (VfxController pattern) ------------------------------------------ const autoVJ = { @@ -143,6 +164,11 @@ export function createEngine({ canvas, quality = 'med' }) { const stats = { fps: 0, frames: 0, acc: 0 }; + // Engine-level performance parameters. These used to be hardwired to knobs + // 0/1/2 inside the frame loop; the assignment router writes them now, and + // io.knobs keeps mirroring the raw hardware for scenes that read it. + const params = { hue: 0, intensity: 0.5 }; + // The rack costs several fullscreen passes, so it stays out of the pipeline // until something actually enables it. let fxEnabled = false; @@ -153,6 +179,7 @@ export function createEngine({ canvas, quality = 'med' }) { let last = 0; let audioEngine = null; let control = null; + let frameHook = null; // fn(dt, t, io) — router/transport slot, same-frame function frame(now) { if (!running) return; @@ -193,10 +220,24 @@ export function createEngine({ canvas, quality = 'med' }) { io.lastPad = control.lastPad; } - // engine-reserved knobs: 0 hue shift, 1 fade-time scale, 2 master intensity - colorMaster.update(dt, io.knobs[0] === 0.5 ? 0 : io.knobs[0]); - autoVJ.fadeTime = 1 + io.knobs[1] * 7; - io.intensity = 0.25 + io.knobs[2] * 0.75 + io.gestures.pulse * 0.35; + if (frameHook) frameHook(dt, t, io); + + colorMaster.update(dt, params.hue); + io.intensity = 0.25 + params.intensity * 0.75 + io.gestures.pulse * 0.35; + + if (warmQueue.length) { + const id = warmQueue.shift(); + try { + const inst = instance(id); + renderer.compile(inst.scene, inst.camera); + } catch (err) { + console.warn(`[engine] prewarm ${id} failed:`, err.message); + } + if (!warmQueue.length) { + for (const resolve of warmResolvers) resolve(); + warmResolvers = []; + } + } autoVJTick(dt); @@ -255,6 +296,12 @@ export function createEngine({ canvas, quality = 'med' }) { instances.forEach((s) => s.resize(width, height)); } window.addEventListener('resize', resize); + // The stage lives in a grid cell now, so its box changes without a window + // resize (drawer, solo view, breakpoints). The window listener stays as a + // no-op backstop; this is the one that actually fires. + if (typeof ResizeObserver !== 'undefined') { + new ResizeObserver(() => resize()).observe(canvas); + } return { sceneList, @@ -262,6 +309,7 @@ export function createEngine({ canvas, quality = 'med' }) { io, colorMaster, autoVJ, + params, // --- effects rack (ported from the VJ-9000 decks) --- fx: fxRack, @@ -302,9 +350,37 @@ export function createEngine({ canvas, quality = 'med' }) { crossfadeTo(first, 0.8); }, + // .sway-format sibling of loadProject: takes a validated project object + // and replays the fx snapshot through the rack so the file is never + // trusted with raw parameter values. + applyProject(project) { + colorMaster.setPalette(project.palette, 2); + const eng = project.engine || {}; + const av = eng.autoVJ || {}; + autoVJ.pool = (av.pool || []).filter((id) => creators[id]); + autoVJ.enabled = !!av.enabled; + autoVJ.minHold = av.minHold ?? 18; + autoVJ.maxHold = av.maxHold ?? 40; + autoVJ.fadeTime = av.fadeTime ?? 4; + autoVJ.holdLeft = autoVJ.minHold; + fxEnabled = !!eng.fxEnabled; + fxRack.reset(); + for (const [key, value] of Object.entries((project.fx && project.fx.params) || {})) { + fxRack.setParam(key, value); + } + const first = (eng.start && eng.start.scene) || autoVJ.pool[0]; + if (first) cutTo(creators[first] ? first : autoVJ.pool[0]); + prewarm(); + }, + setScene(id, seconds = 2.5) { if (creators[id]) crossfadeTo(id, seconds); }, + cutTo, + prewarm, + setFrameHook(fn) { + frameHook = typeof fn === 'function' ? fn : null; + }, nextScene(seconds = 2.5) { const others = autoVJ.pool.filter((id) => id !== slotA); if (others.length) crossfadeTo(others[(Math.random() * others.length) | 0], seconds); diff --git a/src/renderer/engine/fxrack.js b/src/renderer/engine/fxrack.js index de658bc..24a03fc 100644 --- a/src/renderer/engine/fxrack.js +++ b/src/renderer/engine/fxrack.js @@ -192,6 +192,19 @@ export const RANGES = Object.freeze({ asciiCols: [40, 320], }); +/** + * UI grouping for the rack panel. Lives beside RANGES so the deck list and the + * parameter table cannot drift apart: every key here has a RANGES entry, and + * every RANGES key appears in exactly one deck. + */ +export const DECKS = Object.freeze([ + { name: 'Geometrics', keys: ['mirrorX', 'mirrorY', 'kaleidoscope', 'softEdges', 'tiling', 'radialSpokes', 'feedback'] }, + { name: 'Corruption', keys: ['glitch', 'rgbGhost', 'rgbSplit', 'chromaAb', 'chromaAbRadial', 'waveWarp', 'pixelate', 'backskip'] }, + { name: 'Chromatics', keys: ['hue', 'saturation', 'contrast', 'brightness', 'invert', 'edgeDetect', 'sepia', 'grayscale', 'blur', 'scanlines', 'crt', 'vignette'] }, + { name: 'Timecode', keys: ['echoTrails', 'strobe', 'posterizeTime', 'audioReactive'] }, + { name: 'ASCII', keys: ['ascii', 'asciiCols', 'asciiMono', 'asciiAccent', 'asciiPalette'] }, +]); + // ── ASCII constants — AsciilineRenderer.ts, verbatim ──────────────────────── /** ASCILINE's 93-char ramp, dark (space) to light (@). Verbatim. */ const RAMP = " `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@"; From e2fb1b0b8833f2522da1e04caa11e56ce2d43c16 Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:33:47 -0700 Subject: [PATCH 04/26] Transport and project store src/renderer/audio/transport.js plays the timeline's audio track through AudioBufferSourceNodes scheduled on the context clock (sample-accurate seek, clip fades, loop region) and reports visual-lane clip entries to a single subscriber. src/renderer/project/projectstore.js owns the .sway document lifecycle: open/apply hydration order, sequential media loading with decoded-size pre-flight, collect() reassembly, save/saveAs, and media registration for the kit. Both ride the existing analyser bus, so timeline audio drives the visuals like every other source. Co-Authored-By: Claude Fable 5 --- src/renderer/app.js | 40 ++++- src/renderer/audio/transport.js | 243 +++++++++++++++++++++++++ src/renderer/project/projectstore.js | 260 +++++++++++++++++++++++++++ 3 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 src/renderer/audio/transport.js create mode 100644 src/renderer/project/projectstore.js diff --git a/src/renderer/app.js b/src/renderer/app.js index 5ee1851..cf5df8f 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -12,6 +12,8 @@ import { renderMarkdown, slugify } from './markdown.js'; import { createSampler } from './audio/sampler.js'; import { createSynth, PRESET_NAMES, TABLE_NAMES, MOD_SOURCES, MOD_DESTS } from './audio/synth.js'; import { RANGES as fxRanges, DECKS as fxDecks } from './engine/fxrack.js'; +import { createTransport } from './audio/transport.js'; +import { createProjectStore } from './project/projectstore.js'; const $ = (sel) => document.querySelector(sel); @@ -855,6 +857,7 @@ function applyKnobShim() { function updateHud() { applyKnobShim(); + if (state.transport) state.transport.update(); if (state.screen === 'studio') { // The analyser is shared, so the meter reflects whatever source is live. state.audio.update(1 / 60); @@ -1015,6 +1018,9 @@ async function main() { // The synth shares the sampler's routing: heard on the speakers and mixed // into the analyser, so playing it drives the visuals. state.synth = createSynth(state.audio.ctx, audioOuts); + // Timeline playback rides the same bus, so the backing track drives the + // visuals through the one analyser like every other sound in the app. + state.transport = createTransport(state.audio.ctx, audioOuts); // A pad strike plays its sample and feeds the visuals; the raw note plays // the synth, so the Sway is an instrument as well as a controller. @@ -1038,6 +1044,29 @@ async function main() { state.engine.attachAudio(state.audio); state.engine.attachControl(state.midi.control); + state.projectStore = createProjectStore({ + engine: state.engine, + audio: state.audio, + sampler: state.sampler, + synth: state.synth, + transport: state.transport, + midi: state.midi, + setSynthEnabled: (v) => { + state.synthEnabled = v; + const el = $('#synth-enable'); + if (el) el.checked = v; + }, + }); + // Interim visual-lane routing until the assignment router owns it: seeks + // and play starts cut, clip boundaries honor the clip's transition. + state.transport.onVisualClip(({ clip, cause }) => { + if (cause === 'boundary' && clip.transition.type === 'crossfade') { + state.engine.setScene(clip.scene, clip.transition.duration); + } else { + state.engine.cutTo(clip.scene); + } + }); + // restore learned MIDI bindings const settings = await window.akswayj.settings.get(); if (settings.midiOverrides) state.midi.setOverrides(settings.midiOverrides); @@ -1049,7 +1078,16 @@ async function main() { // Automation handle. The page CSP admits no remote or inline script, so this // is reachable only from the app's own bundle and from AKSWAYJ_PROBE, which // is how the build is verified headlessly (see docs/ENVIRONMENT.md). - window.__akswayj = { state, studio, openStudio, openDocs, renderPads, renderSamples }; + window.__akswayj = { + state, + studio, + openStudio, + openDocs, + renderPads, + renderSamples, + transport: state.transport, + projectStore: state.projectStore, + }; wireDoctor(); wireProjects(); diff --git a/src/renderer/audio/transport.js b/src/renderer/audio/transport.js new file mode 100644 index 0000000..90fddde --- /dev/null +++ b/src/renderer/audio/transport.js @@ -0,0 +1,243 @@ +// Transport — timeline playback for one audio track and one visual track. +// Audio clips are AudioBufferSourceNodes scheduled against the AudioContext +// clock (sample-accurate seek/loop/fades; the page CSP forbids file:// media, +// so buffers arrive decoded from the project store). The visual track never +// touches the engine directly: entering a clip fires the onVisualClip +// subscriber (the router), which decides cut versus crossfade. +// +// Clock: position = ctx.currentTime - startedAt while playing, so the +// transport cannot drift against the audio it schedules. The loop seam is +// checked once per update() call (frame-quantized; accepted for v1). + +export function createTransport(ctx, destinationNodes) { + const outs = Array.isArray(destinationNodes) ? destinationNodes : [destinationNodes]; + const master = ctx.createGain(); + for (const node of outs) { + try { + master.connect(node); + } catch { + /* duplicate connections are harmless */ + } + } + + const state = { + playing: false, + position: 0, + duration: 0, + loop: { enabled: false, start: 0, end: 0 }, + activeVisualClip: null, // clip id or null + }; + + let timeline = null; // the project.timeline object, edited live by the UI + const buffers = new Map(); // mediaId -> AudioBuffer + let startedAt = 0; + let live = []; // [{ src, gain, clip }] + let visualCb = null; + let pendingVisualCause = null; // fire the clip under the playhead on next update + + function audioTrack() { + return timeline ? timeline.tracks.find((t) => t.type === 'audio') : null; + } + function visualTrack() { + return timeline ? timeline.tracks.find((t) => t.type === 'visual') : null; + } + + function refreshDuration() { + let end = 0; + if (timeline) { + for (const t of timeline.tracks) { + for (const c of t.clips) end = Math.max(end, c.end); + } + } + state.duration = end; + } + + function stopSources() { + for (const v of live) { + try { + v.src.onended = null; + v.src.stop(); + } catch { + /* already ended */ + } + v.src.disconnect(); + v.gain.disconnect(); + } + live = []; + } + + // (Re)schedules every audio clip still ahead of `position`. Clips whose + // buffer has not arrived yet are skipped; setBuffer reschedules when it + // lands, so late media joins the mix at the right offset. + function scheduleFrom(position) { + stopSources(); + const track = audioTrack(); + if (!track || track.muted) return; + const now = ctx.currentTime; + for (const clip of track.clips) { + if (clip.end <= position) continue; + const buffer = buffers.get(clip.media); + if (!buffer) continue; + const src = ctx.createBufferSource(); + src.buffer = buffer; + const gain = ctx.createGain(); + src.connect(gain); + gain.connect(master); + + const startsAhead = clip.start > position; + const when = startsAhead ? now + (clip.start - position) : now; + const offset = clip.offset + (startsAhead ? 0 : position - clip.start); + const remaining = clip.end - (startsAhead ? clip.start : position); + if (remaining <= 0 || offset >= buffer.duration) { + gain.disconnect(); + continue; + } + + const level = clip.gain * (track.gain ?? 1); + const inEnd = clip.start + clip.fadeIn; + const outStart = clip.end - clip.fadeOut; + gain.gain.setValueAtTime(level, when); + if (clip.fadeIn > 0 && inEnd > Math.max(clip.start, position)) { + const from = startsAhead ? 0 : level * Math.min(1, (position - clip.start) / clip.fadeIn); + gain.gain.setValueAtTime(from, when); + gain.gain.linearRampToValueAtTime(level, when + (inEnd - Math.max(clip.start, position))); + } + if (clip.fadeOut > 0 && outStart > Math.max(clip.start, position)) { + gain.gain.setValueAtTime(level, when + (outStart - Math.max(clip.start, position))); + gain.gain.linearRampToValueAtTime(0, when + (clip.end - Math.max(clip.start, position))); + } + + src.start(when, offset, Math.min(remaining, buffer.duration - offset)); + live.push({ src, gain, clip }); + } + } + + function visualClipAt(position) { + const track = visualTrack(); + if (!track) return null; + for (const clip of track.clips) { + if (position >= clip.start && position < clip.end) return clip; + } + return null; + } + + function fireVisual(clip, cause) { + state.activeVisualClip = clip ? clip.id : null; + if (clip && visualCb) visualCb({ clip, cause }); + } + + function seekInternal(seconds, cause) { + state.position = Math.max(0, Math.min(seconds, state.duration)); + if (state.playing) { + startedAt = ctx.currentTime - state.position; + scheduleFrom(state.position); + } + pendingVisualCause = null; + fireVisual(visualClipAt(state.position), cause); + } + + function pauseInternal() { + if (!state.playing) return; + state.position = ctx.currentTime - startedAt; + state.playing = false; + stopSources(); + } + + return { + state, + + setTimeline(tl) { + timeline = tl; + refreshDuration(); + state.loop = tl ? tl.loop : { enabled: false, start: 0, end: 0 }; + stopSources(); + state.playing = false; + state.position = 0; + state.activeVisualClip = null; + }, + + // Re-sorts clips and recomputes duration after the UI edits the timeline + // in place; reschedules if audio is rolling. + refresh() { + if (!timeline) return; + for (const t of timeline.tracks) t.clips.sort((a, b) => a.start - b.start); + refreshDuration(); + if (state.playing) scheduleFrom(state.position); + }, + + setBuffer(mediaId, audioBuffer) { + if (audioBuffer) buffers.set(mediaId, audioBuffer); + else buffers.delete(mediaId); + if (state.playing) scheduleFrom(state.position); + }, + hasBuffer(mediaId) { + return buffers.has(mediaId); + }, + getBuffer(mediaId) { + return buffers.get(mediaId) || null; + }, + + play() { + if (state.playing || !timeline) return; + if (ctx.state === 'suspended') ctx.resume().catch(() => {}); + state.playing = true; + startedAt = ctx.currentTime - state.position; + scheduleFrom(state.position); + pendingVisualCause = null; + fireVisual(visualClipAt(state.position), 'play'); + }, + + pause: pauseInternal, + + stop() { + state.playing = false; + stopSources(); + state.position = 0; + state.activeVisualClip = null; + }, + + seek(seconds) { + seekInternal(seconds, 'seek'); + }, + + setLoop(start, end, enabled) { + state.loop.start = Math.max(0, start); + state.loop.end = Math.max(state.loop.start, end); + state.loop.enabled = !!enabled && state.loop.end > state.loop.start; + if (timeline) timeline.loop = state.loop; + }, + + onVisualClip(cb) { + visualCb = cb; + }, + + update() { + if (!state.playing) return; + state.position = ctx.currentTime - startedAt; + if (state.loop.enabled && state.position >= state.loop.end) { + seekInternal(state.loop.start, 'seek'); + return; + } + if (state.position >= state.duration && state.duration > 0) { + pauseInternal(); + state.position = state.duration; + return; + } + const clip = visualClipAt(state.position); + const id = clip ? clip.id : null; + if (id !== state.activeVisualClip) fireVisual(clip, pendingVisualCause || 'boundary'); + pendingVisualCause = null; + }, + + collect() { + if (timeline) refreshDuration(); + return timeline; + }, + + dispose() { + stopSources(); + master.disconnect(); + buffers.clear(); + }, + }; +} diff --git a/src/renderer/project/projectstore.js b/src/renderer/project/projectstore.js new file mode 100644 index 0000000..5cb5108 --- /dev/null +++ b/src/renderer/project/projectstore.js @@ -0,0 +1,260 @@ +// Project store — owns the current .sway document and its lifecycle: open, +// hydrate the live modules, load media, reassemble on save. The document is +// the single source of truth for meta, palette, media, timeline, and +// assignments; the timeline and assignment objects are handed to the +// transport and router BY REFERENCE, so UI edits mutate the document +// directly and collect() only has to refresh what live modules own +// (kit, synth patch, fx params, autoVJ, midi overrides). + +import { validateProject, uid } from '../../shared/swayproject.js'; + +// decodeAudioData expands to Float32 PCM; a long compressed file can decode +// to gigabytes. Estimated from cached duration when known. +const DECODED_WARN_BYTES = 600e6; +const DECODED_REFUSE_BYTES = 1.5e9; + +export function createProjectStore(deps) { + const { engine, audio, sampler, synth, transport, midi } = deps; + let router = deps.router || null; + let setSynthEnabled = deps.setSynthEnabled || (() => {}); + + const state = { + path: null, + name: 'Untitled', + dirty: false, + loading: false, + mediaLoading: 0, // count of media files still decoding + warnings: [], + }; + + let doc = null; + + function project() { + return doc ? doc.project : null; + } + + function decodedEstimate(media) { + if (!media.duration) return 0; + return media.duration * 48000 * 2 * 4; + } + + async function readMediaBytes(media) { + const p = media.resolvedPath || media.path; + const bytes = await window.akswayj.files.readAudio(p); + return bytes; + } + + // Loads every media file the kit or timeline references. Sequential on + // purpose: one decode at a time keeps the render loop breathing. Never + // awaited by open — the show starts while stems stream in. + async function loadMediaAsync() { + const p = project(); + if (!p) return; + const kitIds = new Set(p.sampler.kit.pads.filter(Boolean).map((pad) => pad.id)); + const clipIds = new Set(); + for (const t of p.timeline.tracks) { + if (t.type !== 'audio') continue; + for (const c of t.clips) clipIds.add(c.media); + } + const wanted = p.media.filter((m) => kitIds.has(m.id) || clipIds.has(m.id)); + state.mediaLoading = wanted.length; + for (const m of wanted) { + try { + const est = decodedEstimate(m); + if (est > DECODED_REFUSE_BYTES) { + state.warnings.push(`${m.name}: would decode to ~${Math.round(est / 1e6)} MB; not loaded`); + continue; + } + if (est > DECODED_WARN_BYTES) { + state.warnings.push(`${m.name}: decodes to ~${Math.round(est / 1e6)} MB`); + } + const bytes = await readMediaBytes(m); + if (kitIds.has(m.id)) { + const info = await sampler.loadSample(m.id, bytes.slice().buffer, { name: m.name }); + if (info && info.duration) m.duration = info.duration; + } + if (clipIds.has(m.id)) { + // A media used by both kit and timeline decodes twice (the sampler + // decodes internally); rare enough that sharing is not worth a + // sampler API change in v1. + const buffer = await audio.ctx.decodeAudioData(bytes.slice().buffer); + m.duration = buffer.duration; + transport.setBuffer(m.id, buffer); + } + } catch (err) { + state.warnings.push(`${m.name}: ${err.message}`); + } finally { + state.mediaLoading--; + } + } + // Pads restore once every kit sample had its chance to load. + if (kitIds.size) { + const result = sampler.setKit(p.sampler.kit); + for (const name of result.missing || []) state.warnings.push(`kit: ${name} missing`); + } + } + + function applySamplerKnobs() { + const kn = project().sampler.knobs; + sampler.setKnob(4, kn.master); + sampler.setKnob(5, kn.cutoff); + sampler.setKnob(6, kn.rate); + sampler.setKnob(7, kn.send); + } + + // Hydration order matters: synth before engine (a patch swap is silent; + // a scene cut is visible), router assignments before the transport starts + // firing visual clips at it. + function applyProject(input, path) { + const { doc: valid, warnings } = validateProject(input); + doc = valid; + const p = doc.project; + state.path = path || null; + state.name = p.meta.name; + state.warnings = warnings; + state.dirty = false; + + if (p.synth.patch) synth.setPatch(p.synth.patch); + else if (p.synth.preset) synth.loadPreset(p.synth.preset); + setSynthEnabled(p.synth.enabled); + + engine.applyProject(p); + if (router) router.setAssignments(p.assignments); + midi.setOverrides({ ...(p.midiOverrides || {}) }); + transport.setTimeline(p.timeline); + applySamplerKnobs(); + + state.loading = false; + loadMediaAsync(); + return warnings; + } + + function collect() { + if (!doc) return null; + const p = doc.project; + p.meta.name = state.name; + p.sampler.kit = sampler.getKit(); + const patch = JSON.parse(JSON.stringify(synth.patch)); + p.synth.patch = patch; + p.synth.preset = patch.name || p.synth.preset; + p.fx.params = { ...engine.fx.params }; + p.engine.fxEnabled = engine.fxEnabled; + p.engine.autoVJ = { + enabled: engine.autoVJ.enabled, + pool: engine.autoVJ.pool.slice(), + minHold: engine.autoVJ.minHold, + maxHold: engine.autoVJ.maxHold, + fadeTime: engine.autoVJ.fadeTime, + }; + if (router) p.assignments = router.getAssignments(); + p.midiOverrides = midi.getOverrides(); + p.timeline = transport.collect() || p.timeline; + return doc; + } + + async function fillMediaStats() { + for (const m of project().media) { + if (m.sha256 && m.bytes) continue; + try { + const info = await window.akswayj.files.statAudio(m.resolvedPath || m.path); + m.sha256 = info.sha256; + m.bytes = info.bytes; + } catch { + /* unreadable media was already reported at load */ + } + } + } + + async function saveTo(path) { + collect(); + await fillMediaStats(); + const result = await window.akswayj.project.write(path, doc); + state.path = result.path; + state.dirty = false; + return result; + } + + return { + state, + get doc() { + return doc; + }, + project, + applyProject, + collect, + setRouter(r) { + router = r; + }, + setSynthEnabledHook(fn) { + setSynthEnabled = fn; + }, + markDirty() { + state.dirty = true; + }, + + async openPath(path) { + state.loading = true; + const result = await window.akswayj.project.read(path); + return applyProject(result.doc, result.path); + }, + + async openTemplate(id) { + state.loading = true; + const result = await window.akswayj.project.readTemplate(id); + return applyProject(result.doc, null); + }, + + async openFromDialog() { + const picked = await window.akswayj.project.openDialog(); + if (!picked) return null; + return this.openPath(picked.path); + }, + + async save() { + if (!state.path) return this.saveAs(); + return saveTo(state.path); + }, + + async saveAs() { + const picked = await window.akswayj.project.saveDialog(state.name); + if (!picked) return null; + return saveTo(picked.path); + }, + + // Registers a picked audio file as project media and loads it into the + // sampler pool; the kit surface assigns it to pads from there. + async addMedia(file) { + const p = project(); + if (!p) return null; + const existing = p.media.find((m) => (m.resolvedPath || m.path) === file.path); + const media = existing || { + id: uid('m'), + name: file.name, + path: file.path, + resolvedPath: file.path, + sha256: null, + bytes: null, + duration: null, + }; + const bytes = await window.akswayj.files.readAudio(file.path); + const info = await sampler.loadSample(media.id, bytes.slice().buffer, { name: media.name }); + if (info && info.duration) media.duration = info.duration; + if (!existing) p.media.push(media); + state.dirty = true; + return media; + }, + + // Decodes a media entry for timeline use (drag onto the audio lane). + async loadMediaBuffer(mediaId) { + const p = project(); + const m = p && p.media.find((x) => x.id === mediaId); + if (!m) return null; + if (transport.hasBuffer(mediaId)) return transport.getBuffer(mediaId); + const bytes = await readMediaBytes(m); + const buffer = await audio.ctx.decodeAudioData(bytes.slice().buffer); + m.duration = buffer.duration; + transport.setBuffer(mediaId, buffer); + return buffer; + }, + }; +} From 7e29dd8d234971acafa96e3a58f9afa338272180 Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:38:11 -0700 Subject: [PATCH 05/26] =?UTF-8?q?Assignment=20router=20=E2=80=94=20one=20d?= =?UTF-8?q?ispatch=20path=20for=20every=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/renderer/control/router.js owns the project's assignment table and dispatches pad strikes (sample / scene cut-or-fade / momentary fx punch), learned-CC button toggles, note routing (assigned pads stop double-firing the synth; Theory Engine pitches still reach it), change-driven knob targets, and per-frame gesture routes (gestures win on shared targets, held punches re-assert last). It runs inside the engine frame via setFrameHook, drives the transport's visual lane (autoVJ suspended while the timeline owns the stage), records the last-touched control for the surface UI, and is midi.learn's first caller. midi.js now emits cc events and carries pad indices on note/noteoff; keyboard pads route through the router, wiring sampler.release for gate mode. Co-Authored-By: Claude Fable 5 --- src/renderer/app.js | 92 ++++----- src/renderer/control/router.js | 280 +++++++++++++++++++++++++++ src/renderer/midi/midi.js | 14 +- src/renderer/project/projectstore.js | 8 +- 4 files changed, 338 insertions(+), 56 deletions(-) create mode 100644 src/renderer/control/router.js diff --git a/src/renderer/app.js b/src/renderer/app.js index cf5df8f..79f226b 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -14,6 +14,7 @@ import { createSynth, PRESET_NAMES, TABLE_NAMES, MOD_SOURCES, MOD_DESTS } from ' import { RANGES as fxRanges, DECKS as fxDecks } from './engine/fxrack.js'; import { createTransport } from './audio/transport.js'; import { createProjectStore } from './project/projectstore.js'; +import { createRouter } from './control/router.js'; const $ = (sel) => document.querySelector(sel); @@ -32,6 +33,9 @@ const state = { midi: null, audio: null, engine: null, + transport: null, + router: null, + projectStore: null, helpVisible: false, monitorVisible: false, }; @@ -412,6 +416,12 @@ async function restoreKit(saved) { } } state.sampler.setKit(saved); + // A restored kit pad must also fire from hardware: give every filled pad a + // sample assignment unless the project already says otherwise. + const pads = state.router.getAssignments().pads; + (saved.pads || []).forEach((pad, i) => { + if (pad && pad.id && !pads[i]) pads[i] = { type: 'sample', pad: i }; + }); if (missing.length) { console.warn('[kit] missing sample files:', missing.join(', ')); } @@ -498,7 +508,7 @@ function renderModMatrix() { function wireSynth() { $('#synth-enable').addEventListener('change', (e) => { state.synthEnabled = e.target.checked; - if (!state.synthEnabled) state.synth.allNotesOff(); + state.router.synthEnabled = e.target.checked; }); $('#synth-preset').addEventListener('change', (e) => { @@ -831,33 +841,7 @@ function startPerformance(project) { bumpHelpBar(); } -// Interim, replaced by the assignment router: mirror hardware knobs 0-2 onto -// the engine params they used to be hardwired to. Change-driven so an idle -// knob never stomps a project's fadeTime or a UI edit. -const knobShim = { last: null }; -function applyKnobShim() { - const k = state.midi.control.knobs; - if (!knobShim.last) { - knobShim.last = [k[0], k[1], k[2]]; - return; - } - if (k[0] !== knobShim.last[0]) { - knobShim.last[0] = k[0]; - state.engine.params.hue = k[0] === 0.5 ? 0 : k[0]; - } - if (k[1] !== knobShim.last[1]) { - knobShim.last[1] = k[1]; - state.engine.autoVJ.fadeTime = 1 + k[1] * 7; - } - if (k[2] !== knobShim.last[2]) { - knobShim.last[2] = k[2]; - state.engine.params.intensity = k[2]; - } -} - function updateHud() { - applyKnobShim(); - if (state.transport) state.transport.update(); if (state.screen === 'studio') { // The analyser is shared, so the meter reflects whatever source is live. state.audio.update(1 / 60); @@ -990,12 +974,19 @@ function wirePerform() { if (pad >= 0 && !e.repeat) { state.midi.control.pads[pad] = 0.9; state.midi.control.lastPad = pad; - state.sampler.trigger(pad, 0.9); + state.router.handleMidiEvent({ kind: 'pad', idx: pad, vel: 0.9 }); } } bumpHelpBar(); }); + // Gate-mode pads need the key release; the router no-ops for other modes. + window.addEventListener('keyup', (e) => { + if (state.screen !== 'perform') return; + const pad = PAD_KEYS.indexOf(e.key.toLowerCase()); + if (pad >= 0) state.router.handleMidiEvent({ kind: 'noteoff', idx: pad }); + }); + $('#btn-back').addEventListener('click', () => { state.engine.stop(); show('projects'); @@ -1022,28 +1013,33 @@ async function main() { // visuals through the one analyser like every other sound in the app. state.transport = createTransport(state.audio.ctx, audioOuts); - // A pad strike plays its sample and feeds the visuals; the raw note plays - // the synth, so the Sway is an instrument as well as a controller. + // Every MIDI event goes to the assignment router — one dispatch path for + // hardware, keyboard pads, and the timeline alike. state.midi = await createMidi({ onEvent: (e) => { - if (e.kind === 'pad' && e.idx >= 0) { - state.sampler.trigger(e.idx, e.vel); - if (state.screen === 'studio') flashPad(e.idx); - } else if (e.kind === 'note') { - if (state.synthEnabled) state.synth.noteOn(e.note, e.vel); - } else if (e.kind === 'noteoff') { - state.synth.noteOff(e.note); - } else if (e.kind === 'bend') { - state.synth.pitchBend(e.value); - } else if (e.kind === 'mod') { - state.synth.modulation(e.value); - } + if (state.router) state.router.handleMidiEvent(e); + if (e.kind === 'pad' && e.idx >= 0 && state.screen === 'studio') flashPad(e.idx); }, }); state.engine = createEngine({ canvas: $('#stage'), quality: 'med' }); state.engine.attachAudio(state.audio); state.engine.attachControl(state.midi.control); + state.router = createRouter({ + engine: state.engine, + sampler: state.sampler, + synth: state.synth, + transport: state.transport, + midi: state.midi, + onDirty: () => state.projectStore && state.projectStore.markDirty(), + }); + state.router.onSynthToggle((v) => { + state.synthEnabled = v; + const el = $('#synth-enable'); + if (el) el.checked = v; + }); + state.engine.setFrameHook(state.router.frame); + state.projectStore = createProjectStore({ engine: state.engine, audio: state.audio, @@ -1051,21 +1047,14 @@ async function main() { synth: state.synth, transport: state.transport, midi: state.midi, + router: state.router, setSynthEnabled: (v) => { state.synthEnabled = v; + state.router.synthEnabled = v; const el = $('#synth-enable'); if (el) el.checked = v; }, }); - // Interim visual-lane routing until the assignment router owns it: seeks - // and play starts cut, clip boundaries honor the clip's transition. - state.transport.onVisualClip(({ clip, cause }) => { - if (cause === 'boundary' && clip.transition.type === 'crossfade') { - state.engine.setScene(clip.scene, clip.transition.duration); - } else { - state.engine.cutTo(clip.scene); - } - }); // restore learned MIDI bindings const settings = await window.akswayj.settings.get(); @@ -1087,6 +1076,7 @@ async function main() { renderSamples, transport: state.transport, projectStore: state.projectStore, + router: state.router, }; wireDoctor(); diff --git a/src/renderer/control/router.js b/src/renderer/control/router.js new file mode 100644 index 0000000..1dbdee2 --- /dev/null +++ b/src/renderer/control/router.js @@ -0,0 +1,280 @@ +// Assignment router — the single dispatch point between the control surface +// (hardware MIDI, keyboard pads) and everything that can be played: sampler, +// synth, engine, fx rack, transport. Owns the project's assignment table and +// applies it three ways: +// * events — pad strikes, button CCs, note routing (handleMidiEvent) +// * frames — knob positions (change-driven) and gesture routes (every +// frame; gestures win over knobs on the same target), via the +// engine frame hook +// * timeline — the transport's visual-lane events (cut vs crossfade), with +// autoVJ suspended while the timeline drives the stage +// It also records the last physically touched control, which is what lets the +// on-screen surface follow the hardware. + +import { defaultAssignments, parseTarget, applyCurve } from '../../shared/swayproject.js'; + +const SAMPLER_KNOB_INDEX = { master: 4, cutoff: 5, rate: 6, send: 7 }; +const KNOB_EPSILON = 1 / 512; + +export function createRouter({ engine, sampler, synth, transport, midi, onDirty }) { + let assignments = defaultAssignments(); + let synthOn = true; + let synthToggleCb = null; + let touchCb = null; + const lastTouched = { id: null, at: 0 }; + + const heldPunches = new Map(); // pad idx -> { param, prev, value } + const lastKnobs = new Array(8).fill(null); + const lastButtonVals = new Array(8).fill(0); + const lastGesture = new Map(); // route index -> last mapped value + const samplerKnobs = { master: 0.5, cutoff: 0.5, rate: 0.5, send: 0.5 }; + let autoVJPrev = null; // engine autoVJ state before the timeline took over + + function touch(id) { + lastTouched.id = id; + lastTouched.at = performance.now(); + if (touchCb) touchCb(id); + } + + function sourceValue(io, source) { + switch (source) { + case 'xy:x': return io.xy.x; + case 'xy:y': return io.xy.y; + case 'gesture:pulse': return io.gestures.pulse; + case 'gesture:press': return io.gestures.press; + case 'gesture:sway': return io.gestures.sway; + default: return 0; + } + } + + // Continuous dispatch. `mapped` is already min/max/curve-applied. + function driveTarget(targetStr, mapped) { + const t = parseTarget(targetStr); + if (!t) return; + switch (t.ns) { + case 'engine': + if (t.key === 'hue') engine.params.hue = mapped; + else if (t.key === 'intensity') engine.params.intensity = mapped; + else if (t.key === 'fadeTime') engine.autoVJ.fadeTime = mapped; + break; + case 'fx': + engine.setFxParam(t.key, mapped); + break; + case 'synth': + if (t.key !== 'enabled') synth.setParam(t.key, mapped); + break; + case 'sampler': { + const idx = SAMPLER_KNOB_INDEX[t.key]; + if (idx !== undefined) { + const v = Math.max(0, Math.min(1, mapped)); + sampler.setKnob(idx, v); + samplerKnobs[t.key] = v; + } + break; + } + // transport targets are toggles; a continuous control cannot drive them + } + } + + function executeToggle(targetStr) { + const t = parseTarget(targetStr); + if (!t) return; + switch (t.ns) { + case 'engine': + if (t.key === 'fxEnabled') engine.fxEnabled = !engine.fxEnabled; + else if (t.key === 'autoVJ') engine.autoVJ.enabled = !engine.autoVJ.enabled; + break; + case 'fx': + engine.setFxParam(t.key, !engine.fx.params[t.key]); + break; + case 'synth': + if (t.key === 'enabled') { + synthOn = !synthOn; + if (!synthOn) synth.allNotesOff(); + if (synthToggleCb) synthToggleCb(synthOn); + } + break; + case 'transport': + if (t.key === 'playPause') transport.state.playing ? transport.pause() : transport.play(); + else if (t.key === 'stop') transport.stop(); + break; + } + } + + function synthWantsNote(idx) { + const mode = assignments.noteRouting.synth; + if (!synthOn || mode === 'off') return false; + if (mode === 'always') return true; + // 'unassigned': pads with an assignment no longer double-fire the synth; + // notes outside the pad range (Theory Engine pitches) always reach it. + return idx == null || idx < 0 || !assignments.pads[idx]; + } + + function firePad(idx, vel) { + const a = assignments.pads[idx]; + touch(`pad:${idx}`); + if (!a) return; + if (a.type === 'sample') { + sampler.trigger(a.pad ?? idx, vel); + } else if (a.type === 'scene') { + engine.autoVJ.enabled = false; + if (a.transition.type === 'crossfade') engine.setScene(a.scene, a.transition.duration); + else engine.cutTo(a.scene); + } else if (a.type === 'fxPunch' && !heldPunches.has(idx)) { + heldPunches.set(idx, { param: a.param, prev: engine.fx.params[a.param], value: a.value }); + engine.setFxParam(a.param, a.value); + } + } + + function releasePad(idx) { + const a = assignments.pads[idx]; + const punch = heldPunches.get(idx); + if (punch) { + heldPunches.delete(idx); + engine.setFxParam(punch.param, punch.prev); + } + if (a && a.type === 'sample') sampler.release(a.pad ?? idx); // gate pads only; no-op otherwise + } + + function handleCc(e) { + // Touch-to-select for the factory continuous targets. + if (e.target) touch(e.target); + + // Button slots match by learned CC number (and channel when pinned). + for (let i = 0; i < assignments.buttons.length; i++) { + const b = assignments.buttons[i]; + if (!b || b.cc === null || b.cc !== e.cc) continue; + if (b.channel !== null && b.channel !== e.channel) continue; + touch(`button:${i}`); + const rising = e.value >= 0.5 && lastButtonVals[i] < 0.5; + lastButtonVals[i] = e.value; + if (rising && b.action && b.action.type === 'toggle') executeToggle(b.action.target); + } + } + + function handleVisualClip({ clip, cause }) { + if (autoVJPrev === null) { + autoVJPrev = engine.autoVJ.enabled; + engine.autoVJ.enabled = false; + } + if (cause === 'boundary' && clip.transition.type === 'crossfade') { + engine.setScene(clip.scene, clip.transition.duration); + } else { + engine.cutTo(clip.scene); // seeks and play starts must land instantly + } + } + transport.onVisualClip(handleVisualClip); + + return { + lastTouched, + samplerKnobs, + + get synthEnabled() { + return synthOn; + }, + set synthEnabled(v) { + synthOn = !!v; + if (!synthOn) synth.allNotesOff(); + }, + onSynthToggle(cb) { + synthToggleCb = cb; + }, + onTouch(cb) { + touchCb = cb; + }, + + setAssignments(a) { + assignments = a || defaultAssignments(); + heldPunches.clear(); + lastGesture.clear(); + }, + getAssignments() { + return assignments; + }, + + // THE onEvent for createMidi; keyboard pads call it too, so keys and + // hardware take one dispatch path. + handleMidiEvent(e) { + if (e.kind === 'pad') { + if (e.idx >= 0) firePad(e.idx, e.vel); + } else if (e.kind === 'note') { + if (synthWantsNote(e.idx != null ? e.idx : -1) && typeof e.note === 'number') { + synth.noteOn(e.note, e.vel); + } + } else if (e.kind === 'noteoff') { + if (e.idx != null && e.idx >= 0) releasePad(e.idx); + if (typeof e.note === 'number' && synthOn && assignments.noteRouting.synth !== 'off') { + synth.noteOff(e.note); // release is unconditional per mode: a voice must never hang + } + } else if (e.kind === 'bend') { + synth.pitchBend(e.value); + } else if (e.kind === 'mod') { + synth.modulation(e.value); + } else if (e.kind === 'cc') { + handleCc(e); + } + }, + + // Registered via engine.setFrameHook — runs inside the frame, after + // control ingestion, before the palette/intensity update. + frame(dt, t, io) { + transport.update(); + + // Restore autoVJ once the timeline lets go of the stage. + if (autoVJPrev !== null && (!transport.state.playing || !transport.state.activeVisualClip)) { + engine.autoVJ.enabled = autoVJPrev; + autoVJPrev = null; + } + + // Knobs: change-driven, so an idle knob never fights a UI edit. + for (let i = 0; i < 8; i++) { + const v = io.knobs[i]; + if (lastKnobs[i] === null) { + lastKnobs[i] = v; + continue; + } + if (Math.abs(v - lastKnobs[i]) < KNOB_EPSILON) continue; + lastKnobs[i] = v; + touch(`knob:${i}`); + const a = assignments.knobs[i]; + if (a) driveTarget(a.target, a.min + applyCurve(a.curve, v) * (a.max - a.min)); + } + + // Gesture routes: applied after knobs — gestures win on a shared target. + for (let gi = 0; gi < assignments.gestures.length; gi++) { + const g = assignments.gestures[gi]; + if (!g.enabled) continue; + const mapped = g.min + applyCurve(g.curve, sourceValue(io, g.source)) * (g.max - g.min); + if (Math.abs((lastGesture.get(gi) ?? Infinity) - mapped) < 1e-4) continue; + lastGesture.set(gi, mapped); + driveTarget(g.target, mapped); + } + + // A held punch is re-asserted last so nothing overwrites it mid-hold. + for (const punch of heldPunches.values()) { + engine.setFxParam(punch.param, punch.value); + } + }, + + // midi.learn's first caller. Persists device-level overrides and marks + // the project dirty so the binding lands in the file on next save. + async learnBinding(target) { + const result = await midi.learn(target); + try { + await window.akswayj.settings.set({ midiOverrides: midi.getOverrides() }); + } catch { + /* settings persistence is best-effort */ + } + if (onDirty) onDirty(); + return result; + }, + cancelLearn() { + midi.cancelLearn(); + }, + + dispose() { + heldPunches.clear(); + touchCb = null; + }, + }; +} diff --git a/src/renderer/midi/midi.js b/src/renderer/midi/midi.js index 8d073c7..378a068 100644 --- a/src/renderer/midi/midi.js +++ b/src/renderer/midi/midi.js @@ -110,6 +110,10 @@ export async function createMidi({ onEvent } = {}) { // CC 1 is the modulation wheel by convention; the synth uses it as a // mod-matrix source. if (d1 === 1 && onEvent) onEvent({ kind: 'mod', value: d2 / 127 }); + // Every CC also goes out as an event: the router matches button + // bindings and touch-to-select on it — continuous state alone lands + // silently in `control`, which the UI cannot observe per-control. + if (onEvent) onEvent({ kind: 'cc', cc: d1, value: d2 / 127, channel: ch, target }); pushMonitor(`CC${d1}=${d2} ch${ch + 1}${target ? ' → ' + target : ''}`); } else if (type === 0x90 && d2 > 0) { const idx = padIndexFor(d1); @@ -122,13 +126,15 @@ export async function createMidi({ onEvent } = {}) { onEvent({ kind: 'pad', idx, vel: d2 / 127 }); // The raw note goes out too: pads drive the visuals, but the synth // needs the actual pitch, including notes outside the pad range that - // the Sway's Theory Engine sends. - onEvent({ kind: 'note', note: d1, vel: d2 / 127, channel: ch }); + // the Sway's Theory Engine sends. idx rides along so note routing + // can tell an assigned pad from a free pitch. + onEvent({ kind: 'note', note: d1, vel: d2 / 127, channel: ch, idx }); } } else if (type === 0x80 || (type === 0x90 && d2 === 0)) { - // Pads decay in the engine, but a synth voice has to be released. + // Pads decay in the engine, but a synth voice has to be released — + // and a gate-mode pad needs its index to release the sampler. pushMonitor(`NOTE OFF ${d1} ch${ch + 1}`); - if (onEvent) onEvent({ kind: 'noteoff', note: d1, channel: ch }); + if (onEvent) onEvent({ kind: 'noteoff', note: d1, channel: ch, idx: padIndexFor(d1) }); } else if (type === 0xe0) { // Pitch bend: 14-bit little-endian, centre 8192 -> 0.5. const value = ((d2 << 7) | d1) / 16383; diff --git a/src/renderer/project/projectstore.js b/src/renderer/project/projectstore.js index 5cb5108..19b3acc 100644 --- a/src/renderer/project/projectstore.js +++ b/src/renderer/project/projectstore.js @@ -87,10 +87,16 @@ export function createProjectStore(deps) { state.mediaLoading--; } } - // Pads restore once every kit sample had its chance to load. + // Pads restore once every kit sample had its chance to load. A filled + // kit pad must also fire from hardware, so it gets a sample assignment + // unless the project already routes that pad elsewhere. if (kitIds.size) { const result = sampler.setKit(p.sampler.kit); for (const name of result.missing || []) state.warnings.push(`kit: ${name} missing`); + const padsA = p.assignments.pads; + p.sampler.kit.pads.forEach((pad, i) => { + if (pad && pad.id && !padsA[i]) padsA[i] = { type: 'sample', pad: i }; + }); } } From bbe96496f22bbbf9af21c0091ba0f696f8c83c75 Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:59:01 -0700 Subject: [PATCH 06/26] Single-surface cockpit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One always-live page replaces the five screens: top bar (project, transport, scene readout, pills, deck buttons), scene bank + auto group, center stage, assignment rail + input box, timeline band (ruler with loop region and locators, visual clip lane, audio waveform lane, playhead), and the Sway deck — a stroke line-art schematic of the hardware with live LED beam, sensor field, gesture chips, knob arcs, pads, and buttons, sharing one control-id grammar with the router so click-to-select and hardware touch land on the same node. Deep panels (synth, rack, kit) live in a drawer that never stops the render loop; docs and the system checks are modals; the blast door reveals the already-running stage on ENTER. Projects open and save as .sway files from the project menu; AKSWAYJ_AUTOPLAY takes a template id or a .sway path. Bundled Source Code Pro replaces remote fonts (CSP has no font-src). Co-Authored-By: Claude Fable 5 --- scripts/build-renderer.js | 9 + src/renderer/app.js | 1055 ++++++++++-------- src/renderer/fonts/SourceCodePro-latin.woff2 | Bin 0 -> 22044 bytes src/renderer/index.html | 304 +++-- src/renderer/project/projectstore.js | 5 +- src/renderer/styles.css | 631 +++++++---- src/renderer/ui/assign.js | 421 +++++++ src/renderer/ui/drawer.js | 62 + src/renderer/ui/frame.js | 36 + src/renderer/ui/popover.js | 55 + src/renderer/ui/surface.js | 292 +++++ src/renderer/ui/timeline.js | 426 +++++++ src/renderer/ui/wave.js | 65 ++ 13 files changed, 2509 insertions(+), 852 deletions(-) create mode 100644 src/renderer/fonts/SourceCodePro-latin.woff2 create mode 100644 src/renderer/ui/assign.js create mode 100644 src/renderer/ui/drawer.js create mode 100644 src/renderer/ui/frame.js create mode 100644 src/renderer/ui/popover.js create mode 100644 src/renderer/ui/surface.js create mode 100644 src/renderer/ui/timeline.js create mode 100644 src/renderer/ui/wave.js diff --git a/scripts/build-renderer.js b/scripts/build-renderer.js index 849177b..086c47d 100644 --- a/scripts/build-renderer.js +++ b/scripts/build-renderer.js @@ -27,6 +27,15 @@ async function main() { for (const f of ['index.html', 'styles.css']) { fs.copyFileSync(path.join(root, 'src', 'renderer', f), path.join(dist, f)); } + // 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)); + } + } console.log('[build] renderer bundled to dist/'); } diff --git a/src/renderer/app.js b/src/renderer/app.js index 79f226b..f32ea2a 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -1,34 +1,34 @@ -// AKSWAYJ renderer shell — three screens: -// boot the Doctor: system checks + one-click fixes -// projects official project picker -// perform the show: engine canvas + HUD -// Design goal: a user who double-clicked the installer reaches "playing" -// with exactly one more click (or zero, via ?autoplay=). +// SwayCommand cockpit — one always-live page. The stage renders in the +// center from boot to quit; the timeline, the Sway deck, the rails, the +// drawer, and every overlay work on top of it without ever stopping the +// render loop. Projects are .sway files; the legacy screens are gone. import { createEngine } from './engine/engine.js'; import { createAudioEngine } from './engine/audio.js'; import { createMidi } from './midi/midi.js'; -import { renderMarkdown, slugify } from './markdown.js'; +import { renderMarkdown } from './markdown.js'; import { createSampler } from './audio/sampler.js'; import { createSynth, PRESET_NAMES, TABLE_NAMES, MOD_SOURCES, MOD_DESTS } from './audio/synth.js'; import { RANGES as fxRanges, DECKS as fxDecks } from './engine/fxrack.js'; import { createTransport } from './audio/transport.js'; import { createProjectStore } from './project/projectstore.js'; import { createRouter } from './control/router.js'; +import { initFrames } from './ui/frame.js'; +import { openPopover, closePopover, popoverOpen, wirePopover } from './ui/popover.js'; +import { createWave } from './ui/wave.js'; +import { createSurface } from './ui/surface.js'; +import { createAssign } from './ui/assign.js'; +import { createDrawer } from './ui/drawer.js'; +import { createTimeline } from './ui/timeline.js'; const $ = (sel) => document.querySelector(sel); const state = { - screen: 'boot', docs: [], currentDoc: null, - docsReturnScreen: 'boot', - studioReturnScreen: 'boot', sampler: null, synth: null, synthEnabled: true, - projects: [], - project: null, checks: [], midi: null, audio: null, @@ -36,27 +36,77 @@ const state = { transport: null, router: null, projectStore: null, - helpVisible: false, monitorVisible: false, + entered: false, // door opened }; -// ---------------------------------------------------------------- screens +const ui = { + surface: null, + assign: null, + drawer: null, + timeline: null, + wave: null, +}; + +const studio = { + sources: [], + selectedSample: null, + systemAudio: { supported: false, detail: '' }, +}; + +// ---------------------------------------------------------------- overlays -function show(screen) { - state.screen = screen; - for (const s of ['boot', 'projects', 'studio', 'docs', 'perform']) { - $(`#screen-${s}`).classList.toggle('active', s === screen); +const MODALS = ['docs', 'help', 'system']; + +function openModal(id) { + $(`#modal-${id}`).hidden = false; +} + +function closeModal(id) { + $(`#modal-${id}`).hidden = true; +} + +function modalOpen(id) { + return !$(`#modal-${id}`).hidden; +} + +function topModal() { + // system sits above the others in z, so it pops first + for (const id of ['system', 'help', 'docs']) { + if (modalOpen(id)) return id; } - if (screen === 'perform') state.engine.resize(); + return null; +} + +// Esc peels one layer: popover, then drawer, then the topmost modal, then +// timeline selection, then the deck selection. "Back" does not exist. +function escapePop() { + if (popoverOpen()) return closePopover(); + if (ui.drawer.isOpen()) return ui.drawer.close(); + const modal = topModal(); + if (modal) { + if (modal === 'system' && !state.entered) return; // first run gates on ENTER + return closeModal(modal); + } + if (ui.timeline.hasSelection()) return ui.timeline.clearSelection(); + if (ui.assign.current()) return selectControl(null); +} + +let noticeTimer = null; +function notice(html, ms = 5000) { + const el = $('#notice'); + el.innerHTML = html; + el.classList.add('visible'); + clearTimeout(noticeTimer); + noticeTimer = setTimeout(() => el.classList.remove('visible'), ms); } -// ---------------------------------------------------------------- boot / doctor +// ---------------------------------------------------------------- doctor const STATUS_ICON = { ok: '●', warn: '▲', fail: '✕', info: '○' }; function renderChecks() { - const list = $('#check-list'); - list.innerHTML = state.checks + $('#check-list').innerHTML = state.checks .map( (c) => `
  • @@ -74,8 +124,6 @@ function renderChecks() { async function rendererChecks() { const checks = []; - - // WebGL2 try { const probe = document.createElement('canvas'); const gl = probe.getContext('webgl2'); @@ -89,7 +137,6 @@ async function rendererChecks() { checks.push({ id: 'gpu', label: 'Graphics (WebGL2)', status: 'fail', detail: e.message }); } - // MIDI if (state.midi.available) { const c = state.midi.control; checks.push({ @@ -99,14 +146,13 @@ async function rendererChecks() { detail: c.isSway ? `Sway online: “${c.portName}” — factory map armed.` : c.connected - ? `No Sway yet, but listening on: ${c.portName}. Incoming CC and note messages are matched against the Sway factory map; bindings can be overridden in settings.json.` + ? `No Sway yet, but listening on: ${c.portName}. Incoming CC and note messages are matched against the Sway factory map.` : 'No MIDI devices right now. Hot-plug any time — mouse & keyboard are fully mapped meanwhile.', }); } else { checks.push({ id: 'midi', label: 'MIDI', status: 'warn', detail: 'WebMIDI unavailable — mouse & keyboard control still work.' }); } - // Audio inputs try { const inputs = await state.audio.listInputs(); checks.push({ @@ -137,16 +183,18 @@ async function runDoctor() { : 'ok'; $('#boot-status').textContent = worst === 'ok' - ? 'All clear. Pick a project and play.' + ? 'All clear.' : worst === 'warn' ? 'Playable now — a couple of notes above.' : 'Something needs attention above — you can still continue.'; const enter = $('#btn-enter'); enter.disabled = false; enter.focus(); - if (worst === 'ok' && !state._autoAdvanced) { + if (worst === 'ok' && !state._autoAdvanced && !state.entered) { state._autoAdvanced = true; - setTimeout(() => state.screen === 'boot' && show('projects'), 1400); + setTimeout(() => { + if (!state.entered && modalOpen('system')) enterCockpit(); + }, 1400); } } @@ -171,143 +219,31 @@ function wireDoctor() { if (result.ok) check.fix = null; } renderChecks(); - if (result.ok) runDoctor(); // re-verify the whole picture + if (result.ok) runDoctor(); }); - $('#btn-enter').addEventListener('click', () => show('projects')); + $('#btn-enter').addEventListener('click', enterCockpit); $('#btn-recheck').addEventListener('click', runDoctor); -} - -// ---------------------------------------------------------------- projects - -function renderProjects() { - $('#project-grid').innerHTML = state.projects - .map( - (p) => ` -
    -
    ${p.palette.map(() => '').join('')}
    -

    ${p.name}

    -
    - ${p.vibe} - ${p.bpmHint ? `${p.bpmHint} BPM` : ''} - ${p.pairsWith ? `pairs with Audima demo` : ''} -
    -

    ${p.description}

    -
    PLAY →
    -
    ` - ) - .join(''); - - // Swatch colors via CSSOM — the page CSP (rightly) blocks markup-inline styles. - for (const card of document.querySelectorAll('#project-grid .card')) { - const p = state.projects.find((x) => x.id === card.dataset.project); - card.querySelectorAll('.card-swatches i').forEach((el, i) => { - el.style.background = p.palette[i % p.palette.length]; - }); - } -} - -function wireProjects() { - const grid = $('#project-grid'); - const open = (el) => { - const p = state.projects.find((x) => x.id === el.dataset.project); - if (p) startPerformance(p); - }; - grid.addEventListener('click', (e) => { - const card = e.target.closest('[data-project]'); - if (card) open(card); - }); - grid.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - const card = e.target.closest('[data-project]'); - if (card) open(card); - } + $('#btn-system').addEventListener('click', () => { + closeModal('help'); + openModal('system'); + runDoctor(); }); } -// ---------------------------------------------------------------- studio -// Audio source selection and the pad kit builder. Both live here because they -// are two halves of one job: deciding what the application listens to, and -// what it plays back. - -const studio = { - sources: [], - selectedSample: null, - selectedPad: 0, - systemAudio: { supported: false, detail: '' }, -}; - -async function openStudio() { - if (state.screen !== 'studio') state.studioReturnScreen = state.screen; - show('studio'); - if (!studio.systemAudio.detail) { - studio.systemAudio = await window.akswayj.platform.systemAudio(); - $('#system-audio-note').textContent = studio.systemAudio.detail; - } - await refreshSources(); - renderSamples(); - renderPads(); - renderSynthPanel(); - renderFxPanel(); +// The blast door opens once the system checks are dismissed; the stage was +// already rendering behind it. +function enterCockpit() { + closeModal('system'); + if (state.entered) return; + state.entered = true; + const door = $('#door'); + door.classList.add('open'); + setTimeout(() => door.classList.add('gone'), 1000); + notice('H — controls'); } -function returnFromStudio() { - const target = state.studioReturnScreen || 'boot'; - show(target); - if (target === 'perform') state.engine.start(); -} - -async function refreshSources() { - let inputs = []; - try { - inputs = await state.audio.listInputs(); - } catch { - inputs = []; - } - studio.sources = inputs; - - const rows = []; - rows.push( - `
  • ` - ); - for (const d of inputs) { - rows.push(`
  • `); - } - rows.push('
  • '); - $('#source-list').innerHTML = rows.join(''); - markCurrentSource(); -} - -function markCurrentSource() { - const a = state.audio.state; - for (const btn of document.querySelectorAll('#source-list button')) { - const kind = btn.dataset.src; - const isCurrent = - (kind === 'system' && a.source === 'system') || - (kind === 'internal' && a.source === 'internal') || - (kind === 'input' && a.source === 'input' && a.deviceId === btn.dataset.id); - btn.classList.toggle('current', isCurrent); - } - $('#studio-source').textContent = a.deviceLabel || a.source || '—'; -} - -async function selectSource(kind, id) { - try { - if (kind === 'system') await state.audio.useSystemAudio(); - else if (kind === 'input') await state.audio.useInput(id); - else { - state.audio.releaseInput(); - state.audio.startInternal(); - } - } catch (err) { - $('#system-audio-note').textContent = `Could not switch source: ${err.message}`; - } - markCurrentSource(); -} - -// --- kit builder ------------------------------------------------------------- +// ---------------------------------------------------------------- samples / kit function renderSamples() { const list = state.sampler ? state.sampler.listSamples() : []; @@ -315,45 +251,10 @@ function renderSamples() { ? list .map( (s) => - `` + `
  • ` ) .join('') - : '
  • No samples loaded.
  • '; -} - -function renderPads() { - const kit = state.sampler ? state.sampler.getKit() : { pads: [] }; - const samples = state.sampler ? state.sampler.listSamples() : []; - const nameOf = (id) => { - const s = samples.find((x) => x.id === id); - return s ? s.name : ''; - }; - const cells = []; - for (let i = 0; i < 16; i++) { - const pad = kit.pads && kit.pads[i]; - const filled = pad && pad.id; - cells.push( - `
    - ${i + 1}${filled ? nameOf(pad.id) : ''} -
    ` - ); - } - $('#pad-grid').innerHTML = cells.join(''); - $('#pad-selected').textContent = `· pad ${studio.selectedPad + 1}`; - - const pad = kit.pads && kit.pads[studio.selectedPad]; - $('#pad-mode').value = (pad && pad.mode) || 'oneshot'; - $('#pad-gain').value = pad && typeof pad.gain === 'number' ? pad.gain : 1; - $('#pad-choke').value = pad && pad.chokeGroup !== null && pad.chokeGroup !== undefined ? pad.chokeGroup : ''; -} - -function padOptions() { - const choke = $('#pad-choke').value; - return { - mode: $('#pad-mode').value, - gain: Number($('#pad-gain').value), - chokeGroup: choke === '' ? null : Number(choke), - }; + : '
  • No samples loaded.
  • '; } async function addSamples() { @@ -372,11 +273,7 @@ async function addSamples() { for (const file of picked) { note.textContent = `Loading ${file.name}…`; try { - const bytes = await window.akswayj.files.readAudio(file.path); - // The IPC layer delivers a Uint8Array view; decodeAudioData needs its - // own ArrayBuffer, and the view may not span the whole buffer. - const copy = bytes.slice().buffer; - await state.sampler.loadSample(file.path, copy, { name: file.name }); + await state.projectStore.addMedia(file); loaded++; } catch (err) { failures.push(`${file.name}: ${err.message}`); @@ -386,24 +283,10 @@ async function addSamples() { ? `Loaded ${loaded}. Failed: ${failures.join('; ')}` : `Loaded ${loaded} sample${loaded === 1 ? '' : 's'}.`; renderSamples(); - renderPads(); } -function flashPad(index) { - const el = document.getElementById(`pad-${index}`); - if (!el) return; - el.classList.add('hit'); - setTimeout(() => el.classList.remove('hit'), 110); -} - -async function saveKit() { - const kit = state.sampler.getKit(); - const samples = state.sampler.listSamples().map((s) => ({ id: s.id, name: s.name })); - await window.akswayj.settings.set({ kit: { ...kit, samples } }); - $('#kit-note').textContent = 'Kit saved. It reloads automatically at startup.'; -} - -// Re-reads every sample file the saved kit references, then restores the pads. +// Re-reads every sample file a legacy saved kit references, then restores the +// pads and mirrors them into pad assignments. async function restoreKit(saved) { if (!saved || !saved.samples || !saved.samples.length) return; const missing = []; @@ -416,25 +299,35 @@ async function restoreKit(saved) { } } state.sampler.setKit(saved); - // A restored kit pad must also fire from hardware: give every filled pad a - // sample assignment unless the project already says otherwise. const pads = state.router.getAssignments().pads; (saved.pads || []).forEach((pad, i) => { if (pad && pad.id && !pads[i]) pads[i] = { type: 'sample', pad: i }; }); - if (missing.length) { - console.warn('[kit] missing sample files:', missing.join(', ')); - } + refreshDeck(); + if (missing.length) console.warn('[kit] missing sample files:', missing.join(', ')); } -// --- synth ------------------------------------------------------------------- -// Controls are generated from the synth's own control manifest, which is in -// theDAW's VisualControl shape, so the same manifest can drive theDAW's mapper. +function wireKit() { + $('#btn-add-samples').addEventListener('click', addSamples); + $('#sample-list').addEventListener('click', (e) => { + const btn = e.target.closest('[data-sample]'); + if (!btn) return; + studio.selectedSample = btn.dataset.sample; + renderSamples(); + notice('Click a pad on the deck to place the sample'); + }); + $('#sample-list').addEventListener('dragstart', (e) => { + const btn = e.target.closest('[data-sample]'); + if (!btn) return; + e.dataTransfer.setData('application/x-sway-media', btn.dataset.sample); + e.dataTransfer.effectAllowed = 'copy'; + }); +} + +// ---------------------------------------------------------------- synth panel -// Only the groups worth surfacing live; the manifest carries more. const SYNTH_GROUPS = ['OSC1', 'OSC2', 'FILTER1', 'ENV1', 'LFO', 'FX', 'GLOBAL']; -// Two octaves from C3, in the standard tracker layout. const KEY_ROW = [ ['a', 48], ['w', 49], ['s', 50], ['e', 51], ['d', 52], ['f', 53], ['t', 54], ['g', 55], ['y', 56], ['h', 57], ['u', 58], ['j', 59], @@ -453,7 +346,6 @@ function renderSynthPanel() { const html = []; for (const group of SYNTH_GROUPS) { const rows = []; - // Wavetable choice is a list, not a range, so it is added by hand. if (group === 'OSC1' || group === 'OSC2') { const osc = group.toLowerCase(); const cur = synth.getParam(`${osc}.table`); @@ -480,7 +372,6 @@ function renderSynthPanel() { } $('#synth-decks').innerHTML = html.join(''); - // on-screen keyboard $('#synth-keys').innerHTML = KEY_ROW.map(([key, note]) => { const sharp = [1, 3, 6, 8, 10].includes(note % 12); return ``; @@ -509,10 +400,12 @@ function wireSynth() { $('#synth-enable').addEventListener('change', (e) => { state.synthEnabled = e.target.checked; state.router.synthEnabled = e.target.checked; + state.projectStore.markDirty(); }); $('#synth-preset').addEventListener('change', (e) => { state.synth.loadPreset(e.target.value); + state.projectStore.markDirty(); renderSynthPanel(); }); @@ -523,6 +416,7 @@ function wireSynth() { const value = el.type === 'checkbox' ? el.checked : el.tagName === 'SELECT' ? el.value : Number(el.value); state.synth.setParam(key, value); + state.projectStore.markDirty(); const readout = document.querySelector(`[data-synthval="${key}"]`); if (readout) readout.textContent = Number(value).toFixed(3); }); @@ -535,6 +429,7 @@ function wireSynth() { if (!rows[i]) return; rows[i][el.dataset.field] = el.dataset.field === 'amount' ? Number(el.value) : el.value; state.synth.setMatrix(rows); + state.projectStore.markDirty(); renderModMatrix(); }); $('#mod-rows').addEventListener('click', (e) => { @@ -543,16 +438,17 @@ function wireSynth() { const rows = state.synth.getMatrix(); rows.splice(Number(del.dataset.modDel), 1); state.synth.setMatrix(rows); + state.projectStore.markDirty(); renderModMatrix(); }); $('#btn-mod-add').addEventListener('click', () => { const rows = state.synth.getMatrix(); rows.push({ source: 'lfo1', dest: 'filter1.cutoff', amount: 0.3 }); state.synth.setMatrix(rows); + state.projectStore.markDirty(); renderModMatrix(); }); - // on-screen keyboard: pointer plays, release stops const keys = $('#synth-keys'); keys.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-note]'); @@ -571,10 +467,10 @@ function wireSynth() { window.addEventListener('blur', releaseAll); } -// The computer keyboard plays the synth only while the Studio is open, so it -// never fights the perform-screen shortcuts. -function studioKeyDown(e) { - if (state.screen !== 'studio' || e.repeat) return false; +// The computer keyboard plays the synth only while the synth drawer is open, +// so it never fights the cockpit shortcuts. +function synthKeyDown(e) { + if (!ui.drawer.isOpen('synth') || e.repeat) return false; if (/^(input|select|textarea)$/i.test(e.target.tagName)) return false; const entry = KEY_ROW.find(([k]) => k === e.key.toLowerCase()); if (!entry) return false; @@ -586,7 +482,7 @@ function studioKeyDown(e) { return true; } -function studioKeyUp(e) { +function synthKeyUp(e) { const entry = KEY_ROW.find(([k]) => k === e.key.toLowerCase()); if (!entry || !heldKeys.has(entry[1])) return; heldKeys.delete(entry[1]); @@ -595,9 +491,7 @@ function studioKeyUp(e) { if (btn) btn.classList.remove('down'); } -// --- effects rack ------------------------------------------------------------ -// Controls are generated from the rack's own RANGES table and DECKS grouping, -// so the panel cannot drift out of step with what setParam actually accepts. +// ---------------------------------------------------------------- effects rack function renderFxPanel() { const rack = state.engine.fx; @@ -632,9 +526,11 @@ function renderFxPanel() { function wireFx() { $('#fx-enable').addEventListener('change', (e) => { state.engine.fxEnabled = e.target.checked; + state.projectStore.markDirty(); }); $('#btn-fx-reset').addEventListener('click', () => { state.engine.resetFx(); + state.projectStore.markDirty(); renderFxPanel(); }); $('#fx-decks').addEventListener('input', (e) => { @@ -643,77 +539,22 @@ function wireFx() { const key = el.dataset.fx; const value = el.type === 'checkbox' ? el.checked : el.type === 'color' ? el.value : Number(el.value); state.engine.setFxParam(key, value); + state.projectStore.markDirty(); const readout = document.querySelector(`[data-fxval="${key}"]`); if (readout) readout.textContent = Number(value).toFixed(2); }); } -function wireStudio() { - for (const btn of document.querySelectorAll('[data-open-studio]')) { - btn.addEventListener('click', () => openStudio()); - } - $('#btn-studio-close').addEventListener('click', returnFromStudio); - - $('#source-list').addEventListener('click', (e) => { - const btn = e.target.closest('[data-src]'); - if (btn && !btn.disabled) selectSource(btn.dataset.src, btn.dataset.id); - }); - $('#btn-refresh-sources').addEventListener('click', refreshSources); - - $('#btn-add-samples').addEventListener('click', addSamples); - $('#btn-save-kit').addEventListener('click', saveKit); - $('#btn-clear-pad').addEventListener('click', () => { - state.sampler.clearPad(studio.selectedPad); - renderPads(); - }); - - $('#sample-list').addEventListener('click', (e) => { - const btn = e.target.closest('[data-sample]'); - if (!btn) return; - studio.selectedSample = btn.dataset.sample; - renderSamples(); - }); - - // Clicking a pad assigns the selected sample, or auditions an assigned pad. - $('#pad-grid').addEventListener('click', (e) => { - const cell = e.target.closest('[data-pad]'); - if (!cell) return; - const index = Number(cell.dataset.pad); - studio.selectedPad = index; - const kit = state.sampler.getKit(); - const assigned = kit.pads && kit.pads[index] && kit.pads[index].id; - if (studio.selectedSample && studio.selectedSample !== assigned) { - state.sampler.assignPad(index, studio.selectedSample, padOptions()); - } else if (assigned) { - state.sampler.trigger(index, 0.9); - flashPad(index); - } - renderPads(); - }); - - for (const id of ['#pad-mode', '#pad-gain', '#pad-choke']) { - $(id).addEventListener('change', () => { - const kit = state.sampler.getKit(); - const pad = kit.pads && kit.pads[studio.selectedPad]; - if (pad && pad.id) state.sampler.assignPad(studio.selectedPad, pad.id, padOptions()); - }); - } -} - // ---------------------------------------------------------------- documentation -// The viewer reads the Markdown that ships with the application, so the -// documentation is available offline and always matches the installed build. - async function openDocs(docId) { - if (state.screen !== 'docs') state.docsReturnScreen = state.screen; if (!state.docs.length) { state.docs = await window.akswayj.docs.list(); $('#docs-list').innerHTML = state.docs .map((d) => `
  • `) .join(''); } - show('docs'); + openModal('docs'); await loadDoc(docId || state.currentDoc || (state.docs[0] && state.docs[0].id)); } @@ -763,8 +604,6 @@ function showExternalNote(text) { externalNoteTimer = setTimeout(() => el.remove(), 6000); } -// Documentation links fall into three classes: another bundled document, -// an anchor within the current one, or an external URL for the system browser. async function followDocLink(href) { if (href.startsWith('#')) { scrollToAnchor(href.slice(1)); @@ -782,7 +621,6 @@ async function followDocLink(href) { const [rel, anchor] = href.split('#'); const from = state.currentDoc || 'README.md'; const baseDir = from.includes('/') ? from.slice(0, from.lastIndexOf('/')) : ''; - // Resolve the relative target against the current document's directory. const segments = (baseDir ? baseDir.split('/') : []).concat(rel.split('/')); const resolved = []; for (const seg of segments) { @@ -800,19 +638,9 @@ async function followDocLink(href) { } } -// Opening the docs from a performance stops the render loop, so returning -// restarts it rather than leaving a frozen stage. -function returnFromDocs() { - const target = state.docsReturnScreen; - show(target); - if (target === 'perform') state.engine.start(); -} - function wireDocs() { - for (const btn of document.querySelectorAll('[data-open-docs]')) { - btn.addEventListener('click', () => openDocs()); - } - $('#btn-docs-close').addEventListener('click', returnFromDocs); + $('#btn-docs').addEventListener('click', () => (modalOpen('docs') ? closeModal('docs') : openDocs())); + $('#btn-docs-close').addEventListener('click', () => closeModal('docs')); $('#docs-list').addEventListener('click', (e) => { const btn = e.target.closest('[data-doc]'); @@ -830,145 +658,286 @@ function wireDocs() { }); } -// ---------------------------------------------------------------- perform +// ---------------------------------------------------------------- topbar -function startPerformance(project) { - state.project = project; - state.engine.loadProject(project); - $('#hud-project').textContent = project.name; - show('perform'); - state.engine.start(); - bumpHelpBar(); +function fmtClock(t) { + const m = Math.floor(t / 60); + const s = t - m * 60; + return `${m < 10 ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s.toFixed(1)}`; } -function updateHud() { - if (state.screen === 'studio') { - // The analyser is shared, so the meter reflects whatever source is live. - state.audio.update(1 / 60); - $('#studio-meter').style.width = `${Math.round(state.audio.state.level * 100)}%`; - const v = state.synth.activeVoices; - const voicePill = $('#synth-voices'); - voicePill.textContent = `${v} voice${v === 1 ? '' : 's'}`; - voicePill.classList.toggle('pill-on', v > 0); +function updateProjectButton() { + const btn = $('#project-btn'); + btn.textContent = state.projectStore.state.name; + btn.classList.toggle('dirty', state.projectStore.state.dirty); +} + +async function openProjectMenu() { + const store = state.projectStore; + const [recent, templates] = await Promise.all([ + window.akswayj.project.recent().catch(() => []), + window.akswayj.project.templates().catch(() => []), + ]); + const rows = [ + '', + '', + '', + '', + ]; + const recents = recent.filter((r) => r.path !== store.state.path); + if (recents.length) { + rows.push('
    RECENT
    '); + for (const r of recents.slice(0, 6)) { + rows.push(``); + } + } + if (templates.length) { + rows.push('
    TEMPLATES
    '); + for (const t of templates) { + rows.push(``); + } } - if (state.screen === 'perform') { - const eng = state.engine; - $('#hud-scene').textContent = eng.currentScene ? eng.currentScene.name : ''; - $('#hud-auto').textContent = eng.autoVJ.enabled ? 'AUTO-VJ' : 'MANUAL'; - $('#hud-auto').classList.toggle('pill-on', eng.autoVJ.enabled); - $('#hud-fps').textContent = `${eng.stats.fps} fps`; + openPopover($('#project-btn'), rows.join(''), async (choice, data) => { + const guard = () => !store.state.dirty || window.confirm('Discard unsaved changes?'); + try { + if (choice === 'new' && guard()) await store.openTemplate('first-flight'); + else if (choice === 'open' && guard()) await store.openFromDialog(); + else if (choice === 'save') await store.save(); + else if (choice === 'saveas') await store.saveAs(); + else if (choice === 'recent' && guard()) await store.openPath(data.path); + else if (choice === 'template' && guard()) await store.openTemplate(data.id); + } catch (err) { + notice(`Project: ${err.message}`, 7000); + } + postProjectLoad(); + }); +} - const c = state.midi.control; - const midiPill = $('#hud-midi'); - midiPill.textContent = c.isSway ? 'SWAY' : c.connected ? 'MIDI' : 'MOUSE/KEYS'; - midiPill.classList.toggle('pill-on', c.isSway || c.connected); +function wireTopbar() { + $('#project-btn').addEventListener('click', openProjectMenu); + $('#t-play').addEventListener('click', () => { + state.transport.state.playing ? state.transport.pause() : state.transport.play(); + }); + $('#t-stop').addEventListener('click', () => state.transport.stop()); + $('#t-loop').addEventListener('click', () => { + const loop = state.transport.state.loop; + state.transport.setLoop(loop.start, loop.end, !loop.enabled); + ui.timeline.render(); + }); + $('#deckbar').addEventListener('click', (e) => { + const btn = e.target.closest('[data-drawer]'); + if (btn) ui.drawer.toggle(btn.dataset.drawer); + }); + $('#btn-help').addEventListener('click', () => (modalOpen('help') ? closeModal('help') : openModal('help'))); +} - const a = state.audio.state; - $('#hud-audio').textContent = - a.source === 'input' ? 'LIVE AUDIO' : a.source === 'system' ? 'SYSTEM AUDIO' : a.source === 'internal' ? 'INT. GROOVE' : 'NO AUDIO'; +// ---------------------------------------------------------------- input box - if (state.monitorVisible) { - $('#midi-monitor').textContent = state.midi.monitor.join('\n') || '(waiting for MIDI…)'; - } +async function openSourceMenu() { + let inputs = []; + try { + inputs = await state.audio.listInputs(); + } catch { + inputs = []; + } + studio.sources = inputs; + const a = state.audio.state; + const rows = [ + ``, + ]; + for (const d of inputs) { + rows.push( + `` + ); } - requestAnimationFrame(updateHud); + rows.push(``); + openPopover($('#input-src'), rows.join(''), async (choice, data) => { + try { + if (choice === 'system') await state.audio.useSystemAudio(); + else if (choice === 'input') await state.audio.useInput(data.id); + else { + state.audio.releaseInput(); + state.audio.startInternal(); + } + } catch (err) { + notice(`Source: ${err.message}`, 6000); + } + updateSourceLabel(); + }); } -let helpBarTimer = null; -function bumpHelpBar() { - const bar = $('#help-bar'); - bar.classList.add('visible'); - clearTimeout(helpBarTimer); - helpBarTimer = setTimeout(() => bar.classList.remove('visible'), 6000); +function updateSourceLabel() { + const a = state.audio.state; + $('#input-src').textContent = a.deviceLabel || (a.source === 'internal' ? 'Internal groove' : a.source) || '—'; } -function wirePerform() { - const canvas = $('#stage'); +// ---------------------------------------------------------------- scenes / auto - // Mouse = hand position whenever the Sway isn't driving; buttons/wheel map gestures. - canvas.addEventListener('pointermove', (e) => { - if (state.midi.control.isSway) return; - const r = canvas.getBoundingClientRect(); - state.midi.control.xy.x = (e.clientX - r.left) / r.width; - state.midi.control.xy.y = 1 - (e.clientY - r.top) / r.height; +function renderSceneBank() { + const pool = state.engine.autoVJ.pool; + $('#scene-bank').innerHTML = state.engine.sceneList + .map((s) => { + const poolIdx = pool.indexOf(s.id); + const digit = poolIdx >= 0 && poolIdx < 9 ? poolIdx + 1 : ''; + return `
  • `; + }) + .join(''); +} + +function updateAutoBox() { + const av = state.engine.autoVJ; + $('#auto-toggle').classList.toggle('on', av.enabled); + if (document.activeElement !== $('#auto-min')) $('#auto-min').value = Math.round(av.minHold); + if (document.activeElement !== $('#auto-max')) $('#auto-max').value = Math.round(av.maxHold); + if (document.activeElement !== $('#auto-fade')) $('#auto-fade').value = av.fadeTime; +} + +function wireScenes() { + $('#scene-bank').addEventListener('click', (e) => { + const btn = e.target.closest('[data-scene]'); + if (!btn) return; + state.engine.autoVJ.enabled = false; + state.engine.setScene(btn.dataset.scene); + updateAutoBox(); }); - canvas.addEventListener('pointerdown', () => { - if (!state.midi.control.isSway) state.midi.control.gestures.press = 1; + $('#scene-bank').addEventListener('dragstart', (e) => { + const btn = e.target.closest('[data-scene]'); + if (!btn) return; + e.dataTransfer.setData('application/x-sway-scene', btn.dataset.scene); + e.dataTransfer.effectAllowed = 'copy'; }); - canvas.addEventListener('pointerup', () => { - if (!state.midi.control.isSway) state.midi.control.gestures.press = 0; + $('#auto-toggle').addEventListener('click', () => { + state.engine.autoVJ.enabled = !state.engine.autoVJ.enabled; + updateAutoBox(); }); - canvas.addEventListener( - 'wheel', - (e) => { - if (!state.midi.control.isSway) { - const g = state.midi.control.gestures; - g.pulse = Math.min(1, g.pulse + Math.abs(e.deltaY) * 0.002); - setTimeout(() => (g.pulse *= 0.5), 150); - } - }, - { passive: true } - ); + $('#auto-min').addEventListener('change', (e) => { + state.engine.autoVJ.minHold = Math.max(1, Number(e.target.value) || 18); + state.projectStore.markDirty(); + }); + $('#auto-max').addEventListener('change', (e) => { + state.engine.autoVJ.maxHold = Math.max(state.engine.autoVJ.minHold, Number(e.target.value) || 40); + state.projectStore.markDirty(); + }); + $('#auto-fade').addEventListener('change', (e) => { + state.engine.autoVJ.fadeTime = Math.max(0, Number(e.target.value) || 4); + state.projectStore.markDirty(); + }); +} + +// ---------------------------------------------------------------- deck / selection - const PAD_KEYS = ['z', 'x', 'c', 'v', 'b', 'n', 'm', ',']; +function selectControl(target) { + ui.assign.select(target); + ui.surface.select(target); +} +// Pad labels + button lit states, refreshed on assignment/kit changes. +function refreshDeck() { + const asg = state.router.getAssignments(); + const samples = state.sampler.listSamples(); + const kit = state.sampler.getKit(); + const labels = []; + for (let i = 0; i < 16; i++) { + const a = asg.pads[i]; + if (!a) { + labels.push(''); + } else if (a.type === 'sample') { + const pad = kit.pads && kit.pads[a.pad ?? i]; + const s = pad && samples.find((x) => x.id === pad.id); + labels.push(s ? s.name.replace(/\.[a-z0-9]+$/i, '') : ''); + } else if (a.type === 'scene') { + const meta = state.engine.sceneList.find((s) => s.id === a.scene); + labels.push(meta ? meta.name : a.scene); + } else { + labels.push(a.param); + } + } + ui.surface.refresh(labels, buttonLitStates()); +} + +function buttonLitStates() { + const asg = state.router.getAssignments(); + return asg.buttons.map((b) => { + if (!b || !b.action) return false; + const t = b.action.target; + if (t === 'engine:fxEnabled') return state.engine.fxEnabled; + if (t === 'engine:autoVJ') return state.engine.autoVJ.enabled; + if (t === 'synth:enabled') return state.router.synthEnabled; + if (t === 'transport:playPause') return state.transport.state.playing; + if (t.startsWith('fx:')) return !!state.engine.fx.params[t.slice(3)]; + return false; + }); +} + +function onDeckSelect(target) { + // With a sample armed from the kit drawer, clicking a pad places it. + if (studio.selectedSample && target.startsWith('pad:') && ui.drawer.isOpen('kit')) { + const i = Number(target.slice(4)); + state.sampler.assignPad(i, studio.selectedSample, { mode: 'oneshot', gain: 1 }); + const asg = state.router.getAssignments(); + asg.pads[i] = { type: 'sample', pad: i }; + state.projectStore.markDirty(); + refreshDeck(); + } + selectControl(target); +} + +// ---------------------------------------------------------------- keyboard + +const PAD_KEYS = ['z', 'x', 'c', 'v', 'b', 'n', 'm', ',']; + +function wireKeyboard() { window.addEventListener('keydown', (e) => { - if (state.screen !== 'perform') { - // The Studio's musical keyboard claims its keys before any shortcut. - if (studioKeyDown(e)) return; - const key = e.key.toLowerCase(); - const typing = /^(input|select|textarea)$/i.test(e.target.tagName); - if (e.key === 'Escape') { - if (state.screen === 'docs') returnFromDocs(); - else if (state.screen === 'studio') returnFromStudio(); - else if (state.screen === 'projects') show('boot'); - } else if (typing) { - // a form control has focus; leave the keystroke alone - } else if (key === 'd' && state.screen !== 'docs') { - openDocs(); - } else if (key === 's' && state.screen !== 'studio') { - openStudio(); - } + if (e.key === 'Escape') { + escapePop(); return; } + if (/^(input|select|textarea)$/i.test(e.target.tagName)) return; + if (synthKeyDown(e)) return; const k = e.key.toLowerCase(); - // Number keys select within the ACTIVE PROJECT's scene pool rather than the - // global registry: a project holds a handful of scenes, the registry holds - // more than there are digits, and the pool is what the performer chose. + const sceneIdx = Number(k) - 1; const pool = state.engine.autoVJ.pool; - if (sceneIdx >= 0 && sceneIdx < pool.length) { + if (k >= '1' && k <= '9' && sceneIdx < pool.length) { state.engine.autoVJ.enabled = false; state.engine.setScene(pool[sceneIdx]); + updateAutoBox(); } else if (k === ' ') { e.preventDefault(); state.engine.nextScene(); } else if (k === 'a') { state.engine.autoVJ.enabled = !state.engine.autoVJ.enabled; + updateAutoBox(); } else if (k === 'f') { document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen(); } else if (k === 'h') { - state.helpVisible = !state.helpVisible; - $('#help-overlay').classList.toggle('visible', state.helpVisible); + modalOpen('help') ? closeModal('help') : openModal('help'); } else if (k === 'k') { // The MIDI monitor is on K because M is a pad key. state.monitorVisible = !state.monitorVisible; $('#midi-monitor').classList.toggle('visible', state.monitorVisible); } else if (k === 'd') { - state.engine.stop(); - openDocs(); + modalOpen('docs') ? closeModal('docs') : openDocs(); } else if (k === 's') { - state.engine.stop(); - openStudio(); - } else if (k === 'escape') { - if (state.helpVisible || state.monitorVisible) { - state.helpVisible = state.monitorVisible = false; - $('#help-overlay').classList.remove('visible'); - $('#midi-monitor').classList.remove('visible'); - } else { - state.engine.stop(); - show('projects'); - } + ui.drawer.toggle('synth'); + } else if (k === 'r') { + ui.drawer.toggle('rack'); + } else if (k === 'e') { + ui.drawer.toggle('kit'); + } else if (k === 'p') { + state.transport.state.playing ? state.transport.pause() : state.transport.play(); + } else if (k === 'l') { + const loop = state.transport.state.loop; + state.transport.setLoop(loop.start, loop.end, !loop.enabled); + ui.timeline.render(); + } else if (k === 'o') { + $('#cockpit').classList.toggle('solo'); + } else if (k === 'delete' || k === 'backspace') { + if (ui.timeline.deleteSelected()) e.preventDefault(); + } else if (k === 'arrowleft' || k === 'arrowright') { + if (ui.timeline.nudgeSelected(k === 'arrowleft' ? -0.5 : 0.5)) e.preventDefault(); } else { const pad = PAD_KEYS.indexOf(k); if (pad >= 0 && !e.repeat) { @@ -977,49 +946,141 @@ function wirePerform() { state.router.handleMidiEvent({ kind: 'pad', idx: pad, vel: 0.9 }); } } - bumpHelpBar(); }); // Gate-mode pads need the key release; the router no-ops for other modes. window.addEventListener('keyup', (e) => { - if (state.screen !== 'perform') return; + synthKeyUp(e); const pad = PAD_KEYS.indexOf(e.key.toLowerCase()); if (pad >= 0) state.router.handleMidiEvent({ kind: 'noteoff', idx: pad }); }); +} - $('#btn-back').addEventListener('click', () => { - state.engine.stop(); - show('projects'); +// ---------------------------------------------------------------- stage input + +function wireStageInput() { + const canvas = $('#stage'); + canvas.addEventListener('pointermove', (e) => { + if (state.midi.control.isSway) return; + const r = canvas.getBoundingClientRect(); + state.midi.control.xy.x = (e.clientX - r.left) / r.width; + state.midi.control.xy.y = 1 - (e.clientY - r.top) / r.height; }); + canvas.addEventListener('pointerdown', () => { + if (!state.midi.control.isSway) state.midi.control.gestures.press = 1; + }); + canvas.addEventListener('pointerup', () => { + if (!state.midi.control.isSway) state.midi.control.gestures.press = 0; + }); + canvas.addEventListener( + 'wheel', + (e) => { + if (!state.midi.control.isSway) { + const g = state.midi.control.gestures; + g.pulse = Math.min(1, g.pulse + Math.abs(e.deltaY) * 0.002); + setTimeout(() => (g.pulse *= 0.5), 150); + } + }, + { passive: true } + ); +} + +// ---------------------------------------------------------------- frame tick + +let lastTick = 0; +let lastScene = ''; +let lastDeckSync = 0; + +function frameTick(now) { + requestAnimationFrame(frameTick); + const dt = Math.min(0.05, (now - lastTick) / 1000 || 0.016); + lastTick = now; + const eng = state.engine; + const io = eng.io; + + // top bar + const sceneName = eng.currentScene ? eng.currentScene.name : ''; + if (sceneName !== lastScene) { + lastScene = sceneName; + $('#scene-now').textContent = sceneName; + for (const btn of document.querySelectorAll('#scene-bank [data-scene]')) { + btn.classList.toggle('current', eng.currentScene && btn.dataset.scene === eng.currentScene.id); + } + } + $('#t-clock').textContent = fmtClock(state.transport.state.position); + $('#t-play').textContent = state.transport.state.playing ? '❚❚' : '▶'; + $('#t-play').classList.toggle('on', state.transport.state.playing); + $('#t-loop').classList.toggle('on', state.transport.state.loop.enabled); + $('#pill-fps').textContent = eng.stats.fps; - window.addEventListener('mousemove', () => state.screen === 'perform' && bumpHelpBar()); + const c = state.midi.control; + const link = $('#pill-link'); + link.textContent = c.isSway ? 'SWAY' : c.connected ? 'MIDI' : 'KEYS'; + link.classList.toggle('pill-on', c.isSway || c.connected); + + const a = state.audio.state; + $('#pill-in').textContent = + a.source === 'input' ? 'LINE' : a.source === 'system' ? 'LOOPBACK' : a.source === 'internal' ? 'GROOVE' : 'MUTE'; + + // input box + $('#input-meter').style.width = `${Math.round(a.level * 100)}%`; + ui.wave.update(dt, a.bands, '#2de1fc', '#ff2d95'); + + // deck + timeline + ui.surface.update(io, state.midi.monitor); + ui.timeline.updatePlayhead(); + + // periodic sync of cheap-but-not-per-frame things + if (now - lastDeckSync > 250) { + lastDeckSync = now; + refreshDeck(); + updateProjectButton(); + updateAutoBox(); + const v = state.synth.activeVoices; + const voicePill = $('#synth-voices'); + voicePill.textContent = v; + voicePill.classList.toggle('pill-on', v > 0); + } + + if (state.monitorVisible) { + $('#midi-monitor').textContent = state.midi.monitor.join('\n') || '(waiting for MIDI…)'; + } +} + +// ---------------------------------------------------------------- project glue + +function postProjectLoad() { + renderSceneBank(); + updateAutoBox(); + updateProjectButton(); + updateSourceLabel(); + refreshDeck(); + ui.assign.refresh(); + ui.timeline.render(); + renderSamples(); + if (ui.drawer.isOpen('synth')) renderSynthPanel(); + if (ui.drawer.isOpen('rack')) renderFxPanel(); + $('#synth-enable').checked = state.synthEnabled; } -// ---------------------------------------------------------------- boot sequence +// ---------------------------------------------------------------- boot async function main() { const info = await window.akswayj.info(); $('#boot-version').textContent = `v${info.version} · ${info.platform}`; state.audio = await createAudioEngine(); - // Sampler output goes to the speakers AND into the analyser, so stems the - // player triggers drive the visuals exactly like any other audio source. + // Sampler, synth, and the timeline all feed the speakers AND the analyser, + // so anything the instrument plays drives the visuals. const audioOuts = [state.audio.ctx.destination, state.audio.analyser]; state.sampler = createSampler(state.audio.ctx, audioOuts); - // The synth shares the sampler's routing: heard on the speakers and mixed - // into the analyser, so playing it drives the visuals. state.synth = createSynth(state.audio.ctx, audioOuts); - // Timeline playback rides the same bus, so the backing track drives the - // visuals through the one analyser like every other sound in the app. state.transport = createTransport(state.audio.ctx, audioOuts); // Every MIDI event goes to the assignment router — one dispatch path for // hardware, keyboard pads, and the timeline alike. state.midi = await createMidi({ - onEvent: (e) => { - if (state.router) state.router.handleMidiEvent(e); - if (e.kind === 'pad' && e.idx >= 0 && state.screen === 'studio') flashPad(e.idx); - }, + onEvent: (e) => state.router && state.router.handleMidiEvent(e), }); state.engine = createEngine({ canvas: $('#stage'), quality: 'med' }); state.engine.attachAudio(state.audio); @@ -1035,8 +1096,7 @@ async function main() { }); state.router.onSynthToggle((v) => { state.synthEnabled = v; - const el = $('#synth-enable'); - if (el) el.checked = v; + $('#synth-enable').checked = v; }); state.engine.setFrameHook(state.router.frame); @@ -1051,59 +1111,136 @@ async function main() { setSynthEnabled: (v) => { state.synthEnabled = v; state.router.synthEnabled = v; - const el = $('#synth-enable'); - if (el) el.checked = v; + $('#synth-enable').checked = v; + }, + onApplied: () => ui.timeline && postProjectLoad(), + onMediaLoaded: () => { + if (!ui.timeline) return; + ui.timeline.render(); + renderSamples(); + refreshDeck(); + }, + }); + + // --- UI assembly --- + initFrames(); + wirePopover(); + ui.wave = createWave($('#input-wave')); + ui.surface = createSurface($('#swaydeck'), { onSelect: onDeckSelect }); + ui.drawer = createDrawer({ + onOpenTab: (tab, first) => { + if (tab === 'synth' && first) renderSynthPanel(); + if (tab === 'rack') renderFxPanel(); + if (tab === 'kit') renderSamples(); + }, + }); + ui.assign = createAssign({ + router: state.router, + sampler: state.sampler, + synth: state.synth, + engine: state.engine, + midi: state.midi, + fxRanges, + fxDecks, + onChanged: () => { + state.projectStore.markDirty(); + refreshDeck(); }, + onLearnArmed: (armed) => ui.surface.setArmed(armed), + }); + ui.timeline = createTimeline({ + transport: state.transport, + engine: state.engine, + store: state.projectStore, + onEdit: () => state.projectStore.markDirty(), + }); + new ResizeObserver(() => ui.timeline.render()).observe($('#timeline')); + state.router.onTouch((id) => { + if (!ui.assign.followEnabled() || popoverOpen()) return; + if (ui.assign.current() !== id) selectControl(id); }); + wireDoctor(); + wireTopbar(); + wireKit(); + wireSynth(); + wireFx(); + wireDocs(); + wireScenes(); + wireKeyboard(); + wireStageInput(); + $('#input-src').addEventListener('click', openSourceMenu); + // restore learned MIDI bindings const settings = await window.akswayj.settings.get(); if (settings.midiOverrides) state.midi.setOverrides(settings.midiOverrides); - if (settings.kit) restoreKit(settings.kit); // deliberately not awaited: startup must not block on disk - state.projects = await window.akswayj.projects.list(); - renderProjects(); - - // Automation handle. The page CSP admits no remote or inline script, so this - // is reachable only from the app's own bundle and from AKSWAYJ_PROBE, which - // is how the build is verified headlessly (see docs/ENVIRONMENT.md). + // Automation handle. The page CSP admits no remote or inline script, so + // this is reachable only from the bundle and from AKSWAYJ_PROBE. window.__akswayj = { state, studio, - openStudio, + openStudio: (tab) => ui.drawer.open(tab || 'synth'), openDocs, - renderPads, + renderPads: refreshDeck, renderSamples, transport: state.transport, projectStore: state.projectStore, router: state.router, + selectControl, + openProject: (p) => state.projectStore.openPath(p), + saveProject: () => state.projectStore.save(), }; - wireDoctor(); - wireProjects(); - wireStudio(); - wireSynth(); - wireFx(); - window.addEventListener('keyup', studioKeyUp); - wireDocs(); - wirePerform(); - updateHud(); - - state.audio.autoStart(); // live input if possible, silent internal groove otherwise + // The stage runs from the first frame; the door covers it until ENTER. + state.engine.start(); + requestAnimationFrame(frameTick); + state.audio.autoStart().then(updateSourceLabel); + window.akswayj.platform.systemAudio().then((sa) => (studio.systemAudio = sa)); + // --- boot project --- const params = new URLSearchParams(location.search); const autoplay = params.get('autoplay'); - if (autoplay) { - const p = state.projects.find((x) => x.id === autoplay) || state.projects[0]; - startPerformance(p); - const scene = params.get('scene'); - if (scene) { - state.engine.autoVJ.enabled = false; - state.engine.setScene(scene, 0.3); + let loaded = false; + try { + if (autoplay && autoplay.toLowerCase().endsWith('.sway')) { + await state.projectStore.openPath(autoplay); + loaded = true; + } else if (autoplay) { + await state.projectStore.openTemplate(autoplay); + loaded = true; + } + } catch (err) { + console.warn('[boot] autoplay failed:', err.message); + } + if (!loaded) { + try { + const recent = await window.akswayj.project.recent(); + if (recent.length) { + await state.projectStore.openPath(recent[0].path); + loaded = true; + } + } catch { + /* recents are best-effort */ } - runDoctor(); // still populate the doctor screen in the background + } + if (!loaded) await state.projectStore.openTemplate('first-flight'); + + const scene = params.get('scene'); + if (scene) { + state.engine.autoVJ.enabled = false; + state.engine.setScene(scene, 0.3); + } + + if (settings.kit) restoreKit(settings.kit); // legacy kit, deliberately not awaited + postProjectLoad(); + selectControl(null); + + if (autoplay) { + enterCockpit(); + runDoctor(); // still populate the checks in the background } else { - show('boot'); + openModal('system'); runDoctor(); } } diff --git a/src/renderer/fonts/SourceCodePro-latin.woff2 b/src/renderer/fonts/SourceCodePro-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..bc303f50c5d054c18e6de4411934639c72d67a44 GIT binary patch literal 22044 zcmV(_K-9l?Pew8T0RR9109G6T761SM0HT}#09B&^0RR9100000000000000000000 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

    3bNFmBv9b>LvJJic+O4O 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 - -

    -
    -
    - -
    Gesture VJ instrument for the Audima Labs Sway
    -
    -
    -
    Checking your system…
    -
      -
      - -
      - - - -
      -
      -
      -
      +
      +
      +
      AKSWAYJ
      + +
      + + + 00:00.0 + +
      + +
      + + + +
      + +
      - -
      -
      -
      - -
      Pick a project. Everything is playable instantly — hand over the Sway, mouse, keys, or any MIDI controller.
      -
      - - -
      -
      -
      -
      -
      + - -
      -
      -
      - -
      Studio
      - -
      +
      + + +
      +
      -
      -
      -

      Audio source

      -

      The analysis source that drives every scene. Triggered stems are always mixed in on top.

      -
      - -
      -
      -
        - -

        -
        + -
        -

        Kit builder

        -

        Load stems and one-shots, then click a pad to assign the selected sample. Pads play from the Sway, from Z X C V B N M , and from this grid.

        -
        - - - -
        -
        -
        -
        Samples
        -
          -
          -
          -
          Pads
          -
          -
          - - - -
          -
          -
          -

          -
          -
          +
          + +
          + +
          +
          -
          -
          -

          Synth

          - - - 0 voices +
          +
          + +
          - -
          -
          -

          Effects rack

          - - +
          +
          -
          -
          - - -
          -
          - -
          +
            +

            Select a sample, then click a pad on the deck to place it. Drag one onto the timeline to lay a backing track. Pad settings live in the pad's panel on the right.

            +
            - + - -
            - -
            -
            - - - -
            -
            - AUTO-VJ - MIDI - AUDIO - +
            + -
            
            -    
            - 1–9 project scenes · Space next · A auto-VJ · Z–, pads · F fullscreen · H help · S studio · D docs · K MIDI monitor · Esc back -
            -
            -
            -

            Playing AKSWAYJ

            + + + + -
            + +
            
            +    
            +  
             
               
             
            diff --git a/src/renderer/project/projectstore.js b/src/renderer/project/projectstore.js
            index 19b3acc..3b73c89 100644
            --- a/src/renderer/project/projectstore.js
            +++ b/src/renderer/project/projectstore.js
            @@ -131,7 +131,10 @@ export function createProjectStore(deps) {
                 applySamplerKnobs();
             
                 state.loading = false;
            -    loadMediaAsync();
            +    if (deps.onApplied) deps.onApplied();
            +    loadMediaAsync().then(() => {
            +      if (deps.onMediaLoaded) deps.onMediaLoaded();
            +    });
                 return warnings;
               }
             
            diff --git a/src/renderer/styles.css b/src/renderer/styles.css
            index 0408441..e34cc85 100644
            --- a/src/renderer/styles.css
            +++ b/src/renderer/styles.css
            @@ -1,5 +1,13 @@
            -/* AKSWAYJ — dark stage aesthetic. Everything sits on near-black so the
            -   perform canvas and the chrome feel like one instrument. */
            +/* SwayCommand cockpit — one page, always live. Near-black ground, stroke
            +   line-art chrome, cyan/magenta identity. The stage canvas is the biggest
            +   thing on screen at all times; everything else is instrument furniture. */
            +
            +@font-face {
            +  font-family: "Source Code Pro";
            +  font-style: normal;
            +  font-weight: 300 700;
            +  src: url(./fonts/SourceCodePro-latin.woff2) format("woff2");
            +}
             
             :root {
               --bg: #05060a;
            @@ -13,24 +21,43 @@
               --warn: #fbbf24;
               --fail: #f87171;
               --info: #64748b;
            -  --radius: 14px;
            +  --line: rgba(45, 225, 252, 0.32);
            +  --line-dim: rgba(45, 225, 252, 0.12);
            +  --etch: #0d2a34;
            +  --door-fill: #070a10;
            +  --radius: 10px;
            +  --chamfer: 9px;
            +  --top-h: 34px;
            +  --tl-h: 100px;
            +  --rail-w: 208px;
            +  --z-drawer: 20;
            +  --z-popover: 30;
            +  --z-modal: 40;
            +  --z-system: 50;
            +  --z-notice: 60;
               --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
               --mono: ui-monospace, "Cascadia Code", Consolas, monospace;
            +  --display-font: "Source Code Pro", ui-monospace, "Cascadia Code", Consolas, monospace;
             }
             
             * { box-sizing: border-box; margin: 0; padding: 0; }
            +::-webkit-scrollbar { width: 7px; height: 7px; }
            +::-webkit-scrollbar-thumb { background: rgba(45, 225, 252, .18); border-radius: 4px; }
            +::-webkit-scrollbar-thumb:hover { background: rgba(45, 225, 252, .35); }
            +::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }
             /* Color is this app's content — keep stage palettes intact under Windows
                contrast themes (forced-color-adjust inherits to every element). */
             html { forced-color-adjust: none; }
             html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); overflow: hidden; }
             
            -.screen { position: absolute; inset: 0; display: none; }
            -.screen.active { display: block; }
            +button { font-family: inherit; }
            +
            +/* ---------- shared atoms ---------- */
             
             .btn {
            -  font: 600 14px var(--font); color: var(--text);
            +  font: 600 13px var(--font); color: var(--text);
               background: var(--panel); border: 1px solid var(--panel-edge);
            -  padding: 10px 18px; border-radius: 10px; cursor: pointer;
            +  padding: 8px 16px; border-radius: var(--radius); cursor: pointer;
               transition: border-color .15s, background .15s, transform .05s;
             }
             .btn:hover { border-color: var(--accent); }
            @@ -42,242 +69,414 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
             }
             .btn-primary:hover { border-color: var(--accent); box-shadow: 0 0 24px rgba(45,225,252,.25); }
             .btn-ghost { background: transparent; }
            -.btn-small { padding: 6px 12px; }
            +.btn-small { padding: 5px 10px; font-size: 12px; }
             .btn-fix { border-color: rgba(45,225,252,.45); white-space: nowrap; align-self: center; }
             
             .pill {
            -  font: 700 10.5px var(--font); letter-spacing: .08em;
            -  padding: 4px 10px; border-radius: 999px;
            +  font: 700 10px var(--font); letter-spacing: .08em;
            +  padding: 3px 9px; border-radius: 999px;
               background: var(--panel); border: 1px solid var(--panel-edge);
            -  color: var(--text-dim); text-transform: uppercase;
            +  color: var(--text-dim); text-transform: uppercase; white-space: nowrap;
            +  min-width: 0; overflow: hidden; text-overflow: ellipsis;
             }
             .pill-on { color: var(--accent); border-color: rgba(45,225,252,.5); }
             .pill-dim { opacity: .6; }
            -.pill-pair { color: var(--accent2); border-color: rgba(255,45,149,.4); }
             
            -/* ---------- logo ---------- */
            -.logo {
            -  font-size: 44px; font-weight: 800; letter-spacing: .04em;
            +/* Chamfered control — the angular button vocabulary. The skewed underline
            +   sweeps in on hover; an .on control keeps it lit. */
            +.cham {
            +  position: relative; cursor: pointer;
            +  font: 300 11.5px var(--display-font); letter-spacing: .14em;
            +  color: var(--text-dim); background: rgba(255,255,255,.03);
            +  border: 1px solid var(--panel-edge);
            +  padding: 5px 14px;
            +  clip-path: polygon(7px 0, 100% 0, 100% calc(100% - 7px), calc(100% - 7px) 100%, 0 100%, 0 7px);
            +  transition: color .15s, background .15s, border-color .15s;
            +}
            +.cham::after {
            +  content: ""; position: absolute; left: 10px; bottom: 2px; height: 0;
            +  width: 8%; border-bottom: 2px solid var(--accent);
            +  transform: skewX(45deg); transition: width .25s; opacity: 0;
            +}
            +.cham:hover { color: var(--text); }
            +.cham:hover::after, .cham.on::after { width: calc(100% - 24px); opacity: 1; }
            +.cham.on { color: var(--accent); background: rgba(45,225,252,.07); }
            +
            +.rail-label {
            +  font: 300 10px var(--display-font); letter-spacing: .22em;
            +  color: var(--info); padding: 8px 10px 4px;
             }
            -.logo span {
            +
            +/* Stroke-only chamfered frame injected by ui/frame.js */
            +.framed { position: relative; }
            +.frame-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
            +.frame-svg path { fill: none; stroke: var(--line-dim); vector-effect: non-scaling-stroke; }
            +.frame-svg .frame-tick { stroke: var(--line); }
            +
            +/* ---------- cockpit grid ---------- */
            +
            +#cockpit {
            +  display: grid; height: 100%;
            +  grid-template-rows: var(--top-h) minmax(0, 1fr) var(--tl-h) auto;
            +  grid-template-columns: var(--rail-w) minmax(0, 1fr) var(--rail-w);
            +  grid-template-areas:
            +    "top   top   top"
            +    "left  stage right"
            +    "tl    tl    tl"
            +    "sway  sway  sway";
            +}
            +#cockpit.solo {
            +  grid-template-rows: var(--top-h) minmax(0, 1fr);
            +  grid-template-columns: minmax(0, 1fr);
            +  grid-template-areas: "top" "stage";
            +}
            +#cockpit.solo :is(#rail-left, #rail-right, #timeline, #swaydeck) { display: none; }
            +
            +/* ---------- top bar ---------- */
            +
            +#topbar {
            +  grid-area: top; display: flex; align-items: center; gap: 10px;
            +  padding: 0 10px; border-bottom: 1px solid var(--line-dim);
            +  background: rgba(255,255,255,.015); min-width: 0;
            +}
            +#mark { font: 700 15px var(--font); letter-spacing: .04em; flex-shrink: 0; }
            +#mark span {
               background: linear-gradient(90deg, var(--accent), var(--accent2));
               -webkit-background-clip: text; background-clip: text; color: transparent;
             }
            -.logo-small { font-size: 24px; }
            +#project-btn { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
            +#project-btn.dirty { color: var(--accent2); }
            +#transport { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
            +#t-play, #t-stop { padding: 4px 10px; font-size: 10px; letter-spacing: 0; }
            +#t-clock { font: 300 12px var(--display-font); color: var(--text-dim); min-width: 64px; text-align: center; }
            +#scene-now {
            +  font: 300 13px var(--display-font); letter-spacing: .18em; color: var(--accent);
            +  flex: 1; text-align: center; text-transform: uppercase;
            +  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
            +}
            +#status { display: flex; align-items: center; gap: 6px; flex-shrink: 0; min-width: 0; }
            +#deckbar { display: flex; gap: 4px; flex-shrink: 0; }
             
            -/* ---------- boot ---------- */
            -#screen-boot { display: none; overflow-y: auto; }
            -#screen-boot.active { display: block; }
            -.boot-wrap { max-width: 760px; margin: 0 auto; padding: 7vh 24px 6vh; }
            -.boot-head { text-align: center; margin-bottom: 34px; }
            -.tagline { color: var(--text-dim); margin-top: 6px; font-size: 15px; }
            -.version { color: var(--info); margin-top: 4px; font: 12px var(--mono); }
            -.boot-status { text-align: center; color: var(--accent); font-weight: 600; margin-bottom: 22px; min-height: 22px; }
            +/* ---------- left rail ---------- */
             
            -.check-list { list-style: none; display: flex; flex-direction: column; gap: 10px; }
            -.check {
            -  display: flex; gap: 14px; align-items: flex-start;
            -  background: var(--panel); border: 1px solid var(--panel-edge);
            -  border-radius: var(--radius); padding: 14px 16px;
            -}
            -.check-icon { font-size: 13px; line-height: 20px; width: 16px; flex-shrink: 0; }
            -.check.ok .check-icon { color: var(--ok); }
            -.check.warn .check-icon { color: var(--warn); }
            -.check.fail .check-icon { color: var(--fail); }
            -.check.info .check-icon { color: var(--info); }
            -.check-body { flex: 1; }
            -.check-label { font-weight: 700; font-size: 14px; }
            -.check-detail { color: var(--text-dim); font-size: 13px; margin-top: 3px; line-height: 1.45; }
            -.check-progress { color: var(--accent); font: 12px var(--mono); margin-top: 3px; }
            -.boot-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 26px; gap: 12px; }
            -.boot-actions-right { display: flex; gap: 10px; }
            -
            -/* ---------- projects ---------- */
            -#screen-projects { overflow-y: auto; }
            -.projects-wrap { max-width: 1180px; margin: 0 auto; padding: 6vh 28px; }
            -.projects-head { display: flex; align-items: baseline; gap: 22px; margin-bottom: 30px; flex-wrap: wrap; }
            -.projects-hint { color: var(--text-dim); font-size: 14px; }
            -.projects-docs { margin-left: auto; }
            -
            -.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
            -.card {
            -  background: var(--panel); border: 1px solid var(--panel-edge);
            -  border-radius: var(--radius); padding: 20px; cursor: pointer; position: relative;
            -  transition: border-color .15s, transform .15s, box-shadow .15s;
            -}
            -.card:hover, .card:focus-visible {
            -  border-color: rgba(45,225,252,.55); transform: translateY(-2px);
            -  box-shadow: 0 12px 40px rgba(0,0,0,.5); outline: none;
            -}
            -.card-swatches { display: flex; gap: 5px; margin-bottom: 14px; }
            -.card-swatches i { width: 26px; height: 8px; border-radius: 4px; }
            -.card h3 { font-size: 19px; margin-bottom: 8px; }
            -.card-meta { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
            -.card p { color: var(--text-dim); font-size: 13.5px; line-height: 1.5; }
            -.card-go {
            -  margin-top: 14px; font: 800 12px var(--font); letter-spacing: .14em; color: var(--accent);
            -  opacity: 0; transition: opacity .15s;
            -}
            -.card:hover .card-go, .card:focus-visible .card-go { opacity: 1; }
            -
            -/* ---------- studio: audio source + kit builder ---------- */
            -#screen-studio { overflow-y: auto; }
            -.studio-wrap { max-width: 1180px; margin: 0 auto; padding: 4vh 28px 8vh; }
            -.studio-head { display: flex; align-items: center; gap: 18px; margin-bottom: 26px; }
            -.studio-title { font-size: 20px; font-weight: 700; letter-spacing: .02em; }
            -.studio-head .btn { margin-left: auto; }
            -.studio-cols { display: grid; grid-template-columns: 380px 1fr; gap: 18px; align-items: start; }
            -@media (max-width: 900px) { .studio-cols { grid-template-columns: 1fr; } }
            -
            -.panel {
            -  background: var(--panel); border: 1px solid var(--panel-edge);
            -  border-radius: var(--radius); padding: 20px 22px;
            +#rail-left { grid-area: left; display: flex; flex-direction: column; min-height: 0; margin: 6px 0 6px 6px; padding: 2px; }
            +#scene-bank { list-style: none; flex: 1; min-height: 0; overflow-y: auto; padding: 0 4px; }
            +#scene-bank button {
            +  width: 100%; display: flex; align-items: baseline; gap: 8px; cursor: pointer;
            +  background: none; border: 0; border-left: 2px solid transparent;
            +  color: var(--text-dim); font: 300 12.5px var(--display-font); letter-spacing: .06em;
            +  padding: 4px 8px; text-align: left;
            +}
            +#scene-bank button b { font: 300 10px var(--display-font); color: var(--info); min-width: 12px; }
            +#scene-bank button:hover { color: var(--text); background: var(--panel); }
            +#scene-bank button.current { color: var(--accent); border-left-color: var(--accent); }
            +#scene-bank button.pooled b { color: var(--accent); }
            +#auto-box { padding: 2px 10px 10px; display: flex; flex-direction: column; gap: 7px; }
            +#auto-box label { display: flex; align-items: center; gap: 5px; color: var(--info); font: 300 10px var(--display-font); letter-spacing: .1em; }
            +#auto-box input {
            +  width: 44px; background: #0a0d15; color: var(--text); border: 1px solid var(--panel-edge);
            +  border-radius: 5px; padding: 3px 5px; font: 11px var(--mono);
            +}
            +#auto-toggle { align-self: flex-start; }
            +
            +/* ---------- stage ---------- */
            +
            +#stagecell { grid-area: stage; position: relative; background: #000; min-width: 0; min-height: 0; overflow: hidden; }
            +#stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
            +
            +#door { position: absolute; inset: 0; width: 100%; height: 100%; z-index: var(--z-system); }
            +#door-left, #door-right { transition: transform .9s cubic-bezier(.7, 0, .3, 1); }
            +#lock-left, #lock-right { transform-origin: 500px 280px; transition: transform .5s ease-in-out; }
            +#door.open #door-left { transform: translateX(-101%); }
            +#door.open #door-right { transform: translateX(101%); }
            +#door.open #lock-left { transform: rotate(-90deg); }
            +#door.open #lock-right { transform: rotate(-90deg); }
            +#door.gone { display: none; }
            +@media (prefers-reduced-motion: reduce) {
            +  #door-left, #door-right, #lock-left, #lock-right { transition: none; }
             }
            -.panel h2 { font-size: 16px; margin-bottom: 6px; }
            -.panel-hint { color: var(--text-dim); font-size: 13px; line-height: 1.55; margin-bottom: 16px; }
            -.panel-note { color: var(--info); font-size: 12px; line-height: 1.5; margin-top: 12px; }
             
            -.source-now { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
            -.meter { flex: 1; height: 8px; background: rgba(255,255,255,.07); border-radius: 4px; overflow: hidden; }
            +#notice {
            +  position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
            +  font: 300 11.5px var(--display-font); letter-spacing: .1em; color: var(--text-dim);
            +  background: rgba(0,0,0,.55); border: 1px solid var(--line-dim); padding: 4px 14px;
            +  opacity: 0; transition: opacity .4s; pointer-events: none; z-index: var(--z-notice);
            +  max-width: 90%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
            +}
            +#notice.visible { opacity: 1; }
            +#notice b { color: var(--accent); font-weight: 400; }
            +
            +/* ---------- right rail ---------- */
            +
            +#rail-right { grid-area: right; display: flex; flex-direction: column; gap: 6px; min-height: 0; margin: 6px 6px 6px 0; }
            +#assign { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 2px; }
            +#assign-target {
            +  font: 300 13px var(--display-font); letter-spacing: .18em; color: var(--accent);
            +  padding: 10px 12px 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
            +}
            +#assign-target .chip {
            +  font: 300 9px var(--display-font); letter-spacing: .12em; cursor: pointer;
            +  color: var(--text-dim); background: none; border: 1px solid var(--panel-edge);
            +  padding: 2px 7px; margin-left: auto;
            +}
            +#assign-target .chip + .chip { margin-left: 0; }
            +#assign-target .chip.on { color: var(--accent); border-color: var(--line); }
            +#assign-target .chip.armed { color: var(--accent2); border-color: rgba(255,45,149,.5); animation: learn-flicker .5s steps(2) infinite; }
            +@keyframes learn-flicker { 50% { opacity: .35; } }
            +@media (prefers-reduced-motion: reduce) { #assign-target .chip.armed { animation: none; } }
            +#assign-body { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 12px 10px; }
            +#assign-body .empty { color: var(--info); font: 300 11.5px var(--display-font); letter-spacing: .08em; line-height: 1.7; padding-top: 6px; }
            +.assign-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
            +.assign-row > span {
            +  font: 300 9.5px var(--display-font); letter-spacing: .16em; color: var(--info);
            +  min-width: 52px; flex-shrink: 0;
            +}
            +.assign-row select, .assign-row input[type="number"], .assign-row input[type="text"] {
            +  flex: 1; min-width: 0; background: #0a0d15; color: var(--text);
            +  border: 1px solid var(--panel-edge); border-radius: 5px; padding: 4px 6px; font: 11px var(--mono);
            +}
            +.assign-row input[type="range"] { flex: 1; min-width: 0; }
            +.assign-row input[type="number"] { flex: 0 1 58px; }
            +.assign-row b { color: var(--accent); font: 11px var(--mono); min-width: 30px; text-align: right; }
            +.assign-row .btn { flex-shrink: 0; }
            +.assign-list { margin-bottom: 8px; }
            +
            +#input-box { padding: 2px 2px 10px; }
            +#input-src {
            +  display: block; width: calc(100% - 20px); margin: 2px 10px 8px; cursor: pointer;
            +  background: rgba(255,255,255,.03); border: 1px solid var(--panel-edge); border-radius: 6px;
            +  color: var(--text); font: 11.5px var(--font); padding: 6px 9px; text-align: left;
            +  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
            +}
            +#input-src:hover { border-color: var(--accent); }
            +.meter { height: 6px; margin: 0 10px 8px; background: rgba(255,255,255,.07); overflow: hidden; }
             .meter i {
               display: block; height: 100%; width: 0%;
               background: linear-gradient(90deg, var(--accent), var(--accent2));
            -  transition: width .06s linear;
            -}
            -.source-list { list-style: none; display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
            -.source-list button {
            -  width: 100%; text-align: left; cursor: pointer;
            -  background: rgba(255,255,255,.03); border: 1px solid var(--panel-edge); border-radius: 9px;
            -  color: var(--text); font: 13px var(--font); padding: 9px 12px;
            -}
            -.source-list button:hover { border-color: var(--accent); }
            -.source-list button.current { border-color: var(--accent); color: var(--accent); }
            -.source-list button span { display: block; color: var(--info); font-size: 11.5px; margin-top: 2px; }
            -.source-list button:disabled { opacity: .45; cursor: default; }
            -
            -.kit-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
            -.kit-body { display: grid; grid-template-columns: 230px 1fr; gap: 18px; }
            -@media (max-width: 700px) { .kit-body { grid-template-columns: 1fr; } }
            +}
            +#input-wave { display: block; width: calc(100% - 20px); height: 44px; margin: 0 10px; }
            +
            +/* ---------- timeline ---------- */
            +
            +#timeline {
            +  grid-area: tl; position: relative; display: grid;
            +  grid-template-rows: 18px minmax(0, 1fr) minmax(0, 1.3fr);
            +  border-top: 1px solid var(--line-dim); background: rgba(255,255,255,.012);
            +  min-width: 0; overflow: hidden;
            +}
            +#tl-ruler, #tl-audio { display: block; width: 100%; height: 100%; min-height: 0; }
            +#tl-ruler { cursor: ew-resize; }
            +.tl-lane { position: relative; border-top: 1px solid rgba(255,255,255,.05); border-bottom: 1px solid rgba(255,255,255,.05); min-height: 0; }
            +.tl-clip {
            +  position: absolute; top: 3px; bottom: 3px; min-width: 8px; cursor: grab;
            +  clip-path: polygon(5px 0, 100% 0, 100% calc(100% - 5px), calc(100% - 5px) 100%, 0 100%, 0 5px);
            +  border: 1px solid var(--line); background: rgba(45,225,252,.13);
            +  font: 300 10px var(--display-font); letter-spacing: .1em; color: var(--text);
            +  padding: 2px 8px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
            +  display: flex; align-items: center;
            +}
            +.tl-clip.selected { border-color: var(--accent); background: rgba(45,225,252,.24); }
            +.tl-clip:active { cursor: grabbing; }
            +#tl-playhead {
            +  position: absolute; top: 0; bottom: 0; left: 0; width: 1px;
            +  background: var(--accent); box-shadow: 0 0 6px rgba(45,225,252,.8);
            +  pointer-events: none; will-change: transform;
            +}
            +#timeline.droppable .tl-lane, #timeline.droppable #tl-audio { outline: 1px dashed var(--line); outline-offset: -2px; }
            +
            +/* ---------- sway deck ---------- */
            +
            +#swaydeck { grid-area: sway; width: 100%; aspect-ratio: 1266 / 160; border-top: 1px solid var(--line-dim); background: rgba(255,255,255,.008); }
            +#sway-svg { display: block; width: 100%; height: 100%; }
            +#sway-svg .ctl { cursor: pointer; }
            +#sway-svg .ctl:hover .hit-ring { stroke: var(--accent); opacity: 1; }
            +#sway-svg text { font: 300 9px var(--display-font); letter-spacing: .08em; fill: var(--text-dim); pointer-events: none; user-select: none; }
            +#sway-svg .etch { stroke: var(--line-dim); fill: none; }
            +#sway-svg .lit { stroke: var(--line); fill: none; }
            +#reticle .arm { fill: none; stroke: var(--accent); stroke-width: 1.2; animation: reticle-pulse 1.6s ease-in-out infinite; }
            +#reticle.armed .arm { stroke: var(--accent2); }
            +@keyframes reticle-pulse { 50% { opacity: .45; } }
            +@media (prefers-reduced-motion: reduce) { #reticle .arm { animation: none; } }
            +
            +/* ---------- drawer ---------- */
            +
            +#drawer {
            +  position: fixed; top: var(--top-h); right: calc(var(--rail-w) + 8px); bottom: calc(var(--tl-h) + 160px);
            +  width: 430px; z-index: var(--z-drawer); display: flex; flex-direction: column;
            +  background: rgba(7, 9, 14, .96); border-left: 1px solid var(--line); border-right: 1px solid var(--line-dim);
            +  transform: translateX(calc(100% + var(--rail-w) + 10px)); transition: transform .24s ease-out;
            +}
            +#drawer.open { transform: translateX(0); }
            +@media (prefers-reduced-motion: reduce) { #drawer { transition: none; } }
            +#drawer-head { display: flex; align-items: center; justify-content: flex-end; padding: 4px 8px; border-bottom: 1px solid var(--line-dim); }
            +#drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 20px; }
            +.deck-controls { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
            +.deck-controls .pill { margin-left: auto; }
            +
            +.fx-toggle { display: flex; align-items: center; gap: 7px; color: var(--text); font: 300 11px var(--display-font); letter-spacing: .14em; cursor: pointer; }
            +.fx-toggle select {
            +  background: #0a0d15; color: var(--text); border: 1px solid var(--panel-edge);
            +  border-radius: 5px; padding: 3px 7px; font: 12px var(--font);
            +}
            +.fx-decks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
            +.fx-deck { min-width: 0; }
            +.fx-row {
            +  display: flex; align-items: center; gap: 9px;
            +  padding: 3px 0; color: var(--text-dim); font-size: 12px; cursor: pointer;
            +}
            +.fx-row span { overflow-wrap: anywhere; }
            +.fx-row b { color: var(--accent); font-family: var(--mono); font-size: 11px; font-weight: 600; }
            +.fx-row input[type="range"] { width: 80px; flex-shrink: 0; }
            +.fx-row input[type="color"] { width: 30px; height: 20px; padding: 0; border: 1px solid var(--panel-edge); background: none; }
            +
             .kit-label {
            -  font: 700 10.5px var(--font); letter-spacing: .12em; text-transform: uppercase;
            -  color: var(--info); margin-bottom: 8px;
            +  font: 300 10px var(--display-font); letter-spacing: .18em; text-transform: uppercase;
            +  color: var(--info); margin-bottom: 8px; display: flex; align-items: center; gap: 12px;
             }
            -.kit-label span { color: var(--accent); letter-spacing: 0; text-transform: none; font-size: 12px; }
            -.sample-list { list-style: none; display: flex; flex-direction: column; gap: 4px; max-height: 260px; overflow-y: auto; }
            +.sample-list { list-style: none; display: flex; flex-direction: column; gap: 4px; }
             .sample-list button {
            -  width: 100%; text-align: left; cursor: pointer;
            -  background: none; border: 1px solid transparent; border-radius: 8px;
            +  width: 100%; text-align: left; cursor: grab;
            +  background: none; border: 1px solid transparent; border-radius: 6px;
               color: var(--text-dim); font: 12.5px var(--font); padding: 7px 10px;
             }
             .sample-list button:hover { color: var(--text); background: rgba(255,255,255,.04); }
             .sample-list button.current { border-color: var(--accent); color: var(--accent); }
             .sample-list button em { display: block; font-style: normal; color: var(--info); font-size: 11px; }
            +.sample-list .none { color: var(--info); font-size: 12px; padding: 6px 10px; }
             
            -.pad-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
            -.pad {
            -  aspect-ratio: 1; border-radius: 10px; cursor: pointer;
            -  background: rgba(255,255,255,.035); border: 1px solid var(--panel-edge);
            -  color: var(--text-dim); font: 600 11px var(--font);
            -  display: flex; flex-direction: column; align-items: center; justify-content: center;
            -  gap: 3px; padding: 6px; text-align: center; overflow: hidden;
            -  transition: transform .06s, border-color .12s, background .12s;
            -}
            -.pad:hover { border-color: var(--accent); }
            -.pad.selected { border-color: var(--accent); color: var(--accent); }
            -.pad.filled { background: linear-gradient(160deg, rgba(45,225,252,.14), rgba(255,45,149,.10)); color: var(--text); }
            -.pad.hit { transform: scale(.93); background: rgba(45,225,252,.4); }
            -.pad b { font-size: 9.5px; color: var(--info); font-weight: 700; }
            -.pad span { font-size: 10px; line-height: 1.2; overflow-wrap: anywhere; max-height: 2.4em; overflow: hidden; }
            -
            -.pad-opts { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 14px; align-items: center; }
            -.pad-opts label { color: var(--text-dim); font-size: 12px; display: flex; align-items: center; gap: 6px; }
            -.pad-opts select, .pad-opts input {
            -  background: #0a0d15; color: var(--text); border: 1px solid var(--panel-edge);
            -  border-radius: 7px; padding: 5px 8px; font: 12px var(--font);
            -}
            -.pad-opts input[type="number"] { width: 62px; }
            -.pad-opts input[type="range"] { width: 110px; padding: 0; }
            -.projects-actions { margin-left: auto; display: flex; gap: 8px; }
            -
            -.fx-panel { margin-top: 18px; }
            -.fx-head select {
            -  background: #0a0d15; color: var(--text); border: 1px solid var(--panel-edge);
            -  border-radius: 7px; padding: 4px 8px; font: 12px var(--font); margin-left: 6px;
            -}
            -
            -/* on-screen musical keyboard */
            -.synth-keys { display: flex; gap: 3px; margin: 14px 0 4px; flex-wrap: wrap; }
            +.synth-keys { display: flex; gap: 3px; margin: 4px 0 12px; }
             .skey {
            -  flex: 1 1 44px; min-width: 34px; height: 74px; cursor: pointer;
            +  flex: 1 1 20px; min-width: 16px; height: 56px; cursor: pointer;
               background: linear-gradient(180deg, #1a2030, #10141d);
            -  border: 1px solid var(--panel-edge); border-radius: 0 0 7px 7px;
            +  border: 1px solid var(--panel-edge); border-radius: 0 0 5px 5px;
               color: var(--text-dim); display: flex; align-items: flex-end; justify-content: center;
            -  padding-bottom: 7px; transition: background .05s, border-color .1s;
            +  padding-bottom: 5px; transition: background .05s, border-color .1s;
             }
            -.skey b { font: 700 10px var(--mono); }
            -.skey.sharp { background: linear-gradient(180deg, #05070c, #0b0e15); color: var(--info); height: 58px; }
            +.skey b { font: 700 9px var(--mono); }
            +.skey.sharp { background: linear-gradient(180deg, #05070c, #0b0e15); color: var(--info); height: 42px; }
             .skey:hover { border-color: var(--accent); }
             .skey.down { background: linear-gradient(180deg, var(--accent), #0b6d80); color: #04121a; }
             
             .synth-matrix { margin-top: 16px; }
            -.synth-matrix .kit-label { display: flex; align-items: center; gap: 12px; }
            -.mod-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
            +.mod-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
             .mod-row select {
               background: #0a0d15; color: var(--text); border: 1px solid var(--panel-edge);
            -  border-radius: 7px; padding: 4px 8px; font: 11.5px var(--mono);
            +  border-radius: 5px; padding: 3px 6px; font: 11px var(--mono); max-width: 118px;
             }
             .mod-row span { color: var(--info); }
            -.mod-row input[type="range"] { width: 110px; }
            -.mod-row b { color: var(--accent); font: 11px var(--mono); min-width: 34px; }
            -.fx-head { display: flex; align-items: center; gap: 16px; margin-bottom: 4px; }
            -.fx-head h2 { margin-bottom: 0; }
            -.fx-toggle { display: flex; align-items: center; gap: 7px; color: var(--text); font-size: 13px; cursor: pointer; }
            -.fx-head .btn { margin-left: auto; }
            -.fx-decks { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 18px; margin-top: 14px; }
            -.fx-deck { min-width: 0; }
            -.fx-row {
            -  display: flex; align-items: center; gap: 9px;
            -  padding: 4px 0; color: var(--text-dim); font-size: 12px; cursor: pointer;
            +.mod-row input[type="range"] { width: 80px; }
            +.mod-row b { color: var(--accent); font: 11px var(--mono); min-width: 30px; }
            +
            +.panel-note { color: var(--info); font-size: 11.5px; line-height: 1.5; margin-top: 10px; }
            +
            +/* ---------- overlays ---------- */
            +
            +.modal {
            +  position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
            +  background: rgba(0,0,0,.6); backdrop-filter: blur(6px); z-index: var(--z-modal);
            +}
            +.modal[hidden] { display: none; }
            +#modal-system { z-index: var(--z-system); }
            +.modal-frame {
            +  background: #0b0e16; border: 1px solid var(--line);
            +  clip-path: polygon(16px 0, 100% 0, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0 100%, 0 16px);
            +  max-height: calc(100vh - 60px);
            +}
            +.docs-frame { width: min(1100px, calc(100vw - 70px)); height: calc(100vh - 70px); }
            +
            +.help-card { padding: 26px 30px; max-width: 620px; overflow-y: auto; }
            +.help-card h2 { font: 300 16px var(--display-font); letter-spacing: .22em; margin-bottom: 14px; color: var(--accent); }
            +.help-card table { border-collapse: collapse; width: 100%; }
            +.help-card td { padding: 6px 14px 6px 0; font-size: 13px; vertical-align: top; }
            +.help-card td:first-child { color: var(--accent); font-weight: 600; white-space: nowrap; }
            +.help-card td:last-child { color: var(--text-dim); }
            +.help-card b { color: var(--accent); }
            +.help-card footer { margin-top: 16px; display: flex; align-items: center; justify-content: space-between; color: var(--info); font-size: 12px; }
            +
            +.system-card { padding: 26px 32px; width: min(680px, calc(100vw - 60px)); overflow-y: auto; }
            +.boot-head { text-align: center; margin-bottom: 18px; }
            +.logo { font-size: 40px; font-weight: 800; letter-spacing: .04em; font-family: var(--font); }
            +.logo span {
            +  background: linear-gradient(90deg, var(--accent), var(--accent2));
            +  -webkit-background-clip: text; background-clip: text; color: transparent;
             }
            -.fx-row span { overflow-wrap: anywhere; }
            -.fx-row b { color: var(--accent); font-family: var(--mono); font-size: 11px; font-weight: 600; }
            -.fx-row input[type="range"] { width: 84px; flex-shrink: 0; }
            -.fx-row input[type="color"] { width: 30px; height: 20px; padding: 0; border: 1px solid var(--panel-edge); background: none; }
            +.version { color: var(--info); margin-top: 4px; font: 12px var(--mono); }
            +.boot-status { text-align: center; color: var(--accent); font-weight: 600; margin-bottom: 18px; min-height: 22px; }
            +.check-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
            +.check {
            +  display: flex; gap: 14px; align-items: flex-start;
            +  background: var(--panel); border: 1px solid var(--panel-edge);
            +  border-radius: var(--radius); padding: 12px 14px;
            +}
            +.check-icon { font-size: 13px; line-height: 20px; width: 16px; flex-shrink: 0; }
            +.check.ok .check-icon { color: var(--ok); }
            +.check.warn .check-icon { color: var(--warn); }
            +.check.fail .check-icon { color: var(--fail); }
            +.check.info .check-icon { color: var(--info); }
            +.check-body { flex: 1; }
            +.check-label { font-weight: 700; font-size: 13.5px; }
            +.check-detail { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; line-height: 1.45; }
            +.check-progress { color: var(--accent); font: 12px var(--mono); margin-top: 3px; }
            +.boot-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; gap: 12px; }
            +
            +.midi-monitor {
            +  position: fixed; right: 16px; top: calc(var(--top-h) + 12px); width: 300px;
            +  background: rgba(0,0,0,.72); border: 1px solid var(--panel-edge); border-radius: var(--radius);
            +  padding: 12px 14px; font: 11.5px var(--mono); color: #9fe8ff; line-height: 1.6;
            +  display: none; white-space: pre-wrap; z-index: var(--z-popover);
            +}
            +.midi-monitor.visible { display: block; }
             
            -/* ---------- documentation viewer ---------- */
            -#screen-docs { overflow: hidden; }
            -.docs-layout { display: grid; grid-template-columns: 268px 1fr; height: 100%; }
            +#popover {
            +  position: fixed; z-index: var(--z-popover); min-width: 220px; max-width: 340px;
            +  max-height: 50vh; overflow-y: auto;
            +  background: #0b0e16; border: 1px solid var(--line);
            +  clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
            +  padding: 8px;
            +}
            +#popover .pop-label { font: 300 9.5px var(--display-font); letter-spacing: .18em; color: var(--info); padding: 6px 8px 4px; }
            +#popover button.pop-item {
            +  display: block; width: 100%; text-align: left; cursor: pointer;
            +  background: none; border: 0; border-left: 2px solid transparent;
            +  color: var(--text-dim); font: 12.5px var(--font); padding: 7px 10px;
            +}
            +#popover button.pop-item:hover { color: var(--text); background: var(--panel); }
            +#popover button.pop-item.current { color: var(--accent); border-left-color: var(--accent); }
            +#popover button.pop-item:disabled { opacity: .45; cursor: default; }
            +#popover button.pop-item span { display: block; color: var(--info); font-size: 11px; margin-top: 1px; }
             
            +/* ---------- docs (inside the modal) ---------- */
            +
            +.docs-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: 100%; }
             .docs-nav {
               border-right: 1px solid var(--panel-edge);
            -  padding: 18px 14px 28px;
            -  overflow-y: auto;
            +  padding: 12px 12px 24px; overflow-y: auto;
               background: rgba(255, 255, 255, 0.015);
             }
            -.docs-nav-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 18px; }
            +.docs-nav-head { display: flex; justify-content: flex-end; margin-bottom: 8px; }
             .docs-list, .docs-toc { list-style: none; display: flex; flex-direction: column; gap: 1px; }
             .docs-list button, .docs-toc button {
               width: 100%; text-align: left; background: none; border: 0; cursor: pointer;
            -  color: var(--text-dim); font: 13.5px var(--font);
            -  padding: 7px 10px; border-radius: 8px; border-left: 2px solid transparent;
            +  color: var(--text-dim); font: 13px var(--font);
            +  padding: 6px 10px; border-radius: 7px; border-left: 2px solid transparent;
             }
             .docs-list button:hover, .docs-toc button:hover { color: var(--text); background: var(--panel); }
             .docs-list button.current { color: var(--accent); border-left-color: var(--accent); background: var(--panel); font-weight: 600; }
             .docs-toc-label {
            -  font: 700 10.5px var(--font); letter-spacing: .12em; text-transform: uppercase;
            -  color: var(--info); margin: 22px 0 8px 10px;
            +  font: 300 9.5px var(--display-font); letter-spacing: .18em; text-transform: uppercase;
            +  color: var(--info); margin: 20px 0 8px 10px;
             }
            -.docs-toc button { font-size: 12.5px; padding: 5px 10px; }
            +.docs-toc button { font-size: 12px; padding: 4px 10px; }
             .docs-toc button.lvl-3 { padding-left: 22px; opacity: .82; }
             
            -.docs-body { overflow-y: auto; padding: 46px 56px 96px; }
            +.docs-body { overflow-y: auto; padding: 36px 44px 80px; }
             .docs-body > * { max-width: 78ch; }
            -.docs-body h1 { font-size: 30px; margin-bottom: 18px; letter-spacing: -.01em; }
            +.docs-body h1 { font-size: 28px; margin-bottom: 18px; letter-spacing: -.01em; }
             .docs-body h2 {
            -  font-size: 21px; margin: 40px 0 12px; padding-top: 18px;
            +  font-size: 20px; margin: 38px 0 12px; padding-top: 18px;
               border-top: 1px solid var(--panel-edge);
             }
            -.docs-body h3 { font-size: 16.5px; margin: 26px 0 8px; color: var(--accent); }
            +.docs-body h3 { font-size: 16px; margin: 24px 0 8px; color: var(--accent); }
             .docs-body p { color: var(--text-dim); line-height: 1.68; margin-bottom: 14px; }
             .docs-body ul, .docs-body ol { color: var(--text-dim); line-height: 1.68; margin: 0 0 14px 22px; }
             .docs-body li { margin-bottom: 5px; }
            @@ -295,11 +494,11 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
               padding: 1px 5px; color: #a5eeff;
             }
             .docs-body pre {
            -  background: #0a0d15; border: 1px solid var(--panel-edge); border-radius: 10px;
            +  background: #0a0d15; border: 1px solid var(--panel-edge); border-radius: var(--radius);
               padding: 15px 18px; overflow-x: auto; margin-bottom: 16px; max-width: 100%;
             }
             .docs-body pre code { background: none; border: 0; padding: 0; color: var(--text); font-size: 12.5px; line-height: 1.65; }
            -.docs-body .table-scroll { overflow-x: auto; margin-bottom: 18px; max-width: 100%; border: 1px solid var(--panel-edge); border-radius: 10px; }
            +.docs-body .table-scroll { overflow-x: auto; margin-bottom: 18px; max-width: 100%; border: 1px solid var(--panel-edge); border-radius: var(--radius); }
             .docs-body table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
             .docs-body th {
               text-align: left; font: 600 10.5px var(--font); letter-spacing: .1em; text-transform: uppercase;
            @@ -309,61 +508,19 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
             .docs-body tr:last-child td { border-bottom: none; }
             .docs-external-note {
               position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
            -  background: #0b0e16; border: 1px solid var(--panel-edge); border-radius: 10px;
            +  background: #0b0e16; border: 1px solid var(--panel-edge); border-radius: var(--radius);
               padding: 10px 16px; font: 12.5px var(--mono); color: var(--text-dim); max-width: 76vw;
            -  overflow-wrap: anywhere; z-index: 40;
            -}
            -@media (max-width: 820px) {
            -  .docs-layout { grid-template-columns: 1fr; }
            -  .docs-nav { display: none; }
            -  .docs-body { padding: 28px 22px 80px; }
            -}
            -
            -/* ---------- perform ---------- */
            -#screen-perform { background: #000; }
            -#stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
            -
            -.hud {
            -  position: absolute; top: 0; left: 0; right: 0;
            -  display: flex; justify-content: space-between; align-items: center;
            -  padding: 14px 18px; pointer-events: none;
            -  background: linear-gradient(rgba(0,0,0,.55), transparent);
            -}
            -.hud-left, .hud-right { display: flex; align-items: center; gap: 10px; }
            -.hud-left .btn { pointer-events: auto; }
            -.hud-title { font-weight: 800; font-size: 15px; letter-spacing: .03em; }
            -.hud-scene { color: var(--text-dim); font-size: 13px; }
            -
            -.help-bar {
            -  position: absolute; bottom: 0; left: 0; right: 0; text-align: center;
            -  padding: 14px; color: var(--text-dim); font-size: 12.5px;
            -  background: linear-gradient(transparent, rgba(0,0,0,.6));
            -  opacity: 0; transition: opacity .4s; pointer-events: none;
            +  overflow-wrap: anywhere; z-index: var(--z-notice);
             }
            -.help-bar.visible { opacity: 1; }
            -.help-bar b { color: var(--accent); font-weight: 700; }
             
            -.midi-monitor {
            -  position: absolute; right: 16px; top: 64px; width: 300px;
            -  background: rgba(0,0,0,.72); border: 1px solid var(--panel-edge); border-radius: 10px;
            -  padding: 12px 14px; font: 11.5px var(--mono); color: #9fe8ff; line-height: 1.6;
            -  display: none; white-space: pre-wrap;
            -}
            -.midi-monitor.visible { display: block; }
            +/* ---------- narrow window ---------- */
             
            -.help-overlay {
            -  position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
            -  background: rgba(0,0,0,.6); backdrop-filter: blur(6px);
            -}
            -.help-overlay.visible { display: flex; }
            -.help-card {
            -  background: #0b0e16; border: 1px solid var(--panel-edge); border-radius: var(--radius);
            -  padding: 30px 34px; max-width: 640px;
            +@media (max-width: 1140px) {
            +  :root { --rail-w: 176px; --tl-h: 88px; }
            +  #topbar { gap: 6px; padding: 0 6px; }
            +  .cham { padding: 5px 9px; letter-spacing: .08em; }
            +  #project-btn { max-width: 130px; }
            +  #t-clock { min-width: 56px; }
            +  #timeline { grid-template-rows: 14px minmax(0, 1fr) minmax(0, 1.2fr); }
            +  #drawer { width: 380px; bottom: calc(var(--tl-h) + 118px); }
             }
            -.help-card h2 { margin-bottom: 16px; font-size: 20px; }
            -.help-card table { border-collapse: collapse; width: 100%; }
            -.help-card td { padding: 7px 14px 7px 0; font-size: 13.5px; vertical-align: top; }
            -.help-card td:first-child { color: var(--accent); font-weight: 600; white-space: nowrap; }
            -.help-card td:last-child { color: var(--text-dim); }
            -.help-card p { margin-top: 16px; color: var(--text-dim); font-size: 13px; }
            -.help-card b { color: var(--accent); }
            diff --git a/src/renderer/ui/assign.js b/src/renderer/ui/assign.js
            new file mode 100644
            index 0000000..fe17306
            --- /dev/null
            +++ b/src/renderer/ui/assign.js
            @@ -0,0 +1,421 @@
            +// Assignment rail — the editor for whatever control is selected on the deck.
            +// Pads pick an action (sample / visual / punch), knobs pick a continuous
            +// target with a range, buttons learn a CC and pick a toggle, gesture
            +// dimensions hold modulation routes. All edits mutate the project's live
            +// assignment objects (the router holds the same references) and report
            +// through onChanged so the deck labels and dirty flag stay true.
            +
            +import { parseTarget } from '../../shared/swayproject.js';
            +
            +const $ = (sel) => document.querySelector(sel);
            +
            +// Control ids (pad:5, knob:3, button:2, xy:x, gesture:press) are a different
            +// grammar from assignment targets — parseTarget rejects them by design.
            +function ctlParts(id) {
            +  const at = id.indexOf(':');
            +  return { ns: id.slice(0, at), key: id.slice(at + 1) };
            +}
            +
            +const HEADER_NAMES = {
            +  'xy:x': 'X',
            +  'xy:y': 'Y',
            +  'gesture:pulse': 'PULSE',
            +  'gesture:press': 'PRESS',
            +  'gesture:sway': 'SWAY',
            +};
            +
            +const ENGINE_TARGETS = [
            +  ['engine:hue', 'palette hue', 0, 1],
            +  ['engine:fadeTime', 'fade length', 1, 8],
            +  ['engine:intensity', 'intensity', 0, 1],
            +];
            +const SAMPLER_TARGETS = [
            +  ['sampler:master', 'kit level', 0, 1],
            +  ['sampler:cutoff', 'kit filter', 0, 1],
            +  ['sampler:rate', 'kit rate', 0, 1],
            +  ['sampler:send', 'kit delay', 0, 1],
            +];
            +const TOGGLE_TARGETS = [
            +  ['engine:fxEnabled', 'rack active'],
            +  ['engine:autoVJ', 'auto rotation'],
            +  ['synth:enabled', 'synth notes'],
            +  ['transport:playPause', 'play / pause'],
            +  ['transport:stop', 'stop'],
            +];
            +
            +export function createAssign(deps) {
            +  const { router, sampler, synth, engine, fxRanges, onChanged } = deps;
            +  let selected = null;
            +  let follow = true;
            +  let learning = false;
            +
            +  const header = $('#assign-target');
            +  const body = $('#assign-body');
            +
            +  function asg() {
            +    return router.getAssignments();
            +  }
            +
            +  function esc(s) {
            +    return String(s).replace(/&/g, '&').replace(/ Array.isArray(spec))
            +      .map(([key, spec]) => ``)
            +      .join('');
            +  }
            +
            +  function booleanFxOptions(current) {
            +    return Object.entries(fxRanges)
            +      .filter(([, spec]) => spec === true)
            +      .map(([key]) => ``)
            +      .join('');
            +  }
            +
            +  function continuousTargetOptions(current) {
            +    const groups = [];
            +    groups.push(`${ENGINE_TARGETS.map(([v, l, lo, hi]) => ``).join('')}`);
            +    groups.push(`${numericFxOptions(current)}`);
            +    const manifest = synth.controlManifest().filter((c) => c.kind === 'range');
            +    groups.push(`${manifest.map((c) => ``).join('')}`);
            +    groups.push(`${SAMPLER_TARGETS.map(([v, l, lo, hi]) => ``).join('')}`);
            +    return groups.join('');
            +  }
            +
            +  function sampleOptions(current) {
            +    const rows = sampler.listSamples().map((s) => ``);
            +    return `${rows.join('')}`;
            +  }
            +
            +  function sceneOptions(current) {
            +    return engine.sceneList.map((s) => ``).join('');
            +  }
            +
            +  function renderHeader() {
            +    const learnable = selected && (/^knob:|^xy:|^gesture:/.test(selected) || selected.startsWith('button:'));
            +    header.innerHTML =
            +      `${headerName(selected)}` +
            +      `` +
            +      (learnable ? `` : '') +
            +      (selected ? '' : '');
            +  }
            +
            +  function renderBody() {
            +    if (!selected) {
            +      body.innerHTML = '
            Click a control on the deck, or touch it on the Sway, to edit what it does.
            '; + return; + } + const t = ctlParts(selected); + if (t.ns === 'pad') return renderPad(Number(t.key)); + if (t.ns === 'knob') return renderKnob(Number(t.key)); + if (t.ns === 'button') return renderButton(Number(t.key)); + return renderRoutes(selected); + } + + function renderPad(i) { + const a = asg().pads[i]; + const kind = a ? a.type : 'off'; + const rows = [ + `
            ACTION
            `, + ]; + if (kind === 'sample') { + const kit = sampler.getKit(); + const pad = kit.pads && kit.pads[i]; + rows.push(`
            SAMPLE
            `); + if (pad && pad.id) { + rows.push(`
            MODE
            `); + rows.push(`
            GAIN${pad.gain.toFixed(2)}
            `); + rows.push(`
            CHOKE
            `); + rows.push('
            '); + } + } else if (kind === 'scene') { + rows.push(`
            VISUAL
            `); + rows.push(`
            ENTRY${a.transition.type === 'crossfade' ? ` s` : ''}
            `); + } else if (kind === 'fxPunch') { + const spec = (parseTarget(`fx:${a.param}`) && fxRanges[a.param]) || [0, 1]; + const [lo, hi] = Array.isArray(spec) ? spec : [0, 1]; + rows.push(`
            PARAM
            `); + rows.push(`
            VALUE${Number(a.value).toFixed(2)}
            `); + } + body.innerHTML = rows.join(''); + } + + function renderKnob(i) { + const a = asg().knobs[i]; + const rows = [ + `
            TARGET
            `, + ]; + if (a) { + rows.push(`
            RANGE
            `); + rows.push(`
            CURVE
            `); + } + body.innerHTML = rows.join(''); + } + + function renderButton(i) { + const b = asg().buttons[i]; + const rows = [ + `
            CC${b.cc === null ? '—' : b.cc}${b.cc === null ? 'LEARN captures the hardware button' : ''}
            `, + `
            TARGET
            `, + ]; + body.innerHTML = rows.join(''); + } + + function renderRoutes(source) { + const routes = asg().gestures; + const rows = []; + routes.forEach((g, gi) => { + if (g.source !== source) return; + rows.push( + `
            +
            ROUTE +
            +
            DEPTH +
            +
            ` + ); + }); + if (!rows.length) rows.push('
            No routes. This dimension can drive any engine, rack, synth, or kit parameter.
            '); + rows.push('
            '); + body.innerHTML = rows.join(''); + } + + function changed(structural) { + if (onChanged) onChanged(); + if (structural) renderBody(); + } + + // --- events --------------------------------------------------------------- + + header.addEventListener('click', async (e) => { + const chip = e.target.closest('[data-act]'); + if (!chip) return; + if (chip.dataset.act === 'follow') { + follow = !follow; + renderHeader(); + } else if (chip.dataset.act === 'clear' && selected) { + const t = ctlParts(selected); + if (selected.startsWith('pad:')) { + asg().pads[Number(t.key)] = null; + sampler.clearPad(Number(t.key)); + } else if (selected.startsWith('knob:')) { + asg().knobs[Number(t.key)] = null; + } else if (selected.startsWith('button:')) { + asg().buttons[Number(t.key)] = { cc: null, channel: null, action: null }; + } else { + const g = asg().gestures; + for (let i = g.length - 1; i >= 0; i--) if (g[i].source === selected) g.splice(i, 1); + } + changed(true); + } else if (chip.dataset.act === 'learn' && selected && !learning) { + learning = true; + renderHeader(); + deps.onLearnArmed && deps.onLearnArmed(true); + try { + if (selected.startsWith('button:')) { + // Capture the CC into the button slot via a throwaway learn target. + const slot = ctlParts(selected).key; + const result = await router.learnBinding(`button:${slot}`); + const b = asg().buttons[Number(slot)]; + b.cc = result.cc; + // The captured CC is a button, not a continuous control — drop the + // override the learn call recorded so it never drives a knob path. + const overrides = deps.midi.getOverrides(); + delete overrides[result.target]; + deps.midi.setOverrides(overrides); + } else { + await router.learnBinding(selected); + } + } finally { + learning = false; + renderHeader(); + deps.onLearnArmed && deps.onLearnArmed(false); + changed(true); + } + } + }); + + body.addEventListener('change', (e) => { + if (!selected) return; + const t = ctlParts(selected); + const el = e.target; + + if (selected.startsWith('pad:')) { + const i = Number(t.key); + if (el.matches('[data-pad-kind]')) { + const kind = el.value; + if (kind === 'off') asg().pads[i] = null; + else if (kind === 'sample') asg().pads[i] = { type: 'sample', pad: i }; + else if (kind === 'scene') asg().pads[i] = { type: 'scene', scene: engine.sceneList[0].id, transition: { type: 'cut', duration: 0 } }; + else asg().pads[i] = { type: 'fxPunch', param: 'glitch', value: 0.8 }; + return changed(true); + } + if (el.matches('[data-pad-sample]')) { + if (el.value) sampler.assignPad(i, el.value, padOpts(i)); + else sampler.clearPad(i); + return changed(true); + } + if (el.matches('[data-pad-opt]')) { + const kit = sampler.getKit(); + const pad = kit.pads && kit.pads[i]; + if (pad && pad.id) sampler.assignPad(i, pad.id, padOpts(i)); + return changed(false); + } + const a = asg().pads[i]; + if (el.matches('[data-pad-scene]')) { a.scene = el.value; return changed(false); } + if (el.matches('[data-pad-trans]')) { + a.transition.type = el.value; + if (el.value === 'crossfade' && !a.transition.duration) a.transition.duration = 2.5; + return changed(true); + } + if (el.matches('[data-pad-fade]')) { a.transition.duration = Number(el.value) || 0; return changed(false); } + if (el.matches('[data-pad-param]')) { + a.param = parseTarget(el.value).key; + const spec = fxRanges[a.param]; + if (Array.isArray(spec)) a.value = Math.min(Math.max(a.value, spec[0]), spec[1]); + return changed(true); + } + if (el.matches('[data-pad-value]')) { a.value = Number(el.value); return changed(false); } + } + + if (selected.startsWith('knob:')) { + const i = Number(t.key); + if (el.matches('[data-knob-target]')) { + if (!el.value) asg().knobs[i] = null; + else { + const opt = el.selectedOptions[0]; + asg().knobs[i] = { + target: el.value, + min: Number(opt.dataset.min ?? 0), + max: Number(opt.dataset.max ?? 1), + curve: 'linear', + }; + } + return changed(true); + } + const a = asg().knobs[i]; + if (!a) return; + if (el.matches('[data-knob-min]')) a.min = Number(el.value) || 0; + if (el.matches('[data-knob-max]')) a.max = Number(el.value) || 0; + if (el.matches('[data-knob-curve]')) a.curve = el.value; + return changed(false); + } + + if (selected.startsWith('button:')) { + const b = asg().buttons[Number(t.key)]; + if (el.matches('[data-btn-target]')) { + b.action = el.value ? { type: 'toggle', target: el.value } : null; + return changed(false); + } + } + + // gesture / xy routes + const gi = Number(el.dataset.routeTarget ?? el.dataset.routeMin ?? el.dataset.routeMax ?? el.dataset.routeOn); + const g = asg().gestures[gi]; + if (!g) return; + if (el.dataset.routeTarget !== undefined) { + g.target = el.value; + const opt = el.selectedOptions[0]; + g.min = Number(opt.dataset.min ?? g.min); + g.max = Number(opt.dataset.max ?? g.max); + return changed(true); + } + if (el.dataset.routeMin !== undefined) g.min = Number(el.value) || 0; + if (el.dataset.routeMax !== undefined) g.max = Number(el.value) || 0; + if (el.dataset.routeOn !== undefined) g.enabled = el.checked; + changed(false); + }); + + body.addEventListener('input', (e) => { + // live readouts for range rows + const el = e.target; + if (el.type !== 'range') return; + const b = el.parentElement.querySelector('b'); + if (b) b.textContent = Number(el.value).toFixed(2); + }); + + body.addEventListener('click', (e) => { + if (!selected) return; + if (e.target.closest('[data-pad-trig]')) { + sampler.trigger(Number(ctlParts(selected).key), 0.9); + return; + } + const del = e.target.closest('[data-route-del]'); + if (del) { + asg().gestures.splice(Number(del.dataset.routeDel), 1); + return changed(true); + } + if (e.target.closest('[data-route-add]')) { + asg().gestures.push({ source: selected, target: 'fx:glitch', min: 0, max: 1, curve: 'linear', enabled: true }); + return changed(true); + } + }); + + function padOpts(i) { + const mode = body.querySelector('[data-pad-opt="mode"]'); + const gain = body.querySelector('[data-pad-opt="gain"]'); + const choke = body.querySelector('[data-pad-opt="chokeGroup"]'); + const kit = sampler.getKit(); + const pad = (kit.pads && kit.pads[i]) || {}; + return { + mode: mode ? mode.value : pad.mode || 'oneshot', + gain: gain ? Number(gain.value) : pad.gain ?? 1, + chokeGroup: choke && choke.value !== '' ? Number(choke.value) : null, + loop: (mode ? mode.value : pad.mode) === 'loop', + }; + } + + renderHeader(); + renderBody(); + + return { + select(target) { + selected = target; + learning = false; + renderHeader(); + renderBody(); + }, + current() { + return selected; + }, + followEnabled() { + return follow; + }, + refresh() { + renderBody(); + }, + }; +} diff --git a/src/renderer/ui/drawer.js b/src/renderer/ui/drawer.js new file mode 100644 index 0000000..2d7e788 --- /dev/null +++ b/src/renderer/ui/drawer.js @@ -0,0 +1,62 @@ +// Right-side drawer for the deep panels (synth, rack, kit). Overlays the +// cockpit; the render loop never stops. Content renders lazily on first open +// through the onOpenTab callback. + +const $ = (sel) => document.querySelector(sel); +const TABS = ['synth', 'rack', 'kit']; + +export function createDrawer({ onOpenTab }) { + const drawer = $('#drawer'); + let current = null; + const rendered = new Set(); + + function apply() { + for (const tab of TABS) { + $(`#deck-${tab}`).hidden = tab !== current; + } + // The deckbar is the drawer's tab strip — its buttons show the open tab. + for (const btn of document.querySelectorAll('#deckbar [data-drawer]')) { + btn.classList.toggle('on', btn.dataset.drawer === current); + } + } + + function open(tab) { + current = TABS.includes(tab) ? tab : TABS[0]; + drawer.hidden = false; + // next frame so the slide-in transition runs from the hidden state + requestAnimationFrame(() => drawer.classList.add('open')); + if (!rendered.has(current)) { + rendered.add(current); + onOpenTab && onOpenTab(current, true); + } else { + onOpenTab && onOpenTab(current, false); + } + apply(); + } + + function close() { + current = null; + drawer.classList.remove('open'); + apply(); + setTimeout(() => { + if (!current) drawer.hidden = true; + }, 260); + } + + $('#drawer-close').addEventListener('click', close); + + return { + open, + close, + toggle(tab) { + if (current === tab) close(); + else open(tab); + }, + isOpen(tab) { + return tab === undefined ? current !== null : current === tab; + }, + currentTab() { + return current; + }, + }; +} diff --git a/src/renderer/ui/frame.js b/src/renderer/ui/frame.js new file mode 100644 index 0000000..5961db3 --- /dev/null +++ b/src/renderer/ui/frame.js @@ -0,0 +1,36 @@ +// Stroke-only chamfered SVG frames for .framed containers. clip-path would +// destroy a CSS border, so the frame is drawn as an absolutely positioned SVG +// path, redrawn by one shared ResizeObserver. + +const CUT = 12; // chamfer size in px + +function draw(el) { + const w = el.clientWidth; + const h = el.clientHeight; + if (!w || !h) return; + let svg = el.querySelector(':scope > .frame-svg'); + if (!svg) { + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'frame-svg'); + svg.setAttribute('aria-hidden', 'true'); + el.prepend(svg); + } + svg.setAttribute('viewBox', `0 0 ${w} ${h}`); + const c = Math.min(CUT, w / 4, h / 4); + svg.innerHTML = + `` + + // corner accents on the two chamfers + `` + + ``; +} + +export function initFrames(root = document) { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) draw(entry.target); + }); + for (const el of root.querySelectorAll('.framed')) { + draw(el); + observer.observe(el); + } + return observer; +} diff --git a/src/renderer/ui/popover.js b/src/renderer/ui/popover.js new file mode 100644 index 0000000..6a1980f --- /dev/null +++ b/src/renderer/ui/popover.js @@ -0,0 +1,55 @@ +// Shared anchored popover — one #popover root, one open at a time. Items are +// plain buttons carrying data-choice; the opener's callback receives the +// chosen value. Dismissed by outside click, Esc (via the overlay stack), or +// choosing an item. + +const $ = (sel) => document.querySelector(sel); + +let onChoose = null; +let openedAt = 0; + +export function openPopover(anchorEl, html, choose) { + const pop = $('#popover'); + pop.innerHTML = html; + pop.hidden = false; + onChoose = choose || null; + openedAt = performance.now(); + + const r = anchorEl.getBoundingClientRect(); + const pw = Math.min(340, window.innerWidth - 20); + let x = Math.min(r.left, window.innerWidth - pw - 10); + let y = r.bottom + 6; + pop.style.left = `${Math.max(6, x)}px`; + pop.style.top = '0px'; + // measure after content lands, then keep it on screen + const ph = pop.offsetHeight; + if (y + ph > window.innerHeight - 8) y = Math.max(8, r.top - ph - 6); + pop.style.top = `${y}px`; +} + +export function closePopover() { + const pop = $('#popover'); + pop.hidden = true; + pop.innerHTML = ''; + onChoose = null; +} + +export function popoverOpen() { + return !$('#popover').hidden; +} + +export function wirePopover() { + const pop = $('#popover'); + pop.addEventListener('click', (e) => { + const item = e.target.closest('[data-choice]'); + if (!item || item.disabled) return; + const fn = onChoose; + closePopover(); + if (fn) fn(item.dataset.choice, item.dataset); + }); + window.addEventListener('pointerdown', (e) => { + if (pop.hidden) return; + if (performance.now() - openedAt < 120) return; // the opening click itself + if (!pop.contains(e.target)) closePopover(); + }); +} diff --git a/src/renderer/ui/surface.js b/src/renderer/ui/surface.js new file mode 100644 index 0000000..71eda75 --- /dev/null +++ b/src/renderer/ui/surface.js @@ -0,0 +1,292 @@ +// The Sway deck — a stroke line-art schematic of the hardware, drawn once as +// SVG and mutated per frame. It is the assignment surface: every interactive +// node carries data-ctl in the shared control-id grammar (pad:N, knob:N, +// button:N, xy:x, xy:y, gesture:*), so clicking on screen and touching the +// hardware select the same thing. +// +// Geometry is a stylized 1266×160 schematic: each cluster keeps the +// hardware's internal proportions, only the spacing between clusters +// stretches. Mirror-symmetric about x=633: rear LED beam, IR sensor field, +// per side 4 click knobs over a 4×2 pad block with 2×2 slim buttons inboard, +// center preset row, display, and scroll wheel (display-only). + +const NS = 'http://www.w3.org/2000/svg'; + +// Screen cell → pad index. The physical order is unverified (HANDOFF pad +// ambiguity) — correcting it after a hardware monitor session means editing +// only this table. Bottom rows first, left cluster 0-7, right cluster 8-15. +export const PAD_CELLS = [ + { x: 32, y: 125 }, { x: 104, y: 125 }, { x: 176, y: 125 }, { x: 248, y: 125 }, + { x: 32, y: 92 }, { x: 104, y: 92 }, { x: 176, y: 92 }, { x: 248, y: 92 }, + { x: 954, y: 125 }, { x: 1026, y: 125 }, { x: 1098, y: 125 }, { x: 1170, y: 125 }, + { x: 954, y: 92 }, { x: 1026, y: 92 }, { x: 1098, y: 92 }, { x: 1170, y: 92 }, +]; +const PAD_W = 64; +const PAD_H = 27; + +const KNOB_X = [56, 132, 208, 284, 982, 1058, 1134, 1210]; +const KNOB_Y = 72; +const KNOB_R = 13; + +const BTN_CELLS = [ + { x: 330, y: 92 }, { x: 362, y: 92 }, { x: 330, y: 125 }, { x: 362, y: 125 }, + { x: 878, y: 92 }, { x: 910, y: 92 }, { x: 878, y: 125 }, { x: 910, y: 125 }, +]; +const BTN_W = 26; +const BTN_H = 27; + +const CHIPS = [ + { ctl: 'xy:x', label: 'X' }, + { ctl: 'xy:y', label: 'Y' }, + { ctl: 'gesture:pulse', label: 'PULSE' }, + { ctl: 'gesture:press', label: 'PRESS' }, + { ctl: 'gesture:sway', label: 'SWAY' }, +]; +const CHIP_W = 60; +const CHIP_H = 18; +const CHIP_Y = 58; +const CHIP_X0 = 633 - (CHIPS.length * CHIP_W + (CHIPS.length - 1) * 8) / 2; + +const BEAM_X0 = 24; +const BEAM_X1 = 1242; +const SEG_GAP = 2; +const SEG_W = (BEAM_X1 - BEAM_X0 - 15 * SEG_GAP) / 16; + +export function createSurface(container, { onSelect }) { + const parts = []; + parts.push(''); + + // LED beam + for (let i = 0; i < 16; i++) { + const x = BEAM_X0 + i * (SEG_W + SEG_GAP); + parts.push(``); + } + + // IR sensor field + parts.push(''); + for (let i = 0; i < 16; i++) { + const x = BEAM_X0 + i * (SEG_W + SEG_GAP) + SEG_W / 2; + parts.push(``); + } + parts.push(''); + + // Gesture chips + for (let i = 0; i < CHIPS.length; i++) { + const x = CHIP_X0 + i * (CHIP_W + 8); + const c = CHIPS[i]; + parts.push( + `` + + `` + + `${c.label}` + + `` + + `` + + '' + ); + } + + // Knobs + const arcC = 2 * Math.PI * KNOB_R; + for (let i = 0; i < 8; i++) { + const cx = KNOB_X[i]; + parts.push( + `` + + `` + + `` + + `` + + `` + + '' + ); + } + + // Pads + for (let i = 0; i < 16; i++) { + const c = PAD_CELLS[i]; + parts.push( + `` + + `` + + `` + + `` + + `` + + '' + ); + } + + // Mappable buttons + for (let i = 0; i < 8; i++) { + const c = BTN_CELLS[i]; + parts.push( + `` + + `` + + `` + + '' + ); + } + + // Center: preset row, display, wheel — device-local, display-only. + for (let i = 0; i < 6; i++) { + parts.push(``); + } + parts.push(''); + parts.push(''); + parts.push(''); + parts.push(''); + parts.push(''); + parts.push(''); + + // Selection reticle — corner brackets + pulsing diamond arms. + parts.push( + '' + + '' + + '' + + '' + + '' + + '' + ); + + parts.push(''); + container.innerHTML = parts.join(''); + + const svg = container.querySelector('#sway-svg'); + const $id = (id) => svg.querySelector(`#${CSS.escape(id)}`); + + svg.addEventListener('click', (e) => { + const node = e.target.closest('[data-ctl]'); + if (node && onSelect) onSelect(node.dataset.ctl); + }); + svg.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const node = e.target.closest('[data-ctl]'); + if (node && onSelect) { + e.preventDefault(); + onSelect(node.dataset.ctl); + } + }); + + const reticle = $id('reticle'); + const knobArcs = Array.from({ length: 8 }, (_, i) => $id(`knob-arc-${i}`)); + const knobTicks = Array.from({ length: 8 }, (_, i) => $id(`knob-tick-${i}`)); + const padFills = Array.from({ length: 16 }, (_, i) => $id(`pad-fill-${i}`)); + const padTicks = Array.from({ length: 16 }, (_, i) => $id(`pad-tick-${i}`)); + const padLabels = Array.from({ length: 16 }, (_, i) => $id(`pad-label-${i}`)); + const btnFills = Array.from({ length: 8 }, (_, i) => $id(`btn-fill-${i}`)); + const leds = Array.from({ length: 16 }, (_, i) => $id(`led-${i}`)); + const dims = CHIPS.map((c) => $id(`dim-${c.ctl.replace(':', '-')}`)); + const xyDot = $id('xy-dot'); + const sensorField = $id('sensor-field'); + const oledLine = $id('oled-line'); + const oledSub = $id('oled-sub'); + + const last = { knobs: new Array(8).fill(-1), pads: new Array(16).fill(-1), oled: '' }; + + function ledColor(palette, i, intensity) { + // 5 palette colors spread across 16 segments, linearly interpolated. + const f = (i / 15) * (palette.length - 1); + const a = palette[Math.floor(f)]; + const b = palette[Math.min(palette.length - 1, Math.floor(f) + 1)]; + const t = f - Math.floor(f); + const r = Math.round(((a.r + (b.r - a.r) * t) * intensity) * 255); + const g = Math.round(((a.g + (b.g - a.g) * t) * intensity) * 255); + const bl = Math.round(((a.b + (b.b - a.b) * t) * intensity) * 255); + return `rgb(${r},${g},${bl})`; + } + + return { + el: svg, + + update(io, monitor) { + const arcC = 2 * Math.PI * KNOB_R; + const sweep = arcC * 0.75; // 270° of travel + for (let i = 0; i < 8; i++) { + const v = io.knobs[i]; + if (Math.abs(v - last.knobs[i]) > 0.002) { + last.knobs[i] = v; + knobArcs[i].setAttribute('stroke-dasharray', `${(v * sweep).toFixed(1)} ${arcC.toFixed(1)}`); + knobTicks[i].setAttribute('transform', `rotate(${(-135 + v * 270).toFixed(1)} ${KNOB_X[i]} ${KNOB_Y})`); + } + } + for (let i = 0; i < 16; i++) { + const v = io.pads[i]; + if (Math.abs(v - last.pads[i]) > 0.005) { + last.pads[i] = v; + padFills[i].setAttribute('opacity', (v * 0.65).toFixed(2)); + } + } + const level = 0.12 + io.intensity * io.level * 0.9; + for (let i = 0; i < 16; i++) { + leds[i].setAttribute('fill', ledColor(io.palette, i, Math.min(1, 0.25 + io.intensity * 0.75))); + leds[i].setAttribute('opacity', Math.min(1, level + io.beat * 0.5).toFixed(2)); + } + xyDot.setAttribute('cx', (BEAM_X0 + io.xy.x * (BEAM_X1 - BEAM_X0)).toFixed(1)); + xyDot.setAttribute('cy', (48 - io.xy.y * 22).toFixed(1)); + xyDot.setAttribute('r', (3 + io.gestures.pulse * 4).toFixed(1)); + sensorField.setAttribute('stroke-opacity', (0.4 + io.gestures.press * 0.6).toFixed(2)); + const chipVals = [io.xy.x, io.xy.y, io.gestures.pulse, io.gestures.press, io.gestures.sway]; + for (let i = 0; i < dims.length; i++) { + dims[i].setAttribute('width', (chipVals[i] * (CHIP_W - 6)).toFixed(1)); + } + const line = monitor && monitor.length ? monitor[0] : ''; + if (line !== last.oled) { + last.oled = line; + oledLine.textContent = line.slice(0, 30); + } + }, + + // Pad labels + assignment ticks; button lit state. Called on assignment + // or kit changes, not per frame. + refresh(labels, buttonLit) { + for (let i = 0; i < 16; i++) { + const text = labels[i] || ''; + padLabels[i].textContent = text.length > 9 ? `${text.slice(0, 8)}…` : text; + padTicks[i].setAttribute('opacity', text ? '0.9' : '0'); + } + for (let i = 0; i < 8; i++) { + btnFills[i].setAttribute('opacity', buttonLit && buttonLit[i] ? '0.5' : '0'); + } + }, + + setStatus(text) { + oledSub.textContent = (text || '').slice(0, 34); + }, + + select(target) { + if (!target) { + reticle.setAttribute('visibility', 'hidden'); + return; + } + const node = svg.querySelector(`[data-ctl="${CSS.escape(target)}"]`); + if (!node) { + reticle.setAttribute('visibility', 'hidden'); + return; + } + const b = node.getBBox(); + const pad = 5; + const x0 = b.x - pad; + const y0 = b.y - pad; + const x1 = b.x + b.width + pad; + const y1 = b.y + b.height + pad; + const arm = Math.min(10, Math.max(6, b.width / 5)); + svg.querySelector('#ret-tl').setAttribute('d', `M ${x0} ${y0 + arm} V ${y0} H ${x0 + arm}`); + svg.querySelector('#ret-tr').setAttribute('d', `M ${x1 - arm} ${y0} H ${x1} V ${y0 + arm}`); + svg.querySelector('#ret-bl').setAttribute('d', `M ${x0} ${y1 - arm} V ${y1} H ${x0 + arm}`); + svg.querySelector('#ret-br').setAttribute('d', `M ${x1 - arm} ${y1} H ${x1} V ${y1 - arm}`); + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + const place = (id, x, y) => { + const el = svg.querySelector(id); + el.setAttribute('x', x - 3); + el.setAttribute('y', y - 3); + el.setAttribute('transform', `rotate(45 ${x} ${y})`); + }; + place('#ret-n', cx, y0 - 6); + place('#ret-s', cx, y1 + 6); + place('#ret-w', x0 - 6, cy); + place('#ret-e', x1 + 6, cy); + reticle.setAttribute('visibility', 'visible'); + }, + + setArmed(armed) { + reticle.classList.toggle('armed', !!armed); + }, + }; +} diff --git a/src/renderer/ui/timeline.js b/src/renderer/ui/timeline.js new file mode 100644 index 0000000..46a9b50 --- /dev/null +++ b/src/renderer/ui/timeline.js @@ -0,0 +1,426 @@ +// Timeline band — ruler with loop region and locators, one visual lane of +// scene clips (DOM), one audio lane with waveforms (canvas), and a playhead. +// The clip objects it edits are the project's own timeline objects; every +// structural change goes through transport.refresh() and onEdit(). + +import { uid } from '../../shared/swayproject.js'; + +const $ = (sel) => document.querySelector(sel); +const EDGE = 8; // px resize zone on clip edges +const PEAK_BUCKETS = 2048; + +export function createTimeline({ transport, engine, store, onEdit }) { + const root = $('#timeline'); + const ruler = $('#tl-ruler'); + const visualLane = $('#tl-visual'); + const audioCanvas = $('#tl-audio'); + const playhead = $('#tl-playhead'); + + let pxPerSec = 12; + let scrollX = 0; // seconds at the left edge + let selectedId = null; + const peaks = new Map(); // mediaId -> Float32Array(2 * PEAK_BUCKETS) + + function timeline() { + return transport.collect(); + } + function visualClips() { + const tl = timeline(); + return tl ? tl.tracks.find((t) => t.type === 'visual').clips : []; + } + function audioClips() { + const tl = timeline(); + return tl ? tl.tracks.find((t) => t.type === 'audio').clips : []; + } + + function xToTime(x) { + return Math.max(0, scrollX + x / pxPerSec); + } + function timeToX(t) { + return (t - scrollX) * pxPerSec; + } + + function fitCanvas(canvas) { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.round(canvas.clientWidth * dpr); + const h = Math.round(canvas.clientHeight * dpr); + if (w && h && (canvas.width !== w || canvas.height !== h)) { + canvas.width = w; + canvas.height = h; + } + return dpr; + } + + function niceStep() { + const target = 80 / pxPerSec; // ~80 px between labeled ticks + const steps = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300]; + return steps.find((s) => s >= target) || 600; + } + + function fmtTime(t) { + const m = Math.floor(t / 60); + const s = t - m * 60; + return m ? `${m}:${s < 10 ? '0' : ''}${Math.floor(s)}` : `${Math.round(s * 10) / 10}`; + } + + function css(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + } + + function renderRuler() { + const dpr = fitCanvas(ruler); + const ctx = ruler.getContext('2d'); + const w = ruler.width; + const h = ruler.height; + ctx.clearRect(0, 0, w, h); + const accent = css('--accent') || '#2de1fc'; + const dim = css('--info') || '#64748b'; + + const step = niceStep(); + const first = Math.floor(scrollX / step) * step; + ctx.font = `${9 * dpr}px ${css('--mono') || 'monospace'}`; + ctx.fillStyle = dim; + ctx.strokeStyle = 'rgba(255,255,255,.14)'; + ctx.lineWidth = 1; + for (let t = first; timeToX(t) * dpr < w; t += step) { + const x = Math.round(timeToX(t) * dpr) + 0.5; + if (x < 0) continue; + ctx.beginPath(); + ctx.moveTo(x, h * 0.45); + ctx.lineTo(x, h); + ctx.stroke(); + ctx.fillText(fmtTime(t), x + 3 * dpr, h * 0.62); + } + + const loop = transport.state.loop; + if (loop.end > loop.start) { + const x0 = timeToX(loop.start) * dpr; + const x1 = timeToX(loop.end) * dpr; + ctx.fillStyle = loop.enabled ? 'rgba(45,225,252,.22)' : 'rgba(45,225,252,.09)'; + ctx.fillRect(x0, 0, x1 - x0, h * 0.4); + ctx.strokeStyle = accent; + ctx.beginPath(); + ctx.moveTo(x0 + 0.5, 0); + ctx.lineTo(x0 + 0.5, h * 0.4); + ctx.moveTo(x1 + 0.5, 0); + ctx.lineTo(x1 + 0.5, h * 0.4); + ctx.stroke(); + } + + const tl = timeline(); + if (tl) { + ctx.fillStyle = accent; + for (const loc of tl.locators) { + const x = timeToX(loc.time) * dpr; + if (x < -8 || x > w + 8) continue; + ctx.save(); + ctx.translate(x, h * 0.3); + ctx.rotate(Math.PI / 4); + ctx.fillRect(-3 * dpr, -3 * dpr, 6 * dpr, 6 * dpr); + ctx.restore(); + } + } + } + + function buildPeaks(mediaId) { + if (peaks.has(mediaId)) return peaks.get(mediaId); + const buffer = transport.getBuffer(mediaId); + if (!buffer) return null; + const data = buffer.getChannelData(0); + const out = new Float32Array(PEAK_BUCKETS * 2); + const per = Math.max(1, Math.floor(data.length / PEAK_BUCKETS)); + for (let b = 0; b < PEAK_BUCKETS; b++) { + let lo = 0; + let hi = 0; + const start = b * per; + const end = Math.min(data.length, start + per); + for (let i = start; i < end; i += 4) { + const v = data[i]; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + out[b * 2] = lo; + out[b * 2 + 1] = hi; + } + peaks.set(mediaId, out); + return out; + } + + function renderAudio() { + const dpr = fitCanvas(audioCanvas); + const ctx = audioCanvas.getContext('2d'); + const w = audioCanvas.width; + const h = audioCanvas.height; + ctx.clearRect(0, 0, w, h); + const accent = css('--accent') || '#2de1fc'; + + for (const clip of audioClips()) { + const x0 = timeToX(clip.start) * dpr; + const x1 = timeToX(clip.end) * dpr; + if (x1 < 0 || x0 > w) continue; + const selectedClip = clip.id === selectedId; + ctx.fillStyle = selectedClip ? 'rgba(45,225,252,.16)' : 'rgba(45,225,252,.07)'; + ctx.fillRect(x0, 2 * dpr, x1 - x0, h - 4 * dpr); + ctx.strokeStyle = selectedClip ? accent : 'rgba(45,225,252,.4)'; + ctx.strokeRect(x0 + 0.5, 2 * dpr + 0.5, x1 - x0 - 1, h - 4 * dpr - 1); + + const pk = buildPeaks(clip.media); + const buffer = transport.getBuffer(clip.media); + if (pk && buffer) { + ctx.strokeStyle = 'rgba(45,225,252,.55)'; + ctx.beginPath(); + const mid = h / 2; + const span = h * 0.42; + const clipDur = clip.end - clip.start; + const px0 = Math.max(0, x0); + const px1 = Math.min(w, x1); + for (let x = px0; x <= px1; x += 2) { + const t = clip.offset + ((x - x0) / (x1 - x0)) * clipDur; + const b = Math.min(PEAK_BUCKETS - 1, Math.floor((t / buffer.duration) * PEAK_BUCKETS)); + ctx.moveTo(x + 0.5, mid + pk[b * 2] * span); + ctx.lineTo(x + 0.5, mid + pk[b * 2 + 1] * span); + } + ctx.stroke(); + } + } + } + + function renderVisual() { + const rows = []; + for (const clip of visualClips()) { + const left = timeToX(clip.start); + const width = (clip.end - clip.start) * pxPerSec; + if (left + width < 0 || left > visualLane.clientWidth) continue; + const meta = engine.sceneList.find((s) => s.id === clip.scene); + rows.push( + `
            ${meta ? meta.name : clip.scene}
            ` + ); + } + visualLane.innerHTML = rows.join(''); + } + + function render() { + renderRuler(); + renderVisual(); + renderAudio(); + } + + function edited() { + transport.refresh(); + onEdit && onEdit(); + render(); + } + + // --- interactions --------------------------------------------------------- + + // Ruler: drag scrubs; Shift+drag sets the loop region; double-click drops a + // locator; a locator click within 6 px jumps to it. + let rulerDrag = null; + ruler.addEventListener('pointerdown', (e) => { + ruler.setPointerCapture(e.pointerId); + const t = xToTime(e.offsetX); + if (e.shiftKey) { + rulerDrag = { mode: 'loop', from: t }; + transport.setLoop(t, t, false); + } else { + const tl = timeline(); + const near = tl && tl.locators.find((l) => Math.abs(timeToX(l.time) - e.offsetX) < 6); + if (near) { + transport.seek(near.time); + } else { + rulerDrag = { mode: 'scrub' }; + transport.seek(t); + } + } + render(); + }); + ruler.addEventListener('pointermove', (e) => { + if (!rulerDrag) return; + const t = xToTime(e.offsetX); + if (rulerDrag.mode === 'scrub') transport.seek(t); + else { + transport.setLoop(Math.min(rulerDrag.from, t), Math.max(rulerDrag.from, t), true); + const tl = timeline(); + if (tl) tl.loop = transport.state.loop; + } + render(); + }); + ruler.addEventListener('pointerup', () => { + if (rulerDrag && rulerDrag.mode === 'loop') edited(); + rulerDrag = null; + }); + ruler.addEventListener('dblclick', (e) => { + const tl = timeline(); + if (!tl) return; + tl.locators.push({ id: uid('loc'), name: '', time: xToTime(e.offsetX), color: null }); + edited(); + }); + + // Zoom / pan anywhere on the band. + root.addEventListener( + 'wheel', + (e) => { + e.preventDefault(); + if (e.shiftKey) { + scrollX = Math.max(0, scrollX + (e.deltaY || e.deltaX) / pxPerSec); + } else { + const rect = root.getBoundingClientRect(); + const anchorT = xToTime(e.clientX - rect.left); + pxPerSec = Math.min(400, Math.max(1.5, pxPerSec * (e.deltaY > 0 ? 1 / 1.2 : 1.2))); + scrollX = Math.max(0, anchorT - (e.clientX - rect.left) / pxPerSec); + } + render(); + }, + { passive: false } + ); + + // Visual clips: drag body to move, edges to resize; double-click empty lane + // lays the current scene down. + let clipDrag = null; + visualLane.addEventListener('pointerdown', (e) => { + const el = e.target.closest('[data-clip]'); + if (!el) return; + const clip = visualClips().find((c) => c.id === el.dataset.clip); + if (!clip) return; + selectedId = clip.id; + const rect = el.getBoundingClientRect(); + const atStart = e.clientX - rect.left < EDGE; + const atEnd = rect.right - e.clientX < EDGE; + clipDrag = { + clip, + mode: atEnd ? 'end' : atStart ? 'start' : 'move', + grabT: xToTime(e.clientX - visualLane.getBoundingClientRect().left) - clip.start, + }; + visualLane.setPointerCapture(e.pointerId); + render(); + }); + visualLane.addEventListener('pointermove', (e) => { + if (!clipDrag) return; + const t = xToTime(e.clientX - visualLane.getBoundingClientRect().left); + const c = clipDrag.clip; + const snap = (v) => Math.max(0, Math.round(v * 2) / 2); + if (clipDrag.mode === 'move') { + const dur = c.end - c.start; + c.start = snap(t - clipDrag.grabT); + c.end = c.start + dur; + } else if (clipDrag.mode === 'end') { + c.end = Math.max(c.start + 0.5, snap(t)); + } else { + c.start = Math.min(c.end - 0.5, snap(t)); + } + renderVisual(); + }); + visualLane.addEventListener('pointerup', () => { + if (clipDrag) edited(); + clipDrag = null; + }); + visualLane.addEventListener('dblclick', (e) => { + if (e.target.closest('[data-clip]')) return; + const t = Math.round(xToTime(e.offsetX) * 2) / 2; + const scene = engine.currentScene ? engine.currentScene.id : engine.sceneList[0].id; + visualClips().push({ id: uid('v'), scene, start: t, end: t + 8, transition: { type: 'cut', duration: 0 } }); + edited(); + }); + + // Drops: scenes from the bank onto the visual lane, samples from the kit + // onto the audio lane. + for (const lane of [visualLane, audioCanvas]) { + lane.addEventListener('dragover', (e) => { + e.preventDefault(); + root.classList.add('droppable'); + }); + lane.addEventListener('dragleave', () => root.classList.remove('droppable')); + } + visualLane.addEventListener('drop', (e) => { + e.preventDefault(); + root.classList.remove('droppable'); + const scene = e.dataTransfer.getData('application/x-sway-scene'); + if (!scene) return; + const t = Math.round(xToTime(e.offsetX) * 2) / 2; + visualClips().push({ id: uid('v'), scene, start: t, end: t + 8, transition: { type: 'cut', duration: 0 } }); + edited(); + }); + audioCanvas.addEventListener('drop', async (e) => { + e.preventDefault(); + root.classList.remove('droppable'); + const mediaId = e.dataTransfer.getData('application/x-sway-media'); + if (!mediaId) return; + const t = Math.round(xToTime(e.offsetX) * 2) / 2; + const buffer = await store.loadMediaBuffer(mediaId); + if (!buffer) return; + const media = store.project().media.find((m) => m.id === mediaId); + audioClips().push({ + id: uid('c'), + name: media ? media.name : '', + media: mediaId, + start: t, + end: t + buffer.duration, + offset: 0, + gain: 1, + fadeIn: 0, + fadeOut: 0, + }); + edited(); + }); + + let lastPlayheadX = -1; + return { + render, + edited, + + updatePlayhead() { + const x = timeToX(transport.state.position); + if (Math.abs(x - lastPlayheadX) < 0.5) return; + lastPlayheadX = x; + const w = root.clientWidth; + if (x < 0 || x > w) { + if (transport.state.playing && x > w) { + scrollX = transport.state.position; + render(); + } + playhead.style.opacity = x < 0 ? '0' : '1'; + } else { + playhead.style.opacity = '1'; + } + playhead.style.transform = `translateX(${Math.max(0, Math.min(w, x)).toFixed(1)}px)`; + }, + + deleteSelected() { + if (!selectedId) return false; + const va = visualClips(); + const aa = audioClips(); + let idx = va.findIndex((c) => c.id === selectedId); + if (idx >= 0) va.splice(idx, 1); + else { + idx = aa.findIndex((c) => c.id === selectedId); + if (idx >= 0) aa.splice(idx, 1); + else return false; + } + selectedId = null; + edited(); + return true; + }, + + nudgeSelected(dt) { + const clip = visualClips().find((c) => c.id === selectedId) || audioClips().find((c) => c.id === selectedId); + if (!clip) return false; + const dur = clip.end - clip.start; + clip.start = Math.max(0, clip.start + dt); + clip.end = clip.start + dur; + edited(); + return true; + }, + + hasSelection() { + return selectedId !== null; + }, + clearSelection() { + selectedId = null; + render(); + }, + invalidateMedia(mediaId) { + peaks.delete(mediaId); + }, + }; +} diff --git a/src/renderer/ui/wave.js b/src/renderer/ui/wave.js new file mode 100644 index 0000000..07a01d0 --- /dev/null +++ b/src/renderer/ui/wave.js @@ -0,0 +1,65 @@ +// Layered sinewave activity display, driven by the real analyser bands — +// bass sets the slow deep wave, mids the body, highs the ripple. Draws only +// when the canvas is visible; amplitudes ease so silence settles to calm +// lines instead of freezing. + +export function createWave(canvas) { + const ctx = canvas.getContext('2d'); + let t = 0; + const eased = { bass: 0, mid: 0, high: 0 }; + + const LAYERS = [ + { band: 'bass', freq: 1.6, speed: 0.9, amp: 0.9, width: 1.5, alpha: 0.85 }, + { band: 'mid', freq: 3.4, speed: 1.7, amp: 0.65, width: 1.0, alpha: 0.6 }, + { band: 'mid', freq: 5.2, speed: -1.2, amp: 0.5, width: 1.0, alpha: 0.4 }, + { band: 'high', freq: 9.0, speed: 2.6, amp: 0.35, width: 0.75, alpha: 0.35 }, + ]; + + function fit() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.round(canvas.clientWidth * dpr); + const h = Math.round(canvas.clientHeight * dpr); + if (w && h && (canvas.width !== w || canvas.height !== h)) { + canvas.width = w; + canvas.height = h; + } + } + + return { + update(dt, bands, accentA, accentB) { + fit(); + const w = canvas.width; + const h = canvas.height; + if (!w || !h) return; + t += dt; + const k = 1 - Math.exp(-dt * 8); + eased.bass += (bands.bass - eased.bass) * k; + eased.mid += (bands.mid - eased.mid) * k; + eased.high += (bands.high - eased.high) * k; + + ctx.clearRect(0, 0, w, h); + const grad = ctx.createLinearGradient(0, 0, w, 0); + grad.addColorStop(0, accentA); + grad.addColorStop(1, accentB); + ctx.strokeStyle = grad; + ctx.lineCap = 'round'; + + const mid = h / 2; + const maxAmp = h * 0.42; + for (const layer of LAYERS) { + const level = 0.08 + eased[layer.band] * 0.92; + ctx.globalAlpha = layer.alpha; + ctx.lineWidth = layer.width * (window.devicePixelRatio || 1); + ctx.beginPath(); + for (let x = 0; x <= w; x += 3) { + const p = x / w; + const envelope = Math.sin(p * Math.PI); // pinch the ends + const y = mid + Math.sin(p * layer.freq * Math.PI * 2 + t * layer.speed * 2) * maxAmp * layer.amp * level * envelope; + x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + } + ctx.globalAlpha = 1; + }, + }; +} From 710fd616444c5c6a61dae30cfd5dc88add10a5a2 Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:03:35 -0700 Subject: [PATCH 07/26] Retire legacy presets; template scene pads; verification hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 8 legacy projects/*.json and the projects:list channel are gone — templates are the only bundled projects now, and each ships its pool on the right-cluster pads as instant cuts. AKSWAYJ_WINDOW=WxH forces an initial window size for narrow-layout screenshots; __akswayj gains renderTimeline. Assign-rail number inputs keep a readable minimum width. Co-Authored-By: Claude Fable 5 --- projects/beam-sixteen.json | 12 ----- projects/chrysanthemum.json | 12 ----- projects/dnb-tunnel.json | 12 ----- projects/first-flight.json | 12 ----- projects/garage-neon.json | 12 ----- projects/hiphop-voxels.json | 12 ----- projects/hyperspace.json | 12 ----- projects/index.json | 12 ----- projects/nebula-drift.json | 12 ----- projects/templates/beam-sixteen.sway | 18 ++++++- projects/templates/chrysanthemum.sway | 72 ++++++++++++++++++++++++--- projects/templates/dnb-tunnel.sway | 36 ++++++++++++-- projects/templates/first-flight.sway | 72 ++++++++++++++++++++++++--- projects/templates/garage-neon.sway | 45 +++++++++++++++-- projects/templates/hiphop-voxels.sway | 27 ++++++++-- projects/templates/hyperspace.sway | 63 ++++++++++++++++++++--- projects/templates/nebula-drift.sway | 45 +++++++++++++++-- scripts/convert-projects.js | 17 ++++++- src/main/main.js | 29 ++--------- src/preload/preload.js | 4 -- src/renderer/app.js | 1 + src/renderer/styles.css | 2 +- 22 files changed, 359 insertions(+), 180 deletions(-) delete mode 100644 projects/beam-sixteen.json delete mode 100644 projects/chrysanthemum.json delete mode 100644 projects/dnb-tunnel.json delete mode 100644 projects/first-flight.json delete mode 100644 projects/garage-neon.json delete mode 100644 projects/hiphop-voxels.json delete mode 100644 projects/hyperspace.json delete mode 100644 projects/index.json delete mode 100644 projects/nebula-drift.json diff --git a/projects/beam-sixteen.json b/projects/beam-sixteen.json deleted file mode 100644 index e0280eb..0000000 --- a/projects/beam-sixteen.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "beam-sixteen", - "name": "Beam Sixteen", - "description": "A love letter to the hardware: sixteen light columns, one per Sway IR sensor. Sweep your hand along the surface and watch the row answer; strike pads to detonate single beams.", - "pairsWith": null, - "vibe": "anthemic", - "bpmHint": 0, - "palette": ["#dbe9ff", "#9fc5ff", "#5e8fce", "#2de1fc", "#f4f9ff"], - "scenes": ["beams", "nebula"], - "autoVJ": { "enabled": false, "minHold": 30, "maxHold": 60, "fadeTime": 6 }, - "start": { "scene": "beams" } -} diff --git a/projects/chrysanthemum.json b/projects/chrysanthemum.json deleted file mode 100644 index a619090..0000000 --- a/projects/chrysanthemum.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "chrysanthemum", - "name": "Chrysanthemum", - "description": "The full geometry load. Mandelbulb solids that re-knit on the bass, a resonating orb throwing Chladni figures, nebulae that lurch on every beat. Press to morph the fractal from Mandelbulb into Julia and watch the solid transmute.", - "pairsWith": null, - "vibe": "hyperreal", - "bpmHint": 0, - "palette": ["#39ff14", "#ff10f0", "#00fff7", "#ffea00", "#ff5400"], - "scenes": ["mandelbulb", "vjshader", "ferrofluid", "chladni", "lattice", "nebula", "warp", "spectra"], - "autoVJ": { "enabled": true, "minHold": 26, "maxHold": 52, "fadeTime": 6 }, - "start": { "scene": "mandelbulb" } -} diff --git a/projects/dnb-tunnel.json b/projects/dnb-tunnel.json deleted file mode 100644 index a20f3ab..0000000 --- a/projects/dnb-tunnel.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "dnb-tunnel", - "name": "DNB Tunnel", - "description": "Pairs with Audima's official Ableton DNB demo pack. Relentless forward motion: the wormhole eats the bassline, acid greens strobe on the highs, press to choke the throat tight — and strike a pad to jump.", - "pairsWith": "Audima Ableton DNB demo pack", - "vibe": "relentless", - "bpmHint": 174, - "palette": ["#39ff14", "#0affef", "#ff3131", "#cfff04", "#7df9ff"], - "scenes": ["warp", "swarm", "mandelbulb", "beams"], - "autoVJ": { "enabled": true, "minHold": 12, "maxHold": 24, "fadeTime": 2 }, - "start": { "scene": "warp" } -} diff --git a/projects/first-flight.json b/projects/first-flight.json deleted file mode 100644 index 6d1e83f..0000000 --- a/projects/first-flight.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "first-flight", - "name": "First Flight", - "description": "The out-of-the-box show. Every scene in rotation, forgiving fades, a palette that flatters any music. Wave a hand over the Sway — or just move the mouse — and you are performing.", - "pairsWith": null, - "vibe": "welcoming", - "bpmHint": 0, - "palette": ["#ff2d95", "#7a0bc0", "#2de1fc", "#f9f871", "#ff6b35"], - "scenes": ["beams", "swarm", "ribbons", "voxels", "warp", "nebula", "mandelbulb", "cymatic", "vjshader"], - "autoVJ": { "enabled": true, "minHold": 20, "maxHold": 45, "fadeTime": 5 }, - "start": { "scene": "beams" } -} diff --git a/projects/garage-neon.json b/projects/garage-neon.json deleted file mode 100644 index a81e3a7..0000000 --- a/projects/garage-neon.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "garage-neon", - "name": "Garage Neon", - "description": "Built to run alongside Audima's official Ableton Garage demo pack. Skippy two-step energy: ribbons chase your hand, beams answer the kick, hot pink on deep violet.", - "pairsWith": "Audima Ableton Garage demo pack", - "vibe": "club", - "bpmHint": 132, - "palette": ["#ff2d95", "#7a0bc0", "#2de1fc", "#ff6b35", "#f9f871"], - "scenes": ["ribbons", "valley", "lattice", "beams", "swarm"], - "autoVJ": { "enabled": true, "minHold": 16, "maxHold": 32, "fadeTime": 3 }, - "start": { "scene": "ribbons" } -} diff --git a/projects/hiphop-voxels.json b/projects/hiphop-voxels.json deleted file mode 100644 index b20533d..0000000 --- a/projects/hiphop-voxels.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "hiphop-voxels", - "name": "Hip Hop Voxels", - "description": "Pairs with Audima's official Ableton Hip Hop demo pack. Head-nod tempo, heavyweight blocks: the voxel field rides the 808, pads drop ripples across the grid, golds over deep browns.", - "pairsWith": "Audima Ableton Hip Hop demo pack", - "vibe": "heavyweight", - "bpmHint": 88, - "palette": ["#ffb300", "#ff6f00", "#8d5524", "#fff3c4", "#e63946"], - "scenes": ["voxels", "ribbons", "nebula"], - "autoVJ": { "enabled": true, "minHold": 24, "maxHold": 48, "fadeTime": 5 }, - "start": { "scene": "voxels" } -} diff --git a/projects/hyperspace.json b/projects/hyperspace.json deleted file mode 100644 index a84ae93..0000000 --- a/projects/hyperspace.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "hyperspace", - "name": "Hyperspace", - "description": "Deep space at the throat of a wormhole. Fall toward the aperture, bank with a hand, and strike a pad to punch the jump — charge, launch, cruise, settle. Star-streaked, chrome-cold, built for the drop.", - "pairsWith": null, - "vibe": "transluminal", - "bpmHint": 0, - "palette": ["#ffffff", "#9bf6ff", "#4361ee", "#7209b7", "#f72585"], - "scenes": ["warp", "vjshader", "mandelbulb", "lattice", "valley", "spectra", "swarm"], - "autoVJ": { "enabled": true, "minHold": 22, "maxHold": 46, "fadeTime": 3 }, - "start": { "scene": "warp" } -} diff --git a/projects/index.json b/projects/index.json deleted file mode 100644 index b49f800..0000000 --- a/projects/index.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "order": [ - "first-flight", - "hyperspace", - "chrysanthemum", - "beam-sixteen", - "garage-neon", - "dnb-tunnel", - "hiphop-voxels", - "nebula-drift" - ] -} diff --git a/projects/nebula-drift.json b/projects/nebula-drift.json deleted file mode 100644 index 2854763..0000000 --- a/projects/nebula-drift.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "nebula-drift", - "name": "Nebula Drift", - "description": "Interstellar gas with a pulse. Glide a hand for parallax through the folds, press to condense them, strike pads to detonate shockwaves through the cloud. Atmospheric, but it moves.", - "pairsWith": null, - "vibe": "ambient", - "bpmHint": 0, - "palette": ["#0f4c5c", "#5bc0be", "#9bf6ff", "#3a506b", "#e0fbfc"], - "scenes": ["nebula", "ferrofluid", "chladni", "cymatic", "swarm"], - "autoVJ": { "enabled": true, "minHold": 40, "maxHold": 80, "fadeTime": 10 }, - "start": { "scene": "nebula" } -} diff --git a/projects/templates/beam-sixteen.sway b/projects/templates/beam-sixteen.sway index 02da201..f9caab1 100644 --- a/projects/templates/beam-sixteen.sway +++ b/projects/templates/beam-sixteen.sway @@ -116,8 +116,22 @@ null, null, null, - null, - null, + { + "type": "scene", + "scene": "beams", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "nebula", + "transition": { + "type": "cut", + "duration": 0 + } + }, null, null, null, diff --git a/projects/templates/chrysanthemum.sway b/projects/templates/chrysanthemum.sway index fc78eb4..c6611e0 100644 --- a/projects/templates/chrysanthemum.sway +++ b/projects/templates/chrysanthemum.sway @@ -122,14 +122,70 @@ null, null, null, - null, - null, - null, - null, - null, - null, - null, - null + { + "type": "scene", + "scene": "mandelbulb", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "vjshader", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "ferrofluid", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "chladni", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "lattice", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "nebula", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "warp", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "spectra", + "transition": { + "type": "cut", + "duration": 0 + } + } ], "knobs": [ { diff --git a/projects/templates/dnb-tunnel.sway b/projects/templates/dnb-tunnel.sway index 852792c..510cf44 100644 --- a/projects/templates/dnb-tunnel.sway +++ b/projects/templates/dnb-tunnel.sway @@ -118,10 +118,38 @@ null, null, null, - null, - null, - null, - null, + { + "type": "scene", + "scene": "warp", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "swarm", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "mandelbulb", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "beams", + "transition": { + "type": "cut", + "duration": 0 + } + }, null, null, null, diff --git a/projects/templates/first-flight.sway b/projects/templates/first-flight.sway index 55c35a0..3422868 100644 --- a/projects/templates/first-flight.sway +++ b/projects/templates/first-flight.sway @@ -123,14 +123,70 @@ null, null, null, - null, - null, - null, - null, - null, - null, - null, - null + { + "type": "scene", + "scene": "beams", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "swarm", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "ribbons", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "voxels", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "warp", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "nebula", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "mandelbulb", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "cymatic", + "transition": { + "type": "cut", + "duration": 0 + } + } ], "knobs": [ { diff --git a/projects/templates/garage-neon.sway b/projects/templates/garage-neon.sway index 8a64e24..eefa7ff 100644 --- a/projects/templates/garage-neon.sway +++ b/projects/templates/garage-neon.sway @@ -119,11 +119,46 @@ null, null, null, - null, - null, - null, - null, - null, + { + "type": "scene", + "scene": "ribbons", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "valley", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "lattice", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "beams", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "swarm", + "transition": { + "type": "cut", + "duration": 0 + } + }, null, null, null diff --git a/projects/templates/hiphop-voxels.sway b/projects/templates/hiphop-voxels.sway index 84b79f6..1c71526 100644 --- a/projects/templates/hiphop-voxels.sway +++ b/projects/templates/hiphop-voxels.sway @@ -117,9 +117,30 @@ null, null, null, - null, - null, - null, + { + "type": "scene", + "scene": "voxels", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "ribbons", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "nebula", + "transition": { + "type": "cut", + "duration": 0 + } + }, null, null, null, diff --git a/projects/templates/hyperspace.sway b/projects/templates/hyperspace.sway index 44b550c..768b8f1 100644 --- a/projects/templates/hyperspace.sway +++ b/projects/templates/hyperspace.sway @@ -121,13 +121,62 @@ null, null, null, - null, - null, - null, - null, - null, - null, - null, + { + "type": "scene", + "scene": "warp", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "vjshader", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "mandelbulb", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "lattice", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "valley", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "spectra", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "swarm", + "transition": { + "type": "cut", + "duration": 0 + } + }, null ], "knobs": [ diff --git a/projects/templates/nebula-drift.sway b/projects/templates/nebula-drift.sway index 8d53ac2..e500bcf 100644 --- a/projects/templates/nebula-drift.sway +++ b/projects/templates/nebula-drift.sway @@ -119,11 +119,46 @@ null, null, null, - null, - null, - null, - null, - null, + { + "type": "scene", + "scene": "nebula", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "ferrofluid", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "chladni", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "cymatic", + "transition": { + "type": "cut", + "duration": 0 + } + }, + { + "type": "scene", + "scene": "swarm", + "transition": { + "type": "cut", + "duration": 0 + } + }, null, null, null diff --git a/scripts/convert-projects.js b/scripts/convert-projects.js index 37dbbef..16658eb 100644 --- a/scripts/convert-projects.js +++ b/scripts/convert-projects.js @@ -11,7 +11,12 @@ const root = path.join(__dirname, '..'); const legacyDir = path.join(root, 'projects'); const outDir = path.join(legacyDir, 'templates'); -const index = JSON.parse(fs.readFileSync(path.join(legacyDir, 'index.json'), 'utf8')); +const legacyIndex = path.join(legacyDir, 'index.json'); +if (!fs.existsSync(legacyIndex)) { + console.log('[convert] no legacy projects/index.json — the conversion already ran; templates/ is the output.'); + process.exit(0); +} +const index = JSON.parse(fs.readFileSync(legacyIndex, 'utf8')); fs.mkdirSync(outDir, { recursive: true }); const order = []; @@ -25,6 +30,16 @@ for (const id of index.order) { continue; } const doc = legacyToSway(legacy); + // Right-cluster pads (8-15) come pre-assigned to the template's pool + // scenes as instant cuts, so a fresh project answers the hardware + // immediately; the left cluster stays free for samples. + doc.project.engine.autoVJ.pool.slice(0, 8).forEach((scene, i) => { + doc.project.assignments.pads[8 + i] = { + type: 'scene', + scene, + transition: { type: 'cut', duration: 0 }, + }; + }); const outPath = path.join(outDir, `${id}.sway`); fs.writeFileSync(outPath, JSON.stringify(doc, null, 2) + '\n'); order.push(id); diff --git a/src/main/main.js b/src/main/main.js index 34f61f3..3f33004 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -67,26 +67,6 @@ function writeSettings(patch) { return merged; } -function projectsDir() { - // package root in dev; resources/app.asar root when packaged - return path.join(__dirname, '..', '..', 'projects'); -} - -function listProjects() { - const dir = projectsDir(); - const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8')); - return index.order - .map((id) => { - try { - return JSON.parse(fs.readFileSync(path.join(dir, `${id}.json`), 'utf8')); - } catch (err) { - console.error(`[projects] failed to load ${id}:`, err.message); - return null; - } - }) - .filter(Boolean); -} - // --- audio files ------------------------------------------------------------ // Stems and one-shots are chosen through the OS file dialog and read in the // main process; the renderer receives raw bytes and decodes them itself. The @@ -171,9 +151,12 @@ function readDoc(id) { } function createWindow() { + // Verification hook: AKSWAYJ_WINDOW=960x600 forces an initial size so the + // narrow-window layout can be screenshot-tested headlessly. + const sizeOverride = /^(\d+)x(\d+)$/.exec(process.env.AKSWAYJ_WINDOW || ''); win = new BrowserWindow({ - width: 1440, - height: 900, + width: sizeOverride ? Number(sizeOverride[1]) : 1440, + height: sizeOverride ? Number(sizeOverride[2]) : 900, minWidth: 960, minHeight: 600, backgroundColor: '#05060a', @@ -293,8 +276,6 @@ app.whenReady().then(() => { } }); - ipcMain.handle('projects:list', () => listProjects()); - // --- .sway project files --- ipcMain.handle('project:openDialog', async () => { const settings = readSettings(); diff --git a/src/preload/preload.js b/src/preload/preload.js index 084a39e..ae7a6dc 100644 --- a/src/preload/preload.js +++ b/src/preload/preload.js @@ -17,10 +17,6 @@ contextBridge.exposeInMainWorld('akswayj', { }, }, - projects: { - list: () => ipcRenderer.invoke('projects:list'), - }, - project: { openDialog: () => ipcRenderer.invoke('project:openDialog'), saveDialog: (name) => ipcRenderer.invoke('project:saveDialog', name), diff --git a/src/renderer/app.js b/src/renderer/app.js index f32ea2a..778ddd2 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -1184,6 +1184,7 @@ async function main() { openDocs, renderPads: refreshDeck, renderSamples, + renderTimeline: () => ui.timeline.render(), transport: state.transport, projectStore: state.projectStore, router: state.router, diff --git a/src/renderer/styles.css b/src/renderer/styles.css index e34cc85..878b972 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -236,7 +236,7 @@ button { font-family: inherit; } border: 1px solid var(--panel-edge); border-radius: 5px; padding: 4px 6px; font: 11px var(--mono); } .assign-row input[type="range"] { flex: 1; min-width: 0; } -.assign-row input[type="number"] { flex: 0 1 58px; } +.assign-row input[type="number"] { flex: 0 1 58px; min-width: 46px; } .assign-row b { color: var(--accent); font: 11px var(--mono); min-width: 30px; text-align: right; } .assign-row .btn { flex-shrink: 0; } .assign-list { margin-bottom: 8px; } From 709d2b309ad7cf410ce14d56a9eb1effc82b566a Mon Sep 17 00:00:00 2001 From: Daniel Joaquiin Trujillo <54636507+danieljtrujillo@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:21:23 -0700 Subject: [PATCH 08/26] Regenerate documentation for the cockpit; select audio clips on the band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bundled doc, the README, and the internal handoff now describe the single-page cockpit, the .sway format, the assignment model, and the current probe surface — claims verified against source, links and headings checked. Audio-lane clips gained pointer hit-testing (select, move, trailing-edge resize) closing the gap the review found. Co-Authored-By: Claude Fable 5 --- HANDOFF.md | 324 ++++++++---------------------------- README.md | 55 +++--- docs/ARCHITECTURE.md | 43 ++--- docs/AUDIO.md | 39 +++-- docs/DOCTOR.md | 12 +- docs/ENGINE.md | 81 +++++---- docs/ENVIRONMENT.md | 74 +++++--- docs/INDEX.md | 25 +-- docs/INSTALLATION.md | 4 +- docs/MIDI.md | 87 ++++++---- docs/OVERVIEW.md | 67 ++++++-- docs/PROJECTS.md | 216 +++++++++++++----------- docs/SCENE_CONTRACT.md | 25 ++- docs/STUDIO.md | 116 +++++++++---- docs/SYNTH.md | 8 +- docs/TROUBLESHOOTING.md | 10 +- src/renderer/ui/timeline.js | 37 ++++ 17 files changed, 640 insertions(+), 583 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index ae84f14..e9782ad 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,298 +1,120 @@ # AKSWAYJ handoff -Written 2026-08-19 for the next engineer or agent. Every number below was measured against the **installed build**, not inferred from source. Where a claim is a measurement, the measurement and the probe output are given. +Written 2026-08-20 for the next engineer or agent, after the single-page cockpit redesign. This is an internal engineering note. It is deliberately **not** in `docs/`, not in the documentation index, and not packaged into the app — `electron-builder.yml` ships `docs/**/*.md` and `README.md` by exact name, so a root-level file never reaches an end user. -This is an internal engineering note. It is deliberately **not** in `docs/`, not in the documentation index, and not packaged into the app — `electron-builder.yml` ships `docs/**/*.md` and `README.md` by exact name, so a root-level file never reaches an end user. - -Product documentation lives in [`docs/`](docs/). Start with [docs/OVERVIEW.md](docs/OVERVIEW.md) for terminology; this note assumes it. +Product documentation lives in [`docs/`](docs/) and was regenerated against this source tree on 2026-08-20. Start with [docs/OVERVIEW.md](docs/OVERVIEW.md) for terminology; this note assumes it. --- -## 1. Do this before touching anything +## 1. Repository state -**There is no version control.** `git rev-parse` reports "not a git repository". A `.gitignore` exists; `.git` does not. 14 scenes, the synth, the effects rack, and the documentation suite exist only as working-tree files with no history and no recovery path. +Version control exists. The working branch is `cockpit`; `main` holds the pre-redesign import. Recent history on `cockpit`: -```sh -cd c:/Users/Cyboman/Documents/Dev/AKSWAYJ -git init && git add -A && git commit -m "AKSWAYJ initial import" +``` +710fd61 Retire legacy presets; template scene pads; verification hooks +bbe9649 Single-surface cockpit +7e29dd8 Assignment router — one dispatch path for every control +e2fb1b0 Transport and project store +b42f1b7 Engine: instant cut, prewarm, ResizeObserver, frame hook, .sway apply +dabe8a2 Add .sway project IPC surface ``` -`node_modules` 457 MB, `release` 417 MB, `dist` 2.1 MB — all gitignored. - -**Second hazard, hit twice already.** Background agents edited scene files *after* a build was cut, so a packaged installer did not contain the code that had just been reviewed. Both times it was caught only by comparing file mtimes against the build timestamp. Before packaging: confirm nothing is running, and that nothing under `src/` is newer than `dist/renderer.bundle.js`. +`node_modules`, `release`, and `dist` are gitignored. Commit in small units; the pre-git era of this project lost history it never had. --- -## 2. The reported problem: scenes the user cannot find - -The user reported the Ferrofluid Orb, Cymatic Plate, Chrome Valley, and Quantum Lattice as "not visible anywhere". - -**It is five scenes, not four — `spectra` sits in the same blind spot.** - -The scenes are correctly registered, correctly pooled, and render perfectly. A probe confirmed that pressing `3` in Chrysanthemum lands on "Ferrofluid Orb" in 3.5 s with the HUD naming it correctly. They are invisible because **every surface that could name a scene declines to**, and because one of them is too dark to notice. Two independent causes; fixing either alone leaves the complaint standing. - -### Cause A — nothing in the interface reveals that a scene exists - -Ranked by contribution. - -**A1 — The default project excludes all of them. Probability of encountering one is exactly zero.** -`projects/index.json` `order[0]` is `first-flight`, the first card and the de-facto default. Its pool is 9 scenes: `beams, swarm, ribbons, voxels, warp, nebula, mandelbulb, cymatic, vjshader`. Auto-VJ, `Space`, and every digit key are all incapable of reaching the other five from there. - -| Project | Pool size | Contains, of the five | -|---|---|---| -| `first-flight` (default) | 9 | none | -| `chrysanthemum` | 8 | ferrofluid, chladni, lattice, spectra | -| `hyperspace` | 7 | valley, lattice, spectra | -| `garage-neon` | 5 | valley, lattice | -| `nebula-drift` | 5 | ferrofluid, chladni | -| `dnb-tunnel` | 4 | none | -| `hiphop-voxels` | 3 | none | -| `beam-sixteen` | 2 | none (Auto-VJ disabled) | - -Four of eight projects contain none. *Fix: add them to `first-flight.json`.* - -**A2 — No project opens on any of them.** -All eight `start.scene` values are legacy scenes: `beams, warp, mandelbulb, beams, ribbons, warp, voxels, nebula`. Even in the four projects that do pool one, the user is never shown one on entry. Combined with A1, the number of ways to see one of these scenes without prior knowledge is **zero**. *Fix: make one of them the `start.scene` of `chrysanthemum` or `hyperspace`.* - -**A3 — The picker discloses no scene names.** -`renderProjects()` (`src/renderer/app.js:177-192`) emits swatches, name, vibe/BPM/pairsWith pills, description, and a PLAY affordance. `p.scenes` is never referenced. Measured across the rendered grid: **2,439 characters of picker DOM, zero scene names.** The one case-insensitive hit for "chladni" is prose inside Chrysanthemum's description. There is no scene list, gallery, or browser anywhere — `sceneList` is never imported into `app.js`. - -**A4 — The digit keys are unlabeled, and the two labels that exist contradict each other.** -`app.js:934-939` maps `Number(key)-1` into `state.engine.autoVJ.pool` — the *project* pool. The index therefore changes per project: - -| Project | Digit map for the five | -|---|---| -| `chrysanthemum` | 3=ferrofluid, 4=chladni, 5=lattice, 8=spectra | -| `hyperspace` | 4=lattice, 5=valley, 6=spectra | -| `garage-neon` | 2=valley, 3=lattice | -| `nebula-drift` | 2=ferrofluid, 3=chladni | +## 2. What the redesign did -Nothing on screen states any of this. The help bar (`index.html:167`) says `1–9 project scenes`; the help overlay (`index.html:183`) says `1–8 scenes`. They disagree, neither names a scene, and `1–8` is a leftover from the obsolete registry-indexed behaviour still described in `docs/ENGINE.md:42`. +The five full-window screens (boot, projects, studio, docs, perform) are gone. The app is one always-live page: the stage renders from the first frame to quit, and every panel works on top of it. Layout and interaction model: [docs/OVERVIEW.md](docs/OVERVIEW.md#the-cockpit). -**A5 — The in-app documentation actively asserts these scenes do not exist.** -The Documentation screen renders `docs/*.md`, and those are stale at 8 scenes: +New modules: -| File | Stale claim | +| Path | Purpose | |---|---| -| `docs/SCENE_CONTRACT.md:114` | "The registry holds eight scenes"; inventory table stops at `cymatic` | -| `docs/ENGINE.md:42` | "the eight scene modules"; claims digits index `sceneList` directly | -| `docs/ENVIRONMENT.md:54` | "the eight ids in the scene registry" | -| `docs/PROJECTS.md:82-89` | Stale pool tables — hyperspace shown as 4 scenes vs its actual 7 | - -A grep of `docs/` for the five scene names returns **one** hit, and it is the word "lattice" inside the `warp` shader description. The installed `app.asar` contains all their display names, so the build is current and only the docs lie. *Fix: regenerate the inventory and pool tables from the registry and project JSONs.* - -**A6 — Auto-VJ is slow even in the right project.** Expected wait 88–260 s, and never in the four projects above. - -### Cause B — the Ferrofluid Orb is near-invisible even when on screen - -Specific to `ferrofluid`; a rendering problem, not navigation. Mean luminance and lit-pixel fraction (luma > 18) over the stage area of captured frames: - -| Scene | Mean luma | Lit pixels | -|---|---|---| -| **ferrofluid** | **0.9** | **1.0 %** | -| valley | 3.3 | 5.3 % | -| beams | 3.3 | 8.4 % | -| lattice | 3.5 | 3.0 % | -| chladni | 4.1 | 10.1 % | -| voxels | 4.2 | 11.6 % | -| mandelbulb | 11.4 | 26.7 % | -| vjshader | 17.1 | 24.3 % | - -**12× dimmer than the Mandelbulb, 3.7× dimmer than the next-dimmest scene.** Causes in order: - -1. It is black chrome by design — `MeshStandardMaterial({ color: 0x010101, metalness: 0.99, roughness: 0.003 })`. Nearly all its light is *reflected*. -2. The upstream reflection source is gone. theDAW loads `piz_compressed.exr` through `PMREMGenerator`; scenes may not load assets ([scene contract](docs/SCENE_CONTRACT.md) rule 4), so a weak procedural gradient stands in. Black chrome has almost nothing to reflect. -3. Upstream's `UnrealBloomPass(strength 1.65)` is absent — the compositor owns tone shaping — and an additive fresnel shell substitutes for it. -4. `fieldStrength = pow(abs(np.y), 1.5)` puts structure only at the poles. Faithful to the original; do not "fix" it. -5. Quiet input. A floor of 0.16 was added so silence still shows a standing spike field. - -Act on 2 and 3: raise `envMapIntensity`, brighten the procedural environment, strengthen the fresnel shell. **Do not touch the Rosensweig maths** — it is a verified faithful port. +| `src/shared/swayproject.js` | The `.sway` document: schema, defaults, `validateProject` (fills defaults, warns, never throws on content), `legacyToSway`. Shared by main and renderer; dependency-free. | +| `src/main/projectfile.js` | `.sway` I/O: read/write with atomic rename, size cap, format gating; id-gated bundled templates; recents pruning; `statAudio` (sha256 + bytes). | +| `src/renderer/project/projectstore.js` | Document lifecycle: open/apply (fixed hydration order), async sequential media loading, collect-on-save, media registration. | +| `src/renderer/control/router.js` | The single dispatch point: pad/button/note events, change-driven knob dispatch, per-frame gesture routes, timeline visual clips, LEARN persistence, touch-to-select. | +| `src/renderer/audio/transport.js` | Timeline playback: `AudioBufferSourceNode` scheduling on the context clock, loop, seek, per-clip fades; visual lane fires the router. | +| `src/renderer/ui/frame.js`, `popover.js`, `wave.js` | Chrome: panel frames, the shared popover, the INPUT box band display. | +| `src/renderer/ui/surface.js` | The Sway deck schematic (SVG, mutated per frame); the control-id grammar; `PAD_CELLS`. | +| `src/renderer/ui/assign.js` | The assignment panel: per-control editors, FOLLOW/LEARN/CLEAR. | +| `src/renderer/ui/drawer.js` | The SYNTH / RACK / KIT drawer shell; lazy first render. | +| `src/renderer/ui/timeline.js` | The timeline band: ruler (scrub/loop/locators), visual lane (DOM clips), audio lane (canvas waveforms), playhead. | + +Changes in existing modules: + +- **engine.js** — `cutTo` (instant switch), `prewarm` (one scene per frame after project apply), a `ResizeObserver` on the stage canvas, `params { hue, intensity }` replacing the hardwired knobs 0–2, `setFrameHook` (the router's slot), `applyProject` (the `.sway` shape; replays the fx snapshot through `setFxParam`), and `fx` / `fxEnabled` / `setFxParam` / `resetFx` on the public object. `autoVJ.fadeTime` is no longer overwritten per frame. +- **fxrack.js** — exports `DECKS` beside `RANGES`; the phantom deck keys `zoomPunch` / `slitScan` / `timeDisplace` are dropped and `audioReactive` / `asciiAccent` are surfaced, so every deck key has a range entry and the UI cannot silently skip parameters. +- **midi.js** — emits the full event set (`pad`, `note`, `noteoff`, `bend`, `mod`, `cc` with resolved target); note-offs are no longer swallowed. The router is the one `onEvent` consumer. +- **audio.js** (analysis) — `useSystemAudio()` reads Windows WASAPI loopback correctly (video track dropped on arrival, explicit no-audio-track failure), `state.source` gained `'system'`, `state.deviceId` records the granted input, `releaseInput`/`stopInternal` exported. +- **main.js** — IPC gained `project:*` (openDialog, saveDialog, read, write, recent, templates, readTemplate) and `files:statAudio`; `projects:list` is gone with the picker. `AKSWAYJ_AUTOPLAY` now takes a template id or a `.sway` path; `AKSWAYJ_WINDOW=WxH` forces the initial window size for layout screenshots. +- **app.js** — rebuilt as cockpit assembly. `window.__akswayj` is now `{ state, studio, openStudio(tab), openDocs, renderPads, renderSamples, transport, projectStore, router, selectControl, openProject, saveProject }`; `state.screen` no longer exists. + +Projects: the 8 legacy presets became bundled templates (`projects/templates/*.sway`, converted via `legacyToSway`), reachable from the project menu. Format reference: [docs/PROJECTS.md](docs/PROJECTS.md). --- -## 3. The next objective: a single-page cockpit - -The brief: **one page, everything inside the window, a command center for a full-blown Audima Labs Sway entertainment application.** Reference images are coming from the user — **ask for them and treat them as authoritative over everything in this section.** - -### The real budget is 1266 × 683, not 1440 × 900 - -`src/main/main.js:173-177` requests 1440 × 900 (min 960 × 600). Measured in the installed build: **innerWidth 1266, innerHeight 683, dpr 1.5** — the request is clamped by this machine's 1280 × 720 DIP work area at 150 % scaling, and chrome eats 14 px. Budget against **864,678 px²**, not 1,296,000. At the declared minimum the budget falls to ~554,000 px². - -### What exists today is the opposite of a cockpit - -Five mutually exclusive full-window screens, swapped by `show()` (`app.js:39-46`) toggling one `.active` class. Four of the five scroll. +## 3. What is verified -| Screen | scrollHeight vs 683 | Overflow | Notes | -|---|---|---|---| -| `studio` | 2525 | **+1842 px (3.70 screens)** | 4 stacked panels | -| `docs` body | 4123 | +3440 px (6.0 screens) | screen itself is correctly viewport-locked | -| `boot` | 989 | +306 px | **Enter CTA sits 176 px below the fold** | -| `projects` | 986 | +303 px | 2 of 8 cards cut | -| `perform` | fits | — | the only one that fits | - -**Studio holds 138 interactive controls (154 targets with pads). Only 10 — 7.2 % — are fully visible.** At a true 1440 × 900 it is still 17/138 (12.3 %). Control density, not panel size, is the core problem. - -| Panel | Height | Controls | -|---|---|---| -| Synth | 1087 px | 87 (59 rows + 17 keys + mod matrix) | -| Audio source | 701 px | 8 | -| Kit builder | 684 px | 6 + 16 pads | -| Effects rack | 559 px | 36 | - -A cockpit must absorb ~2.84 M px² of Studio content into 0.86 M px² of window — **3.3 : 1**. - -### Four findings that change the design, not just the layout - -**3a — Opening Studio or Docs halts the render loop.** Measured: `engine.stats.frames`/`acc` freeze across a 250 ms sample after `s` or `d`, and resume after `Esc`. Cause: `app.js:953-957` calls `state.engine.stop()` before `openStudio()`/`openDocs()`. **Today it is impossible to touch any of the 36 FX params, the 84-entry synth manifest, the 16 pads, or the audio source while a frame is being drawn.** A cockpit removes those `stop()` calls entirely, which also removes `returnFromStudio()`/`returnFromDocs()`. - -**3b — `engine.resize()` is bound only to the window `resize` event.** `engine.js:257`, plus one explicit call at `app.js:45`. There is no `ResizeObserver`. `resize()` correctly reads `canvas.clientWidth`, so it *will* work in a grid cell — but any cockpit that changes the stage cell without a window resize (collapsing a drawer, toggling a rail) leaves the renderer, `rtA`/`rtB`/`rtComp`, and the FX rack at stale dimensions. **Add a `ResizeObserver` on the stage element.** - -**3c — All three responsive breakpoints are dead.** `@media (max-width: 900px)`, `(max-width: 700px)`, `(max-width: 820px)` in `styles.css:130,160,316` sit below `minWidth: 960`; the narrowest reachable viewport is ~946 px. Roughly 8 lines of stylesheet never fire, and there is currently **no** working layout adaptation. - -**3d — The audio level meter renders 0 px wide.** `.meter { flex: 1 }` shares a flex row with `#studio-source`, whose full device label ("DEFAULT - MICROPHONE (4- ARCTIS NOVA PRO) (1038:12CD)") consumes the whole 335 px row. Measured 0 × 8 px. It also only updates while the Studio screen is open (`app.js:841`), so there is no level indication during a performance at all. Needs `min-width: 0` / truncation on the pill, and the update moved out of the screen check. - -### Space budget for the redesign - -Performance-critical — must be live while frames render: - -| Element | Today | Cost today | -|---|---|---| -| Stage canvas | full-bleed | 1266 × 683, 100 % | -| Scene selection (14) | **0 on-screen controls** | keyboard only; 9 of 14 reachable in a session | -| Project selection (8) | separate screen | 1124 × 838 of cards | -| Auto-VJ / transport | 1 read-only pill | 0 buttons; minHold/maxHold/fadeTime have no UI | -| Audio source + level | buried in Studio | 8 controls, 449 px list, 0 px meter | -| 16 pads | Studio | 433 × 433 px = **21.6 % of the window** for 16 cells; 56 px cells would be 248 × 248 | -| MIDI / Sway state, fps | 2 pills | fine as-is | - -Setup-only, safe behind a drawer, tab, or overlay: the synth's 87 controls, the rack's 36, the kit builder's file management, the Doctor's 8 checks, and the docs viewer (the clearest candidate for eviction — 6 screens of scroll, and it is already correctly viewport-locked, which is the pattern to reuse). - -**Reusable groundwork:** both the synth panel and the FX panel already generate their controls from a manifest — `synth.controlManifest()` (84 entries, theDAW's `VisualControl` shape) and the exported `RANGES` table in `fxrack.js` (36 entries). A cockpit can re-render the same data into any layout without touching the engines. +- Code-level: the documentation set was rewritten 2026-08-20 by reading every claim out of this source tree (module by module, including `swayproject.js`, `router.js`, `transport.js`, `projectfile.js`, and the `ui/` set). The docs are the closest thing to a spec; where they and the code disagree, something regressed. +- 14 scenes registered (`scenes/index.js`); all 8 templates parse and carry the expected pools (checked by script against `projects/templates/`). +- The pre-redesign hardware baseline still applies: Sway USB identity `VID 0x0483` / `PID 0x52A4` confirmed on hardware, port name `Audima Labs The Sway`, factory CC map exercised on the installed build. +- Not re-measured since the redesign: frame rates per scene, window-size budgets, and installed-build behavior. The redesign has not been packaged and installed yet — treat any installed-build claim as stale until a new build is cut and probed. --- -## 4. State of the code +## 4. Known gaps -| Path | Purpose | -|---|---| -| `src/main/main.js` | Window, permissions, IPC, doc whitelist, audio-file dialog, loopback handler | -| `src/main/doctor.js` | Startup system checks | -| `src/main/audima.js` | Audima CDN client, minisign verification | -| `src/main/driver-install.js` | Windows DFU driver install via elevated `pnputil` | -| `src/preload/preload.js` | The entire IPC surface behind `contextBridge` | -| `src/renderer/app.js` | Screen flow, HUD, Studio, synth panel, FX panel, docs viewer | -| `src/renderer/markdown.js` | Dependency-free Markdown renderer | -| `src/renderer/engine/engine.js` | Dual-target crossfade compositor, Auto-VJ, FX hook | -| `src/renderer/engine/colormaster.js` | 10 palettes, crossfaded | -| `src/renderer/engine/fxrack.js` | VJ-9000 effect decks, 36 parameters | -| `src/renderer/engine/scenes/*.js` | 14 scenes | -| `src/renderer/audio/audio.js` | Analysis, band split, beat detect, sources | -| `src/renderer/audio/sampler.js` | Pad kit playback | -| `src/renderer/audio/synth.js` + `wavetables.js` | Vital-class wavetable synth | -| `src/renderer/midi/midi.js`, `swaymap.js` | Sway-first MIDI, factory map | - -### Scenes and provenance — three licences in play - -| Scene | Origin | Licence | +| Gap | Detail | Fix path | |---|---|---| -| beams, swarm, ribbons, voxels, nebula, cymatic, warp, mandelbulb | original | project MIT | -| **ferrofluid** | theDAW `cymatics/sphere-shader.ts` | **Apache-2.0**, notice retained | -| **chladni** | theDAW `cymatics/cymatics-shader.ts` | **Apache-2.0**, notice retained | -| **valley** | theDAW `cymatics/landscape-shader.ts` | **Apache-2.0**, notice retained | -| **lattice** | theDAW `lib/quantumLattice.ts` | theDAW repo MIT | -| **vjshader** | VJ-9000 `shader/shaderPresets.ts` | **no licence upstream** | -| **spectra** | VJ-9000 `spectra/SpectraRenderer.ts` | **no licence upstream** | -| fxrack | VJ-9000 `components/VideoOutput.tsx` | **no licence upstream** | - -**`voxels.js` is the user's favourite and is off-limits.** Unmodified since 18:13 on the day it was written; keep it that way unless the user says otherwise. - -Licensing landmines: - -1. **VJ-9000 has no LICENCE file in any commit** — verified across full history. Default is all rights reserved. theDAW's README credits Daniel Joaquin Trujillo, matching this user's account, so it is their own work to reuse; a third party could not rely on that. Flagged to the user; a licence should be added before distribution. -2. **Apache-2.0 cymatics shaders** require the notice retained and changes stated. Every port's header does both — **do not strip them**. -3. **theDAW's LICENCE names "Stability AI"**, inherited from `stable-audio-tools`. Attribution to GANTASMO comes from the README. -4. **Audima's terms forbid redistributing their binaries.** The Doctor fetches from `cdn.audima.com.au` onto the user's machine and never bundles. Keep it that way. +| Loop seam is frame-quantized | `transport.update()` checks the loop boundary once per rAF call, so the seam lands up to one frame late; the audio sources themselves stop at the check, not on the audio clock. Accepted for v1. | Schedule the post-seam sources ahead on the context clock instead of rescheduling at the check (`src/renderer/audio/transport.js`, `update`). | +| Decoded-size pre-flight is an estimate | `projectstore.js` warns above ~600 MB and refuses above ~1.5 GB of estimated decoded size, assuming 48 kHz stereo Float32 — and only when a duration is already cached, so a first-time oversized file has no pre-flight and hits the decoder directly. | Persist duration on first add (already done via `addMedia`) and consider probing the container header for duration before decode. | +| Pad physical order unverified | `PAD_CELLS` in `src/renderer/ui/surface.js` maps screen cells to pad indices assuming bottom rows first, left cluster 0–7, right cluster 8–15. Not yet checked against the hardware. | One `K`-monitor session with the Sway; if wrong, reorder the `PAD_CELLS` entries — nothing else references the geometry. | +| Sway button CCs are learn-only | Audima has not published the eight buttons' CC numbers; slots ship empty (`cc: null`) until LEARN captures them. | Same monitor session could recover the factory numbers; add them to `swaymap.js` as defaults if stable. | +| Dual decode for shared media | A media file used by both the kit and the timeline decodes twice — the sampler decodes internally, the transport needs its own `AudioBuffer` (`projectstore.js`, `loadMediaAsync`). Rare enough that v1 does not share. | Give the sampler a `getBuffer(id)` accessor and hand the same buffer to the transport. | +| ~~Audio-lane clips cannot be selected on the band~~ | Fixed the day this note was written: `#tl-audio` now hit-tests pointerdown — click selects, drag moves, the trailing edge resizes, Delete/arrows apply (`src/renderer/ui/timeline.js`). Kept here because the fix landed after the docs pass. | — | -### Audio - -One analysis source at a time: any input device, Windows WASAPI loopback (`setDisplayMediaRequestHandler` with `audio: 'loopback'`), or a silent internal 120 BPM groove. Loopback is Windows-only; macOS/Linux need a virtual device and the UI says so. - -`synth.js` is a Vital-class wavetable synth — 3 oscillators over 7 generated spectral tables, unison, sub, noise, 2 filters, 3 envelopes, 4 LFOs, a 9 × 15 modulation matrix, 7 effects, 7 presets. It does **not** do Vital's spectral warp, sample/text-to-wavetable import, a wavetable editor, or full MPE. See [docs/SYNTH.md](docs/SYNTH.md). +--- -Sampler and synth both connect to speakers *and* analyser, so anything played drives the visuals. +## 5. Packaging hazard -### theDAW alignment already in place — preserve this +Hit twice before the redesign: source files edited **after** a build was cut, so the packaged installer did not contain the code that had just been reviewed. It was caught only by comparing file mtimes against the build timestamp. Before packaging: -- `synth.voiceTrigger()` returns theDAW's exact signature `(ctx, dest, midi, velocity, when, duration, master)`. Verified: arity 7, one voice during the note, zero after release. -- `synth.controlManifest()` returns theDAW's `VisualControl` shape, 84 entries. -- `fxrack.js` mirrors theDAW's audio-routing pattern: a parameter table with per-key ranges and band-driven modulation. -- All audio modules are factory functions taking `ctx` and a destination array. +1. Confirm no agent or watcher is still writing under `src/`. +2. Run `npm run build:renderer`, then verify nothing under `src/` is newer than `dist/renderer.bundle.js`. +3. Then `npm run dist:win` (or `dist:mac` / `dist:linux`). Output lands in `release/`; the Windows installer is one-click, per-user, `%LOCALAPPDATA%\Programs\akswayj`, silent with `/S`. -A module that cannot be lifted into theDAW unchanged is a divergence. +Docs are not bundled by the renderer build — `docs/**/*.md` and `README.md` are packaged as files — so documentation edits need only a repack, not a bundle. --- -## 5. Verifying work without a human +## 6. Verifying work without a human -No test suite. Verification drives the real app headlessly through env vars read in `src/main/main.js`. +No test suite. Verification drives the real app headlessly through env vars read in `src/main/main.js` ([docs/ENVIRONMENT.md](docs/ENVIRONMENT.md) has the full reference and working probe examples): | Variable | Effect | |---|---| | `AKSWAYJ_SHOT` | Capture the window to a PNG, then quit | | `AKSWAYJ_SHOT_DELAY` | ms before capture, default 5000 | -| `AKSWAYJ_AUTOPLAY` | Boot into a project id | -| `AKSWAYJ_SCENE` | Force a scene id. **Bypasses navigation — never use it to judge discoverability** | -| `AKSWAYJ_PROBE` | Run JS in the page 3000 ms after load; return value prints as `[probe] …` | - -`window.__akswayj` exposes `{ state, studio, openStudio, openDocs, renderPads, renderSamples }`; `state` carries `engine, midi, audio, sampler, synth, projects, screen`. - -```powershell -$env:ELECTRON_RUN_AS_NODE = '' # REQUIRED -$env:AKSWAYJ_SHOT = "$env:TEMP\x.png" -$env:AKSWAYJ_SHOT_DELAY = '12000' -$env:AKSWAYJ_PROBE = "(async () => JSON.stringify({ scenes: window.__akswayj.state.engine.sceneList.length }))()" -& "$env:LOCALAPPDATA\Programs\akswayj\AKSWAYJ.exe" 2>$null | Select-String 'probe' -``` +| `AKSWAYJ_WINDOW` | `WxH` initial window size, for narrow-layout screenshots | +| `AKSWAYJ_AUTOPLAY` | Template id **or** `.sway` file path; skips the SYSTEM modal | +| `AKSWAYJ_SCENE` | Force a scene id. Bypasses navigation — never use it to judge discoverability | +| `AKSWAYJ_PROBE` | Run JS in the page 3000 ms after load; result prints as `[probe] …` | -Four traps, all hit during this work: +`window.__akswayj` is the automation handle (shape in section 2). Standing traps, all hit during earlier work: -- **`ELECTRON_RUN_AS_NODE` is set in these shells.** Electron then starts as plain Node and dies with `Cannot read properties of undefined (reading 'whenReady')`. Clear it every launch. +- **`ELECTRON_RUN_AS_NODE` may be set in agent shells.** Electron then starts as plain Node and dies with `Cannot read properties of undefined (reading 'whenReady')`. Clear it every launch. - **`AKSWAYJ_SHOT_DELAY` must exceed the probe's own timers**, or the app quits mid-probe and prints nothing. -- **Env vars leak between runs.** Clear `AKSWAYJ_AUTOPLAY`/`AKSWAYJ_SCENE` explicitly. -- **Do not read the WebGL canvas by drawing it into a 2D canvas** — the renderer runs without `preserveDrawingBuffer` and it reads back pure black. Use `AKSWAYJ_SHOT` and measure the PNG; that is how the luminance table in section 2 was produced. - -Build: `npm run build:renderer`, then `npm run dist:win`. Output `release/AKSWAYJ-Setup-0.1.0.exe`, one-click, per-user, installs to `%LOCALAPPDATA%\Programs\akswayj`, silent with `/S`. +- **Env vars leak between runs.** Clear `AKSWAYJ_AUTOPLAY` / `AKSWAYJ_SCENE` explicitly. +- **Do not read the WebGL canvas by drawing it into a 2D canvas** — the renderer runs without `preserveDrawingBuffer` and reads back pure black. Use `AKSWAYJ_SHOT` and measure the PNG. --- -## 6. Known defects - -| Item | Detail | -|---|---| -| **No git repository** | Fix first | -| **Five scenes undiscoverable** | Section 2, Cause A. Not fixed | -| **Ferrofluid Orb near-invisible** | Section 2, Cause B. Not fixed | -| **Studio/Docs halt the renderer** | `app.js:953-957`. Blocks all live control | -| **Level meter renders 0 px wide** | Flex row collapse; also only updates on the Studio screen | -| **`engine.resize()` has no ResizeObserver** | Stage in a resizable cell will render stale | -| **3 dead CSS breakpoints** | All below `minWidth: 960` | -| **3 FX params silently unrendered** | `FX_DECKS` names `zoomPunch`, `slitScan`, `timeDisplace`; none has a `RANGES` entry, so they are skipped without warning. Two `RANGES` entries (`audioReactive`, `asciiAccent`) are exposed nowhere | -| **FX rack ships disabled** | `#fx-enable` is unchecked in `index.html` | -| **Docs stale at 8 scenes / 6 projects** | `SCENE_CONTRACT.md`, `ENGINE.md`, `ENVIRONMENT.md`, `PROJECTS.md` | -| **Help bar and overlay disagree** | `1–9` vs `1–8`, neither naming a scene | -| **MIDI-learn has no UI** | `midi.learn()` has no caller; `midiOverrides` is read at startup and hand-editable but never written | -| **Binaries unsigned** | SmartScreen warns; macOS un-notarized | -| **4 factory-map values unconfirmed** | Knob-press CCs, button defaults, pad channel 1 vs 16, MPE. **The Sway is now connected** — a `K` monitor session would settle them | -| **No automated tests** | Screenshot and probe only | - ---- - -## 7. Verified facts - -Measured against the installed build, 2026-08-19: +## 7. Standing constraints -- 14 scenes registered, all rendering at 60 fps on Intel HD 630 / Radeon Vega M -- 8 projects, 18 documents, 84-entry synth manifest, 36 FX parameters, 138 Studio controls -- Sway physically connected: port `Audima Labs The Sway`, manufacturer `STMicroelectronics`, Doctor reports `Connected (normal mode)`, DFU driver staged -- USB identity confirmed on hardware, matching the reverse-engineered values: VID `0x0483`, PID `0x52A4` -- Real viewport 1266 × 683 at dpr 1.5; installer 89.8 MB +- **`voxels.js` is the user's favourite scene and is off-limits** unless they say otherwise. +- **Apache-2.0 ports** (`ferrofluid`, `chladni`, `valley` — from theDAW's cymatics shaders) carry the required licence notice and statement of changes in their headers. Do not strip them. +- **VJ-9000 has no licence file upstream** (`vjshader`, `spectra`, the fx rack derive from it). It is the author's own work, but a licence should be added before third-party distribution. +- **Audima's terms forbid redistributing their binaries.** The Doctor fetches from `cdn.audima.com.au` onto the user's machine and never bundles. Keep it that way. +- **theDAW alignment**: the synth keeps theDAW's `voiceTrigger` signature and `controlManifest` shape; audio modules are factory functions taking `ctx` and a destination array. A module that cannot be lifted into theDAW unchanged is a divergence. diff --git a/README.md b/README.md index bb19092..7c5612d 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ # AKSWAYJ -AKSWAYJ 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: 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. +AKSWAYJ 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. AKSWAYJ 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. - Fourteen procedural visual scenes, each driven by audio analysis and gesture input, rendered with three.js on WebGL2. -- Eight preconfigured projects. Three are tuned to pair with Audima's official Ableton demo packs (Garage, DNB, Hip Hop). +- 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; eight bundled templates (three tuned to pair with Audima's official Ableton demo packs — Garage, DNB, Hip Hop) provide starting points. +- A timeline with an audio lane (waveform clips scheduled sample-accurately on the audio clock) and a visual lane (scene clips with per-clip cut or fade entry), plus loop region, locators, and scrubbing. +- 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, or momentary effect punches; knobs drive any engine, rack, synth, or kit 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, with per-control overrides applied from the settings file; any class-compliant MIDI controller drives the same controls. -- An in-application documentation viewer that renders the bundled Markdown documentation, including this file, without network access. +- 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 kit builder that maps stems and one-shots onto the Sway's sixteen pads; triggered samples are heard and also drive the visuals. +- 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 36-parameter effects rack (mirror, kaleidoscope, glitch, color, trails, ASCII, and more) applied to the composited frame when enabled. +- 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 @@ -47,41 +51,46 @@ Build-system details: [docs/BUILD.md](docs/BUILD.md). Environment variables and | Input | Effect | |---|---| -| Hand position over the Sway (XY) | Continuous steering of the active scene | -| Pulse / Press / Sway gestures | Brightness surge / compression / camera lean | -| Knobs 1–3 | Palette hue rotation, crossfade length, master intensity (reserved by the engine) | -| Knobs 4–8 | Scene-specific parameters | -| Pads 1–16 | Discrete scene events (beam bursts, grid ripples, ribbon jumps) | +| Hand position over the Sway (XY) | Continuous steering of the active scene; assignable to any parameter as modulation routes | +| Pulse / Press / Sway gestures | Brightness surge / compression / camera lean by default; each dimension holds any number of modulation routes | +| Knobs 1–8 | Assignable. Factory table: hue, crossfade length, intensity on 1–3; kit level, filter, rate, delay on 5–8 | +| Pads 1–16 | Assignable: sample, scene switch (cut or fade entry), or momentary effect punch; discrete scene events besides | +| Buttons 1–8 | Learned from the hardware, then toggle anything: rack, Auto-VJ, synth, transport | | Pad strike, or a sharp Press, in the Wormhole scene | Fires the hyperspace jump: charge, launch, cruise, and settle segments, 2.07 seconds in total | -| Mouse move / button / wheel | XY position / Press / Pulse when no Sway is bound | -| `1`–`9` | Select a scene from the active project’s pool (disables Auto-VJ) | +| 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) | +| `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 1–8 | +| `P` / `L` | Play or pause the timeline / toggle the loop | +| `Delete`, `←` `→` | Remove / nudge the selected timeline clip | +| `S` / `R` / `E` | Synth / rack / kit drawer | | `F` | Toggle fullscreen | -| `H` | Help overlay | +| `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) | -| `S` | Studio: audio source, kit builder, synth, effects rack | -| `D` | Documentation viewer | -| `Esc` | Close an overlay, leave the documentation viewer, or step back one screen | +| `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 | ## 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 Documentation button on the Doctor and project-picker screens, opens it. +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. | Document | Scope | |---|---| | [docs/INDEX.md](docs/INDEX.md) | Documentation map and reading order | -| [docs/OVERVIEW.md](docs/OVERVIEW.md) | System overview, terminology, component map | +| [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) | Audio source selection (including system-audio loopback) and the pad kit builder | -| [docs/MIDI.md](docs/MIDI.md) | Device detection, factory map, MIDI-learn, message routing | -| [docs/AUDIO.md](docs/AUDIO.md) | Analysis chain, band definitions, beat detection, internal groove | -| [docs/ENGINE.md](docs/ENGINE.md) | Render pipeline, crossfade compositor, Auto-VJ, ColorMaster | -| [docs/PROJECTS.md](docs/PROJECTS.md) | Project file format and the bundled projects | +| [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 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c64dc68..7f1b489 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -AKSWAYJ runs as a standard two-process Electron application: a main process with operating-system access and a context-isolated renderer that receives a fixed API surface through a preload script. This document covers the process model, the module inventory, the renderer screen flow, the complete IPC surface, renderer bundling, the security model, and the packaged file layout. System-level context and terminology: [OVERVIEW.md](OVERVIEW.md). Environment variables and file locations: [ENVIRONMENT.md](ENVIRONMENT.md). +AKSWAYJ runs as a standard two-process Electron application: a main process with operating-system access and a context-isolated renderer that receives a fixed API surface through a preload script. This document covers the process model, the module inventory, the renderer page structure, the complete IPC surface, renderer bundling, the security model, and the packaged file layout. System-level context and terminology: [OVERVIEW.md](OVERVIEW.md). Environment variables and file locations: [ENVIRONMENT.md](ENVIRONMENT.md). ## Process model @@ -8,7 +8,7 @@ AKSWAYJ runs as a standard two-process Electron application: a main process with |---|---|---| | Main | `src/main/main.js` | Window lifecycle, session permission handlers, IPC handlers, settings file, project loading, bundled-documentation access; delegates to `doctor.js`, `audima.js`, `driver-install.js` | | Preload | `src/preload/preload.js` | Exposes `window.akswayj` through `contextBridge`; the renderer has no other path to the main process | -| Renderer | `dist/renderer.bundle.js` (built from `src/renderer/app.js`) | Screens, HUD, MIDI input, audio analysis, WebGL rendering, documentation viewer | +| Renderer | `dist/renderer.bundle.js` (built from `src/renderer/app.js`) | The cockpit interface, MIDI input, audio analysis, sample/synth/timeline playback, WebGL rendering, documentation modal | The `BrowserWindow` is created with `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: false`. Window parameters: 1440 × 900 default size, 960 × 600 minimum, background color `#05060a`, menu bar auto-hidden, shown only after the `ready-to-show` event. The window loads `dist/index.html` from disk via `loadFile`, with optional `autoplay` and `scene` query parameters taken from the `AKSWAYJ_AUTOPLAY` and `AKSWAYJ_SCENE` environment variables (see [ENVIRONMENT.md](ENVIRONMENT.md)). @@ -24,18 +24,20 @@ On macOS, `activate` recreates the window when none exists; on every other platf | `src/main/driver-install.js` | Windows DFU driver staging via `pnputil.exe` under a user-approved UAC elevation | `installDfuDriver` | | `src/preload/preload.js` | `contextBridge` exposure of the IPC surface as `window.akswayj` | none (side effect) | | `src/shared/constants.js` | Sway USB identity, Audima endpoints and public key, application name | `SWAY`, `AUDIMA`, `APP` | -| `src/renderer/app.js` | Screen flow, Doctor UI, project picker, documentation viewer, HUD, keyboard and pointer input | none (bundle entry) | +| `src/renderer/app.js` | Cockpit assembly, Doctor UI, documentation modal, keyboard and pointer input | none (bundle entry) | | `src/renderer/markdown.js` | Dependency-free Markdown renderer for the documentation viewer, plus the heading-to-anchor slug rule | `renderMarkdown`, `slugify` | | `src/renderer/engine/engine.js` | Render pipeline and Auto-VJ | `createEngine` | | `src/renderer/engine/audio.js` | Audio analysis and the internal groove | `createAudioEngine` | | `src/renderer/midi/midi.js` | MIDI detection, routing, MIDI-learn overrides | `createMidi` | | `scripts/build-renderer.js` | esbuild renderer bundling into `dist/` | none (build script) | -## Screen flow in app.js +## Page structure in app.js -The renderer is a single HTML document with four `
            ` elements — `#screen-boot`, `#screen-projects`, `#screen-docs`, `#screen-perform` — toggled by the `active` class. The startup sequence in `main()`: fetch application info over `app:info`, create the MIDI layer, the audio engine, and the render engine (quality tier `med`), restore `midiOverrides` from the settings file, load the project list, wire event handlers, start audio (`autoStart`: live input when available, internal groove otherwise), then branch on the `autoplay` query parameter. +The renderer is a single HTML document — the cockpit — with no screen switching. A CSS grid (`#cockpit`) lays out the top bar, the two rails, the stage cell, the timeline band, and the Sway deck; the drawer (`#drawer`) and the modals (`#modal-system`, `#modal-help`, `#modal-docs`) overlay it, and the render loop never stops for any of them. Region-by-region description: [OVERVIEW.md](OVERVIEW.md#the-cockpit). -### Boot (Doctor) +The startup sequence in `main()`: fetch application info over `app:info`; create the audio engine, sampler, synth, and transport on one `AudioContext` (all three producers connect to the destination and the analyser); create the MIDI layer with the router as its event sink; create the render engine (quality tier `med`) and register the router as its frame hook; create the project store; assemble the UI modules (`src/renderer/ui/*.js`); restore `midiOverrides` from the settings file; expose `window.__akswayj`; start the engine and audio (`autoStart`: live input when available, internal groove otherwise); load the boot project (`autoplay` parameter, else the most recent project, else the `first-flight` template); then either open the blast door directly (`autoplay`) or open the SYSTEM modal and run the Doctor. + +### The SYSTEM modal (Doctor) `runDoctor()` runs the main-process checks (`doctor:run`) and three renderer-local checks in parallel, then renders the combined list: @@ -45,25 +47,21 @@ The renderer is a single HTML document with four `
            ` elements — `#scre | `midi` | WebMIDI availability and connected port (Sway, generic controller, or none) | `ok`, `warn` | | `audio` | Audio input enumeration | `ok`, `info` | -The aggregate status is `fail` if any check failed, otherwise `warn` if any check warned, otherwise `ok`; checks with status `info` do not raise the aggregate. When the aggregate is `ok` — every check reports `ok` or `info` — a 1400 ms timer advances to the project picker, provided the boot screen is still active. The auto-advance fires at most once per session (`state._autoAdvanced`). Manual paths exist regardless of status: the Enter button (enabled after every run) and the Re-run checks button. +The aggregate status is `fail` if any check failed, otherwise `warn` if any check warned, otherwise `ok`; checks with status `info` do not raise the aggregate. When the aggregate is `ok` — every check reports `ok` or `info` — a 1400 ms timer opens the blast door, provided the SYSTEM modal is still open. The auto-advance fires at most once per session (`state._autoAdvanced`). Manual paths exist regardless of status: the ENTER button (enabled after every run) and the RECHECK button. The modal is reachable later through the SYSTEM button in the CONTROLS modal. A fix button click invokes `doctor:fix`; progress events arrive on `fix:progress` and are written into the check's progress element. On a successful fix the button is removed and the full Doctor re-runs. -### Project picker - -Cards are rendered from `projects:list`; swatch colors are applied through the CSSOM rather than markup-inline style attributes. Click or the Enter key starts the performance; Escape returns to the boot screen. A Documentation button opens the documentation viewer. - -### Perform +### Input handling -`startPerformance(project)` loads the project into the engine, switches screens, and starts the render loop. Keyboard handling on this screen: digits `1`–`8` select a scene directly from `engine.sceneList` — the eight-entry registry in `src/renderer/engine/scenes/index.js`, independent of the project pool — and disable Auto-VJ, Space crossfades to another scene from the project pool, `A` toggles Auto-VJ, `F` toggles fullscreen, `H` toggles the help overlay, `K` toggles the MIDI monitor, `D` stops the engine and opens the documentation viewer, `Z X C V B N M ,` strike pads 1–8, and Escape closes an open overlay or stops the engine and returns to the picker. The MIDI monitor sits on `K` because `M` is the pad 7 key: the pad lookup is the final branch of the key handler, so `M` never reaches an overlay toggle. Pointer input feeds the control state whenever no Sway is driving: pointer position maps to XY, buttons to the Press gesture, the wheel to the Pulse gesture. The HUD (scene name, Auto-VJ state, input source, audio source, FPS) updates every animation frame. +One window-level key handler serves the whole page (key list: [README](../README.md#controls)); `Escape` peels the topmost layer — popover, drawer, modal, timeline selection, deck selection — and the synth tracker keys apply only while the SYNTH drawer is open. Pointer input on the stage feeds the control state whenever no Sway is driving: position maps to XY, buttons to the Press gesture, the wheel to the Pulse gesture. The MIDI monitor sits on `K` because `M` is the pad 7 key: the pad lookup is the final branch of the key handler, so `M` never reaches an overlay toggle. -With `?autoplay=`, the boot screen is bypassed: the performance starts immediately and the Doctor still runs in the background to populate its screen. +With `?autoplay=