Wire MetersPage into app with gate/compressor indicators

- App.tsx: Import and render MetersPage when 'Meters' button toggled;
  add Meters button to header between Patch and PiPedal buttons
- useMixerDaemon.ts: Add gateOpen and compressorReduction fields to
  MixerChannel interface
- MetersPage.tsx: Add gate open/closed indicator (green/red G badge)
  and compressor gain reduction bar to ChannelMeterTile
- MetersPage.css: Add gate indicator and compressor GR track styles
This commit is contained in:
2026-06-23 20:11:11 -04:00
parent 84cad11745
commit 1212e3915e
8 changed files with 1290 additions and 54 deletions
+372 -11
View File
@@ -1,19 +1,380 @@
import React from 'react';
import { usePiPedalWS } from './hooks/usePiPedalWS';
import { MixerPage } from './components/MixerPage/MixerPage';
import React, { useState } from 'react';
import { useMixerDaemon, MixerChannel, MixerBus, MixerRoute } from './hooks/useMixerDaemon';
import { useMixerScenes } from './hooks/useMixerScenes';
import { useChannelPresets } from './hooks/useChannelPresets';
import { DaemonSceneManager } from './components/DaemonSceneManager/DaemonSceneManager';
import { ConnectionStatus } from './components/ConnectionStatus';
import { PatchBay } from './components/PatchBay/PatchBay';
import { useJackPorts } from './hooks/useJackPorts';
import { MetersPage } from './components/MetersPage/MetersPage';
import './App.css';
// ── Channel Preset Panel ────────────────────────────────────
const ChannelPresetPanel: React.FC<{
channelIndex: number;
channelLabel: string;
presets: { id: string; name: string; channelIndex: number | null; createdAt: number }[];
loading: boolean;
onSave: (name: string) => void;
onLoad: (presetId: string) => void;
onDelete: (presetId: string) => void;
onClose: () => void;
}> = ({ channelIndex, channelLabel, presets, loading, onSave, onLoad, onDelete, onClose }) => {
const [presetName, setPresetName] = useState('');
const handleSave = () => {
const trimmed = presetName.trim();
if (!trimmed) return;
onSave(trimmed);
setPresetName('');
};
return (
<div className="preset-panel" onClick={(e) => e.stopPropagation()}>
<div className="preset-panel-header">
<span className="preset-panel-title">Presets {channelLabel}</span>
<button className="preset-panel-close" onClick={onClose}></button>
</div>
<div className="preset-panel-list">
{loading && presets.length === 0 && (
<div className="preset-panel-empty">Loading...</div>
)}
{!loading && presets.length === 0 && (
<div className="preset-panel-empty">No presets saved</div>
)}
{presets.map((p) => (
<div key={p.id} className="preset-row">
<span className="preset-row-name">{p.name}</span>
<div className="preset-row-actions">
<button
className="preset-row-btn load"
onClick={() => onLoad(p.id)}
>
Load
</button>
<button
className="preset-row-btn delete"
onClick={() => onDelete(p.id)}
>
</button>
</div>
</div>
))}
</div>
<div className="preset-panel-save">
<input
className="preset-panel-input"
type="text"
placeholder="Preset name..."
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
maxLength={60}
/>
<button
className="preset-panel-save-btn"
onClick={handleSave}
disabled={!presetName.trim()}
>
Save
</button>
</div>
</div>
);
};
// ── Channel Strip ──────────────────────────────────────────
const ChannelStrip: React.FC<{
ch: MixerChannel;
onVolume: (db: number) => void;
onPan: (pan: number) => void;
onMute: () => void;
onSolo: () => void;
onPresets: () => void;
}> = ({ ch, onVolume, onPan, onMute, onSolo, onPresets }) => (
<div className={`daemon-channel ${ch.mute ? 'muted' : ''}`}>
<div className="daemon-channel-header">
<span className="daemon-channel-label">{ch.label}</span>
<span className="daemon-channel-type">{ch.type}</span>
</div>
<div className="daemon-channel-controls">
<label>
Vol
<input type="range" min="-96" max="12" step="0.5" value={ch.volume}
onChange={(e) => onVolume(parseFloat(e.target.value))} />
<span className="daemon-value">{ch.volume.toFixed(1)}dB</span>
</label>
<label>
Pan
<input type="range" min="-1" max="1" step="0.1" value={ch.pan}
onChange={(e) => onPan(parseFloat(e.target.value))} />
<span className="daemon-value">{ch.pan.toFixed(1)}</span>
</label>
</div>
<div className="daemon-channel-buttons">
<button className={`daemon-btn ${ch.mute ? 'active' : ''}`} onClick={onMute}>M</button>
<button className={`daemon-btn ${ch.solo ? 'active' : ''}`} onClick={onSolo}>S</button>
<button className="daemon-btn preset-btn" onClick={onPresets} title="Channel presets">P</button>
</div>
</div>
);
// ── Bus Strip ──────────────────────────────────────────────
const BusStrip: React.FC<{ bus: MixerBus; channels: MixerChannel[]; routes: MixerRoute[] }> = ({
bus, channels, routes,
}) => {
const channelRoutes = routes.filter((r) => r.targetBusId === bus.id && r.sourceType === 'channel');
return (
<div className="daemon-bus">
<div className="daemon-channel-header">
<span className="daemon-channel-label">{bus.name}</span>
<span className="daemon-channel-type">{bus.type}</span>
</div>
<div className="daemon-bus-meters">
<span className="daemon-value">{bus.volume.toFixed(1)}dB</span>
{bus.mute && <span className="daemon-badge muted-badge">MUTED</span>}
</div>
{channelRoutes.length > 0 && (
<div className="daemon-bus-routes">
<div className="daemon-bus-routes-title">Inputs</div>
{channelRoutes.map((r) => {
const ch = channels.find((c) => c.channelIndex === r.sourceId || c.channelIndex + 3 === r.sourceId);
const name = ch ? ch.label : `Ch ${r.sourceId}`;
return (
<div key={`${r.sourceId}${r.targetBusId}`} className="daemon-bus-route-row">
<span className="daemon-route-name">{name}</span>
<span className="daemon-route-level">{r.level.toFixed(1)}dB</span>
</div>
);
})}
</div>
)}
</div>
);
};
// ── Master Strip ───────────────────────────────────────────
const MasterStrip: React.FC<{ bus: MixerBus }> = ({ bus }) => (
<div className="daemon-master">
<div className="daemon-channel-header">
<span className="daemon-channel-label">{bus.name}</span>
</div>
<div className="daemon-bus-meters">
<span className="daemon-value">{bus.volume.toFixed(1)}dB</span>
</div>
</div>
);
// ── App ────────────────────────────────────────────────────
const App: React.FC = () => {
const { connectionState, plugins, pedalboardName, discover, lastUpdate } = usePiPedalWS();
const { state, loading, error, refresh, setVolume, setPan, setMute, setSolo } = useMixerDaemon();
const { scenes, loading: scenesLoading, error: scenesError, saveScene, loadScene, deleteScene, refresh: refreshScenes } = useMixerScenes();
const { presets, loading: presetsLoading, error: presetsError, savePreset, loadPreset, deletePreset, refreshPresets } = useChannelPresets();
const { portsState, refresh: refreshJack, connect, disconnect, disconnectAll, actionState, actionError } = useJackPorts();
const [showScenes, setShowScenes] = useState(false);
const [showMeters, setShowMeters] = useState(false);
const [showPiPedal, setShowPiPedal] = useState(false);
const [showPatchBay, setShowPatchBay] = useState(false);
const [presetChannel, setPresetChannel] = useState<number | null>(null);
const pipedalUrl = 'http://192.168.0.245:8080/';
// Load presets when a channel's preset panel opens
const handlePresetOpen = (channelIndex: number) => {
setPresetChannel(channelIndex);
refreshPresets(channelIndex);
};
const handleSavePreset = async (name: string) => {
if (presetChannel === null) return;
// Get current state and extract this channel's params
if (!state) return;
const ch = state.channels.find((c) => c.channelIndex === presetChannel);
if (!ch) return;
await savePreset(presetChannel, name, {
volume: ch.volume,
pan: ch.pan,
mute: ch.mute,
solo: ch.solo,
});
};
const channelPresets = presetChannel !== null
? presets.filter((p) => p.channelIndex === presetChannel || p.channelIndex === null)
: [];
return (
<div className="app">
<MixerPage
plugins={plugins}
connectionState={connectionState}
pedalboardName={pedalboardName}
lastUpdate={lastUpdate}
onRefresh={discover}
/>
{/* Header */}
<header className="mixer-header">
<div className="mixer-header-left">
<h1 className="mixer-title">OPLabs Mixer</h1>
{state && <span className="mixer-pedalboard-name">{state.physicalInputCount}ch / {state.sampleRate}Hz</span>}
</div>
<div className="mixer-header-right">
<ConnectionStatus
state={{ status: error ? 'error' : loading ? 'connecting' : 'connected', error: error || undefined }}
onRetry={refresh}
/>
<button
className={`mixer-scenes-btn ${showScenes ? 'active' : ''}`}
onClick={() => setShowScenes((v) => !v)}
>
🎬 Scenes
</button>
<button className={`mixer-scenes-btn ${showPatchBay ? 'active' : ''}`} onClick={() => setShowPatchBay((v) => !v)}>
🔌 Patch
</button>
<button className={`mixer-scenes-btn ${showMeters ? 'active' : ''}`} onClick={() => setShowMeters((v) => !v)}>
📊 Meters
</button>
<button className={`mixer-scenes-btn ${showPiPedal ? 'active' : ''}`} onClick={() => setShowPiPedal((v) => !v)}>
🎛 PiPedal
</button>
<button className="mixer-refresh-btn" onClick={refresh}> Refresh</button>
</div>
</header>
{/* Main content */}
{showMeters ? (
<MetersPage
state={state}
loading={loading}
error={error}
onRefresh={refresh}
onBack={() => setShowMeters(false)}
/>
) : (
<div className={`mixer-content ${showPiPedal ? 'mixer-content-split' : ''}`}>
{/* Mixer controls */}
<div className="mixer-content-main">
{loading && <div className="mixer-empty"><p>Connecting to mixer daemon...</p></div>}
{error && (
<div className="mixer-empty">
<h2>Mixer Daemon Unreachable</h2>
<p>{error}</p>
<button className="mixer-refresh-btn large" onClick={refresh}> Retry</button>
</div>
)}
{state && state.channels.length === 0 && (
<div className="mixer-empty">
<h2>No Channels</h2>
<p>No mixer channels configured. Add channels via the daemon.</p>
</div>
)}
{state && state.channels.length > 0 && (
<>
{/* Channel strips */}
<div className="daemon-channels-section">
<div className="mixer-section-header">
<h2>Channels</h2>
<span className="mixer-count">{state.channels.length}</span>
</div>
<div className="daemon-channels-grid">
{state.channels.map((ch) => (
<div key={ch.channelIndex} className="daemon-channel-wrapper">
<ChannelStrip
ch={ch}
onVolume={(db) => setVolume(ch.channelIndex, db)}
onPan={(p) => setPan(ch.channelIndex, p)}
onMute={() => setMute(ch.channelIndex, !ch.mute)}
onSolo={() => setSolo(ch.channelIndex, !ch.solo)}
onPresets={() => handlePresetOpen(ch.channelIndex)}
/>
{/* Preset panel for this channel */}
{presetChannel === ch.channelIndex && (
<ChannelPresetPanel
channelIndex={ch.channelIndex}
channelLabel={ch.label}
presets={channelPresets}
loading={presetsLoading}
onSave={handleSavePreset}
onLoad={(presetId) => loadPreset(ch.channelIndex, presetId)}
onDelete={(presetId) => deletePreset(ch.channelIndex, presetId)}
onClose={() => setPresetChannel(null)}
/>
)}
</div>
))}
</div>
</div>
{/* Buses with routing */}
{state.buses.map((bus) => (
<div key={bus.id} className="daemon-buses-section">
{bus.type === 'Master' ? (
<MasterStrip bus={bus} />
) : (
<BusStrip bus={bus} channels={state.channels} routes={state.routes} />
)}
</div>
))}
{/* Output routing */}
{state.outputRoutes && state.outputRoutes.length > 0 && (
<div className="daemon-routing-info">
<div className="mixer-section-header">
<h2>Output Routing</h2>
</div>
<div className="daemon-routing-grid">
{state.outputRoutes.map((r, i) => (
<div key={i} className="daemon-route-chip">
Bus {r.sourceBusId} Out {r.targetStartChannel}+{r.channels - 1}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
{/* PiPedal iframe */}
{showPiPedal && (
<div className="mixer-pipedal-pane">
<iframe src={pipedalUrl} title="PiPedal" className="mixer-pipedal-iframe"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups" />
</div>
)}
</div>
)}
{showScenes && (
<DaemonSceneManager
scenes={scenes}
loading={scenesLoading}
error={scenesError}
onClose={() => setShowScenes(false)}
onSaveScene={saveScene}
onLoadScene={loadScene}
onDeleteScene={deleteScene}
/>
)}
{/* Patch Bay */}
{showPatchBay && (
<div className="mixer-patch-pane">
<PatchBay
portsState={portsState}
onConnect={connect}
onDisconnect={disconnect}
onDisconnectAll={disconnectAll}
onRefresh={refreshJack}
actionState={actionState}
actionError={actionError}
/>
</div>
)}
</div>
);
};
@@ -0,0 +1,446 @@
/* ── Meter Bridge fullscreen layout ─────────────── */
.meters-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #0d0d1a;
color: #e0e0e0;
font-family: 'Cooper Hewitt', -apple-system, BlinkMacSystemFont, sans-serif;
padding: 12px 16px;
gap: 16px;
}
/* ── Toolbar ───────────────────────────────────────── */
.meters-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #222;
flex-shrink: 0;
}
.meters-back-btn {
background: #1a1a2e;
color: #e0e0e0;
border: 1px solid #333;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: all 0.15s;
}
.meters-back-btn:hover {
border-color: #e94560;
background: rgba(233, 69, 96, 0.1);
}
.meters-title {
font-size: 0.95rem;
font-weight: 700;
color: #e0e0e0;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.meters-count {
font-size: 0.7rem;
color: #888;
background: #1a1a2e;
padding: 2px 9px;
border-radius: 10px;
font-weight: 600;
}
.meters-refresh-btn {
margin-left: auto;
background: #1a1a2e;
color: #e0e0e0;
border: 1px solid #333;
padding: 4px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.15s;
}
.meters-refresh-btn:hover {
border-color: #e94560;
background: rgba(233, 69, 96, 0.1);
}
/* ── Empty state ───────────────────────────────────── */
.meters-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: #888;
text-align: center;
gap: 12px;
}
.meters-empty h2 {
font-size: 1.2rem;
color: #e0e0e0;
}
.meters-empty p {
font-size: 0.9rem;
max-width: 400px;
line-height: 1.5;
}
.meters-retry-btn {
background: #e94560;
color: #fff;
border: none;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
/* ── Sections ──────────────────────────────────────── */
.meters-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.meters-section-header {
display: flex;
align-items: center;
gap: 8px;
}
.meters-section-header h2 {
font-size: 0.8rem;
font-weight: 600;
color: #aaa;
text-transform: uppercase;
letter-spacing: 1px;
}
.meters-section-count {
font-size: 0.6rem;
color: #666;
background: #1a1a2e;
padding: 1px 7px;
border-radius: 10px;
font-weight: 600;
}
/* ── Channels grid ─────────────────────────────────── */
.meters-grid {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
}
/* ── Meter tile (channel / bus / master) ───────────── */
.meter-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: #111122;
border: 1px solid #222;
border-radius: 8px;
padding: 8px 6px 6px;
min-width: 72px;
max-width: 80px;
flex-shrink: 0;
transition: border-color 0.2s;
}
.meter-tile:hover {
border-color: #444;
}
.meter-tile--muted {
opacity: 0.45;
}
.meter-tile--bus {
min-width: 60px;
max-width: 68px;
padding: 6px 4px 4px;
}
/* Badge (label + type) */
.meter-tile-badge {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
width: 100%;
}
.meter-tile-name {
font-size: 0.65rem;
font-weight: 600;
color: #ddd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: center;
}
.meter-tile-type {
font-size: 0.5rem;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Status flags (M / S) */
.meter-tile-status {
display: flex;
gap: 3px;
min-height: 14px;
}
.meter-tile-flag {
font-size: 0.5rem;
font-weight: 800;
padding: 1px 5px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1.2;
}
.flag-mute {
background: #e94560;
color: #fff;
}
.flag-solo {
background: #ff9800;
color: #000;
}
/* Gate indicator */
.meter-tile-gate {
font-size: 0.5rem;
font-weight: 800;
padding: 1px 5px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1.2;
transition: background 0.15s, color 0.15s;
}
.meter-tile-gate.gate-open {
background: #4caf50;
color: #fff;
}
.meter-tile-gate.gate-closed {
background: #e94560;
color: #fff;
}
/* Bars container */
.meter-tile-bars {
display: flex;
gap: 3px;
flex: 1;
min-height: 120px;
width: 100%;
}
/* ── Individual meter bar ──────────────────────────── */
.meter-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
.meter-bar--compact {
gap: 1px;
}
.meter-bar--compact .meter-bar-track {
min-height: 60px;
border-radius: 2px;
}
.meter-bar-label {
font-size: 0.55rem;
font-weight: 700;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meter-bar-track {
position: relative;
width: 100%;
flex: 1;
min-height: 80px;
background: rgba(0, 0, 0, 0.6);
border-radius: 3px;
border: 1px solid #2a2a2a;
overflow: hidden;
}
.meter-bar-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transition: height 0.04s linear;
border-radius: 1px;
}
.meter-bar-peak {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: rgba(255, 255, 255, 0.85);
border-radius: 1px;
transition: bottom 0.12s ease-out;
z-index: 2;
}
.meter-bar-tick {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: rgba(255, 255, 255, 0.08);
z-index: 1;
}
.meter-bar-tick--yellow {
background: rgba(255, 152, 0, 0.18);
}
.meter-bar-tick--green {
background: rgba(76, 175, 80, 0.12);
}
.meter-bar-db {
font-size: 0.55rem;
font-family: 'Courier New', Courier, monospace;
color: #888;
white-space: nowrap;
}
.meter-bar-db.hot {
color: #f44336;
font-weight: 700;
}
/* ── Compressor gain reduction indicator ───────────── */
.meter-tile-comp {
display: flex;
align-items: center;
gap: 3px;
width: 100%;
padding: 0 2px;
}
.meter-comp-track {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.5);
border-radius: 2px;
overflow: hidden;
border: 1px solid #2a2a2a;
}
.meter-comp-fill {
height: 100%;
background: #ff9800;
border-radius: 1px;
transition: width 0.1s linear;
}
.meter-comp-label {
font-size: 0.45rem;
font-family: 'Courier New', Courier, monospace;
color: #ff9800;
white-space: nowrap;
min-width: 28px;
text-align: right;
}
/* ── Bus grid (more compact) ───────────────────────── */
.meters-grid--bus {
gap: 6px;
}
/* ── Master output row ─────────────────────────────── */
.meters-master-row {
display: flex;
align-items: flex-end;
gap: 10px;
background: #111122;
border: 1px solid #e94560;
border-radius: 8px;
padding: 10px 14px;
max-width: 500px;
}
.meters-master-row .meter-bar {
max-width: 60px;
flex: 0 0 50px;
}
.meters-master-row .meter-bar-track {
min-height: 100px;
}
.meters-master-info {
display: flex;
flex-direction: column;
gap: 4px;
margin-left: auto;
align-items: flex-end;
}
.meters-master-label {
font-size: 0.7rem;
font-weight: 700;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
}
.meters-master-db {
font-size: 1rem;
font-weight: 700;
font-family: 'Courier New', Courier, monospace;
color: #e0e0e0;
}
/* ── Scroll container for the whole page ───────────── */
.meters-page {
overflow-y: auto;
}
/* ── Responsive small screens ──────────────────────── */
@media (max-width: 720px) {
.meters-page {
padding: 8px;
gap: 10px;
}
.meter-tile {
min-width: 60px;
max-width: 66px;
padding: 6px 4px 4px;
}
.meter-tile-bars {
min-height: 80px;
}
.meter-bar-track {
min-height: 60px;
}
.meters-master-row {
max-width: 100%;
padding: 8px 10px;
}
.meters-master-row .meter-bar-track {
min-height: 70px;
}
}
@@ -0,0 +1,288 @@
import React, { useEffect, useRef } from 'react';
import type { MixerChannel, MixerBus, MixerState } from '../../hooks/useMixerDaemon';
import './MetersPage.css';
// ── Constants ──────────────────────────────────────────────────
const DB_MIN = -60;
const DB_MAX = 0;
/**
* Converts a dB value to a fill percentage (0100).
* Uses a sqrt curve so 20 dB appears around 50 % (VU-ish response).
*/
function dbToPercent(db: number): number {
const clamped = Math.max(DB_MIN, Math.min(DB_MAX, db));
const normalized = (clamped - DB_MIN) / (DB_MAX - DB_MIN);
return Math.sqrt(normalized) * 100;
}
/**
* Return an HSL colour string for the given fill percentage.
* Green (< 60 % of range) → hue ~120°
* Yellow (6085 %) → hue ~50°
* Red (> 85 %) → hue ~0°
*/
function pctColor(pct: number): string {
if (pct < 60) return `hsl(${120 - (pct / 60) * 70}, 85%, 45%)`;
if (pct < 85) return `hsl(${50 - ((pct - 60) / 25) * 50}, 95%, 50%)`;
return `hsl(0, 90%, 50%)`;
}
// ── Subcomponents ─────────────────────────────────────────────
interface MeterBarProps {
label: string;
/** Current dB level ( 60 … 0 ) */
level: number;
/** Peak hold duration in ms (default 2000) */
peakHold?: number;
/** Compact variant (for buses / master) */
compact?: boolean;
}
/**
* A single mono LEDstyle meter bar with peakhold dot.
*/
const MeterBar: React.FC<MeterBarProps> = ({ label, level, peakHold = 2000, compact = false }) => {
const peakRef = useRef<number>(DB_MIN);
const peakTimer = useRef<number | null>(null);
const [, forceUpdate] = React.useState(0);
useEffect(() => {
if (level > peakRef.current) {
peakRef.current = level;
if (peakTimer.current !== null) clearTimeout(peakTimer.current);
peakTimer.current = window.setTimeout(() => {
peakRef.current = Math.max(level, DB_MIN);
forceUpdate((n) => n + 1);
}, peakHold);
}
return () => {
if (peakTimer.current !== null) clearTimeout(peakTimer.current);
};
}, [level, peakHold]);
const pct = dbToPercent(level);
const peakPct = dbToPercent(peakRef.current);
return (
<div className={`meter-bar ${compact ? 'meter-bar--compact' : ''}`}>
<span className="meter-bar-label">{label}</span>
<div className="meter-bar-track">
<div
className="meter-bar-fill"
style={{ height: `${Math.min(100, pct)}%`, backgroundColor: pctColor(pct) }}
/>
{/* Peak hold dot */}
<div className="meter-bar-peak" style={{ bottom: `${Math.min(100, peakPct)}%` }} />
{/* Reference ticks */}
<div className="meter-bar-tick" style={{ bottom: `${dbToPercent(-6)}%` }} />
<div className="meter-bar-tick meter-bar-tick--yellow" style={{ bottom: `${dbToPercent(-20)}%` }} />
<div className="meter-bar-tick meter-bar-tick--green" style={{ bottom: `${dbToPercent(-40)}%` }} />
</div>
<span className={`meter-bar-db ${level > -10 ? 'hot' : ''}`}>
{level > DB_MIN ? `${level >= 0 ? '+' : ''}${level.toFixed(1)}` : '-∞'}
</span>
</div>
);
};
// ── Tile components ────────────────────────────────────────────
interface ChannelMeterTileProps {
channel: MixerChannel;
}
/**
* A large meter tile for a single channel stereo bars,
* name badge, mute / solo status, gate indicator, and compressor GR.
*/
const ChannelMeterTile: React.FC<ChannelMeterTileProps> = ({ channel }) => {
const l = channel.vuLeft ?? DB_MIN;
const r = channel.vuRight ?? DB_MIN;
const gateOpen = channel.gateOpen ?? true;
const compRed = channel.compressorReduction ?? 0;
return (
<div className={`meter-tile ${channel.mute ? 'meter-tile--muted' : ''}`}>
<div className="meter-tile-badge">
<span className="meter-tile-name">{channel.label || `Ch ${channel.channelIndex + 1}`}</span>
<span className="meter-tile-type">{channel.type}</span>
</div>
<div className="meter-tile-status">
{channel.mute && <span className="meter-tile-flag flag-mute">M</span>}
{channel.solo && <span className="meter-tile-flag flag-solo">S</span>}
<span className={`meter-tile-gate ${gateOpen ? 'gate-open' : 'gate-closed'}`}
title={gateOpen ? 'Gate: Open' : 'Gate: Closed'}>
G
</span>
</div>
{/* Compressor gain reduction meter */}
{compRed > 0.5 && (
<div className="meter-tile-comp">
<div className="meter-comp-track">
<div className="meter-comp-fill" style={{ width: `${Math.min(100, compRed * 8)}%` }} />
</div>
<span className="meter-comp-label">{compRed.toFixed(1)}dB</span>
</div>
)}
<div className="meter-tile-bars">
<MeterBar label="L" level={l} />
<MeterBar label="R" level={r} />
</div>
</div>
);
};
interface BusMeterTileProps {
bus: MixerBus;
}
/**
* A compact meter tile for a bus (or master).
*/
const BusMeterTile: React.FC<BusMeterTileProps> = ({ bus }) => {
const l = bus.vuLeft ?? DB_MIN;
const r = bus.vuRight ?? DB_MIN;
return (
<div className={`meter-tile meter-tile--bus ${bus.mute ? 'meter-tile--muted' : ''}`}>
<div className="meter-tile-badge">
<span className="meter-tile-name">{bus.name || `Bus ${bus.id}`}</span>
<span className="meter-tile-type">{bus.type}</span>
</div>
<div className="meter-tile-status">
{bus.mute && <span className="meter-tile-flag flag-mute">M</span>}
</div>
<div className="meter-tile-bars">
<MeterBar label="L" level={l} compact />
<MeterBar label="R" level={r} compact />
</div>
</div>
);
};
// ── Main page component ────────────────────────────────────────
interface Props {
state: MixerState | null;
loading: boolean;
error: string | null;
onRefresh: () => void;
onBack: () => void;
}
/**
* Dedicated meterbridge page — fullscreen VU meter display for
* all mixer channels, buses, and the master output.
*
* Designed to be readable at a glance from across the room,
* like a hardware mixing console's meter bridge.
*/
export const MetersPage: React.FC<Props> = ({ state, loading, error, onRefresh, onBack }) => {
if (loading) {
return (
<div className="meters-page">
<div className="meters-empty">
<p>Connecting to mixer daemon</p>
</div>
</div>
);
}
if (error) {
return (
<div className="meters-page">
<div className="meters-empty">
<h2>Mixer Daemon Unreachable</h2>
<p>{error}</p>
<button className="meters-retry-btn" onClick={onRefresh}> Retry</button>
</div>
</div>
);
}
if (!state) {
return (
<div className="meters-page">
<div className="meters-empty">
<p>No mixer data.</p>
</div>
</div>
);
}
const { channels, buses } = state;
// Derive master level from buses (best available if daemon doesn't report master VU)
const masterL = buses.length > 0
? Math.max(...buses.map((b) => b.vuLeft ?? DB_MIN))
: DB_MIN;
const masterR = buses.length > 0
? Math.max(...buses.map((b) => b.vuRight ?? DB_MIN))
: DB_MIN;
return (
<div className="meters-page">
{/* Toolbar */}
<div className="meters-toolbar">
<button className="meters-back-btn" onClick={onBack} title="Back to mixer">
Back
</button>
<span className="meters-title">Meter Bridge</span>
<span className="meters-count">{channels.length}ch</span>
<button className="meters-refresh-btn" onClick={onRefresh}></button>
</div>
{/* Channel meters */}
{channels.length > 0 && (
<div className="meters-section">
<div className="meters-section-header">
<h2>Channels</h2>
<span className="meters-section-count">{channels.length}</span>
</div>
<div className="meters-grid">
{channels.map((ch) => (
<ChannelMeterTile key={ch.channelIndex} channel={ch} />
))}
</div>
</div>
)}
{/* Bus meters (non-master) */}
{buses.filter((b) => b.type !== 'Master').length > 0 && (
<div className="meters-section">
<div className="meters-section-header">
<h2>Buses</h2>
<span className="meters-section-count">{buses.filter((b) => b.type !== 'Master').length}</span>
</div>
<div className="meters-grid meters-grid--bus">
{buses
.filter((b) => b.type !== 'Master')
.map((bus) => (
<BusMeterTile key={bus.id} bus={bus} />
))}
</div>
</div>
)}
{/* Master output — always shown when there's data */}
<div className="meters-section">
<div className="meters-section-header">
<h2>Master</h2>
</div>
<div className="meters-master-row">
<MeterBar label="L" level={masterL} peakHold={3000} />
<MeterBar label="R" level={masterR} peakHold={3000} />
<div className="meters-master-info">
<span className="meters-master-label">Master Out</span>
<span className="meters-master-db">
{Math.max(masterL, masterR) > DB_MIN
? `${Math.max(masterL, masterR).toFixed(1)} dB`
: '-∞'}
</span>
</div>
</div>
</div>
</div>
);
};
+141
View File
@@ -0,0 +1,141 @@
import { useState, useEffect, useRef, useCallback } from 'react';
/** A single channel strip as reported by the mixer daemon. */
export interface MixerChannel {
channelIndex: number;
label: string;
volume: number; // dB
pan: number; // -1.0 (left) to 1.0 (right)
mute: boolean;
solo: boolean;
type: string; // "Instrument" | "Mic" | "Line"
hpEnabled: boolean;
hpFrequency: number;
/** VU meter level for left channel in dB (-60 .. 0). Optional — daemon may not populate this. */
vuLeft?: number;
/** VU meter level for right channel in dB (-60 .. 0). Optional — daemon may not populate this. */
vuRight?: number;
/** Gate open (true = signal passing, false = gated/closed). Optional — daemon may not populate this. */
gateOpen?: boolean;
/** Compressor gain reduction in dB (0 = no reduction, higher = more reduction). Optional. */
compressorReduction?: number;
}
/** A bus as reported by the mixer daemon. */
export interface MixerBus {
id: number;
name: string;
type: string;
volume: number;
mute: boolean;
/** VU meter level for left channel in dB (-60 .. 0). Optional. */
vuLeft?: number;
/** VU meter level for right channel in dB (-60 .. 0). Optional. */
vuRight?: number;
}
/** Full mixer state from GET /api/mixer/state */
export interface MixerRoute {
sourceId: number;
sourceType: string;
targetBusId: number;
level: number;
}
export interface MixerState {
channels: MixerChannel[];
buses: MixerBus[];
routes: MixerRoute[];
outputRoutes: { sourceBusId: number; sourceStartChannel: number; targetStartChannel: number; channels: number }[];
sampleRate: number;
physicalInputCount: number;
physicalOutputCount: number;
}
export interface UseMixerDaemonReturn {
state: MixerState | null;
loading: boolean;
error: string | null;
refresh: () => void;
setVolume: (idx: number, db: number) => Promise<void>;
setPan: (idx: number, pan: number) => Promise<void>;
setMute: (idx: number, mute: boolean) => Promise<void>;
setSolo: (idx: number, solo: boolean) => Promise<void>;
}
function apiUrl(path: string): string {
const base = window.location.origin;
return `${base}${path}`;
}
async function apiPost(path: string, body: Record<string, unknown>): Promise<void> {
const res = await fetch(apiUrl(path), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
}
/**
* Hook that polls the mixer daemon state via /api/mixer/state and
* provides control functions (volume, pan, mute, solo).
*/
export function useMixerDaemon(): UseMixerDaemonReturn {
const [state, setState] = useState<MixerState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<number | null>(null);
const fetchState = useCallback(async () => {
try {
const res = await fetch(apiUrl('/api/mixer/state'));
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
const data: MixerState = await res.json();
setState(data);
setError(null);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
} finally {
setLoading(false);
}
}, []);
// Poll on mount + every 2s
useEffect(() => {
fetchState();
intervalRef.current = window.setInterval(fetchState, 2000);
return () => {
if (intervalRef.current !== null) clearInterval(intervalRef.current);
};
}, [fetchState]);
const setVolume = useCallback(async (idx: number, db: number) => {
await apiPost(`/api/mixer/channel/${idx}/volume`, { volumeDb: db });
fetchState();
}, [fetchState]);
const setPan = useCallback(async (idx: number, pan: number) => {
await apiPost(`/api/mixer/channel/${idx}/pan`, { pan });
fetchState();
}, [fetchState]);
const setMute = useCallback(async (idx: number, mute: boolean) => {
await apiPost(`/api/mixer/channel/${idx}/mute`, { mute });
fetchState();
}, [fetchState]);
const setSolo = useCallback(async (idx: number, solo: boolean) => {
await apiPost(`/api/mixer/channel/${idx}/solo`, { solo });
fetchState();
}, [fetchState]);
return { state, loading, error, refresh: fetchState, setVolume, setPan, setMute, setSolo };
}