feat(shapeseq): implement Layer 1 modules

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.
This commit is contained in:
w1n5t0n 2026-03-24 02:02:01 +02:00
parent e3b94d644f
commit c8015da35a
4 changed files with 1152 additions and 0 deletions

View file

@ -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);
}
}
}

View file

@ -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<Object>}
*/
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<number>} 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;
}
}

View file

@ -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' },
],
};

View file

@ -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);
}
}
}