feat: add React mixer UI — MixerPage, ChannelStrip, MasterBus, useMixerWS hook
- useMixerWS: WebSocket hook connecting to ws://192.168.0.245:8080/ws with PiPedalSocket-compatible protocol (request/reply via replyTo) and auto-reconnect with exponential backoff. - ChannelStrip: per-input channel control with volume fader, pan, mute, solo, and channel label/type display. - MasterBus: master/subgroup/aux bus strip with volume fader and mute. - MixerPage: full mixer console view with horizontal strip layout, real-time state polling (2s), and connection status indicator. - AppThemed: added 'Mixer Console' drawer item + conditional rendering. Build verified: tsc clean, vite build passes (1760 modules, 2.63s).
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
// useMixerWS — WebSocket hook for op-pedal mixer engine protocol
|
||||
//
|
||||
// Connects to pipedald's WebSocket endpoint and provides the mixer
|
||||
// command set (mixerGetState, mixerSetChannelVolume, etc.) matching
|
||||
// the PiPedalSocket message format used in PiPedalSocket.cpp handlers.
|
||||
//
|
||||
// Protocol:
|
||||
// Send: [{"message": "mixerGetState", "replyTo": N}]
|
||||
// Reply: [{"reply": N, "message": "mixerState"}, {…body…}]
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface MixerChannelState {
|
||||
channelIndex: number;
|
||||
volume: number; // dB
|
||||
pan: number; // -1..1
|
||||
mute: boolean;
|
||||
solo: boolean;
|
||||
type: string; // "Instrument" | "Mic" | "Line"
|
||||
label: string;
|
||||
hpEnabled: boolean;
|
||||
hpFrequency: number;
|
||||
}
|
||||
|
||||
export interface MixerBusState {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string; // "Master" | "Subgroup" | "Aux" | "FxReturn"
|
||||
volume: number; // dB
|
||||
mute: boolean;
|
||||
}
|
||||
|
||||
export interface MixerRouteState {
|
||||
sourceId: number;
|
||||
targetBusId: number;
|
||||
level: number; // dB
|
||||
sourceType: string; // "channel" | "bus"
|
||||
}
|
||||
|
||||
export interface MixerState {
|
||||
channels: MixerChannelState[];
|
||||
buses: MixerBusState[];
|
||||
routes: MixerRouteState[];
|
||||
}
|
||||
|
||||
export type WsStatus =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "error";
|
||||
|
||||
export interface MixerWsHandle {
|
||||
/** Current connection status. */
|
||||
status: WsStatus;
|
||||
/** Last error message (cleared on reconnect). */
|
||||
lastError: string | null;
|
||||
/** Send a mixer command and await the reply. */
|
||||
send: (command: string, params?: unknown) => Promise<unknown>;
|
||||
/** Manually connect (auto-called on mount; safe to call again after disconnect). */
|
||||
connect: () => void;
|
||||
/** Disconnect manually. */
|
||||
disconnect: () => void;
|
||||
}
|
||||
|
||||
// ── Defaults ───────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_WS_URL = "ws://192.168.0.245:8080/ws";
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────
|
||||
|
||||
export function useMixerWS(wsUrl: string = DEFAULT_WS_URL): MixerWsHandle {
|
||||
const [status, setStatus] = useState<WsStatus>("disconnected");
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const replyMapRef = useRef<Map<number, (value: unknown) => void>>(new Map());
|
||||
const nextReplyToRef = useRef(0);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const wsUrlRef = useRef(wsUrl);
|
||||
|
||||
// Keep the URL ref current without triggering re-renders.
|
||||
useEffect(() => {
|
||||
wsUrlRef.current = wsUrl;
|
||||
}, [wsUrl]);
|
||||
|
||||
/** Clean up WS and timers. */
|
||||
const cleanup = useCallback(() => {
|
||||
// Clear reconnect timer.
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Close socket cleanly.
|
||||
const sock = wsRef.current;
|
||||
if (sock) {
|
||||
sock.onopen = null;
|
||||
sock.onclose = null;
|
||||
sock.onerror = null;
|
||||
sock.onmessage = null;
|
||||
if (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING) {
|
||||
sock.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
// Reject all pending promises.
|
||||
const pending = replyMapRef.current;
|
||||
if (pending.size > 0) {
|
||||
const err = new Error("WebSocket disconnected");
|
||||
for (const reject of pending.values()) {
|
||||
reject(err);
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Open a new WebSocket connection. */
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) {
|
||||
return; // already connected or connecting
|
||||
}
|
||||
|
||||
cleanup();
|
||||
setStatus("connecting");
|
||||
setLastError(null);
|
||||
|
||||
const url = wsUrlRef.current;
|
||||
let sock: WebSocket;
|
||||
|
||||
try {
|
||||
sock = new WebSocket(url);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setStatus("error");
|
||||
setLastError(`Failed to create WebSocket: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
sock.onopen = () => {
|
||||
if (!mountedRef.current) { sock.close(); return; }
|
||||
wsRef.current = sock;
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStatus("connected");
|
||||
setLastError(null);
|
||||
};
|
||||
|
||||
sock.onclose = () => {
|
||||
if (!mountedRef.current) return;
|
||||
if (wsRef.current === sock) {
|
||||
wsRef.current = null;
|
||||
}
|
||||
setStatus("disconnected");
|
||||
// Schedule reconnect with exponential backoff.
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
sock.onerror = () => {
|
||||
// The onclose event will fire after onerror, so we don't set
|
||||
// status here to avoid a flash of "error" followed by "disconnected".
|
||||
setLastError("WebSocket connection error");
|
||||
};
|
||||
|
||||
sock.onmessage = (event: MessageEvent) => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(event.data);
|
||||
} catch {
|
||||
// Ignore non-JSON messages.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) return;
|
||||
if (parsed.length < 1) return;
|
||||
|
||||
const header = parsed[0] as Record<string, unknown>;
|
||||
const body = parsed.length >= 2 ? parsed[1] : undefined;
|
||||
|
||||
// If this is a reply to one of our requests, resolve the promise.
|
||||
if (typeof header.reply === "number") {
|
||||
const resolve = replyMapRef.current.get(header.reply);
|
||||
if (resolve) {
|
||||
replyMapRef.current.delete(header.reply);
|
||||
if (header.message === "error") {
|
||||
resolve(Promise.reject(new Error(String(body ?? "Unknown error"))));
|
||||
} else {
|
||||
resolve(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unprompted messages (state updates, etc.) are ignored at this level.
|
||||
// Subscribers can extend the hook later if they need push events.
|
||||
};
|
||||
|
||||
wsRef.current = sock;
|
||||
}, [cleanup]);
|
||||
|
||||
/** Schedule a reconnection attempt with exponential backoff. */
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
if (reconnectTimerRef.current !== null) return; // already scheduled
|
||||
|
||||
const attempt = reconnectAttemptRef.current;
|
||||
const delay = Math.min(
|
||||
INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
|
||||
MAX_RECONNECT_DELAY_MS
|
||||
);
|
||||
reconnectAttemptRef.current = attempt + 1;
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null;
|
||||
if (mountedRef.current) {
|
||||
connect();
|
||||
}
|
||||
}, delay);
|
||||
}, [connect]);
|
||||
|
||||
/** Send a command and wait for the response. */
|
||||
const send = useCallback((command: string, params?: unknown): Promise<unknown> => {
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const sock = wsRef.current;
|
||||
if (!sock || sock.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error("WebSocket not connected"));
|
||||
return;
|
||||
}
|
||||
|
||||
const replyTo = ++nextReplyToRef.current;
|
||||
|
||||
// Build message in PiPedalSocket format: [header, body?]
|
||||
const header: Record<string, unknown> = { message: command, replyTo };
|
||||
const msg = params !== undefined ? [header, params] : [header];
|
||||
|
||||
// Register the reply handler before sending (race-safe).
|
||||
replyMapRef.current.set(replyTo, (value: unknown) => {
|
||||
resolve(value);
|
||||
});
|
||||
|
||||
// Set a timeout for the reply.
|
||||
const timeout = setTimeout(() => {
|
||||
replyMapRef.current.delete(replyTo);
|
||||
reject(new Error(`Command "${command}" timed out after 10s`));
|
||||
}, 10_000);
|
||||
|
||||
// Wrap the resolve to clear the timeout.
|
||||
const originalResolve = replyMapRef.current.get(replyTo)!;
|
||||
replyMapRef.current.set(replyTo, (value: unknown) => {
|
||||
clearTimeout(timeout);
|
||||
originalResolve(value);
|
||||
});
|
||||
|
||||
try {
|
||||
sock.send(JSON.stringify(msg));
|
||||
} catch (err: unknown) {
|
||||
clearTimeout(timeout);
|
||||
replyMapRef.current.delete(replyTo);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
cleanup();
|
||||
setStatus("disconnected");
|
||||
}, [cleanup]);
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
// Connect on mount.
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [connect, cleanup]);
|
||||
|
||||
return { status, lastError, send, connect, disconnect };
|
||||
}
|
||||
Reference in New Issue
Block a user