feat(shapeseq): integrate ShapeSeq into synth mode, replace arpeggiator

- Remove Arpeggiator import and all arp references from a-app.js
- Add ShapeSeq imports (ShapeSeqEngine, StepVisualizer, ChainBuilderUI,
  getDefaultBus) and lazy initialization on first audio start
- In synth mode: show ShapeSeq container (circular viz + chain builder),
  hide flow-field particles. Visual mode unchanged.
- Route joystick or hand tracking inputs to shapeSeq.setSequenceInputs()
  each frame; render step viz at 60fps
- Add HTML: #shapeseq-container with #shapeseq-viz canvas and
  #shapeseq-chain wrapper, sequencer transport controls (play/stop,
  tempo, step count)
- Add CSS: full-screen flex layout, viz takes upper 45%, chain builder
  scrollable below, hidden by default
- Quick play button wires to ShapeSeq start/stop instead of arp

Closes meml-6ry. This completes ShapeSeq Phase 1.
This commit is contained in:
w1n5t0n 2026-03-24 02:17:32 +02:00
parent 027510a7e4
commit 505ed26f21
3 changed files with 161 additions and 70 deletions

View file

@ -21,7 +21,7 @@
<!-- Synth quick controls (play button + drawer, next to back button) --> <!-- Synth quick controls (play button + drawer, next to back button) -->
<div class="synth-quick-controls hidden" id="synth-quick-controls"> <div class="synth-quick-controls hidden" id="synth-quick-controls">
<button class="play-btn" id="quick-play" title="Start audio + arpeggiator"> <button class="play-btn" id="quick-play" title="Start audio + sequencer">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" id="quick-play-icon"><path d="M4 2l10 6-10 6z"/></svg> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" id="quick-play-icon"><path d="M4 2l10 6-10 6z"/></svg>
</button> </button>
<div class="play-drawer" id="play-drawer"> <div class="play-drawer" id="play-drawer">
@ -60,6 +60,12 @@
<!-- Synth visualization canvas --> <!-- Synth visualization canvas -->
<canvas id="synth-vis-canvas"></canvas> <canvas id="synth-vis-canvas"></canvas>
<!-- ShapeSeq UI (synth mode only) -->
<div class="shapeseq-container hidden" id="shapeseq-container">
<canvas id="shapeseq-viz"></canvas>
<div class="shapeseq-chain-wrap" id="shapeseq-chain"></div>
</div>
<!-- Heatmap strip (top) --> <!-- Heatmap strip (top) -->
<div class="heatmap-strip" id="heatmap-strip"> <div class="heatmap-strip" id="heatmap-strip">
<div class="heatmap-cells" id="heatmap-cells"></div> <div class="heatmap-cells" id="heatmap-cells"></div>
@ -151,29 +157,16 @@
<input type="range" id="synth-volume" min="0" max="1" step="0.01" value="0.5"> <input type="range" id="synth-volume" min="0" max="1" step="0.01" value="0.5">
</div> </div>
<div class="synth-row"> <div class="synth-row">
<label>Arpeggiator</label> <label>Sequencer</label>
<button class="action-btn" id="arp-toggle">Play</button> <button class="action-btn" id="seq-toggle">Play</button>
</div>
<div class="synth-row">
<label>Progression</label>
<select id="arp-progression">
<option value="I-vi-IV-V">I-vi-IV-V</option>
<option value="I-IV-vi-V">I-IV-vi-V</option>
<option value="i-VI-III-VII">i-VI-III-VII</option>
<option value="I-V-vi-IV">I-V-vi-IV</option>
</select>
</div> </div>
<div class="synth-row"> <div class="synth-row">
<label>Tempo <span id="tempo-val">120</span></label> <label>Tempo <span id="tempo-val">120</span></label>
<input type="range" id="arp-tempo" min="40" max="240" step="1" value="120"> <input type="range" id="seq-tempo" min="40" max="240" step="1" value="120">
</div> </div>
<div class="synth-row"> <div class="synth-row">
<label>Octaves <span id="octaves-val">2</span></label> <label>Steps <span id="steps-val">8</span></label>
<input type="range" id="arp-octaves" min="1" max="5" step="1" value="2"> <input type="range" id="seq-steps" min="1" max="32" step="1" value="8">
</div>
<div class="synth-row">
<label>Oct Offset <span id="offset-val">0</span></label>
<input type="range" id="arp-offset" min="-2" max="3" step="1" value="0">
</div> </div>
<div class="synth-row" id="midi-row" style="display:none"> <div class="synth-row" id="midi-row" style="display:none">
<label>MIDI Input</label> <label>MIDI Input</label>
@ -293,7 +286,7 @@
<li><strong>Mute (M)</strong> &mdash; removes the parameter from NISPS control; its bar disappears and you get a manual value slider instead</li> <li><strong>Mute (M)</strong> &mdash; removes the parameter from NISPS control; its bar disappears and you get a manual value slider instead</li>
</ul> </ul>
<li><strong>MIDI input</strong> &mdash; connect a MIDI controller and it will be detected automatically; MIDI notes trigger the synth directly</li> <li><strong>MIDI input</strong> &mdash; connect a MIDI controller and it will be detected automatically; MIDI notes trigger the synth directly</li>
<li><strong>Arpeggiator</strong> &mdash; expand the bottom bar to access the built-in arpeggiator with tempo, octave range, offset, and chord progression controls; press Pause to stop it</li> <li><strong>ShapeSeq</strong> &mdash; in synth mode, the sequencer UI appears: a circular step visualizer shows the current pattern; the chain builder below lets you add/remove/reorder algorithmic primitives; press Play to start the generative sequencer</li>
</ul> </ul>
</div> </div>

View file

@ -98,6 +98,35 @@ html, body {
display: none; display: none;
} }
/* ---- ShapeSeq container (synth mode) ---- */
.shapeseq-container {
position: fixed;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
background: #0d0d0d;
overflow: hidden;
}
.shapeseq-container.hidden {
display: none;
}
#shapeseq-viz {
width: 100%;
flex: 0 0 45%;
min-height: 0;
}
.shapeseq-chain-wrap {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding-bottom: calc(120px + var(--safe-bottom, 0px));
}
/* ---- Glass mixin ---- */ /* ---- Glass mixin ---- */
.glass { .glass {
background: var(--glass-bg); background: var(--glass-bg);

View file

@ -4,7 +4,10 @@
import { WasmIML } from './nisps/nisps-wasm.js'; import { WasmIML } from './nisps/nisps-wasm.js';
import { FlowFieldVisualizer } from './ui/visualizer.js'; import { FlowFieldVisualizer } from './ui/visualizer.js';
import { C15Bridge } from './synth/c15-bridge.js'; import { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js'; import { ShapeSeqEngine } from './shapeseq/sequencer.js';
import { StepVisualizer } from './shapeseq/step-viz.js';
import { ChainBuilderUI } from './shapeseq/chain-ui.js';
import { getDefaultBus } from './shapeseq/event-bus.js';
import { MIDIInput } from './synth/midi-input.js'; import { MIDIInput } from './synth/midi-input.js';
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js'; import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js';
import { GamepadInput } from './ui/gamepad.js'; import { GamepadInput } from './ui/gamepad.js';
@ -73,7 +76,9 @@ let handTracker = null;
let visualizer; let visualizer;
let synthVisualizer; let synthVisualizer;
let c15 = null; let c15 = null;
let arpeggiator = null; let shapeSeq = null; // ShapeSeqEngine instance
let stepViz = null; // StepVisualizer
let chainUI = null; // ChainBuilderUI
let midiInput = null; let midiInput = null;
let outputMode = 'visual'; let outputMode = 'visual';
@ -703,7 +708,9 @@ async function init() {
if (el) el.textContent = msg; if (el) el.textContent = msg;
}; };
c15.loadParams(); c15.loadParams();
arpeggiator = new Arpeggiator(c15);
// ShapeSeq is initialized lazily when audio starts (needs AudioContext)
midiInput = new MIDIInput(c15); midiInput = new MIDIInput(c15);
initMIDIControls(); initMIDIControls();
@ -752,6 +759,7 @@ async function init() {
wireQuickPlayControls(); wireQuickPlayControls();
wireGroupDrawer(); wireGroupDrawer();
wireHelp(); wireHelp();
wireShapeSeqControls();
// Resize // Resize
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
@ -1301,6 +1309,7 @@ function setOutputMode(mode) {
const heatmapStrip = document.getElementById('heatmap-strip'); const heatmapStrip = document.getElementById('heatmap-strip');
const synthQuickControls = document.getElementById('synth-quick-controls'); const synthQuickControls = document.getElementById('synth-quick-controls');
const shapeseqContainer = document.getElementById('shapeseq-container');
if (mode === 'synth') { if (mode === 'synth') {
$synthPanel.classList.remove('hidden'); $synthPanel.classList.remove('hidden');
@ -1309,6 +1318,16 @@ function setOutputMode(mode) {
heatmapStrip.classList.add('hidden'); heatmapStrip.classList.add('hidden');
synthQuickControls.classList.remove('hidden'); synthQuickControls.classList.remove('hidden');
synthVisualizer.enableInteraction(true); synthVisualizer.enableInteraction(true);
// Show ShapeSeq UI
if (shapeseqContainer) shapeseqContainer.classList.remove('hidden');
// Resize step viz canvas to fit its container
if (stepViz) {
const vizCanvas = document.getElementById('shapeseq-viz');
if (vizCanvas) {
const rect = vizCanvas.getBoundingClientRect();
stepViz.resize(rect.width, rect.height);
}
}
// Pulse play button if audio not yet started // Pulse play button if audio not yet started
const qp = document.getElementById('quick-play'); const qp = document.getElementById('quick-play');
if (qp) qp.classList.toggle('audio-needs-init', !(c15 && c15.running)); if (qp) qp.classList.toggle('audio-needs-init', !(c15 && c15.running));
@ -1319,6 +1338,8 @@ function setOutputMode(mode) {
heatmapStrip.classList.remove('hidden'); heatmapStrip.classList.remove('hidden');
synthQuickControls.classList.add('hidden'); synthQuickControls.classList.add('hidden');
synthVisualizer.enableInteraction(false); synthVisualizer.enableInteraction(false);
// Hide ShapeSeq UI
if (shapeseqContainer) shapeseqContainer.classList.add('hidden');
} }
routeOutputs(iml.getOutputs()); routeOutputs(iml.getOutputs());
@ -1567,26 +1588,47 @@ function syncRawParamsFromOutputs(outputs) {
}); });
} }
// ---- ShapeSeq lazy init (needs AudioContext from C15) ----
async function ensureShapeSeqInit() {
if (shapeSeq) return; // already initialized
if (!c15 || !c15.audioContext) return; // no audio context yet
const eventBus = getDefaultBus(c15.audioContext);
shapeSeq = new ShapeSeqEngine({ audioContext: c15.audioContext, eventBus, c15Bridge: c15 });
await shapeSeq.init();
// Wire up StepVisualizer and ChainBuilderUI now that engine exists
const seqVizCanvas = document.getElementById('shapeseq-viz');
const seqChainContainer = document.getElementById('shapeseq-chain');
if (seqVizCanvas && !stepViz) {
stepViz = new StepVisualizer({ canvas: seqVizCanvas, eventBus });
const rect = seqVizCanvas.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) stepViz.resize(rect.width, rect.height);
}
if (seqChainContainer && !chainUI) {
chainUI = new ChainBuilderUI({ container: seqChainContainer, chain: shapeSeq.getChain(), eventBus });
}
console.log('[NISPS] ShapeSeq initialized');
}
// ---- Synth controls ---- // ---- Synth controls ----
function wireSynthControls() { function wireSynthControls() {
const startBtn = document.getElementById('synth-start'); const startBtn = document.getElementById('synth-start');
const volumeSlider = document.getElementById('synth-volume'); const volumeSlider = document.getElementById('synth-volume');
const arpToggle = document.getElementById('arp-toggle');
const arpProgression = document.getElementById('arp-progression');
const arpTempo = document.getElementById('arp-tempo');
const arpOctaves = document.getElementById('arp-octaves');
const arpOffset = document.getElementById('arp-offset');
startBtn.addEventListener('click', async () => { startBtn.addEventListener('click', async () => {
const quickPlay = document.getElementById('quick-play'); const quickPlay = document.getElementById('quick-play');
if (c15.running) { if (c15.running) {
arpeggiator.stop(); if (shapeSeq) shapeSeq.stop();
arpToggle.textContent = 'Play'; const seqToggle = document.getElementById('seq-toggle');
if (seqToggle) seqToggle.textContent = 'Play';
await c15.stop(); await c15.stop();
startBtn.textContent = 'Start Audio'; startBtn.textContent = 'Start Audio';
if (quickPlay) quickPlay.classList.add('audio-needs-init'); if (quickPlay) quickPlay.classList.add('audio-needs-init');
} else { } else {
await c15.start(); await c15.start();
await ensureShapeSeqInit();
startBtn.textContent = 'Stop Audio'; startBtn.textContent = 'Stop Audio';
if (quickPlay) quickPlay.classList.remove('audio-needs-init'); if (quickPlay) quickPlay.classList.remove('audio-needs-init');
routeOutputs(iml.getOutputs()); routeOutputs(iml.getOutputs());
@ -1599,43 +1641,49 @@ function wireSynthControls() {
if (quickVol) quickVol.value = e.target.value; if (quickVol) quickVol.value = e.target.value;
}); });
arpToggle.addEventListener('click', () => { }
// ---- ShapeSeq controls (bottom sheet) ----
function wireShapeSeqControls() {
const seqToggle = document.getElementById('seq-toggle');
const seqTempo = document.getElementById('seq-tempo');
const seqSteps = document.getElementById('seq-steps');
if (seqToggle) {
seqToggle.addEventListener('click', async () => {
if (!c15.running) return; if (!c15.running) return;
if (arpeggiator.playing) { await ensureShapeSeqInit();
arpeggiator.stop(); if (!shapeSeq) return;
arpToggle.textContent = 'Play'; if (shapeSeq.isPlaying) {
if (shapeSeq) shapeSeq.stop();
seqToggle.textContent = 'Play';
} else { } else {
arpeggiator.start(); shapeSeq.start();
arpToggle.textContent = 'Stop'; seqToggle.textContent = 'Stop';
} }
}); });
}
arpProgression.addEventListener('change', (e) => { if (seqTempo) {
arpeggiator.progression = e.target.value; seqTempo.addEventListener('input', (e) => {
});
arpTempo.addEventListener('input', (e) => {
const val = parseInt(e.target.value); const val = parseInt(e.target.value);
arpeggiator.bpm = val; if (shapeSeq) shapeSeq.setTempo(val);
document.getElementById('tempo-val').textContent = val; document.getElementById('tempo-val').textContent = val;
const quickBpm = document.getElementById('quick-bpm'); const quickBpm = document.getElementById('quick-bpm');
const quickBpmVal = document.getElementById('quick-bpm-val'); const quickBpmVal = document.getElementById('quick-bpm-val');
if (quickBpm) quickBpm.value = val; if (quickBpm) quickBpm.value = val;
if (quickBpmVal) quickBpmVal.textContent = val; if (quickBpmVal) quickBpmVal.textContent = val;
}); });
}
arpOctaves.addEventListener('input', (e) => { if (seqSteps) {
seqSteps.addEventListener('input', (e) => {
const val = parseInt(e.target.value); const val = parseInt(e.target.value);
arpeggiator.octaves = val; if (shapeSeq) shapeSeq.setStepCount(val);
document.getElementById('octaves-val').textContent = val; document.getElementById('steps-val').textContent = val;
});
arpOffset.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
arpeggiator.octaveOffset = val;
document.getElementById('offset-val').textContent = val;
}); });
} }
}
// ---- MIDI Input ---- // ---- MIDI Input ----
async function initMIDIControls() { async function initMIDIControls() {
@ -1778,22 +1826,23 @@ function wireQuickPlayControls() {
quickPlay.addEventListener('click', async () => { quickPlay.addEventListener('click', async () => {
if (c15.running) { if (c15.running) {
arpeggiator.stop(); if (shapeSeq) shapeSeq.stop();
await c15.stop(); await c15.stop();
// Also update the bottom sheet controls // Also update the bottom sheet controls
const startBtn = document.getElementById('synth-start'); const startBtn = document.getElementById('synth-start');
const arpToggle = document.getElementById('arp-toggle'); const seqToggle = document.getElementById('seq-toggle');
if (startBtn) startBtn.textContent = 'Start Audio'; if (startBtn) startBtn.textContent = 'Start Audio';
if (arpToggle) arpToggle.textContent = 'Play'; if (seqToggle) seqToggle.textContent = 'Play';
} else { } else {
await c15.start(); await c15.start();
arpeggiator.start(); await ensureShapeSeqInit();
if (shapeSeq) shapeSeq.start();
routeOutputs(iml.getOutputs()); routeOutputs(iml.getOutputs());
// Also update the bottom sheet controls // Also update the bottom sheet controls
const startBtn = document.getElementById('synth-start'); const startBtn = document.getElementById('synth-start');
const arpToggle = document.getElementById('arp-toggle'); const seqToggle = document.getElementById('seq-toggle');
if (startBtn) startBtn.textContent = 'Stop Audio'; if (startBtn) startBtn.textContent = 'Stop Audio';
if (arpToggle) arpToggle.textContent = 'Stop'; if (seqToggle) seqToggle.textContent = 'Stop';
} }
updatePlayIcon(); updatePlayIcon();
}); });
@ -1807,10 +1856,10 @@ function wireQuickPlayControls() {
quickBpm.addEventListener('input', (e) => { quickBpm.addEventListener('input', (e) => {
const val = parseInt(e.target.value); const val = parseInt(e.target.value);
arpeggiator.bpm = val; if (shapeSeq) shapeSeq.setTempo(val);
quickBpmVal.textContent = val; quickBpmVal.textContent = val;
// Sync with bottom sheet tempo slider // Sync with bottom sheet tempo slider
const sheetTempo = document.getElementById('arp-tempo'); const sheetTempo = document.getElementById('seq-tempo');
const sheetTempoVal = document.getElementById('tempo-val'); const sheetTempoVal = document.getElementById('tempo-val');
if (sheetTempo) sheetTempo.value = val; if (sheetTempo) sheetTempo.value = val;
if (sheetTempoVal) sheetTempoVal.textContent = val; if (sheetTempoVal) sheetTempoVal.textContent = val;
@ -2142,6 +2191,17 @@ function onResize() {
visualizer.initParticles(); visualizer.initParticles();
synthVisualizer.resize(); synthVisualizer.resize();
// Resize ShapeSeq step viz if visible
if (stepViz && outputMode === 'synth') {
const vizCanvas = document.getElementById('shapeseq-viz');
if (vizCanvas) {
const rect = vizCanvas.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
stepViz.resize(rect.width, rect.height);
}
}
}
// Resize joy-map canvas to match container // Resize joy-map canvas to match container
const size = $joystickContainer.offsetWidth; const size = $joystickContainer.offsetWidth;
if (size > 0) { if (size > 0) {
@ -2175,6 +2235,15 @@ function animate() {
if (gamepad) gamepad.poll(); if (gamepad) gamepad.poll();
if (outputMode === 'synth') { if (outputMode === 'synth') {
synthVisualizer.draw(); synthVisualizer.draw();
// Route inputs to ShapeSeq and render step viz
if (shapeSeq && shapeSeq.isPlaying) {
if (inputMode === 'hands' && handTracker && handTracker.active && handTracker.features) {
shapeSeq.setSequenceInputs([handTracker.features[0], handTracker.features[1]]);
} else {
shapeSeq.setSequenceInputs([joyX, joyY]);
}
}
if (stepViz) stepViz.render();
} else { } else {
visualizer.draw(); visualizer.draw();
} }