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:
parent
e3b94d644f
commit
c8015da35a
4 changed files with 1152 additions and 0 deletions
264
playground/js/shapeseq/clock.js
Normal file
264
playground/js/shapeseq/clock.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
246
playground/js/shapeseq/primitive.js
Normal file
246
playground/js/shapeseq/primitive.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
314
playground/js/shapeseq/projection.js
Normal file
314
playground/js/shapeseq/projection.js
Normal 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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
328
playground/js/shapeseq/step-viz.js
Normal file
328
playground/js/shapeseq/step-viz.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue