hello come back to me

This commit is contained in:
w1n5t0n 2026-03-30 17:05:45 +03:00
parent 2060f10b40
commit 456c426cb1
19 changed files with 4773 additions and 1357 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,319 @@
/**
* ShapeSeq Chain sequential pipeline runner with generator combination modes
*
* Evaluates an ordered list of primitives as a sequential pipeline:
* 1. Generators run first, combined via additive or multiplicative merge
* 2. Processors transform the pattern in chain order
* 3. Converters run in chain order
* 4. Timing modifiers annotate last
*
* Params are distributed flat across primitives in chain order.
* Each primitive gets a deterministic PRNG stream via fork(masterPRNG, index).
*
* Port-ready: explicit state, typed arrays, no closures in hot path.
*
* @module shapeseq/chain
*/
import { createPattern, mergePatterns } from './pattern.js';
import { createPRNG, fork } from './prng.js';
// ── Category execution order ────────────────────────────────────────
const PHASE_ORDER = ['generator', 'processor', 'converter', 'timing'];
// ── Chain class ─────────────────────────────────────────────────────
export class Chain {
constructor() {
/** @private @type {Array<import('./primitive.js').Primitive>} */
this._primitives = [];
/** @type {'additive'|'multiplicative'} */
this.generatorCombineMode = 'additive';
/** @private @type {number} */
this._masterSeed = 0;
/**
* Per-primitive state objects, indexed by position in chain.
* Populated after evaluate() calls; used for freeze support.
* @private @type {Array<Object>}
*/
this._primitiveStates = [];
}
// ── Primitive management ────────────────────────────────────────
/**
* Append a primitive to the end of the chain.
* @param {import('./primitive.js').Primitive} primitive
*/
addPrimitive(primitive) {
this._primitives.push(primitive);
this._primitiveStates.push(primitive.getState());
}
/**
* Remove the primitive at the given index.
* @param {number} index
*/
removePrimitive(index) {
const idx = index | 0;
if (idx < 0 || idx >= this._primitives.length) {
throw new RangeError('removePrimitive: index ' + index + ' out of range [0, ' + (this._primitives.length - 1) + ']');
}
this._primitives.splice(idx, 1);
this._primitiveStates.splice(idx, 1);
}
/**
* Insert a primitive at the given index, shifting others right.
* @param {number} index
* @param {import('./primitive.js').Primitive} primitive
*/
insertPrimitive(index, primitive) {
const idx = index | 0;
if (idx < 0 || idx > this._primitives.length) {
throw new RangeError('insertPrimitive: index ' + index + ' out of range [0, ' + this._primitives.length + ']');
}
this._primitives.splice(idx, 0, primitive);
this._primitiveStates.splice(idx, 0, primitive.getState());
}
/**
* Move a primitive from one position to another.
* @param {number} fromIndex
* @param {number} toIndex
*/
movePrimitive(fromIndex, toIndex) {
const from = fromIndex | 0;
const to = toIndex | 0;
const len = this._primitives.length;
if (from < 0 || from >= len) {
throw new RangeError('movePrimitive: fromIndex ' + fromIndex + ' out of range [0, ' + (len - 1) + ']');
}
if (to < 0 || to >= len) {
throw new RangeError('movePrimitive: toIndex ' + toIndex + ' out of range [0, ' + (len - 1) + ']');
}
const [prim] = this._primitives.splice(from, 1);
const [state] = this._primitiveStates.splice(from, 1);
this._primitives.splice(to, 0, prim);
this._primitiveStates.splice(to, 0, state);
}
/**
* Get the current list of primitives (shallow copy).
* @returns {Array<import('./primitive.js').Primitive>}
*/
getPrimitives() {
return this._primitives.slice();
}
// ── Configuration ───────────────────────────────────────────────
/**
* Total parameter count across all primitives in the chain.
* @returns {number}
*/
get totalParamCount() {
let total = 0;
for (let i = 0; i < this._primitives.length; i++) {
total += this._primitives[i].paramCount;
}
return total;
}
/**
* Get a flat list of all param schemas across all primitives,
* annotated with their primitive and param indices.
*
* @returns {Array<{ primitiveIndex: number, paramIndex: number, schema: Object }>}
*/
getParamSchemas() {
const result = [];
for (let pi = 0; pi < this._primitives.length; pi++) {
const prim = this._primitives[pi];
const schema = prim.paramSchema;
for (let si = 0; si < schema.length; si++) {
result.push({
primitiveIndex: pi,
paramIndex: si,
schema: schema[si],
});
}
}
return result;
}
// ── Evaluation ──────────────────────────────────────────────────
/**
* Evaluate the chain, producing a pattern description.
*
* Pipeline order:
* 1. Generators combined via generatorCombineMode
* 2. Processors sequential transform
* 3. Converters sequential transform
* 4. Timing modifiers annotate last
*
* @param {Float32Array|Array<number>} params - flat param array distributed across primitives
* @param {number} stepCount - number of steps in the output pattern
* @param {number} masterSeed - seed for the master PRNG
* @returns {{ steps: Array, stepCount: number, metadata: Object }}
*/
evaluate(params, stepCount, masterSeed) {
const primitives = this._primitives;
const primCount = primitives.length;
// Create master PRNG from seed
const masterPRNG = createPRNG(masterSeed >>> 0);
// ── Bucket primitives by category, preserving chain order ──
/** @type {Array<{ index: number, prim: Object }>} */
const generators = [];
const processors = [];
const converters = [];
const timingMods = [];
for (let i = 0; i < primCount; i++) {
const entry = { index: i, prim: primitives[i] };
switch (primitives[i].category) {
case 'generator': generators.push(entry); break;
case 'processor': processors.push(entry); break;
case 'converter': converters.push(entry); break;
case 'timing': timingMods.push(entry); break;
}
}
// ── Compute param offsets per primitive ──
const paramOffsets = new Array(primCount);
let offset = 0;
for (let i = 0; i < primCount; i++) {
paramOffsets[i] = offset;
offset += primitives[i].paramCount;
}
// ── Helper: run a single primitive ──
const self = this;
function runPrimitive(entry, inputPattern) {
const idx = entry.index;
const prim = entry.prim;
const pOffset = paramOffsets[idx];
const pCount = prim.paramCount;
// Slice params for this primitive
const primParams = new Float32Array(pCount);
for (let p = 0; p < pCount; p++) {
primParams[p] = pOffset + p < params.length ? +params[pOffset + p] : prim.paramSchema[p].default;
}
// Fork a deterministic PRNG for this primitive
const primRNG = fork(masterPRNG, idx);
// Get current state
const state = self._primitiveStates[idx] || prim.getState();
// Process
const result = prim.process(primParams, inputPattern, state, primRNG);
// Store updated state
self._primitiveStates[idx] = result.nextState;
return result.patternDesc;
}
// ── Phase 1: Generators ──
let pattern;
if (generators.length === 0) {
// Default pattern: all steps triggered
pattern = createPattern(stepCount);
for (let i = 0; i < stepCount; i++) {
pattern.steps[i].trigger = true;
}
} else if (generators.length === 1) {
// Single generator — no merge needed
pattern = runPrimitive(generators[0], createPattern(stepCount));
} else {
// Multiple generators — run each, then merge
let merged = runPrimitive(generators[0], createPattern(stepCount));
for (let g = 1; g < generators.length; g++) {
const next = runPrimitive(generators[g], createPattern(stepCount));
merged = mergePatterns(merged, next, this.generatorCombineMode);
}
pattern = merged;
}
// ── Phase 2: Processors ──
for (let i = 0; i < processors.length; i++) {
pattern = runPrimitive(processors[i], pattern);
}
// ── Phase 3: Converters ──
for (let i = 0; i < converters.length; i++) {
pattern = runPrimitive(converters[i], pattern);
}
// ── Phase 4: Timing modifiers ──
for (let i = 0; i < timingMods.length; i++) {
pattern = runPrimitive(timingMods[i], pattern);
}
return pattern;
}
// ── State management (for freeze) ──────────────────────────────
/**
* Get serializable state for all primitives in the chain.
* @returns {Array<Object>}
*/
getState() {
const states = new Array(this._primitives.length);
for (let i = 0; i < this._primitives.length; i++) {
states[i] = this._primitiveStates[i] || this._primitives[i].getState();
}
return states;
}
/**
* Restore all primitive states from a previously serialized state array.
* @param {Array<Object>} states
*/
setState(states) {
if (!Array.isArray(states)) {
throw new TypeError('setState expects an array of state objects');
}
const len = Math.min(states.length, this._primitives.length);
for (let i = 0; i < len; i++) {
this._primitives[i].setState(states[i]);
this._primitiveStates[i] = states[i];
}
}
/**
* Get the master PRNG seed.
* @returns {number}
*/
getMasterSeed() {
return this._masterSeed;
}
/**
* Set the master PRNG seed.
* @param {number} seed - 32-bit integer seed
*/
setMasterSeed(seed) {
this._masterSeed = seed >>> 0;
}
}

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,505 @@
/**
* ShapeSeq Sequencing Primitives
*
* All 8 primitives for the ShapeSeq generative sequencing system.
* Each extends Primitive and implements process(params, patternDesc, state, rng).
*
* Port-ready: explicit state, no closures, seeded PRNG, typed arrays.
*
* @module shapeseq/primitives
*/
import { Primitive } from './primitive.js';
import { createPattern, clonePattern, setStep } from './pattern.js';
import { next, nextInt } from './prng.js';
// ── Helper: map [0,1] float to integer range [lo, hi] ──────────────
function mapToInt(value, lo, hi) {
const clamped = value < 0 ? 0 : value > 1 ? 1 : value;
return lo + Math.floor(clamped * (hi - lo + 1 - 1e-9));
}
// ── 1. EuclideanRhythm ──────────────────────────────────────────────
/**
* Bjorklund algorithm: distribute `pulses` as evenly as possible
* across `steps`, then apply rotation.
*/
function bjorklund(steps, pulses) {
if (pulses >= steps) {
const result = new Array(steps);
for (let i = 0; i < steps; i++) result[i] = true;
return result;
}
if (pulses <= 0) {
const result = new Array(steps);
for (let i = 0; i < steps; i++) result[i] = false;
return result;
}
// Build pattern using Bjorklund's algorithm
let groups = [];
for (let i = 0; i < pulses; i++) groups.push([true]);
for (let i = 0; i < steps - pulses; i++) groups.push([false]);
while (true) {
const remainder = groups.length - pulses;
if (remainder <= 1) break;
const minLen = Math.min(pulses, remainder);
const newGroups = [];
for (let i = 0; i < minLen; i++) {
newGroups.push(groups[i].concat(groups[groups.length - 1 - i]));
}
// Keep any leftovers
const leftStart = minLen;
const leftEnd = groups.length - minLen;
for (let i = leftStart; i < leftEnd; i++) {
newGroups.push(groups[i]);
}
groups = newGroups;
pulses = minLen;
if (pulses <= 1) break;
}
// Flatten groups
const result = [];
for (let i = 0; i < groups.length; i++) {
for (let j = 0; j < groups[i].length; j++) {
result.push(groups[i][j]);
}
}
return result;
}
export class EuclideanRhythm extends Primitive {
constructor() {
super('EuclideanRhythm', 'generator', [
{ name: 'steps', default: 0.5, boundary: 'clamp' },
{ name: 'pulses', default: 0.5, boundary: 'clamp' },
{ name: 'rotation', default: 0.0, boundary: 'wrap' },
]);
}
process(params, patternDesc, state, rng) {
const stepCount = patternDesc.stepCount;
const steps = mapToInt(params[0], 2, stepCount);
const pulses = mapToInt(params[1], 0, steps);
const rotation = mapToInt(params[2], 0, steps - 1);
const rhythm = bjorklund(steps, pulses);
const pattern = createPattern(stepCount);
for (let i = 0; i < stepCount; i++) {
if (i < steps) {
const srcIdx = (i - rotation + steps) % steps;
if (rhythm[srcIdx]) {
setStep(pattern, i, { trigger: true });
}
}
// Steps beyond `steps` remain untriggered (default)
}
return { patternDesc: pattern, nextState: {} };
}
}
// ── 2. ProbabilityGate ──────────────────────────────────────────────
export class ProbabilityGate extends Primitive {
constructor() {
super('ProbabilityGate', 'processor', [
{ name: 'density', default: 0.7, boundary: 'clamp' },
{ name: 'accentProbability', default: 0.3, boundary: 'clamp' },
]);
}
process(params, patternDesc, state, rng) {
const density = params[0];
const accentProb = params[1];
const pattern = clonePattern(patternDesc);
let currentRng = rng;
for (let i = 0; i < pattern.stepCount; i++) {
const step = pattern.steps[i];
if (step.trigger) {
// Coin flip for survival
const r1 = next(currentRng);
currentRng = r1.nextState;
if (r1.value >= density) {
step.trigger = false;
step.accent = false;
} else {
// Accent coin flip
const r2 = next(currentRng);
currentRng = r2.nextState;
step.accent = r2.value < accentProb;
}
}
}
return { patternDesc: pattern, nextState: {} };
}
}
// ── 3. PitchWalker ──────────────────────────────────────────────────
export class PitchWalker extends Primitive {
constructor() {
super('PitchWalker', 'generator', [
{ name: 'stepSize', default: 0.3, boundary: 'clamp' },
{ name: 'directionBias', default: 0.5, boundary: 'clamp' },
{ name: 'gravity', default: 0.3, boundary: 'clamp' },
{ name: 'range', default: 0.8, boundary: 'clamp' },
]);
/** @private */
this._position = 0.5;
}
getState() {
return { position: this._position };
}
setState(savedState) {
if (savedState && typeof savedState.position === 'number') {
this._position = savedState.position;
}
}
process(params, patternDesc, state, rng) {
const stepSize = params[0];
const directionBias = params[1];
const gravity = params[2];
const range = params[3];
// Restore position from state if provided
let position = (state && typeof state.position === 'number')
? state.position
: this._position;
const pattern = createPattern(patternDesc.stepCount);
let currentRng = rng;
// Use incoming pattern's triggers if available, otherwise all triggered
const srcSteps = patternDesc.steps;
for (let i = 0; i < patternDesc.stepCount; i++) {
const triggered = srcSteps[i].trigger;
if (triggered) {
// Random walk step
const r1 = next(currentRng);
currentRng = r1.nextState;
// Direction: bias + gravity toward center
const gravityPull = (0.5 - position) * gravity;
const biasOffset = (directionBias - 0.5) * 2; // [-1, 1]
const direction = biasOffset + gravityPull;
// Random component: [-1, 1] scaled by stepSize
const randomComponent = (r1.value * 2 - 1) * stepSize * range;
const delta = direction * stepSize * 0.5 + randomComponent;
position = position + delta;
// Clamp to [0, 1]
if (position < 0) position = 0;
if (position > 1) position = 1;
setStep(pattern, i, { trigger: true, pitch: position });
}
// Untriggered steps keep default pitch, trigger=false
}
this._position = position;
return {
patternDesc: pattern,
nextState: { position: position },
};
}
}
// ── 4. Ratchet ──────────────────────────────────────────────────────
export class Ratchet extends Primitive {
constructor() {
super('Ratchet', 'timing', [
{ name: 'maxDivision', default: 0.5, boundary: 'clamp' },
{ name: 'probability', default: 0.5, boundary: 'clamp' },
]);
}
process(params, patternDesc, state, rng) {
const maxDiv = mapToInt(params[0], 1, 4);
const probability = params[1];
const pattern = clonePattern(patternDesc);
let currentRng = rng;
for (let i = 0; i < pattern.stepCount; i++) {
const step = pattern.steps[i];
if (step.trigger) {
const r1 = next(currentRng);
currentRng = r1.nextState;
if (r1.value < probability && maxDiv > 1) {
// Pick a subdivision count in [2, maxDiv]
const r2 = nextInt(currentRng, 2, maxDiv);
currentRng = r2.nextState;
step.subdivisions = r2.value;
}
}
}
return { patternDesc: pattern, nextState: {} };
}
}
// ── 5. SwingGroove ──────────────────────────────────────────────────
export class SwingGroove extends Primitive {
constructor() {
super('SwingGroove', 'timing', [
{ name: 'swingAmount', default: 0.0, boundary: 'clamp' },
{ name: 'swingGrid', default: 0.0, boundary: 'clamp' },
]);
}
process(params, patternDesc, state, rng) {
const swingAmount = params[0];
const pattern = clonePattern(patternDesc);
// Max swing = 0.33 (triplet feel)
const maxOffset = 0.33;
const offset = swingAmount * maxOffset;
// Apply swing to every other step (odd-indexed steps)
for (let i = 1; i < pattern.stepCount; i += 2) {
pattern.steps[i].timeOffset = offset;
}
return { patternDesc: pattern, nextState: {} };
}
}
// ── 6. DensityMorph ─────────────────────────────────────────────────
export class DensityMorph extends Primitive {
constructor() {
super('DensityMorph', 'generator', [
{ name: 'density', default: 0.5, boundary: 'clamp' },
{ name: 'clustering', default: 0.0, boundary: 'clamp' },
]);
}
process(params, patternDesc, state, rng) {
const density = params[0];
const clustering = params[1];
const stepCount = patternDesc.stepCount;
const pattern = createPattern(stepCount);
const numTriggers = Math.floor(density * stepCount);
if (numTriggers <= 0) {
return { patternDesc: pattern, nextState: {} };
}
if (numTriggers >= stepCount) {
for (let i = 0; i < stepCount; i++) {
setStep(pattern, i, { trigger: true });
}
return { patternDesc: pattern, nextState: {} };
}
let currentRng = rng;
if (clustering < 0.01) {
// Even spread: Euclidean-like placement
for (let i = 0; i < numTriggers; i++) {
const idx = Math.floor((i * stepCount) / numTriggers);
setStep(pattern, idx, { trigger: true });
}
} else if (clustering > 0.99) {
// Full clustering: contiguous burst
const r1 = nextInt(currentRng, 0, stepCount - 1);
currentRng = r1.nextState;
const startPos = r1.value;
for (let i = 0; i < numTriggers; i++) {
const idx = (startPos + i) % stepCount;
setStep(pattern, idx, { trigger: true });
}
} else {
// Interpolate: place triggers with clustering-dependent spread
// Use a "center of mass" approach:
// Pick a random center, then distribute triggers around it
// with spread inversely proportional to clustering
const r1 = next(currentRng);
currentRng = r1.nextState;
const center = r1.value * stepCount;
// Spread factor: low clustering = large spread, high = tight
const spreadRadius = (1 - clustering) * stepCount * 0.5;
// Score each step by distance from center (wrapping)
const scores = new Float32Array(stepCount);
for (let i = 0; i < stepCount; i++) {
// Wrapped distance from center
let dist = Math.abs(i - center);
if (dist > stepCount * 0.5) dist = stepCount - dist;
// Add small random jitter to break ties
const r2 = next(currentRng);
currentRng = r2.nextState;
scores[i] = dist / (spreadRadius + 0.001) + r2.value * 0.01;
}
// Select the numTriggers steps with lowest scores
const indices = new Array(stepCount);
for (let i = 0; i < stepCount; i++) indices[i] = i;
indices.sort(function (a, b) { return scores[a] - scores[b]; });
for (let i = 0; i < numTriggers; i++) {
setStep(pattern, indices[i], { trigger: true });
}
}
return { patternDesc: pattern, nextState: {} };
}
}
// ── 7. IntervalLock ─────────────────────────────────────────────────
const SCALES = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // chromatic
[0, 2, 4, 5, 7, 9, 11], // major
[0, 2, 3, 5, 7, 8, 10], // natural minor
[0, 2, 3, 5, 7, 8, 11], // harmonic minor
[0, 2, 4, 7, 9], // pentatonic major
[0, 3, 5, 7, 10], // pentatonic minor
[0, 3, 5, 6, 7, 10], // blues
[0, 2, 3, 5, 7, 9, 10], // dorian
[0, 2, 4, 5, 7, 9, 10], // mixolydian
[0, 2, 4, 6, 8, 10], // whole tone
[0, 2, 3, 5, 6, 8, 9, 11], // diminished
];
export class IntervalLock extends Primitive {
constructor() {
super('IntervalLock', 'converter', [
{ name: 'root', default: 0.0, boundary: 'clamp' },
{ name: 'mode', default: 0.0, boundary: 'clamp' },
{ name: 'octaveRange', default: 0.25, boundary: 'clamp' },
]);
}
process(params, patternDesc, state, rng) {
const root = mapToInt(params[0], 0, 11);
const scaleIdx = mapToInt(params[1], 0, SCALES.length - 1);
const octaveRange = mapToInt(params[2], 1, 4);
const scale = SCALES[scaleIdx];
const pattern = clonePattern(patternDesc);
// Build the full set of MIDI notes in this scale + root + range
const notes = [];
for (let oct = 0; oct < octaveRange; oct++) {
for (let i = 0; i < scale.length; i++) {
const midiNote = root + scale[i] + oct * 12;
if (midiNote <= 127) {
notes.push(midiNote);
}
}
}
if (notes.length === 0) {
return { patternDesc: pattern, nextState: {} };
}
for (let i = 0; i < pattern.stepCount; i++) {
const step = pattern.steps[i];
// Quantize pitch [0,1] to nearest note in our scale
const targetIdx = Math.round(step.pitch * (notes.length - 1));
const clampedIdx = targetIdx < 0 ? 0 : targetIdx >= notes.length ? notes.length - 1 : targetIdx;
// Store as MIDI note / 127 to stay in [0,1]
step.pitch = notes[clampedIdx] / 127;
}
return { patternDesc: pattern, nextState: {} };
}
}
// ── 8. VelocityShaper ───────────────────────────────────────────────
export class VelocityShaper extends Primitive {
constructor() {
super('VelocityShaper', 'processor', [
{ name: 'curveType', default: 0.0, boundary: 'clamp' },
{ name: 'depth', default: 0.5, boundary: 'clamp' },
{ name: 'phase', default: 0.0, boundary: 'wrap' },
]);
}
process(params, patternDesc, state, rng) {
const curveIdx = mapToInt(params[0], 0, 4);
const depth = params[1];
const phase = params[2];
const pattern = clonePattern(patternDesc);
const stepCount = pattern.stepCount;
let currentRng = rng;
for (let i = 0; i < stepCount; i++) {
const step = pattern.steps[i];
if (!step.trigger) continue;
// Phase-shifted position
const pos = ((i / stepCount) + phase) % 1;
let shapeValue;
switch (curveIdx) {
case 0: // flat
shapeValue = 1.0;
break;
case 1: // accent-every-N (accent every 4th step)
shapeValue = ((i + Math.floor(phase * stepCount)) % 4 === 0) ? 1.0 : 0.5;
break;
case 2: // crescendo
shapeValue = pos;
break;
case 3: // decrescendo
shapeValue = 1.0 - pos;
break;
case 4: { // random
const r1 = next(currentRng);
currentRng = r1.nextState;
shapeValue = r1.value;
break;
}
default:
shapeValue = 1.0;
}
// Apply depth: interpolate between uniform (1.0) and shaped
// depth=0 means all same velocity (base), depth=1 means full shape
const baseVelocity = 0.7;
const shaped = shapeValue;
step.velocity = baseVelocity * (1 - depth) + shaped * depth;
// Clamp
if (step.velocity < 0) step.velocity = 0;
if (step.velocity > 1) step.velocity = 1;
}
return { patternDesc: pattern, nextState: {} };
}
}
// ── Registry ────────────────────────────────────────────────────────
export const PRIMITIVE_REGISTRY = {
EuclideanRhythm: EuclideanRhythm,
ProbabilityGate: ProbabilityGate,
PitchWalker: PitchWalker,
Ratchet: Ratchet,
SwingGroove: SwingGroove,
DensityMorph: DensityMorph,
IntervalLock: IntervalLock,
VelocityShaper: VelocityShaper,
};

