Files
op-pedal/vite/src/pipedal/mixer/useMixerWS.ts
T
shawn 0316e4b37f 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).
2026-06-20 15:56:17 -04:00

290 lines
9.0 KiB
TypeScript

// 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 };
}