memlnaut-nisps/playground/js/shapeseq/primitive.js
w1n5t0n af4f19bc72 refactor(PitchWalker): redesign as processor, add reEvalOnLoop
PitchWalker was classified as a generator but always consumed the
incoming pattern's triggers. Reclassify as a processor so it modifies
the pattern produced by an upstream generator (clonePattern instead of
createPattern). Add reEvalOnLoop flag on the Primitive base class; when
set, the sequencer re-evaluates the chain at each loop start so
stateful primitives produce evolving patterns across loops.
2026-04-06 23:37:32 +01:00

254 lines
8.4 KiB
JavaScript

/**
* 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);
}));
/**
* When true, the sequencer re-evaluates the chain at each loop start
* even if inputs haven't changed. Useful for stateful primitives
* (e.g. PitchWalker) whose output evolves across evaluations.
* @type {boolean}
*/
this.reEvalOnLoop = false;
/** @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;
}
}