View file

@ -0,0 +1,304 @@
/**
* ShapeSeq Engine central orchestrator
*
* Wires together the sequence MLP, param mapping, primitive chain,
* projection layer, clock engine, and C15 bridge.
*
* Main loop (triggered by setSequenceInputs):
* 1. Forward inputs to sequenceIML
* 2. Run MLP inference to get 16 outputs
* 3. Map 16 outputs to N primitive params via param-map
* 4. Evaluate the chain to produce a pattern description
* 5. Apply projection transforms
* 6. Schedule the pattern on the clock
*
* Bridge integration:
* - Subscribes to seq.noteOn / seq.noteOff on the event bus
* - Forwards to C15Bridge.noteOn / noteOff
* - Tracks active notes to avoid orphans
*
* @module shapeseq/sequencer
*/
import { createSequenceIML, SEQ_N_OUTPUTS } from './seq-iml.js';
import { Chain } from './chain.js';
import { ClockEngine } from './clock.js';
import { map } from './param-map.js';
import { createProjectionChain, applyProjection, PRESETS } from './projection.js';
import { SEQ } from './event-bus.js';
import {
EuclideanRhythm,
ProbabilityGate,
PitchWalker,
IntervalLock,
VelocityShaper,
} from './primitives.js';
// ── Defaults ─────────────────────────────────────────────────────────
const DEFAULT_BPM = 120;
const DEFAULT_STEP_COUNT = 8;
const DEFAULT_MASTER_SEED = 42;
const DEFAULT_SPREAD = 0.6;
// ── ShapeSeqEngine ───────────────────────────────────────────────────
export class ShapeSeqEngine {
/**
* @param {{ audioContext: AudioContext, eventBus: import('./event-bus.js').EventBus, c15Bridge: import('../synth/c15-bridge.js').C15Bridge }} opts
*/
constructor({ audioContext, eventBus, c15Bridge }) {
if (!audioContext) throw new TypeError('ShapeSeqEngine requires an audioContext');
if (!eventBus) throw new TypeError('ShapeSeqEngine requires an eventBus');
if (!c15Bridge) throw new TypeError('ShapeSeqEngine requires a c15Bridge');
/** @private */ this._audioCtx = audioContext;
/** @private */ this._bus = eventBus;
/** @private */ this._c15 = c15Bridge;
/** @private */ this._sequenceIML = null;
/** @private */ this._chain = null;
/** @private */ this._clock = null;
/** @private */ this._projectionChain = null;
/** @private */ this._stepCount = DEFAULT_STEP_COUNT;
/** @private */ this._masterSeed = DEFAULT_MASTER_SEED;
/** @private */ this._playing = false;
/** @private */ this._initialized = false;
// Track active notes for orphan prevention
/** @private @type {Set<number>} */
this._activeNotes = new Set();
// Bound handlers for event bus (stored for cleanup)
/** @private */
this._onNoteOn = (data) => this._handleNoteOn(data);
/** @private */
this._onNoteOff = (data) => this._handleNoteOff(data);
}
// ── Lifecycle ──────────────────────────────────────────────────────
/**
* Initialize all subsystems: create sequence IML, default chain,
* clock, and projection chain. Must be called before start().
*/
async init() {
// 1. Create the sequence MLP
this._sequenceIML = await createSequenceIML();
// Randomize weights with default spread
this._sequenceIML.drawWeights(DEFAULT_SPREAD);
// 2. Create the default primitive chain
this._chain = new Chain();
this._chain.addPrimitive(new EuclideanRhythm());
this._chain.addPrimitive(new ProbabilityGate());
this._chain.addPrimitive(new PitchWalker());
this._chain.addPrimitive(new IntervalLock());
this._chain.addPrimitive(new VelocityShaper());
this._chain.setMasterSeed(this._masterSeed);
// 3. Set up the clock
this._clock = new ClockEngine(this._audioCtx, this._bus);
this._clock.bpm = DEFAULT_BPM;
// 4. Create default projection chain (expressive preset)
const result = createProjectionChain(PRESETS.expressive);
if (!result.valid) {
throw new Error('Default projection chain invalid: ' + result.error);
}
this._projectionChain = result;
// 5. Subscribe to event bus for C15 bridge integration
this._bus.on(SEQ.NOTE_ON, this._onNoteOn);
this._bus.on(SEQ.NOTE_OFF, this._onNoteOff);
this._initialized = true;
}
/**
* Start the clock. Requires init() to have been called.
*/
start() {
if (!this._initialized) {
throw new Error('ShapeSeqEngine.start() called before init()');
}
if (this._playing) return;
this._playing = true;
this._clock.start();
}
/**
* Stop the clock and release all active notes.
*/
stop() {
if (!this._playing) return;
this._playing = false;
this._clock.stop();
this._releaseAllNotes();
}
/**
* Full cleanup: stop playback, unsubscribe from events, destroy IML.
*/
destroy() {
this.stop();
// Unsubscribe from event bus
this._bus.off(SEQ.NOTE_ON, this._onNoteOn);
this._bus.off(SEQ.NOTE_OFF, this._onNoteOff);
// Destroy the sequence IML instance
if (this._sequenceIML) {
this._sequenceIML.destroy();
this._sequenceIML = null;
}
this._chain = null;
this._clock = null;
this._projectionChain = null;
this._initialized = false;
}
// ── Configuration ──────────────────────────────────────────────────
/**
* Update the clock tempo.
* @param {number} bpm
*/
setTempo(bpm) {
if (this._clock) {
this._clock.setTempo(bpm);
}
}
/**
* Set the number of steps in the generated pattern.
* @param {number} count
*/
setStepCount(count) {
const c = Math.max(1, count | 0);
this._stepCount = c;
}
/**
* Set the projection preset by name.
* @param {'expressive'|'percussive'|'fullRange'} presetName
*/
setProjectionPreset(presetName) {
const preset = PRESETS[presetName];
if (!preset) {
throw new Error('Unknown projection preset: ' + presetName);
}
const result = createProjectionChain(preset);
if (!result.valid) {
throw new Error('Projection chain invalid: ' + result.error);
}
this._projectionChain = result;
}
// ── Chain access (for UI binding) ──────────────────────────────────
/** @returns {Chain} */
getChain() { return this._chain; }
/** @returns {ClockEngine} */
getClock() { return this._clock; }
/** @returns {WasmIML} */
getSequenceIML() { return this._sequenceIML; }
// ── Input routing ──────────────────────────────────────────────────
/**
* Feed new input values to the sequence MLP and run the full pipeline:
* MLP inference -> param mapping -> chain evaluation -> projection -> clock scheduling.
*
* Call this each frame with the routed input values (e.g., [x, y]).
*
* @param {number[]} values - input array (typically [x, y])
*/
setSequenceInputs(values) {
if (!this._initialized || !this._sequenceIML) return;
// 1. Forward inputs to the sequence IML
this._sequenceIML.setInputs(values);
// 2. Run MLP inference
this._sequenceIML.process();
// 3. Get the 16 MLP outputs
const mlpOutputs = this._sequenceIML.getOutputs();
// 4. Map 16 outputs to N primitive params
const paramCount = this._chain.totalParamCount;
const mappedParams = map(mlpOutputs, paramCount);
// 5. Evaluate the chain to produce a pattern description
const patternDesc = this._chain.evaluate(mappedParams, this._stepCount, this._masterSeed);
// 6. Apply projection transforms
const projectedPattern = applyProjection(this._projectionChain, patternDesc);
// 7. Schedule the pattern on the clock
this._clock.schedulePattern(projectedPattern);
}
// ── ML control ─────────────────────────────────────────────────────
/** @returns {boolean} */
get isPlaying() {
return this._playing;
}
// ── Bridge integration (private) ───────────────────────────────────
/**
* Handle seq.noteOn events from the event bus.
* Converts [0,1] pitch to MIDI note number and forwards to C15.
*
* @private
* @param {Object} data - { pitch, velocity, stepIndex, time, accent, isSubdivision }
*/
_handleNoteOn(data) {
// pitch comes from the projection layer; after RangeMap it's already
// in MIDI note range (e.g., 48-84). Round to nearest integer.
const midiNote = Math.round(data.pitch) | 0;
const velocity = data.velocity;
// Clamp to valid MIDI range
const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote;
const vel = velocity < 0 ? 0 : velocity > 1 ? 1 : velocity;
this._c15.noteOn(note, vel);
this._activeNotes.add(note);
}
/**
* Handle seq.noteOff events from the event bus.
*
* @private
* @param {Object} data - { pitch, velocity, stepIndex, time }
*/
_handleNoteOff(data) {
const midiNote = Math.round(data.pitch) | 0;
const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote;
this._c15.noteOff(note);
this._activeNotes.delete(note);
}
/**
* Release all currently active notes to avoid orphaned noteOns.
* @private
*/
_releaseAllNotes() {
for (const note of this._activeNotes) {
this._c15.noteOff(note);
}
this._activeNotes.clear();
}
}

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

