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:
parent
9b08918b1e
commit
4aedc92bf9
3 changed files with 265 additions and 1 deletions
54
playground/js/shapeseq/mlp-mode.js
Normal file
54
playground/js/shapeseq/mlp-mode.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
148
playground/js/shapeseq/tests/mlp-mode.test.js
Normal file
148
playground/js/shapeseq/tests/mlp-mode.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue