hello come back to me
This commit is contained in:
parent
2060f10b40
commit
456c426cb1
19 changed files with 4773 additions and 1357 deletions
1002
.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain-ui.js
Normal file
1002
.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain-ui.js
Normal file
File diff suppressed because it is too large
Load diff
319
.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain.js
Normal file
319
.claude/worktrees/agent-ae87fe47/playground/js/shapeseq/chain.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,60 +102,122 @@
|
|||
</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">−</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>
|
||||
<!-- Status line (floating, minimal) -->
|
||||
<div class="status-line" id="status-line">
|
||||
<span id="status-text">0 examples · untrained</span>
|
||||
</div>
|
||||
|
||||
<!-- 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">×</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 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 Ex</button>
|
||||
<button class="action-btn dim" id="btn-clear">Clear All</button>
|
||||
<button class="action-btn dim" id="btn-randomize">Randomize</button>
|
||||
</div>
|
||||
<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>
|
||||
<button class="preset-chip" data-preset="rainbow-sweep">Rainbow</button>
|
||||
<button class="preset-chip" data-preset="vortex">Vortex</button>
|
||||
<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>
|
||||
<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">▲</button>
|
||||
</div>
|
||||
|
||||
<!-- Status line -->
|
||||
<div class="sheet-status" id="sheet-status">
|
||||
<span id="status-text">0 examples · untrained</span>
|
||||
<!-- 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">×</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>
|
||||
|
||||
<!-- Sheet content -->
|
||||
<div class="sheet-content" id="sheet-content">
|
||||
<!-- Examples mode actions -->
|
||||
<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">Clear All</button>
|
||||
<button class="action-btn dim" id="btn-randomize">Randomize</button>
|
||||
<!-- 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">×</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>
|
||||
<button class="preset-chip" data-preset="rainbow-sweep">Rainbow</button>
|
||||
<button class="preset-chip" data-preset="vortex">Vortex</button>
|
||||
<button class="preset-chip" data-preset="spiral">Spiral</button>
|
||||
<button class="preset-chip" data-preset="embers">Embers</button>
|
||||
</div>
|
||||
|
||||
<!-- Synth controls -->
|
||||
<div class="synth-panel hidden" id="synth-panel">
|
||||
<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>
|
||||
|
||||
<!-- 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">×</button>
|
||||
</div>
|
||||
<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">−</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>
|
||||
|
||||
<!-- Loss plot -->
|
||||
<div class="loss-section">
|
||||
<label>Loss History</label>
|
||||
<canvas id="loss-canvas" width="280" height="80"></canvas>
|
||||
<!-- 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">×</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<div class="engine-params" id="engine-params"></div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced: raw param sliders -->
|
||||
<details class="advanced-section">
|
||||
<summary>Advanced: Raw Parameters</summary>
|
||||
<div class="raw-params" id="raw-params"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help button -->
|
||||
<button class="help-btn" id="help-btn" title="Help">?</button>
|
||||
|
||||
<!-- 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">×</button>
|
||||
|
|
@ -243,7 +324,7 @@
|
|||
<li><strong>Train</strong> fits the network to your saved examples</li>
|
||||
<li><strong>+ / − feedback</strong> nudges the network: + reinforces the current mapping, − 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 — 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>+ / − 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 +/−)</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 (−)</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 — 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> — 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> — drag each small graph to shape that parameter individually (down = exponential, up = logarithmic)</li>
|
||||
<li><strong>Group curve</strong> — drag the top graph vertically to shape the response curve for the whole group</li>
|
||||
<li><strong>Per-param curve</strong> — drag each small graph to shape that parameter individually</li>
|
||||
<li><strong>Min/Max range</strong> — dual-thumb slider constrains the parameter's output range</li>
|
||||
<li><strong>Mute (M)</strong> — removes the parameter from NISPS control; its bar disappears and you get a manual value slider instead</li>
|
||||
<li><strong>Mute (M)</strong> — removes the parameter from NISPS control</li>
|
||||
</ul>
|
||||
<li><strong>MIDI input</strong> — connect a MIDI controller and it will be detected automatically; MIDI notes trigger the synth directly</li>
|
||||
<li><strong>Arpeggiator</strong> — 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> — connect a MIDI controller and it will be detected automatically</li>
|
||||
<li><strong>Arpeggiator</strong> — 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/<Param_Name> <float 0–1></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
104
playground/js/midi/midi-cc-map.js
Normal file
104
playground/js/midi/midi-cc-map.js
Normal 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 };
|
||||
147
playground/js/midi/midi-output.js
Normal file
147
playground/js/midi/midi-output.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue