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:
-1
File diff suppressed because one or more lines are too long
+42
File diff suppressed because one or more lines are too long
-42
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+372
-11
@@ -1,19 +1,380 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { usePiPedalWS } from './hooks/usePiPedalWS';
|
import { useMixerDaemon, MixerChannel, MixerBus, MixerRoute } from './hooks/useMixerDaemon';
|
||||||
import { MixerPage } from './components/MixerPage/MixerPage';
|
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 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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<MixerPage
|
{/* Header */}
|
||||||
plugins={plugins}
|
<header className="mixer-header">
|
||||||
connectionState={connectionState}
|
<div className="mixer-header-left">
|
||||||
pedalboardName={pedalboardName}
|
<h1 className="mixer-title">OPLabs Mixer</h1>
|
||||||
lastUpdate={lastUpdate}
|
{state && <span className="mixer-pedalboard-name">{state.physicalInputCount}ch / {state.sampleRate}Hz</span>}
|
||||||
onRefresh={discover}
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
/* ── Meter Bridge – full‑screen 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 (0–100).
|
||||||
|
* 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 (60–85 %) → 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%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub‑components ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 LED‑style meter bar with peak‑hold 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 meter‑bridge page — full‑screen 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user