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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+371 -10
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 };
}