fix(manifold): sync output sliders with backend count

This commit is contained in:
monkey-w1n5t0n 2026-07-25 13:55:19 +02:00
parent ec3118004d
commit f75f683f40
8 changed files with 113 additions and 24 deletions

View file

@ -119,8 +119,10 @@ double-click exits). `OutputStage.tsx` is the output columns; drag a bar to set
`compact` prop for the narrow pane.
- **Output modes** (the TOP dock selector, NOT the same axis as `focus`): `src/console/output-mode.ts`
defines `OUTPUT_MODES` = **particles** (default) / midi / osc / synth / editor, each mapping to a
`BackendId`. `DEFAULT_OUTPUT_MODE='particles'`.
defines `OUTPUT_MODES` = **particles** (default) / midi / osc / cv / synth / editor, each mapping to a
`BackendId`. `DEFAULT_OUTPUT_MODE='particles'`. `outputDisplayCount()` is the shared presentation
boundary for the stage and routing rows: MIDI uses its configured CC count, while backends without
a separate count present the full mode parameter set. This does not reshape the MLP or clear examples.
- `src/console/output-mode.ts`, `types.ts`, `model.ts` are the shared vocabulary — read these first
when touching anything cross-cutting:
- `types.ts`: `Focus`, `OutputMode`, `DrawerKey`, `DrawerDepth`, `FeedbackModeUI`, `SoloMode`,

View file

@ -53,22 +53,25 @@ import type {
import type { BackendId } from '../dock/output-state';
import { buildArmMask } from '../dock/output-state';
import { FeedbackController, type ProtoFeedbackMode } from '../feedback';
import { DEFAULT_OUTPUT_MODE, outputModeDescriptor } from './output-mode';
import { DEFAULT_OUTPUT_MODE, outputDisplayCount, outputModeDescriptor } from './output-mode';
import { useSettings, resolveInputMap } from '../settings/settings-store';
import { useBackendManager } from '../backends';
import { useInputLayer } from '../inputs';
/**
* Instrument-mode debug seam, installed on `window.__mf` under `?debug=1`
* (see the effect in ConsoleApp). UI-level analogue of the engine
* `window.__nisps` probe lets Playwright drive mode switches and read the
* rendered param count, since no in-UI mode picker exists yet.
* Console debug seam, installed on `window.__mf` under `?debug=1` (see the
* effect in ConsoleApp). UI-level analogue of the engine `window.__nisps`
* probe lets Playwright drive instrument/output configuration and inspect
* the resulting presentation.
*/
export interface MfDebugHook {
setMode: (id: string) => void;
getModeId: () => string;
paramCount: () => number;
modeIds: () => string[];
setOutputMode: (mode: OutputMode) => void;
setMidiCcCount: (count: number) => void;
displayOutputCount: () => number;
}
declare global {
@ -139,6 +142,9 @@ export function ConsoleApp() {
// named-preset system; these are the live working values.
const [midiOutputId, setMidiOutputId] = useState<string | null>(null);
const [midiCcCount, setMidiCcCount] = useState(8);
const displayOutputCount = outputDisplayCount(outputMode, params.length, {
midi: midiCcCount,
});
const [oscUrl, setOscUrl] = useState('ws://localhost:8765');
const [oscSendRaw, setOscSendRaw] = useState(false);
// VCV bridge: WS URL of the Deno bridge that relays to the VCV module over UDP
@ -322,12 +328,11 @@ export function ConsoleApp() {
if (engine && n != null) engine.reshape({ inputSize: n });
};
// ---- Debug seam for instrument-mode switching (`?debug=1`) ------------------
// ---- Console debug seam (`?debug=1`) ----------------------------------------
// There is no instrument-mode picker in the UI yet (ctx.modes/setModeId are
// plumbed but unrendered), so Playwright drives mode switches through this
// window hook — the UI-level analogue of the engine `window.__nisps` probe.
// Exposes the current modeId, the rendered param count, and the mode ids so
// the schema-modes e2e can switch a mode and assert the derived shape.
// plumbed but unrendered), so Playwright drives instrument/output changes
// through this window hook — the UI-level analogue of the engine
// `window.__nisps` probe.
useEffect(() => {
if (typeof window === 'undefined') return;
const q = new URLSearchParams(window.location.search);
@ -337,11 +342,14 @@ export function ConsoleApp() {
getModeId: () => modeId,
paramCount: () => params.length,
modeIds: () => MF_MODES.map((m) => m.id),
setOutputMode,
setMidiCcCount,
displayOutputCount: () => displayOutputCount,
};
return () => {
if (window.__mf) delete window.__mf;
};
}, [modeId, params]);
}, [modeId, params, displayOutputCount]);
// Drive a pad/joystick/manifold move through the input layer's XY-pad source,
// then mirror the raw position into React state for readouts. The layer's loop
@ -408,6 +416,14 @@ export function ConsoleApp() {
// eslint-disable-next-line react-hooks/exhaustive-deps
[params, version, engine],
);
const displayedParams = useMemo(
() => params.slice(0, displayOutputCount),
[params, displayOutputCount],
);
const displayedValues = useMemo(
() => values.slice(0, displayOutputCount),
[values, displayOutputCount],
);
/** Plot a feedback marker at the input location it was given (session-scoped). */
const pushMarker = (at: [number, number], polarity: 'positive' | 'negative') =>
@ -752,6 +768,7 @@ export function ConsoleApp() {
},
params,
setParam,
displayOutputCount,
outputMode,
setOutputMode,
// ---- output backend transport ----
@ -863,8 +880,8 @@ export function ConsoleApp() {
engine={engine}
version={version}
pos={pos}
layerCount={Math.min(params.length, 8)}
names={params.map((p) => p.name)}
layerCount={Math.min(displayedParams.length, 8)}
names={displayedParams.map((p) => p.name)}
/>
)}
</div>
@ -876,7 +893,7 @@ export function ConsoleApp() {
borderLeft: '1px solid var(--line)',
}}
>
<OutputStage params={params} values={values} onChange={setParam} compact />
<OutputStage params={displayedParams} values={displayedValues} onChange={setParam} compact />
</div>
</div>
) : outputMode === 'particles' ? (
@ -894,8 +911,8 @@ export function ConsoleApp() {
variant={inputMapVariant}
follow={follow}
onLongPress={addPin}
params={params}
values={values}
params={displayedParams}
values={displayedValues}
onChange={setParam}
/>
)}

