memlnaut-nisps/manifold/src/console/model.ts
monkey-w1n5t0n 19b7f7eee8 feat(manifold): add convertible React front-end + simplify console chrome
Adds the Manifold app (manifold/) — the convertible-mode React front-end on
the real NISPS ML+audio engine, served live at meml.lnfinitemonkeys.org/next/.

Console chrome trimmed per UI pass:
- drop mode label + subtitle from the top-left overlay (keep MEMLNaut wordmark)
- remove the composite split preset/ratio readout (top-centre)
- remove the OUTPUT corner tag above the bars
- remove the A/B compare toggle from the verdict cluster
- remove the follow button + input/noise readout (bottom-left)
- remove AltitudeNav focus switcher (bottom-right)
2026-06-28 03:28:45 +02:00

246 lines
7.2 KiB
TypeScript

/**
* Console — shared instrument model: the static modes catalogue + per-param
* shaping helpers. Ported from the window-global `model.jsx`.
*
* KEY CHANGE vs the JSX reference: the pseudo-inference `MF_infer` (sin/cos
* placeholder) and the `useInstrument` hook are GONE. The `values` every
* consumer reads now come from the REAL engine (`engine.getOutputs()`), mapped
* onto a mode's params here via {@link shapeValues}. This file keeps only the
* mode/param DATA + the pure shaping maths.
*
* The `c15` mode and its synth label are relabelled to "Powerful Synth Engine"
* — the string "C15" must never appear in the UI (it survives only as an
* internal mode id).
*/
export type ParamStatus = 'off' | 'fixed' | 'live';
export type ParamGroup = 'formant' | 'pitch' | 'amp' | 'filter' | 'fx' | 'mod';
export type ModeClass = 'Synth' | 'Sequencer' | 'Controller' | 'Visual';
export type ModeInput = 'xy' | 'joystick' | 'audio_in';
/**
* Per-output control row — the unified store used by both the stage
* (OutputStage / ReadoutStrip) and the Outputs/Routing dock. `status` is the
* model-control tri-state; `muted` and `armed` are ORTHOGONAL modifiers
* (dock-spec §3.2 — the deliberate split of the deployed conflated
* frozen↔muted field). Backend-specific specs are populated by the active
* backend adapter (dock-spec §4); their shapes live in dock/output-state.ts and
* are re-declared here loosely to avoid a console→dock import cycle.
*/
export interface MFParam {
name: string;
group: string;
status: ParamStatus;
val: number;
min: number;
max: number;
curve: number;
/** Downstream silence — still computed + visible (distinct from `off`). */
muted?: boolean;
/** Solo / arm — focus training on this output (dock-spec §1.2). */
armed?: boolean;
/** MIDI CC backend spec ({ cc, channel, name, value }). */
midi?: { cc: number; channel: number; name: string; value: number };
/** OSC backend spec ({ path, rangeMin, rangeMax }). */
osc?: { path: string; rangeMin: number; rangeMax: number };
/** VCV backend spec ({ bipolar }). */
vcv?: { bipolar: boolean };
}
export interface MFMode {
id: string;
label: string;
cls: ModeClass;
glyph: string;
input: ModeInput;
params: MFParam[];
placeholder?: boolean;
badge?: string;
}
type Spec = ReadonlyArray<readonly [string, ReadonlyArray<string>]>;
function mkParams(spec: Spec): MFParam[] {
const out: MFParam[] = [];
for (const [group, names] of spec) {
names.forEach((name) =>
out.push({ name, group, status: 'live', val: 0.5, min: 0, max: 1, curve: 0.5 }),
);
}
return out;
}
export const MF_MODES: MFMode[] = [
{
id: 'paf_synth',
label: 'PAF Synth',
cls: 'Synth',
glyph: '∿',
input: 'xy',
params: mkParams([
['formant', ['F1', 'F2', 'F3', 'tilt', 'spread', 'skirt']],
['pitch', ['root', 'glide', 'detune']],
['amp', ['gain', 'attack', 'decay']],
['filter', ['cutoff', 'res', 'env']],
['fx', ['drive', 'air', 'width']],
]),
},
{
id: 'channel_strip',
label: 'Channel Strip',
cls: 'Synth',
glyph: '▤',
input: 'joystick',
params: mkParams([
['filter', ['lo', 'loMid', 'hiMid', 'hi']],
['amp', ['comp', 'gate', 'makeup']],
['fx', ['sat', 'width', 'glue', 'tilt', 'air']],
]),
},
{
id: 'verb_fx',
label: 'Verb FX',
cls: 'Synth',
glyph: '◞',
input: 'joystick',
params: mkParams([
['fx', ['size', 'decay', 'damp', 'diff']],
['mod', ['rate', 'depth']],
['filter', ['lo', 'hi']],
]),
},
{
id: 'elysiamorf',
label: 'Elysiamorf',
cls: 'Synth',
glyph: '❋',
input: 'xy',
params: mkParams([
['formant', ['grain', 'size', 'pos', 'spray']],
['mod', ['rate', 'depth', 'jitter']],
['amp', ['gain', 'env']],
['filter', ['cutoff', 'res']],
['fx', ['blur', 'shimmer', 'freeze', 'width']],
]),
},
{
id: 'memlcelium',
label: 'MEML Celium',
cls: 'Sequencer',
glyph: '☷',
input: 'xy',
params: mkParams([
['mod', ['cvA', 'cvB', 'gate', 'div']],
['pitch', ['root', 'scale', 'oct']],
['amp', ['vca', 'slew']],
]),
},
{
id: 'breakor',
label: 'Breakor',
cls: 'Sequencer',
glyph: '⊟',
input: 'joystick',
params: mkParams([
['mod', ['density', 'swing', 'fill', 'stutter']],
['amp', ['punch', 'decay']],
['filter', ['tone', 'crush']],
['fx', ['glitch', 'rev']],
]),
},
{
id: 'sound_analysis_midi',
label: 'Sound Analysis → MIDI',
cls: 'Controller',
glyph: '⇉',
input: 'audio_in',
badge: '1-input',
params: mkParams([
['mod', ['cc1', 'cc2', 'cc3', 'cc4']],
['pitch', ['note', 'bend']],
['amp', ['vel', 'press']],
]),
},
{
id: 'visualizer',
label: 'Visualizer',
cls: 'Visual',
glyph: '◑',
input: 'xy',
params: mkParams([
['mod', ['hue', 'sat', 'flow', 'warp']],
['amp', ['bloom', 'fade']],
['fx', ['grain', 'trail']],
]),
},
{
// Internal id stays `c15`; the UI label is "Powerful Synth Engine".
id: 'c15',
label: 'Powerful Synth Engine',
cls: 'Synth',
glyph: '◆',
input: 'xy',
placeholder: true,
badge: 'soon',
params: mkParams([['amp', ['a', 'b']]]),
},
];
/** Mirrors the engine's `applyCurve` (≈0.43 ≈ linear). */
export function applyCurve(v: number, c: number): number {
const e = 0.25 + c * 1.75;
return Math.pow(Math.max(0, Math.min(1, v)), e);
}
/**
* Map the engine's raw output vector onto a mode's params, applying each
* param's status / min / max / curve. Replaces `MF_infer`:
* off → 0 (muted)
* fixed → p.val (held static)
* live → engine output[i], shaped by min/max/curve
*
* The engine output is 126-dim; a mode with N params uses the first N.
*/
export function shapeValues(params: MFParam[], engineOut: Float32Array | null): number[] {
return params.map((p, i) => {
if (p.status === 'off') return 0;
if (p.status === 'fixed') return p.val ?? 0.5;
const raw = engineOut && i < engineOut.length ? engineOut[i] : 0.5;
const v = p.min + applyCurve(raw, p.curve) * (p.max - p.min);
return Math.max(0, Math.min(1, v));
});
}
/** Deterministic per-revision gradient-flow stub (visual only; ported as-is). */
export function seededGradient(rev: number): {
norms: number[];
status: string[];
} {
const n = 4;
const norms: number[] = [];
const status: string[] = [];
for (let i = 0; i < n; i++) {
const r = Math.abs((Math.sin((rev + 1) * (i + 1) * 12.9898) * 43758.5453) % 1);
norms.push(0.2 + r * 0.8);
status.push(r > 0.85 ? 'exploding' : r < 0.18 ? 'vanishing' : r < 0.3 ? 'converged' : 'healthy');
}
return { norms, status };
}
/** Map a mode's `input` kind → the engine backend id to drive audio. */
export function modeEngineId(modeId: string): string {
// Mode ids align with engine ids except the relabelled `c15`.
switch (modeId) {
case 'paf_synth':
case 'channel_strip':
case 'verb_fx':
case 'elysiamorf':
case 'memlcelium':
case 'breakor':
return modeId;
case 'sound_analysis_midi':
return 'analysis';
default:
return 'thru';
}
}