diff --git a/software/p5js/README.md b/software/p5js/README.md new file mode 100644 index 0000000..1a850df --- /dev/null +++ b/software/p5js/README.md @@ -0,0 +1,50 @@ +# StringField p5.js Gesture Debugger + +This sketch is a loud, opinionated teaching demo: *data in → motion out*. It +turns StringField gesture JSON into giant state labels, velocity vectors, and a +noisy “projector” layer so the room can see the signal breathe. + +If you’ve got hardware, this is your oscilloscope-with-attitude. If you don’t, +smash the keyboard shortcuts and still get the full show. + +## Browser + hardware requirements + +WebSerial is the gatekeeper. That means **Chrome, Edge, or any Chromium-based +browser**. Firefox and Safari still don’t speak WebSerial (as of today), so the +button will politely sulk. + +You also need: + +- A secure context: `localhost` or `https://` (no `file://` — browsers hate + that). +- A device that emits JSON lines over serial at **115200 baud**. + +## Connect to the device (exact steps) + +1. **Serve the sketch** from a local web server. + - Example: from the repo root run `python3 -m http.server 8000`, then visit + `http://localhost:8000/software/p5js/`. +2. **Open the sketch** in Chrome/Edge. +3. **Click “Connect Serial.”** +4. **Choose your device** in the browser’s port picker. +5. **Watch the status readout** flip to `serial: connected` and the visuals come + alive. + +To disconnect, click the same button again (or yank the cable — the sketch will +announce the drama). + +## No hardware? Still party. + +Keyboard shortcuts are *intentionally* kept alive for classrooms and demos: + +- `P` → pluck +- `S` → scrape +- `B` → bow +- `R` → release +- `H` → harmonic +- `M` → mute +- `T` → tremolo +- `V` → vibrato + +These trigger the same rendering pipeline as real serial messages, so your +teaching flow doesn’t depend on cables behaving. diff --git a/software/p5js/sketch.js b/software/p5js/sketch.js index a90565e..4c8d783 100644 --- a/software/p5js/sketch.js +++ b/software/p5js/sketch.js @@ -6,6 +6,13 @@ let gestureState = 'idle'; let velocity = 0; let stateHistory = []; let noiseLayer; +let serialPort; +let serialReader; +let serialBuffer = ''; +let serialStatus = 'serial: idle'; +let serialStatusSpan; +let serialButton; +let serialHud; // -- Lifecycle ----------------------------------------------------------------- // setup() runs once when the sketch boots. Treat it like lab warm-up: lay out @@ -16,7 +23,10 @@ function setup() { textFont('monospace'); noiseLayer = createGraphics(width, height); noiseLayer.clear(); - // TODO: hook up navigator.serial or WebMIDI; call ingestSerialLine(line) per message. + // WebSerial hookup: minimal overlay + reader loop. The UI stays visible so + // you can teach the "click → pick port → data flows" story in real time. + setupSerialHud(); + setupSerialListeners(); } // draw() fires 60 times per second. Every call repaints the entire scene so the @@ -125,6 +135,153 @@ function registerState(state, vel) { } } +// --------------------------------------------------------------------------- +// WebSerial helpers. These are intentionally chatty so the sketch doubles as a +// teaching tool about how browser serial works. Punk-rock transparency > magic. + +function setupSerialHud() { + serialHud = createDiv(); + serialHud.style('position', 'absolute'); + serialHud.style('top', '16px'); + serialHud.style('right', '16px'); + serialHud.style('padding', '12px 16px'); + serialHud.style('background', 'rgba(5, 5, 20, 0.72)'); + serialHud.style('border', '1px solid rgba(255, 255, 255, 0.2)'); + serialHud.style('border-radius', '10px'); + serialHud.style('color', '#f5f1ff'); + serialHud.style('font-family', 'monospace'); + serialHud.style('font-size', '14px'); + serialHud.style('backdrop-filter', 'blur(4px)'); + serialHud.style('z-index', '10'); + + serialButton = createButton('Connect Serial'); + serialButton.parent(serialHud); + serialButton.style('margin-right', '12px'); + serialButton.style('padding', '6px 12px'); + serialButton.style('border', '1px solid rgba(255, 255, 255, 0.4)'); + serialButton.style('background', 'rgba(255, 90, 90, 0.2)'); + serialButton.style('color', '#fff'); + serialButton.style('cursor', 'pointer'); + serialButton.mousePressed(toggleSerialConnection); + + serialStatusSpan = createSpan(serialStatus); + serialStatusSpan.parent(serialHud); + + if (!('serial' in navigator)) { + serialStatus = 'serial: unsupported (use Chrome/Edge)'; + updateSerialStatus(); + serialButton.attribute('disabled', true); + serialButton.style('opacity', '0.4'); + serialButton.style('cursor', 'not-allowed'); + } +} + +function setupSerialListeners() { + if (!('serial' in navigator)) return; + navigator.serial.addEventListener('disconnect', (event) => { + if (event.target === serialPort) { + disconnectSerial('serial: cable yanked'); + } + }); +} + +function updateSerialStatus() { + if (serialStatusSpan) { + serialStatusSpan.html(serialStatus); + } +} + +async function toggleSerialConnection() { + if (serialPort) { + await disconnectSerial('serial: disconnected'); + return; + } + await connectSerial(); +} + +async function connectSerial() { + if (!('serial' in navigator)) return; + try { + serialStatus = 'serial: requesting port...'; + updateSerialStatus(); + serialPort = await navigator.serial.requestPort(); + await serialPort.open({ baudRate: 115200 }); + serialStatus = 'serial: connected'; + updateSerialStatus(); + serialButton.html('Disconnect Serial'); + startSerialReadLoop(); + } catch (err) { + serialStatus = 'serial: connection cancelled'; + updateSerialStatus(); + serialPort = null; + } +} + +async function disconnectSerial(reason) { + serialStatus = reason || 'serial: disconnected'; + updateSerialStatus(); + if (serialReader) { + try { + await serialReader.cancel(); + } catch (err) { + // ignore: cancelling a closed reader is fine + } + serialReader.releaseLock(); + serialReader = null; + } + if (serialPort) { + try { + await serialPort.close(); + } catch (err) { + // ignore: unplugged or already closed + } + serialPort = null; + } + serialBuffer = ''; + if (serialButton) { + serialButton.html('Connect Serial'); + } +} + +async function startSerialReadLoop() { + if (!serialPort?.readable) { + serialStatus = 'serial: no readable stream'; + updateSerialStatus(); + return; + } + const decoder = new TextDecoderStream(); + const readableClosed = serialPort.readable.pipeTo(decoder.writable); + serialReader = decoder.readable.getReader(); + + try { + while (true) { + const { value, done } = await serialReader.read(); + if (done) break; + serialBuffer += value; + const lines = serialBuffer.split('\n'); + serialBuffer = lines.pop(); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + ingestSerialLine(trimmed); + } + } + } + } catch (err) { + serialStatus = 'serial: read error'; + updateSerialStatus(); + } finally { + try { + await readableClosed; + } catch (err) { + // ignore pipe closure errors; we already handled status updates + } + if (serialPort) { + disconnectSerial('serial: stream closed'); + } + } +} + // Called whenever WebSerial/WebMIDI hands us a full line of JSON. We parse it, // shrug off errors (bad packets shouldn't crash the workshop), and hand the // result to the handler below.