View file

@ -1,9 +1,9 @@
## synth view Immersive UI
- [ ] add hover tooltip for each parameter slider
- [ ] hovering on the name of a module/group at the top should open a little drawer panel that allows us to set
- [ ] minimum and maximum values for each parameter (similar to what tame does)
- [ ] a curve parameter that's normalised and goes between logarithmic and exponential, with a little graph to visualise, to skew the distribution in either direction
- [ ] if the audio engine hasn't been initialised yet, the play button at the top left should be pulsing and have an orange highlight
- [x] add hover tooltip for each parameter slider (canvas tooltip follows mouse, shows name/value/range/curve)
- [x] hovering on the name of a module/group at the top should open a little drawer panel that allows us to set
- [x] minimum and maximum values for each parameter (dual-thumb range slider)
- [x] a curve parameter that's normalised and goes between logarithmic and exponential, with a little graph to visualise, to skew the distribution in either direction (per-param draggable canvas + group master curve with relative adjustment)
- [x] mute toggle per parameter (removes from NISPS, replaces with fixed value slider)
- [x] if the audio engine hasn't been initialised yet, the play button at the top left should be pulsing and have an orange highlight

View file

@ -54,19 +54,26 @@
</select>
</div>
<!-- MIDI CC quick controls (shown in midi-cc mode, mirrors synth quick controls layout) -->
<div class="midi-cc-quick-controls hidden" id="midi-cc-quick-controls">
<button class="play-btn" id="midi-cc-enable-btn" title="Enable MIDI output">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M4 9v2M6 7v4M8 8v3M10 7v4M12 9v2"/>
</svg>
</button>
<select class="preset-select" id="midi-cc-output-select" title="MIDI output device">
<option value="">No device</option>
</select>
<span class="synth-status" id="midi-cc-status" style="font-size:0.65rem;opacity:0.7"></span>
</div>
<!-- Fullscreen flow field canvas -->
<canvas id="vis-canvas"></canvas>
<!-- Synth visualization canvas -->
<canvas id="synth-vis-canvas"></canvas>
<!-- ShapeSeq UI (hidden by default, enabled with ?shapeseq=1) -->
<div class="shapeseq-container hidden" id="shapeseq-container">
<canvas id="shapeseq-viz"></canvas>
<div class="shapeseq-chain-wrap" id="shapeseq-chain"></div>
</div>
<!-- Heatmap strip (top) -->
<!-- Heatmap strip (top) — bars are click/draggable -->
<div class="heatmap-strip" id="heatmap-strip">
<div class="heatmap-cells" id="heatmap-cells"></div>
<div class="heatmap-tooltip" id="heatmap-tooltip"></div>
@ -95,49 +102,72 @@
</div>
</div>
<!-- RL floating buttons -->
<!-- RL floating buttons (with undo between/below) -->
<div class="rl-buttons" id="rl-buttons">
<button class="rl-btn rl-down" id="btn-thumbsdown" title="Explore more"><span class="rl-icon">&minus;</span><span class="key-num">1</span></button>
<button class="rl-btn rl-up" id="btn-thumbsup" title="Keep this"><span class="rl-icon">+</span><span class="key-num">2</span></button>
<button class="rl-undo-btn" id="btn-undo" title="Undo last action">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7h6a4 4 0 0 1 0 8H7"/><path d="M3 7l3-3M3 7l3 3"/>
</svg>
</button>
</div>
<!-- Bottom sheet -->
<div class="bottom-sheet collapsed" id="bottom-sheet">
<!-- Floating mode bar (sticky inside sheet) -->
<div class="floating-bar" id="floating-bar">
<div class="pill-toggle pill-toggle-sm" id="input-toggle">
<button class="pill-opt active" data-input="joystick">Joystick</button>
<button class="pill-opt" data-input="hands">Hands</button>
</div>
<div class="pill-toggle pill-toggle-sm" id="output-toggle-float">
<button class="pill-opt active" data-mode="visual">Visual</button>
<button class="pill-opt" data-mode="synth">Synth</button>
</div>
<button class="float-btn accent" id="btn-train-bar" title="Train on examples">Train</button>
<button class="float-btn" id="btn-randomize-bar" title="Randomize weights">Randomize</button>
<button class="float-btn" id="btn-clear-examples-bar" title="Clear examples">Clear Ex</button>
<button class="follow-pill" id="follow-pill" title="Toggle follow mode">Follow</button>
<button class="osc-pill" id="osc-pill" title="Connect OSC output">OSC</button>
<button class="chevron-btn" id="chevron-btn" title="Expand panel">&#9650;</button>
</div>
<!-- Status line -->
<div class="sheet-status" id="sheet-status">
<!-- Status line (floating, minimal) -->
<div class="status-line" id="status-line">
<span id="status-text">0 examples &middot; untrained</span>
</div>
<!-- Sheet content -->
<div class="sheet-content" id="sheet-content">
<!-- Examples mode actions -->
<!-- Right-side dock (macOS-style) -->
<div class="dock" id="dock">
<button class="dock-icon" data-drawer="training" title="Training">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8h10"/><rect x="1" y="5" width="3" height="6" rx="0.5"/><rect x="12" y="5" width="3" height="6" rx="0.5"/><rect x="3" y="6.5" width="2" height="3" rx="0.3"/><rect x="11" y="6.5" width="2" height="3" rx="0.3"/>
</svg>
<span class="dock-label">Train</span>
</button>
<button class="dock-icon" data-drawer="mode" title="Input / Output mode">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="5" cy="5" r="2"/><circle cx="11" cy="11" r="2"/><path d="M3 13L13 3"/>
</svg>
<span class="dock-label">Mode</span>
</button>
<button class="dock-icon" data-drawer="synth" title="Synth controls">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 10c2-4 4-4 6 0s4-4 6 0"/>
</svg>
<span class="dock-label">Synth</span>
</button>
<button class="dock-icon" data-drawer="params" title="Engine parameters">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="3"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41"/>
</svg>
<span class="dock-label">Engine</span>
</button>
<button class="dock-icon" data-drawer="help" title="Help">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6"/><path d="M6 6a2 2 0 0 1 4 0c0 1.5-2 1.5-2 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor"/>
</svg>
<span class="dock-label">Help</span>
</button>
</div>
<!-- Drawer panels (stack on right side, left of dock) -->
<div class="drawer-stack" id="drawer-stack">
<!-- Training drawer -->
<div class="drawer hidden" id="drawer-training" data-drawer="training">
<div class="drawer-header">
<span>Training</span>
<button class="drawer-close" data-drawer="training">&times;</button>
</div>
<div class="drawer-body">
<div class="action-row" id="examples-actions">
<button class="action-btn" id="btn-add-example">Add Example</button>
<button class="action-btn accent" id="btn-train">Train</button>
<button class="action-btn dim" id="btn-clear-examples">Clear Examples</button>
<button class="action-btn dim" id="btn-clear-examples">Clear Ex</button>
<button class="action-btn dim" id="btn-clear">Clear All</button>
<button class="action-btn dim" id="btn-randomize">Randomize</button>
</div>
<!-- Preset palette -->
<div class="preset-row" id="preset-row">
<span class="preset-label">Presets</span>
<button class="preset-chip" data-preset="calm-to-chaotic">Calm/Chaos</button>
@ -146,9 +176,48 @@
<button class="preset-chip" data-preset="spiral">Spiral</button>
<button class="preset-chip" data-preset="embers">Embers</button>
</div>
<div class="loss-section">
<label>Loss History</label>
<canvas id="loss-canvas" width="280" height="80"></canvas>
</div>
</div>
</div>
<!-- Synth controls -->
<div class="synth-panel hidden" id="synth-panel">
<!-- Mode drawer -->
<div class="drawer hidden" id="drawer-mode" data-drawer="mode">
<div class="drawer-header">
<span>Mode</span>
<button class="drawer-close" data-drawer="mode">&times;</button>
</div>
<div class="drawer-body">
<div class="mode-group">
<span class="mode-label">Input</span>
<div class="pill-toggle pill-toggle-sm" id="input-toggle">
<button class="pill-opt active" data-input="joystick">Joystick</button>
<button class="pill-opt" data-input="hands">Hands</button>
</div>
</div>
<div class="mode-group">
<span class="mode-label">Output</span>
<div class="pill-toggle pill-toggle-sm" id="output-toggle-float">
<button class="pill-opt active" data-mode="visual">Visual</button>
<button class="pill-opt" data-mode="synth">Synth</button>
<button class="pill-opt" data-mode="midi-cc">MIDI CC</button>
</div>
</div>
<div class="mode-group">
<button class="follow-pill" id="follow-pill" title="Toggle follow mode">Follow</button>
</div>
</div>
</div>
<!-- Synth drawer -->
<div class="drawer hidden" id="drawer-synth" data-drawer="synth">
<div class="drawer-header">
<span>Synth</span>
<button class="drawer-close" data-drawer="synth">&times;</button>
</div>
<div class="drawer-body" id="synth-panel">
<div class="synth-row">
<button class="action-btn accent" id="synth-start">Start Audio</button>
<span class="synth-status" id="synth-status">Stopped</span>
@ -190,31 +259,43 @@
<div class="synth-row" id="midi-status-row" style="display:none">
<span class="synth-status" id="midi-status"></span>
</div>
<div class="synth-row osc-row-panel" id="osc-row">
<label>OSC Output</label>
<button class="action-btn" id="osc-toggle">Connect</button>
<span class="synth-status" id="osc-status">Off</span>
</div>
</div>
<!-- Loss plot -->
<div class="loss-section">
<label>Loss History</label>
<canvas id="loss-canvas" width="280" height="80"></canvas>
<!-- MIDI CC drawer (param management) -->
<div class="drawer hidden" id="drawer-midi-cc" data-drawer="midi-cc">
<div class="drawer-header">
<span>MIDI CC</span>
<button class="drawer-close" data-drawer="midi-cc">&times;</button>
</div>
<!-- Advanced: raw param sliders -->
<details class="advanced-section">
<summary>Advanced: Raw Parameters</summary>
<div class="raw-params" id="raw-params"></div>
</details>
<div class="drawer-body" id="midi-cc-panel">
<div class="synth-row">
<label>Parameters</label>
<span id="midi-cc-count">8</span>
<button class="action-btn" id="midi-cc-add" title="Add CC parameter">+</button>
<button class="action-btn" id="midi-cc-remove" title="Remove last CC parameter">&minus;</button>
</div>
<div class="synth-row">
<button class="action-btn" id="midi-cc-import">Import</button>
<button class="action-btn" id="midi-cc-export">Export</button>
</div>
<div id="midi-cc-param-list"></div>
</div>
</div>
<!-- Help button -->
<button class="help-btn" id="help-btn" title="Help">?</button>
<!-- Params drawer (NISPS engine tuning) -->
<div class="drawer hidden" id="drawer-params" data-drawer="params">
<div class="drawer-header">
<span>Engine</span>
<button class="drawer-close" data-drawer="params">&times;</button>
</div>
<div class="drawer-body">
<div class="engine-params" id="engine-params"></div>
</div>
</div>
</div>
<!-- Help modal -->
<!-- Help modal (unchanged) -->
<div class="help-overlay hidden" id="help-overlay">
<div class="help-modal" id="help-modal">
<button class="help-close" id="help-close">&times;</button>
@ -243,7 +324,7 @@
<li><strong>Train</strong> fits the network to your saved examples</li>
<li><strong>+ / &minus; feedback</strong> nudges the network: + reinforces the current mapping, &minus; adds exploration noise</li>
<li>Switch between <strong>Visual</strong> (particle flow field) and <strong>Synth</strong> (C15 synthesizer) output modes</li>
<li>The <strong>heatmap bar</strong> at the top shows all output parameters at a glance</li>
<li>The <strong>heatmap bar</strong> at the top shows all output parameters at a glance &mdash; drag any bar to set its value</li>
</ul>
</div>
@ -253,10 +334,13 @@
<tr><th colspan="2">Touch / Mouse</th></tr>
<tr><td>Drag joystick</td><td>Move through parameter space</td></tr>
<tr><td>+ / &minus; buttons</td><td>Positive / negative feedback</td></tr>
<tr><td>Bottom bar</td><td>Train, Randomize, Clear, Follow mode</td></tr>
<tr><td>Undo (between +/&minus;)</td><td>Revert last feedback action</td></tr>
<tr><td>Drag heatmap bar</td><td>Set parameter value directly</td></tr>
<tr><td>Right-side dock</td><td>Training, Mode, Synth, Params drawers</td></tr>
<tr><th colspan="2">Keyboard</th></tr>
<tr><td><kbd>1</kbd></td><td>Negative feedback (&minus;)</td></tr>
<tr><td><kbd>2</kbd></td><td>Positive feedback (+)</td></tr>
<tr><td><kbd>Z</kbd></td><td>Undo</td></tr>
<tr><th colspan="2">Gamepad (Steam Deck, Xbox, etc.)</th></tr>
<tr><td>Left stick</td><td>Joystick control</td></tr>
<tr><td>LB (left bumper)</td><td>Negative feedback</td></tr>
@ -269,7 +353,7 @@
<div class="help-section">
<h3>Hand Tracking</h3>
<p>Toggle to <strong>Hands</strong> mode in the bottom bar to use your webcam for input.</p>
<p>Toggle to <strong>Hands</strong> mode in the Mode drawer to use your webcam for input.</p>
<ul>
<li><strong>Right hand</strong> controls parameters &mdash; palm position, finger curls, spread, rotation, and pinch map to 14 input dimensions</li>
<li><strong>Left hand</strong> gives feedback via gestures:</li>
@ -282,16 +366,6 @@
</ul>
</div>
<div class="help-section">
<h3>Tips</h3>
<ul>
<li>Start with <strong>Randomize</strong> a few times to hear/see different mappings, then use feedback to refine</li>
<li>Use <strong>Follow</strong> mode to let the joystick wander automatically while you give feedback</li>
<li>In Synth mode, press the <strong>play button</strong> (top left, pulses orange when audio is off) to start audio, then explore</li>
<li>Tap a heatmap cell to see which parameter it controls</li>
</ul>
</div>
<div class="help-section">
<h3>Synth Controls</h3>
<ul>
@ -299,84 +373,16 @@
<li><strong>Hover any bar</strong> in the synth visualizer to see parameter name, current value, range, and curve</li>
<li><strong>Hover a group name</strong> (e.g. "Env A", "Osc B") at the top to open its control drawer:</li>
<ul>
<li><strong>Group curve</strong> &mdash; drag the top graph vertically to shape the response curve for the whole group (adjusts all param curves relatively)</li>
<li><strong>Per-param curve</strong> &mdash; drag each small graph to shape that parameter individually (down&nbsp;=&nbsp;exponential, up&nbsp;=&nbsp;logarithmic)</li>
<li><strong>Group curve</strong> &mdash; drag the top graph vertically to shape the response curve for the whole group</li>
<li><strong>Per-param curve</strong> &mdash; drag each small graph to shape that parameter individually</li>
<li><strong>Min/Max range</strong> &mdash; dual-thumb slider constrains the parameter's output range</li>
<li><strong>Mute (M)</strong> &mdash; removes the parameter from NISPS control; its bar disappears and you get a manual value slider instead</li>
<li><strong>Mute (M)</strong> &mdash; removes the parameter from NISPS control</li>
</ul>
<li><strong>MIDI input</strong> &mdash; connect a MIDI controller and it will be detected automatically; MIDI notes trigger the synth directly</li>
<li><strong>Arpeggiator</strong> &mdash; expand the bottom bar to access the built-in arpeggiator with tempo, octave range, offset, and chord progression controls; press Pause to stop it</li>
<li><strong>MIDI input</strong> &mdash; connect a MIDI controller and it will be detected automatically</li>
<li><strong>Arpeggiator</strong> &mdash; open the Synth drawer in the dock to access tempo, octave range, offset, and chord progression controls</li>
</ul>
</div>
<div class="help-section">
<h3>OSC Output</h3>
<p>Send NISPS parameters to any OSC-capable software (SuperCollider, Max/MSP, Pure Data, TouchDesigner, Ableton, etc.) via a small bridge script that runs on your computer.</p>
<div class="help-osc-steps">
<p><strong>1. Download the bridge</strong></p>
<div class="help-download-row">
<button class="help-download-btn" id="osc-download-bin">Download for <span id="osc-platform-name">your OS</span></button>
<button class="help-download-btn help-download-alt" id="osc-download-src">Source (.ts)</button>
</div>
<p class="help-dim">Binary is standalone, no runtime needed. Source requires <a href="https://deno.land" target="_blank" rel="noopener">Deno</a> or <a href="https://nodejs.org" target="_blank" rel="noopener">Node.js</a>.</p>
<p><strong>2. Run</strong></p>
<pre class="help-code" id="osc-run-instructions">./nisps-osc-bridge</pre>
<p><strong>3. Connect</strong></p>
<p>Click the <strong>OSC</strong> button in the bottom bar (visible in all modes). The dot turns green when connected. You can also find a Connect button in the synth panel when expanded.</p>
</div>
<p style="margin-top:10px"><strong>Configuration</strong></p>
<table class="help-keys">
<tr><td><kbd>--osc-port 9000</kbd></td><td>Target port (default: 57120 / SuperCollider)</td></tr>
<tr><td><kbd>--osc-host 192.168.1.5</kbd></td><td>Target IP (default: 127.0.0.1)</td></tr>
<tr><td><kbd>--osc-prefix /my</kbd></td><td>Address prefix (default: /nisps)</td></tr>
<tr><td><kbd>--ws-port 8000</kbd></td><td>WebSocket listen port (default: 8765)</td></tr>
<tr><td><kbd>--bundle</kbd></td><td>Send OSC bundles instead of individual messages</td></tr>
</table>
<p style="margin-top:10px"><strong>OSC addresses</strong></p>
<p>Each parameter is sent as <code>/nisps/&lt;Param_Name&gt; &lt;float 0&ndash;1&gt;</code>. All 126 synth parameters are available. Values reflect your current preset, curves, and tame settings. Examples:</p>
<pre class="help-code">/nisps/Env_A_Att 0.35
/nisps/SVF_Flt_Cut 0.72
/nisps/Reverb_Mix 0.15</pre>
<details class="help-details">
<summary>SuperCollider example</summary>
<pre class="help-code">// Listen for all NISPS params
OSCdef(\nisps, { |msg, time|
msg.postln;
}, '/nisps/*');
// Map a specific param to a synth
OSCdef(\cutoff, { |msg|
~synth.set(\freq, msg[1].linexp(0, 1, 200, 8000));
}, '/nisps/SVF_Flt_Cut');</pre>
</details>
<details class="help-details">
<summary>Pure Data example</summary>
<pre class="help-code">[netreceive -u -b 57120]
|
[oscparse]
|
[route /nisps]
|
[route /Env_A_Att /SVF_Flt_Cut ...]</pre>
</details>
<details class="help-details">
<summary>Max/MSP example</summary>
<pre class="help-code">[udpreceive 57120]
|
[OSC-route /nisps/Env_A_Att]
|
[scale 0. 1. 200. 8000.]</pre>
</details>
</div>
<button class="help-got-it" id="help-got-it">Got it</button>
</div>
</div>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,104 @@
// MIDI CC Map — user-defined CC parameter definitions
// Each param maps one MLP output to a MIDI CC message.
const STORAGE_KEY = 'nisps-midi-cc-map';
// Default starter set — common CC numbers
const DEFAULT_CC_MAP = [
{ name: 'Cutoff', cc: 74, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
{ name: 'Resonance', cc: 71, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
{ name: 'Attack', cc: 73, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
{ name: 'Release', cc: 72, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
{ name: 'Mod Wheel', cc: 1, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
{ name: 'Volume', cc: 7, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
{ name: 'Pan', cc: 10, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
{ name: 'Expression',cc: 11, channel: 1, min: 0, max: 1, curve: 0.5, muted: false, fixedValue: 0.5 },
];
// Well-known CC names for auto-labeling
const CC_NAMES = {
1: 'Mod Wheel', 2: 'Breath', 5: 'Portamento Time', 7: 'Volume', 10: 'Pan',
11: 'Expression', 64: 'Sustain', 65: 'Portamento', 66: 'Sostenuto',
67: 'Soft Pedal', 70: 'Sound Variation', 71: 'Resonance', 72: 'Release',
73: 'Attack', 74: 'Cutoff', 75: 'Decay', 76: 'Vib Rate', 77: 'Vib Depth',
78: 'Vib Delay', 91: 'Reverb', 92: 'Tremolo', 93: 'Chorus', 94: 'Detune',
95: 'Phaser',
};
/**
* Create a fresh CC param with defaults.
* @param {number} [cc=74] CC number
* @param {number} [channel=1] MIDI channel 1-16
* @returns {object} CC param definition
*/
export function createCCParam(cc = 74, channel = 1) {
return {
name: CC_NAMES[cc] || `CC ${cc}`,
cc,
channel,
min: 0,
max: 1,
curve: 0.5,
muted: false,
fixedValue: 0.5,
};
}
/**
* Load CC map from localStorage, or return default.
* @returns {Array} CC param definitions
*/
export function loadCCMap() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const saved = JSON.parse(raw);
if (Array.isArray(saved) && saved.length > 0) return saved;
}
} catch (e) {
console.warn('[MIDI CC] Failed to load map:', e);
}
return DEFAULT_CC_MAP.map(p => ({ ...p }));
}
/**
* Save CC map to localStorage.
* @param {Array} ccMap
*/
export function saveCCMap(ccMap) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ccMap));
} catch (e) {
console.warn('[MIDI CC] Failed to save map:', e);
}
}
/**
* Export CC map as JSON string (for sharing).
* @param {Array} ccMap
* @returns {string}
*/
export function exportCCMap(ccMap) {
return JSON.stringify(ccMap, null, 2);
}
/**
* Import CC map from JSON string.
* @param {string} json
* @returns {Array|null} parsed map or null on failure
*/
export function importCCMap(json) {
try {
const parsed = JSON.parse(json);
if (!Array.isArray(parsed) || parsed.length === 0) return null;
// Validate shape
for (const p of parsed) {
if (typeof p.cc !== 'number' || typeof p.channel !== 'number') return null;
}
return parsed;
} catch (e) {
return null;
}
}
export { CC_NAMES, DEFAULT_CC_MAP };

View file

@ -0,0 +1,147 @@
// MIDI Output — sends CC messages to external MIDI devices
// Uses Web MIDI API, complementary to midi-input.js
export class MIDIOutput {
constructor() {
this.enabled = false;
this.midiAccess = null;
this.selectedOutputId = null;
this.activeOutput = null;
this._onStatusChange = null;
this._onOutputsChange = null;
this._boundOnStateChange = this._onStateChange.bind(this);
// Throttling
this._lastSentCC = new Map(); // key: `${channel}-${cc}` -> value
this._lastSendTime = 0;
this.sendInterval = 50; // ms — max ~20 sends/sec
this.deadZone = 1; // skip if CC value hasn't changed by at least 1
}
set onStatusChange(fn) { this._onStatusChange = fn; }
set onOutputsChange(fn) { this._onOutputsChange = fn; }
_status(msg) {
console.log('[MIDI Out]', msg);
this._onStatusChange?.(msg);
}
/** Request MIDI access. Returns true if available. */
async init() {
if (this.midiAccess) return true;
if (!navigator.requestMIDIAccess) {
this._status('Web MIDI not supported');
return false;
}
try {
this.midiAccess = await navigator.requestMIDIAccess({ sysex: false });
this.midiAccess.onstatechange = this._boundOnStateChange;
this._status('MIDI output available');
return true;
} catch (err) {
this._status(`MIDI access denied: ${err.message}`);
return false;
}
}
/** Get list of available MIDI outputs as [{id, name}] */
getOutputs() {
if (!this.midiAccess) return [];
const outputs = [];
for (const [id, output] of this.midiAccess.outputs) {
outputs.push({ id, name: output.name || `MIDI Output ${id}` });
}
return outputs;
}
/** Select a specific MIDI output by id */
selectOutput(outputId) {
this.selectedOutputId = outputId;
this.activeOutput = null;
this._lastSentCC.clear();
if (!this.midiAccess || !outputId) return;
const output = this.midiAccess.outputs.get(outputId);
if (output) {
this.activeOutput = output;
this._status(`Selected: ${output.name}`);
}
}
enable() {
if (this.enabled) return;
this.enabled = true;
this._status('MIDI output enabled');
}
disable() {
if (!this.enabled) return;
this.enabled = false;
this._status('MIDI output disabled');
}
toggle() {
if (this.enabled) this.disable();
else this.enable();
}
/**
* Send a CC message. channel is 1-16, cc is 0-127, value is 0-127.
* Applies dead-zone filtering (skips if value unchanged).
*/
sendCC(channel, cc, value) {
if (!this.enabled || !this.activeOutput) return;
const ch = Math.max(0, Math.min(15, (channel - 1) | 0));
const ccNum = Math.max(0, Math.min(127, cc | 0));
const val = Math.max(0, Math.min(127, value | 0));
// Dead-zone filter
const key = `${ch}-${ccNum}`;
const lastVal = this._lastSentCC.get(key);
if (lastVal !== undefined && Math.abs(val - lastVal) < this.deadZone) return;
this._lastSentCC.set(key, val);
this.activeOutput.send([0xB0 | ch, ccNum, val]);
}
/**
* Send multiple CC values at once (throttled).
* @param {Array<{channel: number, cc: number, value: number}>} messages
*/
sendBatch(messages) {
if (!this.enabled || !this.activeOutput) return;
const now = performance.now();
if (now - this._lastSendTime < this.sendInterval) return;
this._lastSendTime = now;
for (const msg of messages) {
this.sendCC(msg.channel, msg.cc, msg.value);
}
}
_onStateChange(e) {
this._onOutputsChange?.(this.getOutputs());
// Reconnect if selected device was re-plugged
if (this.selectedOutputId && this.enabled) {
const output = this.midiAccess.outputs.get(this.selectedOutputId);
if (output) {
this.activeOutput = output;
} else {
this.activeOutput = null;
}
}
}
destroy() {
this.disable();
if (this.midiAccess) {
this.midiAccess.onstatechange = null;
}
this.midiAccess = null;
this.activeOutput = null;
}
}

