From 4aedc92bf9302edc9622e9ae2eaffae65c2dc8f0 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Mon, 6 Apr 2026 23:57:41 +0100 Subject: [PATCH] feat(shapeseq): unified/dual MLP mode switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unified mode: a single MLP drives both timbre and sequence — the timbre MLP's outputs are partitioned and a configurable slice feeds the sequencer pipeline via setSequenceOutputsFromTimbre(). Dual mode (default): separate MLPs for timbre and sequence with independent training. The sequencer owns its own IML instance. --- playground/js/shapeseq/mlp-mode.js | 54 +++++++ playground/js/shapeseq/sequencer.js | 64 +++++++- playground/js/shapeseq/tests/mlp-mode.test.js | 148 ++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 playground/js/shapeseq/mlp-mode.js create mode 100644 playground/js/shapeseq/tests/mlp-mode.test.js diff --git a/playground/js/shapeseq/mlp-mode.js b/playground/js/shapeseq/mlp-mode.js new file mode 100644 index 0000000..e547574 --- /dev/null +++ b/playground/js/shapeseq/mlp-mode.js @@ -0,0 +1,54 @@ +/** + * MLP mode manager — switches between unified and dual MLP operation. + * + * Unified: single MLP, outputs partitioned between timbre and sequence. + * Dual: separate MLPs for timbre and sequence, independent training. + */ + +export const MLP_MODES = Object.freeze({ UNIFIED: 'unified', DUAL: 'dual' }); + +export class MLPModeManager { + constructor() { + this._mode = MLP_MODES.DUAL; // default to dual (current architecture) + this._unifiedSliceStart = 0; // where sequence outputs start in unified mode + this._unifiedSliceCount = 16; // how many outputs go to sequence in unified mode + } + + get mode() { return this._mode; } + + /** + * Configure unified mode: which slice of the timbre MLP outputs feeds the sequencer. + * @param {number} start - first output index for sequence + * @param {number} count - number of outputs for sequence + */ + setUnifiedConfig(start, count) { + this._unifiedSliceStart = start; + this._unifiedSliceCount = count; + } + + get unifiedSliceStart() { return this._unifiedSliceStart; } + get unifiedSliceCount() { return this._unifiedSliceCount; } + + setMode(mode) { + if (mode !== MLP_MODES.UNIFIED && mode !== MLP_MODES.DUAL) { + throw new TypeError('Invalid MLP mode: ' + mode); + } + this._mode = mode; + } + + /** + * Extract sequence params from a timbre MLP's outputs (unified mode). + * @param {Float32Array} timbreOutputs - full output array from the timbre MLP + * @returns {Float32Array} slice for the sequence engine + */ + extractSequenceOutputs(timbreOutputs) { + const start = this._unifiedSliceStart; + const count = this._unifiedSliceCount; + const result = new Float32Array(count); + for (let i = 0; i < count; i++) { + const idx = start + i; + result[i] = idx < timbreOutputs.length ? timbreOutputs[idx] : 0.5; + } + return result; + } +} diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index 5b8ba42..32726d9 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -21,6 +21,7 @@ */ import { createSequenceIML, SEQ_DEFAULT_OUTPUT_COUNT } from './seq-iml.js'; +import { MLPModeManager, MLP_MODES } from './mlp-mode.js'; import { Chain } from './chain.js'; import { ClockEngine } from './clock.js'; import { map } from './param-map.js'; @@ -66,6 +67,9 @@ export class ShapeSeqEngine { /** @private */ this._outputCount = SEQ_DEFAULT_OUTPUT_COUNT; /** @private */ this._stepCount = DEFAULT_STEP_COUNT; + // MLP mode manager (unified vs dual) + /** @private */ this._mlpMode = new MLPModeManager(); + // Freeze-as-algorithm manager /** @private */ this._freezeManager = new FreezeManager(); @@ -281,6 +285,57 @@ export class ShapeSeqEngine { /** @returns {FreezeManager} */ get freezeManager() { return this._freezeManager; } + /** @returns {MLPModeManager} */ + get mlpMode() { return this._mlpMode; } + + // ── MLP mode switching ──────────────────────────────────────────── + + /** + * Switch between unified and dual MLP mode. + * + * - unified: destroy the internal sequence MLP; inference is driven + * externally via setSequenceOutputsFromTimbre(). + * - dual: (re)create the internal sequence MLP via createSequenceIML. + * + * @param {'unified'|'dual'} mode + * @returns {Promise} + */ + async setMLPMode(mode) { + this._mlpMode.setMode(mode); // validates + + if (mode === MLP_MODES.UNIFIED) { + // Destroy internal sequence MLP — unified mode uses external outputs + if (this._sequenceIML) { + this._sequenceIML.destroy(); + this._sequenceIML = null; + } + } else { + // Dual mode: ensure we have a sequence MLP + if (!this._sequenceIML) { + this._sequenceIML = await createSequenceIML({ outputCount: this._outputCount }); + this._sequenceIML.randomiseWeights(DEFAULT_SPREAD); + } + } + + this._bumpGeneration(); + } + + /** + * Feed sequence outputs extracted from the timbre MLP (unified mode). + * + * Runs the downstream pipeline (param mapping -> chain -> projection -> clock) + * without running the internal sequence MLP inference. + * + * @param {Float32Array} timbreOutputs - full output array from the timbre MLP + */ + setSequenceOutputsFromTimbre(timbreOutputs) { + if (!this._initialized) return; + if (this._mlpMode.mode !== MLP_MODES.UNIFIED) return; + + const mlpOutputs = this._mlpMode.extractSequenceOutputs(timbreOutputs); + this._runPipeline(mlpOutputs); + } + // ── Freeze-as-algorithm ──────────────────────────────────────────── /** @@ -334,7 +389,14 @@ export class ShapeSeqEngine { * @param {number[]} values - input array (typically [x, y]) */ setSequenceInputs(values) { - if (!this._initialized || !this._sequenceIML) return; + if (!this._initialized) return; + + // In unified mode, skip — inference is driven externally via + // setSequenceOutputsFromTimbre(). + if (this._mlpMode.mode === MLP_MODES.UNIFIED) return; + + // Dual mode requires the internal sequence MLP + if (!this._sequenceIML) return; // Freeze-as-pattern: skip entire pipeline, just keep looping frozen pattern if (this._freezeManager.isFrozen && this._freezeManager.freezeMode === 'pattern') { diff --git a/playground/js/shapeseq/tests/mlp-mode.test.js b/playground/js/shapeseq/tests/mlp-mode.test.js new file mode 100644 index 0000000..b7fa7b1 --- /dev/null +++ b/playground/js/shapeseq/tests/mlp-mode.test.js @@ -0,0 +1,148 @@ +/** + * Tests for mlp-mode.js — MLPModeManager and MLP_MODES. + * + * Covers mode switching, output slicing, and configuration. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { MLPModeManager, MLP_MODES } from '../mlp-mode.js'; + +/** Approximate equality for Float32 values. */ +function assertClose(actual, expected, eps = 1e-6) { + assert.ok( + Math.abs(actual - expected) < eps, + `Expected ${actual} to be close to ${expected} (eps=${eps})` + ); +} + +// ── MLP_MODES constant ───────────────────────────────────────────── + +describe('MLP_MODES', () => { + it('has UNIFIED and DUAL values', () => { + assert.equal(MLP_MODES.UNIFIED, 'unified'); + assert.equal(MLP_MODES.DUAL, 'dual'); + }); + + it('is frozen', () => { + assert.throws(() => { MLP_MODES.FOO = 'bar'; }, TypeError); + }); +}); + +// ── MLPModeManager ────────────────────────────────────────────────── + +describe('MLPModeManager', () => { + it('defaults to dual mode', () => { + const mgr = new MLPModeManager(); + assert.equal(mgr.mode, MLP_MODES.DUAL); + }); + + it('setMode switches to unified', () => { + const mgr = new MLPModeManager(); + mgr.setMode(MLP_MODES.UNIFIED); + assert.equal(mgr.mode, MLP_MODES.UNIFIED); + }); + + it('setMode switches back to dual', () => { + const mgr = new MLPModeManager(); + mgr.setMode(MLP_MODES.UNIFIED); + mgr.setMode(MLP_MODES.DUAL); + assert.equal(mgr.mode, MLP_MODES.DUAL); + }); + + it('setMode throws on invalid mode', () => { + const mgr = new MLPModeManager(); + assert.throws(() => mgr.setMode('invalid'), TypeError); + assert.throws(() => mgr.setMode(''), TypeError); + assert.throws(() => mgr.setMode(null), TypeError); + assert.throws(() => mgr.setMode(undefined), TypeError); + }); + + it('setUnifiedConfig changes the slice window', () => { + const mgr = new MLPModeManager(); + assert.equal(mgr.unifiedSliceStart, 0); + assert.equal(mgr.unifiedSliceCount, 16); + + mgr.setUnifiedConfig(64, 32); + assert.equal(mgr.unifiedSliceStart, 64); + assert.equal(mgr.unifiedSliceCount, 32); + }); +}); + +// ── extractSequenceOutputs ────────────────────────────────────────── + +describe('extractSequenceOutputs', () => { + it('slices the correct range from timbre outputs', () => { + const mgr = new MLPModeManager(); + mgr.setUnifiedConfig(2, 4); + + const timbre = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]); + const result = mgr.extractSequenceOutputs(timbre); + + assert.equal(result.length, 4); + assertClose(result[0], 0.3); // index 2 + assertClose(result[1], 0.4); // index 3 + assertClose(result[2], 0.5); // index 4 + assertClose(result[3], 0.6); // index 5 + }); + + it('uses default slice (start=0, count=16)', () => { + const mgr = new MLPModeManager(); + const timbre = new Float32Array(126); + for (let i = 0; i < 126; i++) timbre[i] = i / 126; + + const result = mgr.extractSequenceOutputs(timbre); + assert.equal(result.length, 16); + assert.equal(result[0], timbre[0]); + assert.equal(result[15], timbre[15]); + }); + + it('fills with 0.5 when slice extends beyond timbre outputs', () => { + const mgr = new MLPModeManager(); + mgr.setUnifiedConfig(5, 4); + + // Only 6 elements — slice starts at 5, needs 4 (indices 5,6,7,8) + const timbre = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.9]); + const result = mgr.extractSequenceOutputs(timbre); + + assert.equal(result.length, 4); + assertClose(result[0], 0.9); // index 5 — last valid + assert.equal(result[1], 0.5); // index 6 — out of bounds + assert.equal(result[2], 0.5); // index 7 — out of bounds + assert.equal(result[3], 0.5); // index 8 — out of bounds + }); + + it('fills entirely with 0.5 when start is beyond array length', () => { + const mgr = new MLPModeManager(); + mgr.setUnifiedConfig(100, 3); + + const timbre = new Float32Array([0.1, 0.2, 0.3]); + const result = mgr.extractSequenceOutputs(timbre); + + assert.equal(result.length, 3); + assert.equal(result[0], 0.5); + assert.equal(result[1], 0.5); + assert.equal(result[2], 0.5); + }); + + it('returns Float32Array', () => { + const mgr = new MLPModeManager(); + const timbre = new Float32Array(20); + const result = mgr.extractSequenceOutputs(timbre); + assert.ok(result instanceof Float32Array); + }); + + it('handles empty timbre array', () => { + const mgr = new MLPModeManager(); + mgr.setUnifiedConfig(0, 4); + + const timbre = new Float32Array(0); + const result = mgr.extractSequenceOutputs(timbre); + + assert.equal(result.length, 4); + for (let i = 0; i < 4; i++) { + assert.equal(result[i], 0.5); + } + }); +});