feat(shapeseq): unified/dual MLP mode switching

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.
This commit is contained in:
w1n5t0n 2026-04-06 23:57:41 +01:00
parent 9b08918b1e
commit 4aedc92bf9
3 changed files with 265 additions and 1 deletions

View file

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

View file

@ -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<void>}
*/
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') {

View file

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