View file

@ -7,7 +7,16 @@ const HEADER_SIZE = 3;
const MESSAGE_SIZE = 4;
const RING_CAPACITY = 512;
// Cmin7 inversions: root [0,3,7,10], 1st [3,7,10,12], 2nd [7,10,12,15], 3rd [10,12,15,19]
const CMIN7_INVERSIONS = [
[0, 3, 7, 10],
[3, 7, 10, 12],
[7, 10, 12, 15],
[10, 12, 15, 19],
];
const PROGRESSIONS = {
'Cmin7-rand': null, // special case: random inversions
'I-vi-IV-V': [[0,4,7],[9,12,16],[5,9,12],[7,11,14]],
'I-IV-vi-V': [[0,4,7],[5,9,12],[9,12,16],[7,11,14]],
'i-VI-III-VII': [[0,3,7],[8,12,15],[3,7,10],[10,14,17]],
@ -44,9 +53,12 @@ let playing = false;
let bpm = 120;
let octaves = 2;
let octaveOffset = 0;
let progression = 'I-vi-IV-V';
let progression = 'Cmin7-rand';
let direction = 'updown'; // 'up' or 'updown'
let chordIndex = 0;
let noteIndex = 0;
let ascending = true; // for updown mode
let currentInversion = null; // cached Cmin7 inversion for current cycle
let lastNote = -1;
let timer = null;
@ -58,17 +70,27 @@ function scheduleNext() {
timer = setTimeout(scheduleNext, noteDuration);
}
function playNextNote() {
const chords = PROGRESSIONS[progression] || PROGRESSIONS['I-vi-IV-V'];
// Release previous note
if (lastNote >= 0) {
ringWrite(MESSAGE_TYPE.NOTE_OFF, lastNote, 0);
}
const chord = chords[chordIndex];
function getChordNotes() {
const baseNote = 48 + (octaveOffset * 12);
if (progression === 'Cmin7-rand') {
// Pick a random inversion once per cycle, cache until next chord advance
if (!currentInversion) {
currentInversion = CMIN7_INVERSIONS[Math.floor(Math.random() * CMIN7_INVERSIONS.length)];
}
const inv = currentInversion;
const notes = [];
for (let oct = 0; oct < octaves; oct++) {
for (const interval of inv) {
const note = baseNote + interval + (oct * 12);
if (note >= 0 && note <= 127) notes.push(note);
}
}
return notes;
}
const chords = PROGRESSIONS[progression] || PROGRESSIONS['I-vi-IV-V'];
const chord = chords[chordIndex];
const notes = [];
for (let oct = 0; oct < octaves; oct++) {
for (const interval of chord) {
@ -76,19 +98,55 @@ function playNextNote() {
if (note >= 0 && note <= 127) notes.push(note);
}
}
return notes;
}
function advanceIndex(notes) {
const chords = PROGRESSIONS[progression];
const chordCount = (progression === 'Cmin7-rand') ? 1 : (chords ? chords.length : 1);
if (direction === 'updown' && notes.length > 1) {
if (ascending) {
noteIndex++;
if (noteIndex >= notes.length) {
ascending = false;
noteIndex = notes.length - 2; // bounce back, skip the top note
}
} else {
noteIndex--;
if (noteIndex <= 0) {
ascending = true;
noteIndex = 0;
chordIndex = (chordIndex + 1) % chordCount;
currentInversion = null; // pick new random inversion next cycle
}
}
} else {
// up only
noteIndex++;
if (noteIndex >= notes.length) {
noteIndex = 0;
chordIndex = (chordIndex + 1) % chordCount;
currentInversion = null; // pick new random inversion next cycle
}
}
}
function playNextNote() {
// Release previous note
if (lastNote >= 0) {
ringWrite(MESSAGE_TYPE.NOTE_OFF, lastNote, 0);
}
const notes = getChordNotes();
if (notes.length === 0) return;
const note = notes[noteIndex % notes.length];
const note = notes[Math.min(noteIndex, notes.length - 1)];
const velocity = 0.6 + Math.random() * 0.2;
ringWrite(MESSAGE_TYPE.NOTE_ON, note, velocity);
lastNote = note;
noteIndex++;
if (noteIndex >= notes.length) {
noteIndex = 0;
chordIndex = (chordIndex + 1) % chords.length;
}
advanceIndex(notes);
}
function start() {
@ -96,6 +154,8 @@ function start() {
playing = true;
chordIndex = 0;
noteIndex = 0;
ascending = true;
currentInversion = null;
scheduleNext();
postMessage({ type: 'state', playing: true });
}
@ -133,6 +193,7 @@ self.onmessage = (e) => {
if ('octaves' in data) octaves = data.octaves;
if ('octaveOffset' in data) octaveOffset = data.octaveOffset;
if ('progression' in data) progression = data.progression;
if ('direction' in data) direction = data.direction;
break;
}
};

View file

@ -11,11 +11,16 @@ export class Arpeggiator {
this._bpm = 120;
this._octaves = 2;
this._octaveOffset = 0;
this._progression = 'I-vi-IV-V';
this._progression = 'Cmin7-rand';
this._direction = 'updown';
}
get progressionNames() {
return ['I-vi-IV-V', 'I-IV-vi-V', 'i-VI-III-VII', 'I-V-vi-IV'];
return ['Cmin7-rand', 'I-vi-IV-V', 'I-IV-vi-V', 'i-VI-III-VII', 'I-V-vi-IV'];
}
get directionNames() {
return ['up', 'updown'];
}
get playing() { return this._playing; }
@ -44,6 +49,12 @@ export class Arpeggiator {
this._send('set', { progression: v });
}
get direction() { return this._direction; }
set direction(v) {
this._direction = v;
this._send('set', { direction: v });
}
_ensureWorker() {
if (this._worker) return;
@ -70,6 +81,7 @@ export class Arpeggiator {
octaves: this._octaves,
octaveOffset: this._octaveOffset,
progression: this._progression,
direction: this._direction,
});
}

View file

@ -106,7 +106,7 @@ export function initControlSurfaceUI() {
surface.applyPreset('default');
// --- Floating bar: compound axis sliders ---
const $floatingBar = document.getElementById('floating-bar');
const $barContent = document.querySelector('.floating-bar-content') || document.getElementById('floating-bar');
const axisContainer = document.createElement('div');
axisContainer.className = 'cs-axes';
axisContainer.innerHTML = `
@ -124,13 +124,7 @@ export function initControlSurfaceUI() {
</div>
`;
// Insert before the chevron button
const $chevron = document.getElementById('chevron-btn');
if ($chevron) {
$floatingBar.insertBefore(axisContainer, $chevron);
} else {
$floatingBar.appendChild(axisContainer);
}
$barContent.appendChild(axisContainer);
// Wire axis sliders
const axisSliders = {};

View file

@ -23,8 +23,8 @@ const MEMORY_TABLE = [
const PRECISION_TABLE = [
[0.0, { inputCurve: 1.0, deadzone: 0.0, smoothing: 0.0, slewRate: 1.0, momentumZoom: 'off' }],
[0.5, { inputCurve: 1.5, deadzone: 0.05, smoothing: 0.15, slewRate: 0.3, momentumZoom: 'gentle' }],
[1.0, { inputCurve: 3.0, deadzone: 0.15, smoothing: 0.40, slewRate: 0.1, momentumZoom: 'strong' }],
[0.5, { inputCurve: 1.5, deadzone: 0.05, smoothing: 0.15, slewRate: 0.3, momentumZoom: 'off' }],
[1.0, { inputCurve: 3.0, deadzone: 0.15, smoothing: 0.40, slewRate: 0.1, momentumZoom: 'off' }],
];
// ---- Control Presets ----

View file

@ -40,7 +40,7 @@ export const SMOOTHING_MAX = 0.95;
/** @type {string} Default momentum-zoom mode */
export const DEFAULT_MOMENTUM_ZOOM = 'off';
/** @type {string} Default anchor mode */
export const DEFAULT_ANCHOR_MODE = 'auto';
export const DEFAULT_ANCHOR_MODE = 'center';
/** @type {number} Default velocity estimation window in ms */
export const DEFAULT_VELOCITY_WINDOW = 150;
@ -218,6 +218,18 @@ export class InputPipeline {
x = applyDeadzone(x, this._deadzone);
y = applyDeadzone(y, this._deadzone);
// --- 1.5. Circular clamp ---
// Constrain input to a unit circle (radius 0.5 centered at 0.5,0.5)
// so the reachable input space matches the circular joystick UI.
const cx = x - 0.5;
const cy = y - 0.5;
const dist = Math.sqrt(cx * cx + cy * cy);
if (dist > 0.5) {
const scale = 0.5 / dist;
x = 0.5 + cx * scale;
y = 0.5 + cy * scale;
}
// --- 2. Zoom ---
const anchorX = this._resolveAnchorX();
const anchorY = this._resolveAnchorY();
@ -401,24 +413,34 @@ export class InputPipeline {
}
/**
* Get the zoom window bounds in [0,1] space useful for minimap rendering.
* Returns the rectangle of input space that the joystick currently covers.
* @returns {{ x1: number, y1: number, x2: number, y2: number }}
* Get the zoom window as a circle in [0,1] space for minimap rendering.
* Returns center + radius of the input space the joystick currently covers.
* @returns {{ cx: number, cy: number, r: number }}
*/
getZoomWindow() {
const anchorX = this._resolveAnchorX();
const anchorY = this._resolveAnchorY();
const baseZoomX = this._zoomX != null ? this._zoomX : this._zoom;
const baseZoomY = this._zoomY != null ? this._zoomY : this._zoom;
const effZoomX = clamp(baseZoomX * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
const effZoomY = clamp(baseZoomY * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
const halfX = effZoomX / 2;
const halfY = effZoomY / 2;
const baseZoom = this._zoomX != null ? this._zoomX : this._zoom;
const effZoom = clamp(baseZoom * this._momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
return {
x1: clamp(anchorX - halfX, 0, 1),
y1: clamp(anchorY - halfY, 0, 1),
x2: clamp(anchorX + halfX, 0, 1),
y2: clamp(anchorY + halfY, 0, 1),
cx: anchorX,
cy: anchorY,
r: effZoom / 2,
};
}
/**
* Get the zoom window as an axis-aligned bounding rect in [0,1] space.
* For consumers that need {x1, y1, x2, y2} (heatmap, region pinning).
* @returns {{ x1: number, y1: number, x2: number, y2: number }}
*/
getZoomWindowRect() {
const { cx, cy, r } = this.getZoomWindow();
return {
x1: clamp(cx - r, 0, 1),
y1: clamp(cy - r, 0, 1),
x2: clamp(cx + r, 0, 1),
y2: clamp(cy + r, 0, 1),
};
}

View file

@ -311,53 +311,39 @@ export class JoyMapEnhanced {
// ---- Zoom window ----
/**
* Normalize zoom window to { cx, cy, r } circle format.
*/
_normalizeZoomWindow(zw, zoomLevel) {
if (zw && typeof zw.x1 === 'number') return zw;
if (zw && typeof zw.cx === 'number') return zw;
// Synthesize from zoomLevel centered on 0.5
const half = zoomLevel / 2;
return {
x1: 0.5 - half,
y1: 0.5 - half,
x2: 0.5 + half,
y2: 0.5 + half
};
return { cx: 0.5, cy: 0.5, r: zoomLevel / 2 };
}
// ---- Drawing layers ----
_drawDimOverlay(ctx, w, h, zw) {
if (zw.x1 <= 0 && zw.y1 <= 0 && zw.x2 >= 1 && zw.y2 >= 1) return; // no zoom
if (zw.r >= 0.5) return; // no zoom — circle covers entire space
// Draw dim overlay over the entire area, then clear the zoom window
// Draw dim overlay over the entire area, cut out the zoom circle
ctx.save();
ctx.fillStyle = DIM_OVERLAY;
// Use even-odd rule: outer rect minus zoom rect
const zcx = zw.cx * w;
const zcy = (1 - zw.cy) * h;
const zr = zw.r * Math.min(w, h);
// Even-odd: outer rect minus inner circle
ctx.beginPath();
ctx.rect(0, 0, w, h);
// Zoom window rect (Y inverted)
const zx1 = zw.x1 * w;
const zy1 = (1 - zw.y2) * h;
const zx2 = zw.x2 * w;
const zy2 = (1 - zw.y1) * h;
const zw_ = zx2 - zx1;
const zh_ = zy2 - zy1;
// Draw inner rect counter-clockwise for even-odd
ctx.moveTo(zx1, zy1);
ctx.lineTo(zx1, zy1 + zh_);
ctx.lineTo(zx1 + zw_, zy1 + zh_);
ctx.lineTo(zx1 + zw_, zy1);
ctx.closePath();
ctx.arc(zcx, zcy, zr, 0, Math.PI * 2, true); // counter-clockwise for cutout
ctx.fill('evenodd');
ctx.restore();
}
_drawGrid(ctx, w, h, zw, zoomLevel) {
// Determine grid density based on zoom
// zoom 1.0 -> 4x4, 0.5 -> 8x8, 0.25 -> 16x16, etc.
// Base divisions = 4, multiply by 1/zoomLevel
const baseDivisions = 4;
const zoomScale = Math.max(zw.x2 - zw.x1, zw.y2 - zw.y1);
const zoomScale = zw.r * 2;
// We draw multiple grid levels with fading
// Level 0: 4x4 (always)
@ -418,22 +404,25 @@ export class JoyMapEnhanced {
}
_drawZoomWindowBorder(ctx, w, h, zw) {
if (zw.x1 <= 0 && zw.y1 <= 0 && zw.x2 >= 1 && zw.y2 >= 1) return;
if (zw.r >= 0.5) return; // no zoom
const x1 = zw.x1 * w;
const y1 = (1 - zw.y2) * h;
const rw = (zw.x2 - zw.x1) * w;
const rh = (zw.y2 - zw.y1) * h;
const zcx = zw.cx * w;
const zcy = (1 - zw.cy) * h;
const zr = zw.r * Math.min(w, h);
// Fill
ctx.beginPath();
ctx.arc(zcx, zcy, zr, 0, Math.PI * 2);
ctx.fillStyle = ZOOM_WINDOW_FILL;
ctx.fillRect(x1, y1, rw, rh);
ctx.fill();
// Border
ctx.beginPath();
ctx.arc(zcx, zcy, zr, 0, Math.PI * 2);
ctx.strokeStyle = ZOOM_WINDOW_BORDER;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.strokeRect(x1, y1, rw, rh);
ctx.stroke();
ctx.setLineDash([]);
}

View file

@ -144,8 +144,8 @@ export function initPhase4UI(opts) {
// ---------------------------------------------------------------------------
function createFreezeButton(outputPipeline) {
const $floatingBar = document.getElementById('floating-bar');
if (!$floatingBar) {
const $barContent = document.querySelector('.floating-bar-content') || document.getElementById('floating-bar');
if (!$barContent) {
console.warn('[Phase4] floating-bar not found');
return document.createElement('button');
}
@ -163,13 +163,7 @@ function createFreezeButton(outputPipeline) {
btn.title = frozen ? 'Unfreeze output' : 'Freeze output';
});
// Insert before the chevron
const $chevron = document.getElementById('chevron-btn');
if ($chevron) {
$floatingBar.insertBefore(btn, $chevron);
} else {
$floatingBar.appendChild(btn);
}
$barContent.appendChild(btn);
return btn;
}