fix(manifold): sync output sliders with backend count
This commit is contained in:
parent
ec3118004d
commit
f75f683f40
8 changed files with 113 additions and 24 deletions
|
|
@ -119,8 +119,10 @@ double-click exits). `OutputStage.tsx` is the output columns; drag a bar to set
|
||||||
`compact` prop for the narrow pane.
|
`compact` prop for the narrow pane.
|
||||||
|
|
||||||
- **Output modes** (the TOP dock selector, NOT the same axis as `focus`): `src/console/output-mode.ts`
|
- **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
|
defines `OUTPUT_MODES` = **particles** (default) / midi / osc / cv / synth / editor, each mapping to a
|
||||||
`BackendId`. `DEFAULT_OUTPUT_MODE='particles'`.
|
`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
|
- `src/console/output-mode.ts`, `types.ts`, `model.ts` are the shared vocabulary — read these first
|
||||||
when touching anything cross-cutting:
|
when touching anything cross-cutting:
|
||||||
- `types.ts`: `Focus`, `OutputMode`, `DrawerKey`, `DrawerDepth`, `FeedbackModeUI`, `SoloMode`,
|
- `types.ts`: `Focus`, `OutputMode`, `DrawerKey`, `DrawerDepth`, `FeedbackModeUI`, `SoloMode`,
|
||||||
|
|
|
||||||
|
|
@ -53,22 +53,25 @@ import type {
|
||||||
import type { BackendId } from '../dock/output-state';
|
import type { BackendId } from '../dock/output-state';
|
||||||
import { buildArmMask } from '../dock/output-state';
|
import { buildArmMask } from '../dock/output-state';
|
||||||
import { FeedbackController, type ProtoFeedbackMode } from '../feedback';
|
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 { useSettings, resolveInputMap } from '../settings/settings-store';
|
||||||
import { useBackendManager } from '../backends';
|
import { useBackendManager } from '../backends';
|
||||||
import { useInputLayer } from '../inputs';
|
import { useInputLayer } from '../inputs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instrument-mode debug seam, installed on `window.__mf` under `?debug=1`
|
* Console debug seam, installed on `window.__mf` under `?debug=1` (see the
|
||||||
* (see the effect in ConsoleApp). UI-level analogue of the engine
|
* effect in ConsoleApp). UI-level analogue of the engine `window.__nisps`
|
||||||
* `window.__nisps` probe — lets Playwright drive mode switches and read the
|
* probe — lets Playwright drive instrument/output configuration and inspect
|
||||||
* rendered param count, since no in-UI mode picker exists yet.
|
* the resulting presentation.
|
||||||
*/
|
*/
|
||||||
export interface MfDebugHook {
|
export interface MfDebugHook {
|
||||||
setMode: (id: string) => void;
|
setMode: (id: string) => void;
|
||||||
getModeId: () => string;
|
getModeId: () => string;
|
||||||
paramCount: () => number;
|
paramCount: () => number;
|
||||||
modeIds: () => string[];
|
modeIds: () => string[];
|
||||||
|
setOutputMode: (mode: OutputMode) => void;
|
||||||
|
setMidiCcCount: (count: number) => void;
|
||||||
|
displayOutputCount: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -139,6 +142,9 @@ export function ConsoleApp() {
|
||||||
// named-preset system; these are the live working values.
|
// named-preset system; these are the live working values.
|
||||||
const [midiOutputId, setMidiOutputId] = useState<string | null>(null);
|
const [midiOutputId, setMidiOutputId] = useState<string | null>(null);
|
||||||
const [midiCcCount, setMidiCcCount] = useState(8);
|
const [midiCcCount, setMidiCcCount] = useState(8);
|
||||||
|
const displayOutputCount = outputDisplayCount(outputMode, params.length, {
|
||||||
|
midi: midiCcCount,
|
||||||
|
});
|
||||||
const [oscUrl, setOscUrl] = useState('ws://localhost:8765');
|
const [oscUrl, setOscUrl] = useState('ws://localhost:8765');
|
||||||
const [oscSendRaw, setOscSendRaw] = useState(false);
|
const [oscSendRaw, setOscSendRaw] = useState(false);
|
||||||
// VCV bridge: WS URL of the Deno bridge that relays to the VCV module over UDP
|
// 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 });
|
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
|
// There is no instrument-mode picker in the UI yet (ctx.modes/setModeId are
|
||||||
// plumbed but unrendered), so Playwright drives mode switches through this
|
// plumbed but unrendered), so Playwright drives instrument/output changes
|
||||||
// window hook — the UI-level analogue of the engine `window.__nisps` probe.
|
// through this window hook — the UI-level analogue of the engine
|
||||||
// Exposes the current modeId, the rendered param count, and the mode ids so
|
// `window.__nisps` probe.
|
||||||
// the schema-modes e2e can switch a mode and assert the derived shape.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const q = new URLSearchParams(window.location.search);
|
const q = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -337,11 +342,14 @@ export function ConsoleApp() {
|
||||||
getModeId: () => modeId,
|
getModeId: () => modeId,
|
||||||
paramCount: () => params.length,
|
paramCount: () => params.length,
|
||||||
modeIds: () => MF_MODES.map((m) => m.id),
|
modeIds: () => MF_MODES.map((m) => m.id),
|
||||||
|
setOutputMode,
|
||||||
|
setMidiCcCount,
|
||||||
|
displayOutputCount: () => displayOutputCount,
|
||||||
};
|
};
|
||||||
return () => {
|
return () => {
|
||||||
if (window.__mf) delete window.__mf;
|
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,
|
// 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
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[params, version, engine],
|
[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). */
|
/** Plot a feedback marker at the input location it was given (session-scoped). */
|
||||||
const pushMarker = (at: [number, number], polarity: 'positive' | 'negative') =>
|
const pushMarker = (at: [number, number], polarity: 'positive' | 'negative') =>
|
||||||
|
|
@ -752,6 +768,7 @@ export function ConsoleApp() {
|
||||||
},
|
},
|
||||||
params,
|
params,
|
||||||
setParam,
|
setParam,
|
||||||
|
displayOutputCount,
|
||||||
outputMode,
|
outputMode,
|
||||||
setOutputMode,
|
setOutputMode,
|
||||||
// ---- output backend transport ----
|
// ---- output backend transport ----
|
||||||
|
|
@ -863,8 +880,8 @@ export function ConsoleApp() {
|
||||||
engine={engine}
|
engine={engine}
|
||||||
version={version}
|
version={version}
|
||||||
pos={pos}
|
pos={pos}
|
||||||
layerCount={Math.min(params.length, 8)}
|
layerCount={Math.min(displayedParams.length, 8)}
|
||||||
names={params.map((p) => p.name)}
|
names={displayedParams.map((p) => p.name)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -876,7 +893,7 @@ export function ConsoleApp() {
|
||||||
borderLeft: '1px solid var(--line)',
|
borderLeft: '1px solid var(--line)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OutputStage params={params} values={values} onChange={setParam} compact />
|
<OutputStage params={displayedParams} values={displayedValues} onChange={setParam} compact />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : outputMode === 'particles' ? (
|
) : outputMode === 'particles' ? (
|
||||||
|
|
@ -894,8 +911,8 @@ export function ConsoleApp() {
|
||||||
variant={inputMapVariant}
|
variant={inputMapVariant}
|
||||||
follow={follow}
|
follow={follow}
|
||||||
onLongPress={addPin}
|
onLongPress={addPin}
|
||||||
params={params}
|
params={displayedParams}
|
||||||
values={values}
|
values={displayedValues}
|
||||||
onChange={setParam}
|
onChange={setParam}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -606,19 +606,20 @@ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
const values = shapeValues(ctx.params, null); // bar uses shaped held/live value snapshot
|
const activeParams = ctx.params.slice(0, ctx.displayOutputCount);
|
||||||
const counts = ctx.params.reduce<Record<string, number>>((a, p) => {
|
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;
|
a[p.status] = (a[p.status] || 0) + 1;
|
||||||
return a;
|
return a;
|
||||||
}, {});
|
}, {});
|
||||||
const mutedN = ctx.params.filter((p) => p.muted).length;
|
const mutedN = activeParams.filter((p) => p.muted).length;
|
||||||
const modeDesc = outputModeDescriptor(ctx.outputMode);
|
const modeDesc = outputModeDescriptor(ctx.outputMode);
|
||||||
// The particle Mode names its outputs; otherwise use the param names.
|
// The particle Mode names its outputs; otherwise use the param names.
|
||||||
const nameFor = (idx: number, fallback: string) =>
|
const nameFor = (idx: number, fallback: string) =>
|
||||||
ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback;
|
ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback;
|
||||||
|
|
||||||
const expanded = depth === 'expanded';
|
const expanded = depth === 'expanded';
|
||||||
const rows = expanded ? ctx.params : ctx.params.slice(0, 6);
|
const rows = expanded ? activeParams : activeParams.slice(0, 6);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
|
@ -656,8 +657,8 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!expanded && ctx.params.length > 6 && (
|
{!expanded && activeParams.length > 6 && (
|
||||||
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{ctx.params.length - 6} more — expand to edit</span>
|
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{activeParams.length - 6} more — expand to edit</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ export function OutputStage({ params, values, onChange, compact = false }: Outpu
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-testid="output-stage"
|
||||||
|
data-output-count={params.length}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,22 @@ export const DEFAULT_OUTPUT_MODE: OutputMode = OUTPUT_MODES[0].id;
|
||||||
export function outputModeDescriptor(id: OutputMode): OutputModeDescriptor {
|
export function outputModeDescriptor(id: OutputMode): OutputModeDescriptor {
|
||||||
return OUTPUT_MODES.find((m) => m.id === id) ?? OUTPUT_MODES[0];
|
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)));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ export interface ConsoleCtx {
|
||||||
params: MFParam[];
|
params: MFParam[];
|
||||||
/** Patch one output row in the shared store (drives stage + dock in sync). */
|
/** Patch one output row in the shared store (drives stage + dock in sync). */
|
||||||
setParam: (i: number, patch: Partial<MFParam>) => void;
|
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) ----
|
// ---- Output backend transport (backends-spec §1–§5) ----
|
||||||
/** Live status of the active output backend (MIDI/OSC connect state, etc.). */
|
/** Live status of the active output backend (MIDI/OSC connect state, etc.). */
|
||||||
|
|
|
||||||
28
manifold/tests/e2e/output-display-count.spec.ts
Normal file
28
manifold/tests/e2e/output-display-count.spec.ts
Normal 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));
|
||||||
|
});
|
||||||
18
manifold/tests/output-display-count.test.ts
Normal file
18
manifold/tests/output-display-count.test.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue