0316e4b37f
- 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).
290 lines
9.0 KiB
TypeScript
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 };
|
|
}
|