View file

@ -606,19 +606,20 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
}
function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const values = shapeValues(ctx.params, null); // bar uses shaped held/live value snapshot
const counts = ctx.params.reduce<Record<string, number>>((a, p) => {
const activeParams = ctx.params.slice(0, ctx.displayOutputCount);
const values = shapeValues(activeParams, null); // bar uses shaped held/live value snapshot
const counts = activeParams.reduce<Record<string, number>>((a, p) => {
a[p.status] = (a[p.status] || 0) + 1;
return a;
}, {});
const mutedN = ctx.params.filter((p) => p.muted).length;
const mutedN = activeParams.filter((p) => p.muted).length;
const modeDesc = outputModeDescriptor(ctx.outputMode);
// The particle Mode names its outputs; otherwise use the param names.
const nameFor = (idx: number, fallback: string) =>
ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback;
const expanded = depth === 'expanded';
const rows = expanded ? ctx.params : ctx.params.slice(0, 6);
const rows = expanded ? activeParams : activeParams.slice(0, 6);
return (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
@ -656,8 +657,8 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
);
})}
</div>
{!expanded && ctx.params.length > 6 && (
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{ctx.params.length - 6} more expand to edit</span>
{!expanded && activeParams.length > 6 && (
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{activeParams.length - 6} more expand to edit</span>
)}
</>
);

View file

@ -76,6 +76,8 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu
return (
<div
data-testid="output-stage"
data-output-count={params.length}
style={{
position: 'absolute',
inset: 0,

View file

@ -80,3 +80,22 @@ export const DEFAULT_OUTPUT_MODE: OutputMode = OUTPUT_MODES[0].id;
export function outputModeDescriptor(id: OutputMode): OutputModeDescriptor {
return OUTPUT_MODES.find((m) => m.id === id) ?? OUTPUT_MODES[0];
}
/**
* Number of output controls the active backend presents.
*
* The model may expose more parameters than a backend currently maps (MIDI is
* the live example: its CC count is adjustable). Keep that presentation
* boundary separate from the model arity so changing a backend count does not
* silently reshape the net and clear its examples.
*/
export function outputDisplayCount(
id: OutputMode,
availableCount: number,
configuredCounts: Partial<Record<OutputMode, number>> = {},
): number {
const available = Math.max(0, Math.floor(availableCount));
const configured = configuredCounts[id];
if (configured === undefined || !Number.isFinite(configured)) return available;
return Math.max(0, Math.min(available, Math.floor(configured)));
}

View file

@ -76,6 +76,8 @@ export interface ConsoleCtx {
params: MFParam[];
/** Patch one output row in the shared store (drives stage + dock in sync). */
setParam: (i: number, patch: Partial<MFParam>) => void;
/** Active backend outputs currently presented by the stage + routing rows. */
displayOutputCount: number;
// ---- Output backend transport (backends-spec §1§5) ----
/** Live status of the active output backend (MIDI/OSC connect state, etc.). */

View file

@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test';
import { loadProbe } from './helpers';
import type { MfDebugHook } from '../../src/console/ConsoleApp';
declare global {
interface Window {
__mf?: MfDebugHook;
}
}
test('output sliders follow the active backend output count', async ({ page }) => {
await loadProbe(page);
await page.evaluate(() => {
window.__mf!.setOutputMode('midi');
window.__mf!.setMidiCcCount(3);
});
await expect(page.getByTestId('output-stage')).toHaveAttribute('data-output-count', '3');
await page.evaluate(() => window.__mf!.setMidiCcCount(7));
await expect(page.getByTestId('output-stage')).toHaveAttribute('data-output-count', '7');
const fullCount = await page.evaluate(() => {
window.__mf!.setOutputMode('osc');
return window.__mf!.paramCount();
});
await expect(page.getByTestId('output-stage')).toHaveAttribute('data-output-count', String(fullCount));
});

View file

@ -0,0 +1,18 @@
import { expect, test } from 'bun:test';
import { outputDisplayCount } from '../src/console/output-mode';
test('the active MIDI CC count controls the presented output count', () => {
expect(outputDisplayCount('midi', 33, { midi: 8 })).toBe(8);
expect(outputDisplayCount('midi', 33, { midi: 16 })).toBe(16);
});
test('the selector supports configured counts for any output mode', () => {
expect(outputDisplayCount('osc', 33, { osc: 5 })).toBe(5);
expect(outputDisplayCount('synth', 33)).toBe(33);
});
test('configured output counts are integral and capped by available model outputs', () => {
expect(outputDisplayCount('midi', 12, { midi: 99 })).toBe(12);
expect(outputDisplayCount('midi', 12, { midi: 4.9 })).toBe(4);
expect(outputDisplayCount('midi', 12, { midi: -2 })).toBe(0);
});