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:
2026-06-20 15:56:17 -04:00
parent 0a76f5734f
commit 0316e4b37f
6 changed files with 1337 additions and 1 deletions
+18 -1
View File
@@ -74,6 +74,8 @@ import SettingsIcon from './svg/ic_settings.svg?react';
import HelpOutlineIcon from './svg/ic_help_outline.svg?react';
import FxAmplifierIcon from './svg/fx_amplifier.svg?react';
import { PerformanceView } from './PerformanceView';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
import MixerPage from './mixer/MixerPage';
import DialogEx from './DialogEx';
@@ -322,6 +324,7 @@ type AppState = {
banks: BankIndex;
bankDisplayItems: number;
showStatusMonitor: boolean;
mixerView: boolean;
};
class MenuStackHandler implements IDialogStackable {
constructor(app: AppThemedBase) {
@@ -380,7 +383,8 @@ export
editBankDialogOpen: false,
zoomedControlOpen: false,
bankDisplayItems: 5,
showStatusMonitor: this.model_.showStatusMonitor.get()
showStatusMonitor: this.model_.showStatusMonitor.get(),
mixerView: false,
};
@@ -802,6 +806,8 @@ export
<PerformanceView open={this.state.performanceView}
onClose={() => { this.setState({ performanceView: false }); }}
/>
) : this.state.mixerView ? (
<MixerPage onClose={() => { this.setState({ mixerView: false }); }} />
) : (
<div style={{
position: "absolute", width: "100%", height: "100%", userSelect: "none",
@@ -890,6 +896,17 @@ export
</ListItemIcon>
<ListItemText primary='Performance View' />
</ListItemButton>
<ListItemButton key='MixerView'
onClick={(ev: any) => {
ev.stopPropagation();
this.hideDrawer(true);
this.setState({ mixerView: true });
}}>
<ListItemIcon >
<MusicNoteIcon color='inherit' className={classes.menuIcon} style={{ width: 24, height: 24 }} />
</ListItemIcon>
<ListItemText primary='Mixer Console' />
</ListItemButton>
</List>
<Divider />
<ListSubheader className="listSubheader" component="div" id="xnested-list-subheader" style={{ lineHeight: "24px", height: 24, background: "rgba(12,12,12,0.0)" }}
+210
View File
@@ -0,0 +1,210 @@
// ChannelStrip — per-input channel control for the mixer console
//
// Volume fader, pan, mute/solo buttons, channel label, and type badge.
// Matches the op-pedal mixer engine's channel strip parameters.
import React, { useCallback } from "react";
import Box from "@mui/material/Box";
import Slider from "@mui/material/Slider";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Typography from "@mui/material/Typography";
import { styled } from "@mui/material/styles";
import VolumeUp from "@mui/icons-material/VolumeUp";
import VolumeOff from "@mui/icons-material/VolumeOff";
import MusicNote from "@mui/icons-material/MusicNote";
import type { MixerChannelState } from "./useMixerWS";
// ── Props ──────────────────────────────────────────────────────────
export interface ChannelStripProps {
channel: MixerChannelState;
/** Called when a parameter changes — the parent issues the WS command. */
onVolumeChange: (channelIndex: number, volumeDb: number) => void;
onPanChange: (channelIndex: number, pan: number) => void;
onMuteToggle: (channelIndex: number, mute: boolean) => void;
onSoloToggle: (channelIndex: number, solo: boolean) => void;
}
// ── Styled components ──────────────────────────────────────────────
const StripRoot = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
minWidth: 72,
width: 72,
padding: "8px 4px",
borderRadius: 8,
background: theme.palette.mode === "dark"
? "rgba(255,255,255,0.05)"
: "rgba(0,0,0,0.04)",
border: `1px solid ${
theme.palette.mode === "dark"
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.08)"
}`,
userSelect: "none",
}));
const LabelBox = styled(Box)({
width: "100%",
textAlign: "center",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.7rem",
lineHeight: 1.2,
});
const FaderBox = styled(Box)({
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: 120,
width: "100%",
});
const ValueLabel = styled(Typography)({
fontSize: "0.6rem",
lineHeight: 1,
opacity: 0.7,
});
// ── Helpers ────────────────────────────────────────────────────────
function dbToPercent(db: number): number {
// Map -inf..+12 dB to 0..100%
if (db <= -60) return 0;
if (db >= 12) return 100;
return ((db + 60) / 72) * 100;
}
function percentToDb(pct: number): number {
return (pct / 100) * 72 - 60;
}
function formatDb(db: number): string {
if (db <= -60) return "-∞";
return `${db.toFixed(1)} dB`;
}
// ── Component ──────────────────────────────────────────────────────
const ChannelStrip: React.FC<ChannelStripProps> = ({
channel,
onVolumeChange,
onPanChange,
onMuteToggle,
onSoloToggle,
}) => {
const volPct = dbToPercent(channel.volume);
const handleVol = useCallback(
(_: Event, value: number | number[]) => {
const db = percentToDb(value as number);
onVolumeChange(channel.channelIndex, db);
},
[channel.channelIndex, onVolumeChange]
);
const handlePan = useCallback(
(_: Event, value: number | number[]) => {
onPanChange(channel.channelIndex, value as number);
},
[channel.channelIndex, onPanChange]
);
const handleMute = useCallback(() => {
onMuteToggle(channel.channelIndex, !channel.mute);
}, [channel.channelIndex, channel.mute, onMuteToggle]);
const handleSolo = useCallback(() => {
onSoloToggle(channel.channelIndex, !channel.solo);
}, [channel.channelIndex, channel.solo, onSoloToggle]);
return (
<StripRoot>
{/* Channel label */}
<LabelBox>
<Typography variant="caption" noWrap sx={{ fontWeight: 600 }}>
{channel.label || `Ch ${channel.channelIndex + 1}`}
</Typography>
</LabelBox>
{/* Type badge */}
<Typography
variant="caption"
sx={{
fontSize: "0.55rem",
opacity: 0.5,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{channel.type}
</Typography>
{/* Volume fader (vertical) */}
<FaderBox>
<ValueLabel variant="caption">{formatDb(channel.volume)}</ValueLabel>
<Slider
value={volPct}
onChange={handleVol}
orientation="vertical"
min={0}
max={100}
step={0.5}
sx={{ height: 100, flex: "0 0 auto" }}
/>
</FaderBox>
{/* Pan slider */}
<Box sx={{ width: "100%", px: 1 }}>
<Typography variant="caption" sx={{ fontSize: "0.55rem", opacity: 0.5 }}>
Pan
</Typography>
<Slider
value={channel.pan}
onChange={handlePan}
min={-1}
max={1}
step={0.05}
size="small"
valueLabelDisplay="auto"
valueLabelFormat={(v: number) => v === 0 ? "C" : v < 0 ? `L${Math.round(-v * 100)}` : `R${Math.round(v * 100)}`}
/>
</Box>
{/* Mute / Solo buttons */}
<ToggleButtonGroup
size="small"
value={[]}
sx={{ gap: 0.5, "& .MuiToggleButton-root": { px: 1, py: 0, minWidth: 32, height: 28, fontSize: "0.7rem" } }}
>
<ToggleButton
value="mute"
selected={channel.mute}
onChange={handleMute}
color={channel.mute ? "error" : "standard"}
>
{channel.mute ? <VolumeOff fontSize="inherit" /> : <VolumeUp fontSize="inherit" />}
</ToggleButton>
<ToggleButton
value="solo"
selected={channel.solo}
onChange={handleSolo}
color={channel.solo ? "warning" : "standard"}
>
<MusicNote fontSize="inherit" />
</ToggleButton>
</ToggleButtonGroup>
</StripRoot>
);
};
export default ChannelStrip;
+149
View File
@@ -0,0 +1,149 @@
// MasterBus — master output bus strip for the mixer console
//
// Volume fader, mute, bus name. Styled to visually differentiate
// from channel strips (wider, accent colour).
import React, { useCallback } from "react";
import Box from "@mui/material/Box";
import Slider from "@mui/material/Slider";
import ToggleButton from "@mui/material/ToggleButton";
import Typography from "@mui/material/Typography";
import { styled } from "@mui/material/styles";
import VolumeUp from "@mui/icons-material/VolumeUp";
import VolumeOff from "@mui/icons-material/VolumeOff";
import type { MixerBusState } from "./useMixerWS";
// ── Props ──────────────────────────────────────────────────────────
export interface MasterBusProps {
bus: MixerBusState;
onVolumeChange: (busId: number, volumeDb: number) => void;
onMuteToggle: (busId: number, mute: boolean) => void;
}
// ── Styled ─────────────────────────────────────────────────────────
const BusRoot = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
minWidth: 80,
width: 80,
padding: "8px 4px",
borderRadius: 8,
background:
theme.palette.mode === "dark"
? "rgba(167,112,228,0.12)"
: "rgba(103,80,164,0.08)",
border: `1px solid ${
theme.palette.mode === "dark"
? "rgba(167,112,228,0.3)"
: "rgba(103,80,164,0.25)"
}`,
userSelect: "none",
}));
const FaderBox = styled(Box)({
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: 120,
width: "100%",
});
const ValueLabel = styled(Typography)({
fontSize: "0.6rem",
lineHeight: 1,
opacity: 0.7,
});
// ── Helpers ────────────────────────────────────────────────────────
function dbToPercent(db: number): number {
if (db <= -60) return 0;
if (db >= 12) return 100;
return ((db + 60) / 72) * 100;
}
function formatDb(db: number): string {
if (db <= -60) return "-∞";
return `${db.toFixed(1)} dB`;
}
// ── Component ──────────────────────────────────────────────────────
const MasterBus: React.FC<MasterBusProps> = ({
bus,
onVolumeChange,
onMuteToggle,
}) => {
const volPct = dbToPercent(bus.volume);
const handleVol = useCallback(
(_: Event, value: number | number[]) => {
const db = ((value as number) / 100) * 72 - 60;
onVolumeChange(bus.id, db);
},
[bus.id, onVolumeChange]
);
const handleMute = useCallback(() => {
onMuteToggle(bus.id, !bus.mute);
}, [bus.id, bus.mute, onMuteToggle]);
return (
<BusRoot>
{/* Bus name */}
<Typography variant="caption" noWrap sx={{ fontWeight: 700 }}>
{bus.name}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: "0.55rem",
opacity: 0.5,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{bus.type}
</Typography>
{/* Volume fader */}
<FaderBox>
<ValueLabel variant="caption">{formatDb(bus.volume)}</ValueLabel>
<Slider
value={volPct}
onChange={handleVol}
orientation="vertical"
min={0}
max={100}
step={0.5}
sx={{ height: 100, flex: "0 0 auto" }}
/>
</FaderBox>
{/* Mute button */}
<ToggleButton
value="mute"
selected={bus.mute}
onChange={handleMute}
size="small"
color={bus.mute ? "error" : "standard"}
sx={{ px: 1, py: 0, minWidth: 48, height: 28, fontSize: "0.7rem" }}
>
{bus.mute ? (
<VolumeOff fontSize="inherit" />
) : (
<VolumeUp fontSize="inherit" />
)}
</ToggleButton>
</BusRoot>
);
};
export default MasterBus;
+285
View File
@@ -0,0 +1,285 @@
// MixerPage — main mixer console view
//
// Connects to pipedald via useMixerWS, fetches full mixer state,
// renders channel strips and master bus, and sends parameter
// changes back to the engine.
import React, { useEffect, useState, useCallback } from "react";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import ArrowBack from "@mui/icons-material/ArrowBack";
import Refresh from "@mui/icons-material/Refresh";
import Alert from "@mui/material/Alert";
import { styled } from "@mui/material/styles";
import { useMixerWS, type MixerState, type MixerChannelState, type MixerBusState } from "./useMixerWS";
import ChannelStrip from "./ChannelStrip";
import MasterBus from "./MasterBus";
import MixerScenePanel from "./MixerScenePanel";
// ── Props ──────────────────────────────────────────────────────────
export interface MixerPageProps {
/** Called when the user closes the mixer view. */
onClose: () => void;
}
// ── Styled ─────────────────────────────────────────────────────────
const Toolbar = styled(Box)({
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 8px",
flex: "0 0 auto",
});
const ScrollContainer = styled(Box)({
flex: 1,
overflowX: "auto",
overflowY: "hidden",
padding: "8px 12px",
});
const StripRow = styled(Box)({
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
gap: 8,
height: "100%",
minWidth: "min-content",
});
const StickyDivider = styled(Box)(({ theme }) => ({
width: 2,
alignSelf: "stretch",
background:
theme.palette.mode === "dark"
? "rgba(255,255,255,0.15)"
: "rgba(0,0,0,0.1)",
margin: "0 8px",
flex: "0 0 auto",
}));
// ── Hook: state polling ───────────────────────────────────────────
function useMixerState(ws: ReturnType<typeof useMixerWS>) {
const [state, setState] = useState<MixerState | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const fetchState = useCallback(async () => {
try {
const result = await ws.send("mixerGetState");
setState(result as MixerState);
setFetchError(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setFetchError(msg);
}
}, [ws]);
// Fetch state when WS connects, and every 2 seconds while connected.
useEffect(() => {
if (ws.status !== "connected") {
setState(null);
return;
}
fetchState();
const interval = setInterval(fetchState, 2000);
return () => clearInterval(interval);
}, [ws.status, fetchState]);
return { state, fetchError, fetchState };
}
// ── MixerPage ──────────────────────────────────────────────────────
const MixerPage: React.FC<MixerPageProps> = ({ onClose }) => {
const ws = useMixerWS();
const { state, fetchError, fetchState } = useMixerState(ws);
// ── Command helpers ────────────────────────────────────────────
const handleChannelVolume = useCallback(
(channelIndex: number, volume: number) => {
ws.send("mixerSetChannelVolume", { channelIndex, volume }).catch(() => {});
},
[ws]
);
const handleChannelPan = useCallback(
(channelIndex: number, pan: number) => {
ws.send("mixerSetChannelPan", { channelIndex, pan }).catch(() => {});
},
[ws]
);
const handleChannelMute = useCallback(
(channelIndex: number, mute: boolean) => {
ws.send("mixerSetChannelMute", { channelIndex, mute }).catch(() => {});
},
[ws]
);
const handleChannelSolo = useCallback(
(channelIndex: number, solo: boolean) => {
ws.send("mixerSetChannelSolo", { channelIndex, solo }).catch(() => {});
},
[ws]
);
const handleBusVolume = useCallback(
(busId: number, volume: number) => {
ws.send("mixerSetBusVolume", { busId, volume }).catch(() => {});
},
[ws]
);
const handleBusMute = useCallback(
(busId: number, mute: boolean) => {
ws.send("mixerSetBusMute", { busId, mute }).catch(() => {});
},
[ws]
);
// ── Render ─────────────────────────────────────────────────────
const channels: MixerChannelState[] = state?.channels ?? [];
const masterBus: MixerBusState | undefined = state?.buses.find(
(b) => b.type === "Master"
);
const otherBuses = state?.buses.filter((b) => b.type !== "Master") ?? [];
return (
<Box
sx={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
background: (t) => t.palette.background.default,
zIndex: 10,
}}
>
{/* Toolbar */}
<Toolbar>
<IconButton onClick={onClose} size="small">
<ArrowBack />
</IconButton>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
Mixer Console
</Typography>
<IconButton
size="small"
onClick={fetchState}
disabled={ws.status !== "connected"}
>
<Refresh />
</IconButton>
<Typography
variant="caption"
sx={{
opacity: 0.6,
fontSize: "0.6rem",
mr: 1,
}}
>
{ws.status === "connected"
? "● Connected"
: ws.status === "connecting"
? "◌ Connecting..."
: "○ Disconnected"}
</Typography>
</Toolbar>
{/* Scene panel */}
<Box sx={{ px: 2, py: 0.5, flex: "0 0 auto" }}>
<MixerScenePanel ws={ws} disabled={ws.status !== "connected"} />
</Box>
{/* Error banner */}
{fetchError && (
<Alert severity="warning" sx={{ mx: 1, my: 0.5, py: 0 }}>
{fetchError}
</Alert>
)}
{ws.lastError && (
<Alert severity="info" sx={{ mx: 1, my: 0.5, py: 0 }}>
{ws.lastError}
</Alert>
)}
{/* Strip area */}
<ScrollContainer>
{ws.status === "connecting" && !state && (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
gap: 1,
}}
>
<CircularProgress size={20} />
<Typography variant="body2">Connecting to mixer...</Typography>
</Box>
)}
{ws.status === "connected" && state && (
<StripRow>
{/* Channel strips */}
{channels.map((ch) => (
<ChannelStrip
key={ch.channelIndex}
channel={ch}
onVolumeChange={handleChannelVolume}
onPanChange={handleChannelPan}
onMuteToggle={handleChannelMute}
onSoloToggle={handleChannelSolo}
/>
))}
{/* Divider */}
{channels.length > 0 && <StickyDivider />}
{/* Other buses (subgroups, aux, etc.) */}
{otherBuses.map((bus) => (
<MasterBus
key={bus.id}
bus={bus}
onVolumeChange={handleBusVolume}
onMuteToggle={handleBusMute}
/>
))}
{/* Master bus */}
{masterBus && (
<>
<StickyDivider />
<MasterBus
key={masterBus.id}
bus={masterBus}
onVolumeChange={handleBusVolume}
onMuteToggle={handleBusMute}
/>
</>
)}
{/* Empty state */}
{channels.length === 0 && otherBuses.length === 0 && !masterBus && (
<Typography variant="body2" sx={{ opacity: 0.5, p: 4 }}>
No mixer state received yet.
</Typography>
)}
</StripRow>
)}
</ScrollContainer>
</Box>
);
};
export default MixerPage;
+386
View File
@@ -0,0 +1,386 @@
// MixerScenePanel — Scene management for the digital mixer.
//
// Shows 8 numbered scene slots (like a hardware digital mixer).
// Uses the mixer WebSocket to save/load/list scenes via the backend
// mixerSaveScene / mixerLoadScene / mixerGetScenes commands.
//
// Embedded in the MixerPage toolbar area.
import React, { useState, useEffect, useCallback } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Tooltip from "@mui/material/Tooltip";
import Save from "@mui/icons-material/Save";
import Restore from "@mui/icons-material/Restore";
import Refresh from "@mui/icons-material/Refresh";
import Alert from "@mui/material/Alert";
import type { MixerWsHandle } from "./useMixerWS";
export const MAX_SCENES = 8;
const POLL_INTERVAL_MS = 5_000;
export interface MixerSceneInfo {
id: number;
name: string;
}
interface MixerScenePanelProps {
ws: MixerWsHandle;
disabled?: boolean;
}
export default function MixerScenePanel({ ws, disabled }: MixerScenePanelProps) {
const [scenes, setScenes] = useState<MixerSceneInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Save dialog
const [saveOpen, setSaveOpen] = useState(false);
const [saveName, setSaveName] = useState("");
const [saving, setSaving] = useState(false);
// Load confirm dialog
const [loadConfirm, setLoadConfirm] = useState<{
id: number;
name: string;
} | null>(null);
// ── Fetch scene list ────────────────────────────────────────────
const fetchScenes = useCallback(async () => {
if (ws.status !== "connected" || loading) return;
setLoading(true);
try {
const raw = (await ws.send("mixerGetScenes")) as string;
const parsed = JSON.parse(raw) as { scenes: { id: number; name: string }[] };
const list: MixerSceneInfo[] = (parsed.scenes ?? []).map((s) => ({
id: s.id,
name: s.name || `Scene ${s.id}`,
}));
list.sort((a, b) => a.id - b.id);
setScenes(list);
setError(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
setLoading(false);
}
}, [ws, loading]);
// Initial fetch + polling
useEffect(() => {
if (ws.status !== "connected") return;
fetchScenes();
const interval = setInterval(fetchScenes, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [ws.status, fetchScenes]);
// Fetch when reconnected
useEffect(() => {
if (ws.status === "connected") {
fetchScenes();
}
}, [ws.status]);
// ── Slot helpers ────────────────────────────────────────────────
const getSlotScene = useCallback(
(slot: number): MixerSceneInfo | null => scenes[slot] ?? null,
[scenes],
);
// ── Save ────────────────────────────────────────────────────────
const handleSaveClick = useCallback(() => {
setSaveName(`Scene ${scenes.length + 1}`);
setSaveOpen(true);
}, [scenes.length]);
const handleSaveConfirm = useCallback(async () => {
const name = saveName.trim();
if (!name) return;
setSaving(true);
try {
await ws.send("mixerSaveScene", { name });
setSaveOpen(false);
setSaveName("");
await fetchScenes();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
setSaving(false);
}
}, [saveName, ws, fetchScenes]);
// ── Load ────────────────────────────────────────────────────────
const handleSlotClick = useCallback(
(slot: number) => {
const scene = getSlotScene(slot);
if (!scene) {
// Empty slot → save
setSaveName(`Scene ${slot + 1}`);
setSaveOpen(true);
return;
}
setLoadConfirm({ id: scene.id, name: scene.name });
},
[getSlotScene],
);
const handleLoadConfirm = useCallback(async () => {
if (!loadConfirm) return;
try {
await ws.send("mixerLoadScene", { sceneId: loadConfirm.id });
setLoadConfirm(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setLoadConfirm(null);
}
}, [loadConfirm, ws]);
// ── Render slot ─────────────────────────────────────────────────
const renderSlot = useCallback(
(slot: number) => {
const scene = getSlotScene(slot);
const isUsed = scene !== null;
return (
<Tooltip
key={slot}
title={
isUsed
? `Restore "${scene!.name}"`
: `Save current state to slot ${slot + 1}`
}
>
<Box
onClick={() => handleSlotClick(slot)}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: 72,
height: 56,
borderRadius: 1.5,
cursor: disabled ? "default" : "pointer",
bgcolor: isUsed ? "rgba(167, 112, 228, 0.18)" : "rgba(255,255,255,0.04)",
border: isUsed
? "1px solid rgba(167, 112, 228, 0.4)"
: "1px dashed rgba(255,255,255,0.12)",
transition: "all 0.15s",
position: "relative",
userSelect: "none",
opacity: disabled ? 0.5 : 1,
"&:hover": disabled
? {}
: {
bgcolor: isUsed
? "rgba(167, 112, 228, 0.32)"
: "rgba(255,255,255,0.09)",
},
}}
>
{/* Slot number */}
<Typography
sx={{
position: "absolute",
top: 1,
left: 5,
fontSize: 9,
fontWeight: 700,
opacity: 0.35,
lineHeight: 1,
}}
>
{slot + 1}
</Typography>
{isUsed ? (
<>
<Typography
sx={{
fontSize: 10,
fontWeight: 600,
lineHeight: 1.2,
textAlign: "center",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 62,
color: "#d0b0f0",
}}
>
{scene!.name}
</Typography>
</>
) : (
<Typography
sx={{ fontSize: 10, opacity: 0.3, textAlign: "center" }}
>
Empty
</Typography>
)}
</Box>
</Tooltip>
);
},
[getSlotScene, handleSlotClick, disabled],
);
// ── Main render ─────────────────────────────────────────────────
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
{/* Header row */}
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 0.5,
}}
>
<Typography
sx={{
fontWeight: 600,
fontSize: 11,
opacity: 0.6,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
Scenes
</Typography>
<Box sx={{ display: "flex", gap: 0.5, alignItems: "center" }}>
<Tooltip title="Refresh scene list">
<span>
<IconButton
size="small"
onClick={fetchScenes}
disabled={ws.status !== "connected" || disabled}
sx={{ p: 0.5 }}
>
<Refresh sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Save current mixer state as a new scene">
<span>
<Button
size="small"
variant="outlined"
startIcon={<Save sx={{ fontSize: 14 }} />}
onClick={handleSaveClick}
disabled={ws.status !== "connected" || disabled}
sx={{
minHeight: 24,
minWidth: 0,
fontSize: 10,
textTransform: "none",
py: 0,
px: 1,
}}
>
Save
</Button>
</span>
</Tooltip>
</Box>
</Box>
{/* Error banner */}
{error && (
<Alert severity="warning" sx={{ py: 0, px: 1, fontSize: 11 }}>
{error}
</Alert>
)}
{/* Scene slot grid */}
<Box
sx={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
gap: 0.5,
}}
>
{Array.from({ length: MAX_SCENES }, (_, i) => renderSlot(i))}
</Box>
{/* ── Save dialog ─────────────────────────────────────────── */}
<Dialog
open={saveOpen}
onClose={() => setSaveOpen(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Save Mixer Scene</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Scene Name"
fullWidth
variant="outlined"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveConfirm();
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setSaveOpen(false)} color="secondary">
Cancel
</Button>
<Button
onClick={handleSaveConfirm}
variant="contained"
disabled={!saveName.trim() || saving}
>
{saving ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
{/* ── Load confirm dialog ─────────────────────────────────── */}
<Dialog
open={loadConfirm !== null}
onClose={() => setLoadConfirm(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Load Scene</DialogTitle>
<DialogContent>
<Typography variant="body2">
Restore scene{" "}
<strong>{loadConfirm?.name}</strong>?
<br />
This will overwrite current mixer settings.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setLoadConfirm(null)} color="secondary">
Cancel
</Button>
<Button onClick={handleLoadConfirm} variant="contained" color="primary">
Load
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+289
View File
@@ -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 };
}