From c8015da35acc3fd973b4b149cc92f6e2e57cb37c Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Tue, 24 Mar 2026 02:02:01 +0200 Subject: [PATCH] feat(shapeseq): implement Layer 1 modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four modules that build on the Layer 0 foundation: - primitive.js: base class with category system (generator/processor/ timing/converter), param schema validation, boundary enforcement (clamp/wrap/scaled), symbolic process() interface, state management for freeze, and applyBoundary() utility for delta control - clock.js: AudioContext lookahead scheduling (25ms interval, 100ms window) replacing setTimeout arpeggiator. Reads pattern descriptions for per-step swing (timeOffset) and ratchet (subdivisions). Emits seq.noteOn/noteOff/step/loopStart via event bus. - projection.js: composable transform chain with 5 transforms (VelocityCurve, GateThreshold, RangeMap, OctaveFolder, StutterMap) and 3 presets (expressive, percussive, fullRange). Pitch quantization deliberately excluded (handled by Interval Lock primitive). - step-viz.js: Canvas2D circular step visualizer with even angular spacing for any step count. Pitch→radius, velocity→node size, accent→color. 60fps-friendly with pre-allocated coordinate buffers. Tap interaction for step toggling. --- playground/js/shapeseq/clock.js | 264 +++++++++++++++++++++ playground/js/shapeseq/primitive.js | 246 ++++++++++++++++++++ playground/js/shapeseq/projection.js | 314 +++++++++++++++++++++++++ playground/js/shapeseq/step-viz.js | 328 +++++++++++++++++++++++++++ 4 files changed, 1152 insertions(+) create mode 100644 playground/js/shapeseq/clock.js create mode 100644 playground/js/shapeseq/primitive.js create mode 100644 playground/js/shapeseq/projection.js create mode 100644 playground/js/shapeseq/step-viz.js diff --git a/playground/js/shapeseq/clock.js b/playground/js/shapeseq/clock.js new file mode 100644 index 0000000..b61d63a --- /dev/null +++ b/playground/js/shapeseq/clock.js @@ -0,0 +1,264 @@ +/** + * ShapeSeq Clock Engine + * + * AudioContext-based lookahead scheduler that steps through a pattern + * description, emitting seq.* events on the event bus with sample-accurate + * timing. Replaces the old setTimeout-based arpeggiator approach. + * + * Uses the standard Web Audio lookahead pattern: + * - setInterval (~25 ms) checks if events need scheduling in the next ~100 ms + * - Events scheduled via AudioContext.currentTime for sample-accurate timing + * - Visual updates decoupled from audio timing + * + * @module shapeseq/clock + */ + +import { SEQ } from './event-bus.js'; +import { validatePattern } from './pattern.js'; + +// ── Scheduling constants ────────────────────────────────────────────── + +const LOOKAHEAD_MS = 25; // how often the timer fires (ms) +const SCHEDULE_AHEAD = 0.1; // how far ahead to schedule (seconds) + +const SUBDIVISION_VEL_SCALE = 0.8; // velocity multiplier for ratchet hits + +// ── ClockEngine ─────────────────────────────────────────────────────── + +export class ClockEngine { + /** + * @param {AudioContext} audioContext + * @param {import('./event-bus.js').EventBus} [eventBus] + */ + constructor(audioContext, eventBus) { + if (!audioContext) { + throw new TypeError('ClockEngine requires an AudioContext'); + } + + this._ctx = audioContext; + this._bus = eventBus ?? null; + this._bpm = 120; + this._playing = false; + + // Pattern state + this._pattern = null; // current pattern description + this._currentStep = 0; + this._nextNoteTime = 0; // AudioContext time of the next step + + // Scheduler handle + this._timerId = null; + + // Direct callback listeners (besides the event bus) + this._callbacks = []; + + // Track the last scheduled noteOn so we can emit noteOff before the next + this._lastNote = null; // { stepIndex, time, pitch, velocity } + } + + // ── Public properties ─────────────────────────────────────────────── + + get bpm() { return this._bpm; } + set bpm(v) { this._bpm = Math.max(1, +v || 120); } + + get playing() { return this._playing; } + + // ── Public API ────────────────────────────────────────────────────── + + /** + * Start the clock. Begins scheduling from step 0 (or the current step + * if a pattern was hot-swapped while stopped). + */ + start() { + if (this._playing) return; + if (!this._pattern) return; // nothing to play + + this._playing = true; + this._currentStep = 0; + this._nextNoteTime = this._ctx.currentTime + 0.05; // tiny lead-in + this._lastNote = null; + + this._timerId = setInterval(() => this._scheduler(), LOOKAHEAD_MS); + } + + /** Stop the clock and send a final noteOff for the last sounding note. */ + stop() { + if (!this._playing) return; + this._playing = false; + + if (this._timerId !== null) { + clearInterval(this._timerId); + this._timerId = null; + } + + // Release lingering note + if (this._lastNote) { + this._emit(SEQ.NOTE_OFF, { + stepIndex: this._lastNote.stepIndex, + time: this._ctx.currentTime, + pitch: this._lastNote.pitch, + velocity: 0, + }); + this._lastNote = null; + } + } + + /** + * Update tempo. Takes effect on the next scheduled step. + * @param {number} bpm + */ + setTempo(bpm) { + this.bpm = bpm; + } + + /** + * Provide (or replace) the pattern the clock steps through. + * Safe to call while playing — the clock picks up the new pattern + * on the next scheduling pass. + * + * @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc + */ + schedulePattern(patternDesc) { + if (!validatePattern(patternDesc)) { + throw new TypeError('Invalid pattern description'); + } + this._pattern = patternDesc; + + // If the current step is beyond the new pattern's length, wrap it + if (this._currentStep >= patternDesc.stepCount) { + this._currentStep = 0; + } + } + + /** + * Register a direct callback that fires for every scheduled event. + * @param {function} callback — receives (eventName, data) + */ + onEvent(callback) { + if (typeof callback === 'function') { + this._callbacks.push(callback); + } + } + + /** + * Return the current step index (useful for visualization sync). + * @returns {number} + */ + getCurrentStep() { + return this._currentStep; + } + + // ── Internals ─────────────────────────────────────────────────────── + + /** + * The core lookahead scheduler. Called every LOOKAHEAD_MS, it walks + * forward through the pattern scheduling any steps whose time falls + * within the lookahead window. + */ + _scheduler() { + if (!this._pattern) return; + + const deadline = this._ctx.currentTime + SCHEDULE_AHEAD; + + while (this._nextNoteTime < deadline) { + this._scheduleStep(this._currentStep, this._nextNoteTime); + this._advance(); + } + } + + /** + * Schedule all events for a single step (including subdivisions). + * + * @param {number} stepIndex + * @param {number} baseTime - AudioContext time for this step + */ + _scheduleStep(stepIndex, baseTime) { + const step = this._pattern.steps[stepIndex]; + const stepDuration = this._stepDuration(); + + // Apply swing / timeOffset: shift the step forward or back within + // [-0.5, 0.5] of one step's duration. + const offsetTime = baseTime + step.timeOffset * stepDuration; + + // Detect loop wraparound + if (stepIndex === 0) { + this._emit(SEQ.LOOP_START, { stepIndex: 0, time: offsetTime }); + } + + // Visual step event (always fires, even for rests) + this._emit(SEQ.STEP, { stepIndex, time: offsetTime }); + + // If this step has no trigger, we're done (rest) + if (!step.trigger) return; + + const subs = step.subdivisions; + + if (subs <= 1) { + // Single hit + this._scheduleNote(stepIndex, offsetTime, step.pitch, step.velocity, step.accent, false); + } else { + // Ratchet: evenly divide this step's duration + const subDur = stepDuration / subs; + for (let s = 0; s < subs; s++) { + const t = offsetTime + s * subDur; + const vel = s === 0 ? step.velocity : step.velocity * SUBDIVISION_VEL_SCALE; + this._scheduleNote(stepIndex, t, step.pitch, vel, step.accent, s > 0); + } + } + } + + /** + * Schedule a single noteOn (with preceding noteOff for the previous note). + */ + _scheduleNote(stepIndex, time, pitch, velocity, accent, isSubdivision) { + // NoteOff for previous note + if (this._lastNote) { + this._emit(SEQ.NOTE_OFF, { + stepIndex: this._lastNote.stepIndex, + time, + pitch: this._lastNote.pitch, + velocity: 0, + }); + } + + const noteData = { stepIndex, time, pitch, velocity, accent, isSubdivision }; + this._emit(SEQ.NOTE_ON, noteData); + this._lastNote = { stepIndex, time, pitch, velocity }; + } + + /** + * Advance the current step and compute the time for the next step. + */ + _advance() { + this._nextNoteTime += this._stepDuration(); + this._currentStep += 1; + + if (this._currentStep >= this._pattern.stepCount) { + this._currentStep = 0; + } + } + + /** + * Duration of one step in seconds at the current BPM. + * One beat = one step (quarter-note grid). + * @returns {number} + */ + _stepDuration() { + return 60 / this._bpm; + } + + // ── Event emission ────────────────────────────────────────────────── + + /** + * Emit an event to both the event bus and any direct callbacks. + * @param {string} eventName + * @param {Object} data + */ + _emit(eventName, data) { + if (this._bus) { + this._bus.emit(eventName, data); + } + for (let i = 0; i < this._callbacks.length; i++) { + this._callbacks[i](eventName, data); + } + } +} diff --git a/playground/js/shapeseq/primitive.js b/playground/js/shapeseq/primitive.js new file mode 100644 index 0000000..ebec5b6 --- /dev/null +++ b/playground/js/shapeseq/primitive.js @@ -0,0 +1,246 @@ +/** + * ShapeSeq Primitive Base Class and Param Schema System + * + * Base class for all sequencing primitives (generators, processors, + * timing modifiers, converters). Defines the param schema format, + * symbolic process() interface, and state management for freeze support. + * + * Port-ready: explicit state, no closures, typed arrays where possible. + * + * @module shapeseq/primitive + */ + +// ── Valid primitive categories ────────────────────────────────────── + +export const CATEGORIES = Object.freeze([ + 'generator', + 'processor', + 'timing', + 'converter', +]); + +// ── Param schema defaults ─────────────────────────────────────────── + +const DEFAULT_SCALED_RANGE = 0.3; + +// ── Boundary enforcement helpers ──────────────────────────────────── + +/** + * Clamp a value to [0, 1]. + * @param {number} v + * @returns {number} + */ +function clamp01(v) { + return v < 0 ? 0 : v > 1 ? 1 : v; +} + +/** + * Wrap a value into [0, 1) with modular arithmetic. + * @param {number} v + * @returns {number} + */ +function wrap01(v) { + const m = v % 1; + return m < 0 ? m + 1 : m; +} + +/** + * Apply boundary enforcement to a raw param value. + * + * @param {number} value - raw [0,1] value (or delta-adjusted value) + * @param {{ boundary: string, scaledRange?: number }} schema - param schema entry + * @param {number|null} frozenValue - frozen value for 'scaled' boundary (null if not frozen) + * @returns {number} + */ +export function applyBoundary(value, schema, frozenValue) { + switch (schema.boundary) { + case 'wrap': + return wrap01(value); + case 'scaled': { + if (frozenValue === null || frozenValue === undefined) { + return clamp01(value); + } + const range = schema.scaledRange !== undefined ? schema.scaledRange : DEFAULT_SCALED_RANGE; + const lo = frozenValue - range; + const hi = frozenValue + range; + // Map [0,1] input to [lo, hi], then clamp to [0,1] + const mapped = lo + value * (hi - lo); + return clamp01(mapped); + } + case 'clamp': + default: + return clamp01(value); + } +} + +// ── Param schema validation ───────────────────────────────────────── + +/** + * Validate a single param schema entry. + * Throws on invalid entries for fast fail during development. + * + * @param {{ name: string, default: number, boundary: string, scaledRange?: number }} entry + * @param {number} index - position in schema array (for error messages) + */ +function validateSchemaEntry(entry, index) { + if (!entry || typeof entry !== 'object') { + throw new TypeError('paramSchema[' + index + '] must be an object'); + } + if (typeof entry.name !== 'string' || entry.name.length === 0) { + throw new TypeError('paramSchema[' + index + '].name must be a non-empty string'); + } + if (typeof entry.default !== 'number' || entry.default < 0 || entry.default > 1) { + throw new RangeError('paramSchema[' + index + '].default must be in [0,1], got ' + entry.default); + } + if (entry.boundary !== 'clamp' && entry.boundary !== 'wrap' && entry.boundary !== 'scaled') { + throw new TypeError( + "paramSchema[" + index + "].boundary must be 'clamp', 'wrap', or 'scaled', got '" + entry.boundary + "'" + ); + } + if (entry.boundary === 'scaled') { + const sr = entry.scaledRange; + if (sr !== undefined && (typeof sr !== 'number' || sr <= 0 || sr > 1)) { + throw new RangeError('paramSchema[' + index + '].scaledRange must be in (0,1], got ' + sr); + } + } +} + +// ── Primitive base class ──────────────────────────────────────────── + +export class Primitive { + /** + * @param {string} name - unique identifier for this primitive type + * @param {string} category - one of CATEGORIES + * @param {Array<{ name: string, default: number, boundary: string, scaledRange?: number }>} paramSchema + */ + constructor(name, category, paramSchema) { + if (typeof name !== 'string' || name.length === 0) { + throw new TypeError('Primitive name must be a non-empty string'); + } + if (CATEGORIES.indexOf(category) === -1) { + throw new TypeError( + "Primitive category must be one of [" + CATEGORIES.join(', ') + "], got '" + category + "'" + ); + } + if (!Array.isArray(paramSchema)) { + throw new TypeError('paramSchema must be an array'); + } + + // Validate each entry + for (let i = 0; i < paramSchema.length; i++) { + validateSchemaEntry(paramSchema[i], i); + } + + /** @type {string} */ + this.name = name; + + /** @type {string} */ + this.category = category; + + /** + * Frozen copy of the param schema. Each entry: + * { name: string, default: number, boundary: 'clamp'|'wrap'|'scaled', scaledRange?: number } + * @type {Array} + */ + this.paramSchema = Object.freeze(paramSchema.map(function (entry) { + const frozen = { + name: entry.name, + default: entry.default, + boundary: entry.boundary, + }; + if (entry.boundary === 'scaled') { + frozen.scaledRange = entry.scaledRange !== undefined ? entry.scaledRange : DEFAULT_SCALED_RANGE; + } + return Object.freeze(frozen); + })); + + /** @private */ + this._seed = 0; + } + + // ── Param utilities ───────────────────────────────────────────── + + /** + * Total number of parameters this primitive exposes. + * @returns {number} + */ + get paramCount() { + return this.paramSchema.length; + } + + /** + * Get default param values as a Float32Array, one per schema entry. + * @returns {Float32Array} + */ + getDefaults() { + const count = this.paramSchema.length; + const defaults = new Float32Array(count); + for (let i = 0; i < count; i++) { + defaults[i] = this.paramSchema[i].default; + } + return defaults; + } + + // ── Symbolic processing ───────────────────────────────────────── + + /** + * Transform a pattern description. Subclasses MUST override this. + * + * - Generators ignore patternDesc and create a new one (using createPattern()) + * - Processors/timing modifiers clone and transform patternDesc + * - The rng param is a PRNG state from prng.js; consume via next(rng) + * and return the consumed state in the result + * + * @param {Float32Array|Array} params - param values, one per schema entry, each [0,1] + * @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc - input pattern + * @param {Object} state - primitive-specific state (from previous process() call or getState()) + * @param {{ state: number }} rng - PRNG state object from prng.js + * @returns {{ patternDesc: { steps: Array, stepCount: number, metadata: Object }, nextState: Object }} + */ + process(params, patternDesc, state, rng) { + void params; void patternDesc; void state; void rng; + throw new Error(this.name + '.process() must be overridden by subclass'); + } + + // ── State management (for freeze) ────────────────────────────── + + /** + * Get serializable state for this primitive. + * Stateless primitives return {}. Stateful primitives (e.g. Pitch Walker) + * override to include their internal state. + * + * @returns {Object} + */ + getState() { + return {}; + } + + /** + * Restore primitive state from a previously serialized state object. + * Stateless primitives are a no-op. Stateful primitives override. + * + * @param {Object} _state + */ + setState(_state) { + // no-op for stateless primitives + } + + /** + * Get the PRNG seed associated with this primitive. + * Used by freeze-as-algorithm to replay identical sequences. + * + * @returns {number} + */ + getSeed() { + return this._seed; + } + + /** + * Set the PRNG seed for this primitive. + * + * @param {number} seed - 32-bit integer seed + */ + setSeed(seed) { + this._seed = seed >>> 0; + } +} diff --git a/playground/js/shapeseq/projection.js b/playground/js/shapeseq/projection.js new file mode 100644 index 0000000..06fa0da --- /dev/null +++ b/playground/js/shapeseq/projection.js @@ -0,0 +1,314 @@ +/** + * ShapeSeq Projection Layer + * + * Composable chain of post-chain transforms that convert raw [0,1] values + * in a pattern description into final musical values. + * + * Note: pitch quantization is NOT here — that's the Interval Lock primitive. + * + * Port-ready: pure functions, no closures, explicit state. + * + * @module shapeseq/projection + */ + +import { clonePattern } from './pattern.js'; + +// --------------------------------------------------------------------------- +// Transform definitions +// --------------------------------------------------------------------------- + +/** + * Velocity Curve — reshapes [0,1] velocity values. + * + * @param {number} value - Input value in [0,1] + * @param {{ shape: 'linear'|'exponential'|'sCurve' }} params + * @returns {number} Transformed value in [0,1] + */ +function velocityCurveApply(value, params) { + const v = value < 0 ? 0 : value > 1 ? 1 : value; + switch (params.shape) { + case 'exponential': + return v * v; + case 'sCurve': + return (3 - 2 * v) * v * v; // smoothstep: 3v² - 2v³ + case 'linear': + default: + return v; + } +} + +/** @type {import('./projection.js').Transform} */ +export const VelocityCurve = { + name: 'Velocity Curve', + inputType: 'continuous', + outputType: 'continuous', + apply: velocityCurveApply, +}; + +/** + * Gate Threshold — converts continuous [0,1] to boolean. + * + * @param {number} value - Input value in [0,1] + * @param {{ threshold: number }} params - threshold in [0,1] + * @returns {boolean} + */ +function gateThresholdApply(value, params) { + return value > params.threshold; +} + +/** @type {import('./projection.js').Transform} */ +export const GateThreshold = { + name: 'Gate Threshold', + inputType: 'continuous', + outputType: 'boolean', + apply: gateThresholdApply, +}; + +/** + * Range Map — scales [0,1] into [min, max]. + * + * @param {number} value - Input value in [0,1] + * @param {{ min: number, max: number }} params + * @returns {number} Value in [min, max] + */ +function rangeMapApply(value, params) { + const v = value < 0 ? 0 : value > 1 ? 1 : value; + return params.min + v * (params.max - params.min); +} + +/** @type {import('./projection.js').Transform} */ +export const RangeMap = { + name: 'Range Map', + inputType: 'continuous', + outputType: 'continuous', + apply: rangeMapApply, +}; + +/** + * Octave Folder — folds MIDI note numbers into a target range. + * + * @param {number} value - MIDI note number + * @param {{ lowNote: number, highNote: number }} params + * @returns {number} MIDI note number within [lowNote, highNote] + */ +function octaveFolderApply(value, params) { + const low = params.lowNote | 0; + const high = params.highNote | 0; + if (high <= low) return low; + let note = value | 0; + // Fold into range by shifting octaves (12 semitones) + while (note < low) note += 12; + while (note > high) note -= 12; + // If 12-step folding overshoots, clamp (range < 12) + if (note < low) note = low; + if (note > high) note = high; + return note; +} + +/** @type {import('./projection.js').Transform} */ +export const OctaveFolder = { + name: 'Octave Folder', + inputType: 'midi', + outputType: 'midi', + apply: octaveFolderApply, +}; + +/** + * Stutter Map — maps [0,1] to integer repeat count [1, maxRepeats]. + * + * @param {number} value - Input value in [0,1] + * @param {{ maxRepeats: number }} params + * @returns {number} Integer repeat count in [1, maxRepeats] + */ +function stutterMapApply(value, params) { + const v = value < 0 ? 0 : value > 1 ? 1 : value; + const max = (params.maxRepeats | 0) < 1 ? 1 : params.maxRepeats | 0; + // Map [0,1] to [1, maxRepeats] — 0 → 1, 1 → maxRepeats + return Math.min(max, 1 + ((v * (max - 1)) | 0)); +} + +/** @type {import('./projection.js').Transform} */ +export const StutterMap = { + name: 'Stutter Map', + inputType: 'continuous', + outputType: 'continuous', + apply: stutterMapApply, +}; + +// --------------------------------------------------------------------------- +// Projection chain +// --------------------------------------------------------------------------- + +/** + * Create and validate a projection chain. + * + * Validates that the output type of each transform matches the input type + * of the next transform in the chain. + * + * @param {Array<{ transform: Transform, params: Object, field: string }>} transforms + * Each entry specifies: + * - transform: one of the transform objects above + * - params: parameter object for that transform + * - field: which pattern step field this applies to ('velocity'|'pitch'|'trigger'|'subdivisions') + * @returns {{ transforms: Array, valid: boolean, error: string|null }} + */ +export function createProjectionChain(transforms) { + if (!Array.isArray(transforms) || transforms.length === 0) { + return { transforms: [], valid: true, error: null }; + } + + // Group by field to validate type compatibility within each field's sub-chain + const byField = {}; + for (let i = 0; i < transforms.length; i++) { + const entry = transforms[i]; + const field = entry.field || 'velocity'; + if (!byField[field]) byField[field] = []; + byField[field].push({ index: i, entry: entry }); + } + + // Validate type compatibility within each field's sub-chain + const fields = Object.keys(byField); + for (let f = 0; f < fields.length; f++) { + const group = byField[fields[f]]; + for (let i = 1; i < group.length; i++) { + const prev = group[i - 1].entry.transform; + const curr = group[i].entry.transform; + if (prev.outputType !== curr.inputType) { + return { + transforms: transforms, + valid: false, + error: + 'Type mismatch at index ' + group[i].index + + ': ' + prev.name + ' outputs ' + prev.outputType + + ' but ' + curr.name + ' expects ' + curr.inputType, + }; + } + } + } + + return { transforms: transforms, valid: true, error: null }; +} + +/** + * Apply a projection chain to a pattern description. + * + * Returns a new pattern description — does not mutate the input. + * + * Transform application rules: + * - velocity transforms apply to step.velocity + * - gate transforms (outputType === 'boolean') apply to step.trigger (post-chain override) + * - pitch transforms apply to step.pitch + * + * @param {{ transforms: Array, valid: boolean }} chain - from createProjectionChain + * @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc + * @returns {{ steps: Array, stepCount: number, metadata: Object }} + */ +export function applyProjection(chain, patternDesc) { + if (!chain.valid) { + throw new Error('Cannot apply invalid projection chain: ' + chain.error); + } + if (!chain.transforms || chain.transforms.length === 0) { + return patternDesc; + } + + const result = clonePattern(patternDesc); + const steps = result.steps; + const count = result.stepCount; + + // Group transforms by field, preserving order + const velocityChain = []; + const pitchChain = []; + const triggerChain = []; + const subdivisionsChain = []; + + for (let i = 0; i < chain.transforms.length; i++) { + const entry = chain.transforms[i]; + const field = entry.field || 'velocity'; + switch (field) { + case 'velocity': + velocityChain.push(entry); + break; + case 'pitch': + pitchChain.push(entry); + break; + case 'trigger': + triggerChain.push(entry); + break; + case 'subdivisions': + subdivisionsChain.push(entry); + break; + } + } + + for (let s = 0; s < count; s++) { + const step = steps[s]; + const rawVelocity = step.velocity; // capture before velocity transforms + + // Apply velocity transforms + let vel = step.velocity; + for (let t = 0; t < velocityChain.length; t++) { + vel = velocityChain[t].transform.apply(vel, velocityChain[t].params); + } + step.velocity = vel; + + // Apply pitch transforms + let pitch = step.pitch; + for (let t = 0; t < pitchChain.length; t++) { + pitch = pitchChain[t].transform.apply(pitch, pitchChain[t].params); + } + step.pitch = pitch; + + // Apply trigger transforms (gate threshold overrides trigger) + let trig = rawVelocity; // gate threshold uses raw velocity as input + for (let t = 0; t < triggerChain.length; t++) { + trig = triggerChain[t].transform.apply(trig, triggerChain[t].params); + } + if (triggerChain.length > 0) { + step.trigger = !!trig; + } + + // Apply subdivisions transforms (stutter) + let subs = step.subdivisions; + // Normalize subdivisions to [0,1] for continuous input transforms + // subdivisions range is [1,4], map to [0,1] + let subsNorm = (subs - 1) / 3; + for (let t = 0; t < subdivisionsChain.length; t++) { + subsNorm = subdivisionsChain[t].transform.apply(subsNorm, subdivisionsChain[t].params); + } + if (subdivisionsChain.length > 0) { + step.subdivisions = Math.max(1, Math.min(4, Math.round(subsNorm))); + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Projection presets +// --------------------------------------------------------------------------- + +/** + * Pre-built projection chain configurations. + * + * Each preset is a plain array of { transform, params, field } entries + * ready to pass to createProjectionChain(). + */ +export const PRESETS = { + /** Exponential velocity shaping + pitch range map to MIDI 48-84 */ + expressive: [ + { transform: VelocityCurve, params: { shape: 'exponential' }, field: 'velocity' }, + { transform: RangeMap, params: { min: 48, max: 84 }, field: 'pitch' }, + ], + + /** Gate threshold at 0.5 + exponential velocity curve */ + percussive: [ + { transform: GateThreshold, params: { threshold: 0.5 }, field: 'trigger' }, + { transform: VelocityCurve, params: { shape: 'exponential' }, field: 'velocity' }, + ], + + /** Wide pitch range map (24-96) + linear velocity (identity) */ + fullRange: [ + { transform: RangeMap, params: { min: 24, max: 96 }, field: 'pitch' }, + { transform: VelocityCurve, params: { shape: 'linear' }, field: 'velocity' }, + ], +}; diff --git a/playground/js/shapeseq/step-viz.js b/playground/js/shapeseq/step-viz.js new file mode 100644 index 0000000..8682c0c --- /dev/null +++ b/playground/js/shapeseq/step-viz.js @@ -0,0 +1,328 @@ +/** + * ShapeSeq Circular Step Visualizer + * + * Renders steps arranged in a circle (heptagon, tridecagon, etc.) + * with pitch mapped to radial distance, velocity to node size, + * and accent to color brightness. + * + * Designed for 60fps rendering — no allocations in the render loop. + * Port-ready: explicit state, no closures in hot paths. + * + * @module shapeseq/step-viz + */ + +import { SEQ } from './event-bus.js'; + +// ── Constants (pre-allocated, shared across instances) ────────────── + +const TWO_PI = Math.PI * 2; +const HALF_PI = Math.PI * 0.5; + +// Color constants +const COLOR_INACTIVE = 'rgba(255, 255, 255, 0.15)'; +const COLOR_ACTIVE = '#00ccff'; +const COLOR_CURRENT = '#ff6a00'; +const COLOR_ACCENT = '#ffcc00'; +const COLOR_BG = '#0d0d0d'; + +// Glow colors (pre-computed rgba strings) +const GLOW_ACTIVE = 'rgba(0, 204, 255, 0.3)'; +const GLOW_CURRENT = 'rgba(255, 106, 0, 0.4)'; +const GLOW_ACCENT = 'rgba(255, 204, 0, 0.35)'; + +// Layout +const PADDING_RATIO = 0.08; // canvas padding as fraction of min dimension +const OUTER_RADIUS_RATIO = 0.90; // outer ring at 90% of available radius +const INNER_RADIUS_RATIO = 0.30; // inner ring at 30% of available radius + +// Node sizing +const NODE_MIN_RADIUS = 4; +const NODE_MAX_RADIUS = 18; +const NODE_OUTLINE_WIDTH = 1.5; + +// Current-step indicator +const INDICATOR_EXTRA_RADIUS = 8; +const INDICATOR_LINE_WIDTH = 2; + +// Center dot +const CENTER_DOT_RADIUS = 3; + +// ── StepVisualizer ────────────────────────────────────────────────── + +export class StepVisualizer { + /** + * @param {{ canvas: HTMLCanvasElement, eventBus: import('./event-bus.js').EventBus }} opts + */ + constructor({ canvas, eventBus }) { + this._canvas = canvas; + this._ctx = canvas.getContext('2d'); + this._bus = eventBus; + + // State + this._pattern = null; // current pattern description + this._currentStep = -1; // playback position (-1 = none) + this._width = 0; + this._height = 0; + this._cx = 0; // center x + this._cy = 0; // center y + this._maxRadius = 0; // max ring radius in pixels + + // Pre-allocated arrays to avoid per-frame allocation. + // Sized lazily when pattern is set. + this._nodeX = null; // Float64Array — screen x per step + this._nodeY = null; // Float64Array — screen y per step + this._nodeR = null; // Float64Array — rendered radius per step + + // Interaction + this._tapCallback = null; + this._onPointerDown = this._handlePointerDown.bind(this); + + // Event bus subscription + this._onStep = this._handleStep.bind(this); + this._bus.on(SEQ.STEP, this._onStep); + + // Canvas interaction + this._canvas.addEventListener('pointerdown', this._onPointerDown); + + // Initial sizing + this.resize(canvas.getBoundingClientRect().width, canvas.getBoundingClientRect().height); + } + + // ── Public API ────────────────────────────────────────────────── + + /** + * Update the displayed pattern. + * @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc + */ + setPattern(patternDesc) { + this._pattern = patternDesc; + const count = patternDesc ? patternDesc.stepCount : 0; + + // (Re)allocate coordinate buffers only when step count changes + if (!this._nodeX || this._nodeX.length !== count) { + this._nodeX = new Float64Array(count); + this._nodeY = new Float64Array(count); + this._nodeR = new Float64Array(count); + } + + this._computeLayout(); + } + + /** + * Update the playback position. + * @param {number} index — step index (0-based), or -1 for none + */ + setCurrentStep(index) { + this._currentStep = index; + } + + /** + * Draw one frame. Call from requestAnimationFrame. + */ + render() { + const ctx = this._ctx; + const w = this._width; + const h = this._height; + + // Clear + ctx.fillStyle = COLOR_BG; + ctx.fillRect(0, 0, w, h); + + if (!this._pattern || this._pattern.stepCount === 0) return; + + const steps = this._pattern.steps; + const count = this._pattern.stepCount; + const cx = this._cx; + const cy = this._cy; + + // Draw connecting ring (subtle guide circle at midpoint radius) + const midRadius = this._maxRadius * ((OUTER_RADIUS_RATIO + INNER_RADIUS_RATIO) * 0.5); + ctx.beginPath(); + ctx.arc(cx, cy, midRadius, 0, TWO_PI); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Center dot + ctx.beginPath(); + ctx.arc(cx, cy, CENTER_DOT_RADIUS, 0, TWO_PI); + ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.fill(); + + // Draw connector line from center to current step + if (this._currentStep >= 0 && this._currentStep < count) { + const si = this._currentStep; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(this._nodeX[si], this._nodeY[si]); + ctx.strokeStyle = 'rgba(255, 106, 0, 0.2)'; + ctx.lineWidth = INDICATOR_LINE_WIDTH; + ctx.stroke(); + } + + // Draw step nodes + for (let i = 0; i < count; i++) { + const step = steps[i]; + const nx = this._nodeX[i]; + const ny = this._nodeY[i]; + const nr = this._nodeR[i]; + const isCurrent = i === this._currentStep; + + if (isCurrent) { + // Outer glow for current step + ctx.beginPath(); + ctx.arc(nx, ny, nr + INDICATOR_EXTRA_RADIUS, 0, TWO_PI); + ctx.fillStyle = GLOW_CURRENT; + ctx.fill(); + } + + if (step.trigger) { + // Glow behind active nodes + if (!isCurrent) { + const glowColor = step.accent ? GLOW_ACCENT : GLOW_ACTIVE; + ctx.beginPath(); + ctx.arc(nx, ny, nr + 4, 0, TWO_PI); + ctx.fillStyle = glowColor; + ctx.fill(); + } + + // Filled node + ctx.beginPath(); + ctx.arc(nx, ny, nr, 0, TWO_PI); + if (isCurrent) { + ctx.fillStyle = COLOR_CURRENT; + } else if (step.accent) { + ctx.fillStyle = COLOR_ACCENT; + } else { + ctx.fillStyle = COLOR_ACTIVE; + } + ctx.fill(); + } else { + // Dim outline only for untriggered steps + ctx.beginPath(); + ctx.arc(nx, ny, nr, 0, TWO_PI); + ctx.strokeStyle = isCurrent ? COLOR_CURRENT : COLOR_INACTIVE; + ctx.lineWidth = NODE_OUTLINE_WIDTH; + ctx.stroke(); + } + } + } + + /** + * Handle canvas resize. + * @param {number} width — CSS pixels + * @param {number} height — CSS pixels + */ + resize(width, height) { + const dpr = window.devicePixelRatio || 1; + this._canvas.width = width * dpr; + this._canvas.height = height * dpr; + this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + this._width = width; + this._height = height; + this._cx = width * 0.5; + this._cy = height * 0.5; + + const minDim = Math.min(width, height); + this._maxRadius = (minDim * 0.5) * (1 - PADDING_RATIO * 2); + + this._computeLayout(); + } + + /** + * Register a tap callback. + * @param {function(number): void} callback — receives step index + */ + onStepTap(callback) { + this._tapCallback = callback; + } + + /** + * Unsubscribe from event bus and remove DOM listeners. + */ + destroy() { + this._bus.off(SEQ.STEP, this._onStep); + this._canvas.removeEventListener('pointerdown', this._onPointerDown); + this._tapCallback = null; + this._pattern = null; + } + + // ── Private ───────────────────────────────────────────────────── + + /** + * Re-compute node positions from current pattern + canvas size. + * Called when pattern or size changes — NOT per frame. + */ + _computeLayout() { + if (!this._pattern || !this._nodeX) return; + + const steps = this._pattern.steps; + const count = this._pattern.stepCount; + const cx = this._cx; + const cy = this._cy; + const maxR = this._maxRadius; + const outerR = maxR * OUTER_RADIUS_RATIO; + const innerR = maxR * INNER_RADIUS_RATIO; + const radiusRange = outerR - innerR; + + // Angular step: start at top (-PI/2), go clockwise + const angleStep = TWO_PI / count; + + for (let i = 0; i < count; i++) { + const step = steps[i]; + const angle = -HALF_PI + angleStep * i; + + // Pitch -> radial distance: low pitch = outer, high pitch = inner + const pitchNorm = step.pitch; // 0 = low (outer), 1 = high (inner) + const r = outerR - pitchNorm * radiusRange; + + this._nodeX[i] = cx + Math.cos(angle) * r; + this._nodeY[i] = cy + Math.sin(angle) * r; + + // Velocity -> node size + this._nodeR[i] = NODE_MIN_RADIUS + step.velocity * (NODE_MAX_RADIUS - NODE_MIN_RADIUS); + } + } + + /** + * Handle seq.step events from the event bus. + */ + _handleStep(data) { + if (typeof data.stepIndex === 'number') { + this._currentStep = data.stepIndex; + } + } + + /** + * Handle pointer down on the canvas for tap interaction. + */ + _handlePointerDown(e) { + if (!this._tapCallback || !this._pattern) return; + + const rect = this._canvas.getBoundingClientRect(); + const px = e.clientX - rect.left; + const py = e.clientY - rect.top; + const count = this._pattern.stepCount; + + // Find closest step within hit radius + let bestIdx = -1; + let bestDistSq = Infinity; + + for (let i = 0; i < count; i++) { + const dx = px - this._nodeX[i]; + const dy = py - this._nodeY[i]; + const distSq = dx * dx + dy * dy; + // Hit area is the node radius + some tolerance + const hitR = this._nodeR[i] + 12; + if (distSq < hitR * hitR && distSq < bestDistSq) { + bestDistSq = distSq; + bestIdx = i; + } + } + + if (bestIdx >= 0) { + this._tapCallback(bestIdx); + } + } +}