Initial: Standalone mixer app with MixerPage, scene management, WS proxy

This commit is contained in:
2026-06-21 00:03:42 -04:00
commit b8a9f06734
7146 changed files with 1684613 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface-alt: #0f3460;
--accent: #e94560;
--text: #eaeaea;
--text-dim: #9e9e9e;
--border: #2a2a4a;
--success: #4caf50;
--warning: #ff9800;
--error: #f44336;
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ---- Connection Status ---- */
.connection-status {
display: flex;
flex-direction: column;
gap: 4px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.status-label {
font-size: 0.85rem;
color: var(--text-dim);
}
.status-error {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
color: var(--error);
}
.retry-btn {
background: transparent;
border: 1px solid var(--error);
color: var(--error);
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.retry-btn:hover {
background: var(--error);
color: #fff;
}
/* ---- Footer ---- */
.app-footer {
display: flex;
justify-content: space-between;
padding: 12px 24px;
background: var(--surface);
border-top: 1px solid var(--border);
font-size: 0.75rem;
color: var(--text-dim);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import { usePiPedalWS } from './hooks/usePiPedalWS';
import { MixerPage } from './components/MixerPage/MixerPage';
const App: React.FC = () => {
const { connectionState, plugins, pedalboardName, discover, lastUpdate } = usePiPedalWS();
return (
<div className="app">
<MixerPage
plugins={plugins}
connectionState={connectionState}
pedalboardName={pedalboardName}
lastUpdate={lastUpdate}
onRefresh={discover}
/>
</div>
);
};
export default App;
@@ -0,0 +1,45 @@
import React from 'react';
import type { ConnectionState } from '../hooks/usePiPedalWS';
interface Props {
state: ConnectionState;
onRetry?: () => void;
}
const statusColors: Record<string, string> = {
connected: '#4caf50',
connecting: '#ff9800',
disconnected: '#9e9e9e',
error: '#f44336',
};
const statusLabels: Record<string, string> = {
connected: 'Connected to PiPedal',
connecting: 'Connecting...',
disconnected: 'Disconnected',
error: 'Connection Error',
};
export const ConnectionStatus: React.FC<Props> = ({ state, onRetry }) => {
return (
<div className="connection-status">
<div className="status-indicator">
<span
className="status-dot"
style={{ backgroundColor: statusColors[state.status] || '#9e9e9e' }}
/>
<span className="status-label">{statusLabels[state.status] || state.status}</span>
</div>
{state.error && (
<div className="status-error">
<span>{state.error}</span>
{onRetry && (
<button className="retry-btn" onClick={onRetry}>
Retry
</button>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,182 @@
.bus-strip {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--surface, #16213e);
border: 1px solid var(--border, #2a2a4a);
border-radius: var(--radius, 8px);
}
/* ---- Master Section ---- */
.bus-master-section {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 8px;
}
.bus-master-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 50px;
}
.bus-master-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text, #eaeaea);
}
.bus-master-meter {
flex: 1;
max-width: 60px;
}
.bus-master-fader {
flex: 1;
max-width: 120px;
}
/* ---- Bus buttons ---- */
.bus-btn {
width: 24px;
height: 20px;
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.6rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
background: var(--bg, #1a1a2e);
color: var(--text-dim, #9e9e9e);
user-select: none;
line-height: 1;
text-align: center;
}
.bus-btn.mute.active {
background: var(--error, #f44336);
color: #fff;
border-color: var(--error, #f44336);
}
.bus-btn.mini {
width: 18px;
height: 16px;
font-size: 0.5rem;
}
.bus-btn:hover {
opacity: 0.85;
}
/* ---- Divider ---- */
.bus-divider {
height: 1px;
background: var(--border, #2a2a4a);
margin: 0 -4px;
}
/* ---- Per-Channel ---- */
.bus-channels {
display: flex;
gap: 6px;
overflow-x: auto;
padding-bottom: 4px;
}
.bus-channel {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 36px;
max-width: 44px;
padding: 6px 4px;
background: var(--bg, #1a1a2e);
border-radius: 4px;
border: 1px solid var(--border, #2a2a4a);
flex-shrink: 0;
}
.bus-channel-label {
font-size: 0.55rem;
font-weight: 700;
color: var(--text-dim, #9e9e9e);
text-align: center;
}
.bus-channel-meter {
width: 100%;
height: 30px;
display: flex;
justify-content: center;
}
.bus-channel-fader {
width: 100%;
}
.bus-channel-db {
font-size: 0.45rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
text-align: center;
}
/* ---- Fader styles ---- */
.bus-fader {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: var(--border, #2a2a4a);
border-radius: 3px;
outline: none;
cursor: pointer;
border: none;
}
.bus-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 14px;
background: #e94560;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
}
.bus-fader::-moz-range-thumb {
width: 12px;
height: 14px;
background: #e94560;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
}
.bus-fader.mini {
height: 4px;
}
.bus-fader.mini::-webkit-slider-thumb {
width: 8px;
height: 10px;
}
.bus-fader.mini::-moz-range-thumb {
width: 8px;
height: 10px;
}
@@ -0,0 +1,161 @@
import React, { useCallback } from 'react';
import type { PluginInstance } from '../../hooks/usePiPedalWS';
import { LevelMeter } from './LevelMeter';
import './BusStrip.css';
interface Props {
/** The OPLabsBandBus plugin instance */
plugin: PluginInstance;
/** Callback to set control values */
onSetControl: (instanceId: number, key: string, value: number) => void;
}
/**
* Converts a dB value to a mini-fader position (0-100).
*/
function dbToSliderPos(db: number): number {
const clamped = Math.max(-60, Math.min(6, db));
return ((clamped + 60) / 66) * 100;
}
function sliderPosToDb(pos: number): number {
return (pos / 100) * 66 - 60;
}
/**
* Bus meter bridge: per-channel mini faders and mute buttons + master section.
*
* The OPLabsBandBus plugin has 62 ports:
* - ch1Vol..ch8Vol, ch1Pan..ch8Pan, ch1Mute..ch8Mute
* - ch1LevelL..ch8LevelL, ch1LevelR..ch8LevelR
* - masterVol, masterMute, masterLevelL, masterLevelR
*/
export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
const cv = plugin.controlValues;
const masterVol = cv.masterVol ?? 0;
const masterMute = (cv.masterMute ?? 0) >= 0.5;
const masterLevelL = cv.masterLevelL ?? -60;
const masterLevelR = cv.masterLevelR ?? -60;
const channels = Array.from({ length: 8 }, (_, i) => {
const idx = i + 1;
return {
index: idx,
vol: cv[`ch${idx}Vol`] ?? 0,
mute: (cv[`ch${idx}Mute`] ?? 0) >= 0.5,
levelL: cv[`ch${idx}LevelL`] ?? -60,
levelR: cv[`ch${idx}LevelR`] ?? -60,
volKey: `ch${idx}Vol` as const,
muteKey: `ch${idx}Mute` as const,
};
});
const handleMasterVol = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const db = sliderPosToDb(parseFloat(e.target.value));
onSetControl(plugin.instanceId, 'masterVol', parseFloat(db.toFixed(1)));
},
[plugin.instanceId, onSetControl]
);
const toggleMasterMute = useCallback(() => {
onSetControl(plugin.instanceId, 'masterMute', masterMute ? 0 : 1);
}, [plugin.instanceId, masterMute, onSetControl]);
const handleChannelVol = useCallback(
(chIdx: number, volKey: string, e: React.ChangeEvent<HTMLInputElement>) => {
const db = sliderPosToDb(parseFloat(e.target.value));
onSetControl(plugin.instanceId, volKey, parseFloat(db.toFixed(1)));
},
[plugin.instanceId, onSetControl]
);
const toggleChannelMute = useCallback(
(chIdx: number, muteKey: string, currentlyMuted: boolean) => {
onSetControl(plugin.instanceId, muteKey, currentlyMuted ? 0 : 1);
},
[plugin.instanceId, onSetControl]
);
return (
<div className="bus-strip">
{/* Master section */}
<div className="bus-master-section">
<div className="bus-master-header">
<span className="bus-master-label">Master</span>
<button
className={`bus-btn mute ${masterMute ? 'active' : ''}`}
onClick={toggleMasterMute}
title="Master Mute"
>
M
</button>
</div>
<div className="bus-master-meter">
<LevelMeter
levelL={masterLevelL}
levelR={masterLevelR}
mini={true}
label={masterVol > -60 ? `${masterVol.toFixed(1)}dB` : '-∞'}
/>
</div>
<div className="bus-master-fader">
<input
type="range"
min="0"
max="100"
step="0.5"
value={dbToSliderPos(masterVol)}
onChange={handleMasterVol}
className="bus-fader"
title={`Master Volume: ${masterVol.toFixed(1)} dB`}
/>
</div>
</div>
{/* Divider */}
<div className="bus-divider" />
{/* Per-channel mini strips */}
<div className="bus-channels">
{channels.map((ch) => (
<div key={ch.index} className="bus-channel">
<span className="bus-channel-label">
{ch.index}
</span>
<button
className={`bus-btn mute mini ${ch.mute ? 'active' : ''}`}
onClick={() => toggleChannelMute(ch.index, ch.muteKey, ch.mute)}
title={`Ch ${ch.index} Mute`}
>
M
</button>
<div className="bus-channel-meter">
<LevelMeter
levelL={ch.levelL}
levelR={ch.levelR}
mini={true}
/>
</div>
<div className="bus-channel-fader">
<input
type="range"
min="0"
max="100"
step="0.5"
value={dbToSliderPos(ch.vol)}
onChange={(e) => handleChannelVol(ch.index, ch.volKey, e)}
className="bus-fader mini"
title={`Ch ${ch.index} Volume: ${ch.vol.toFixed(1)} dB`}
/>
</div>
<span className="bus-channel-db">
{ch.vol > -60 ? ch.vol.toFixed(1) : '-∞'}
</span>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,244 @@
.channel-strip {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 8px;
background: var(--surface, #16213e);
border: 1px solid var(--border, #2a2a4a);
border-top: 3px solid #666;
border-radius: var(--radius, 8px);
min-width: 80px;
max-width: 100px;
transition: border-top-color 0.3s, box-shadow 0.2s;
}
.channel-strip:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
/* ---- Header ---- */
.channel-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 100%;
}
.channel-label {
font-size: 0.7rem;
font-weight: 600;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
color: var(--text, #eaeaea);
}
/* ---- Mute / Solo ---- */
.channel-mute-solo {
display: flex;
gap: 3px;
width: 100%;
justify-content: center;
}
.channel-btn {
width: 28px;
height: 22px;
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.65rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
background: var(--bg, #1a1a2e);
color: var(--text-dim, #9e9e9e);
user-select: none;
line-height: 1;
}
.channel-btn.mute.active {
background: var(--error, #f44336);
color: #fff;
border-color: var(--error, #f44336);
}
.channel-btn.solo.active {
background: var(--warning, #ff9800);
color: #000;
border-color: var(--warning, #ff9800);
}
.channel-btn:hover {
opacity: 0.85;
}
/* ---- Level Meter Section ---- */
.channel-meter-section {
width: 100%;
display: flex;
justify-content: center;
flex: 1;
min-height: 60px;
}
/* ---- Pan ---- */
.channel-pan {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.channel-pan-label {
font-size: 0.55rem;
text-transform: uppercase;
color: var(--text-dim, #9e9e9e);
letter-spacing: 0.5px;
}
.channel-pan-control {
display: flex;
align-items: center;
gap: 3px;
width: 100%;
}
.channel-pan-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: var(--border, #2a2a4a);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.channel-pan-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.channel-pan-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.channel-pan-value {
font-size: 0.55rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
min-width: 20px;
text-align: center;
}
/* ---- Fader ---- */
.channel-fader-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 100%;
}
.channel-fader {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 100px;
background: linear-gradient(to top, var(--bg, #1a1a2e) 0%, var(--surface-alt, #0f3460) 75%, #4caf50 85%, #ff9800 95%, #f44336 100%);
border-radius: 4px;
outline: none;
cursor: pointer;
border: 1px solid var(--border, #2a2a4a);
writing-mode: vertical-lr;
direction: rtl;
}
/* WebKit vertical slider */
.channel-fader::-webkit-slider-runnable-track {
height: 100px;
border-radius: 3px;
}
.channel-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 12px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
margin-left: -3px;
}
.channel-fader::-moz-range-track {
height: 100px;
border-radius: 3px;
}
.channel-fader::-moz-range-thumb {
width: 18px;
height: 12px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.channel-fader-readout {
font-size: 0.6rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
text-align: center;
}
/* ---- Instrument Selector ---- */
.channel-instrument-selector {
width: 100%;
}
.channel-instrument-select {
width: 100%;
padding: 3px 4px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.6rem;
cursor: pointer;
outline: none;
}
.channel-instrument-select:focus {
border-color: var(--channel-accent, #666);
}
.channel-instrument-select option {
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
}
@@ -0,0 +1,186 @@
import React, { useCallback } from 'react';
import type { PluginInstance } from '../../hooks/usePiPedalWS';
import type { Instrument } from './InstrumentBadge';
import { InstrumentBadge, INSTRUMENT_NAMES, INSTRUMENT_COLORS } from './InstrumentBadge';
import { LevelMeter } from './LevelMeter';
import './ChannelStrip.css';
interface Props {
plugin: PluginInstance;
/** Callback to set a control value on the plugin */
onSetControl: (instanceId: number, key: string, value: number) => void;
}
const INSTRUMENT_VALUES: Instrument[] = [0, 1, 2, 3, 4];
/**
* Map a dB slider position (0-1) to a dB value (-60..+6).
* 75% position = 0 dB (unity).
*/
function posToDb(pos: number): number {
if (pos <= 0) return -60;
if (pos >= 1) return 6;
// Logarithmic taper: 0.75 = 0 dB
const scaled = pos / 0.75;
if (scaled <= 0.001) return -60;
const db = 20 * Math.log10(scaled);
return Math.max(-60, Math.min(6, db));
}
/**
* Map a dB value (-60..+6) to a slider position (0-1).
*/
function dbToPos(db: number): number {
if (db <= -60) return 0;
if (db >= 6) return 1;
// Inverse of posToDb
const linear = Math.pow(10, db / 20);
return Math.min(1, linear * 0.75);
}
/**
* Full channel strip with fader, pan, mute/solo, instrument selector, level meter.
*/
export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
const cv = plugin.controlValues;
const volume = cv.volume ?? 0;
const pan = cv.pan ?? 0;
const mute = (cv.mute ?? 0) >= 0.5;
const solo = (cv.solo ?? 0) >= 0.5;
const instrument = (Math.round(cv.instrument ?? 0) || 0) as Instrument;
const levelL = cv.levelL ?? -60;
const levelR = cv.levelR ?? -60;
const instrumentColor = INSTRUMENT_COLORS[instrument] ?? '#666';
const handleVolumeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const pos = parseFloat(e.target.value) / 100;
const db = posToDb(pos);
onSetControl(plugin.instanceId, 'volume', parseFloat(db.toFixed(1)));
},
[plugin.instanceId, onSetControl]
);
const handlePanChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
onSetControl(plugin.instanceId, 'pan', val);
},
[plugin.instanceId, onSetControl]
);
const toggleMute = useCallback(() => {
onSetControl(plugin.instanceId, 'mute', mute ? 0 : 1);
}, [plugin.instanceId, mute, onSetControl]);
const toggleSolo = useCallback(() => {
onSetControl(plugin.instanceId, 'solo', solo ? 0 : 1);
}, [plugin.instanceId, solo, onSetControl]);
const handleInstrumentChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const val = parseInt(e.target.value, 10);
onSetControl(plugin.instanceId, 'instrument', val);
},
[plugin.instanceId, onSetControl]
);
const volumeSliderPos = dbToPos(volume) * 100;
return (
<div
className="channel-strip"
style={{
borderTopColor: instrumentColor,
'--channel-accent': instrumentColor,
} as React.CSSProperties}
>
{/* Header: channel label + instrument */}
<div className="channel-header">
<span className="channel-label" title={plugin.title || plugin.pluginName}>
{plugin.title || plugin.pluginName || `Ch ${plugin.instanceId}`}
</span>
<InstrumentBadge instrument={instrument} size="small" />
</div>
{/* Mute & Solo buttons */}
<div className="channel-mute-solo">
<button
className={`channel-btn mute ${mute ? 'active' : ''}`}
onClick={toggleMute}
title="Mute"
>
M
</button>
<button
className={`channel-btn solo ${solo ? 'active' : ''}`}
onClick={toggleSolo}
title="Solo"
>
S
</button>
</div>
{/* Level Meter */}
<div className="channel-meter-section">
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
</div>
{/* Pan control */}
<div className="channel-pan">
<label className="channel-pan-label">Pan</label>
<div className="channel-pan-control">
<input
type="range"
min="-1"
max="1"
step="0.01"
value={pan}
onChange={handlePanChange}
className="channel-pan-slider"
title={`Pan: ${pan.toFixed(2)}`}
/>
<span className="channel-pan-value">
{pan === 0 ? 'C' : pan < 0 ? `L${Math.abs(Math.round(pan * 100))}` : `R${Math.round(pan * 100)}`}
</span>
</div>
</div>
{/* Volume fader */}
<div className="channel-fader-section">
<input
type="range"
min="0"
max="100"
step="0.5"
value={volumeSliderPos}
onChange={handleVolumeChange}
className="channel-fader"
title={`Volume: ${volume.toFixed(1)} dB`}
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
/>
<span className="channel-fader-readout">
{volume > -60 ? `${volume >= 0 ? '+' : ''}${volume.toFixed(1)}` : '-∞'}
</span>
</div>
{/* Instrument selector */}
<div className="channel-instrument-selector">
<select
value={instrument}
onChange={handleInstrumentChange}
className="channel-instrument-select"
style={{ borderColor: instrumentColor }}
>
{INSTRUMENT_VALUES.map((val) => (
<option key={val} value={val}>
{INSTRUMENT_NAMES[val]}
</option>
))}
</select>
</div>
</div>
);
};
@@ -0,0 +1,39 @@
.instrument-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
transition: opacity 0.2s;
user-select: none;
}
.instrument-badge:hover {
opacity: 0.85;
}
.instrument-badge[data-size='small'] {
font-size: 0.6rem;
padding: 1px 5px;
gap: 3px;
}
.instrument-badge[data-size='large'] {
font-size: 0.85rem;
padding: 4px 12px;
gap: 6px;
}
.instrument-icon {
font-size: 0.8em;
line-height: 1;
}
.instrument-name {
line-height: 1;
}
@@ -0,0 +1,60 @@
import React from 'react';
import './InstrumentBadge.css';
export type Instrument = 0 | 1 | 2 | 3 | 4;
export const INSTRUMENT_NAMES: Record<Instrument, string> = {
0: 'Guitar',
1: 'Bass',
2: 'Keys',
3: 'Vocals',
4: 'Backing',
};
export const INSTRUMENT_COLORS: Record<Instrument, string> = {
0: '#ff8c00', // Guitar: orange
1: '#2196f3', // Bass: blue
2: '#9c27b0', // Keys: purple
3: '#e91e63', // Vocals: pink
4: '#009688', // Backing: teal
};
export const INSTRUMENT_ICONS: Record<Instrument, string> = {
0: '🎸',
1: '🎸',
2: '🎹',
3: '🎤',
4: '🎵',
};
interface Props {
instrument: Instrument;
size?: 'small' | 'medium' | 'large';
showIcon?: boolean;
}
export const InstrumentBadge: React.FC<Props> = ({
instrument,
size = 'medium',
showIcon = true,
}) => {
const name = INSTRUMENT_NAMES[instrument] ?? `Unknown (${instrument})`;
const color = INSTRUMENT_COLORS[instrument] ?? '#666';
const icon = INSTRUMENT_ICONS[instrument] ?? '';
return (
<span
className="instrument-badge"
data-size={size}
style={{
backgroundColor: `${color}22`,
color: color,
borderColor: color,
}}
title={name}
>
{showIcon && <span className="instrument-icon">{icon}</span>}
<span className="instrument-name">{name}</span>
</span>
);
};
@@ -0,0 +1,114 @@
.level-meter {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
--meter-green: #4caf50;
--meter-yellow: #ff9800;
--meter-red: #f44336;
}
.level-meter.mini {
gap: 1px;
}
.level-meter-stereo-pair {
display: flex;
gap: 2px;
flex: 1;
}
.level-meter-channel {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
width: 14px;
}
.level-meter-channel.mini {
width: 8px;
}
.level-meter-side-label {
font-size: 0.55rem;
color: var(--text-dim);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.level-meter-bar-track {
position: relative;
width: 100%;
flex: 1;
min-height: 60px;
background: rgba(0, 0, 0, 0.5);
border-radius: 2px;
border: 1px solid var(--border);
overflow: hidden;
}
.level-meter.mini .level-meter-bar-track {
min-height: 30px;
}
.level-meter-bar-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transition: height 0.05s linear;
border-radius: 1px;
}
.level-meter-peak {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #fff;
border-radius: 1px;
transition: bottom 0.15s ease-out;
z-index: 2;
}
.level-meter-tick {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: rgba(255, 255, 255, 0.15);
z-index: 1;
}
.level-meter-tick.mark-yellow {
background: rgba(255, 152, 0, 0.3);
}
.level-meter-tick.mark-green {
background: rgba(76, 175, 80, 0.2);
}
.level-meter-label {
font-size: 0.6rem;
color: var(--text-dim);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60px;
}
.level-meter-db-values {
display: flex;
gap: 4px;
font-size: 0.6rem;
font-family: monospace;
color: var(--text-dim);
}
.level-meter-db-values .hot {
color: var(--meter-red);
font-weight: 700;
}
@@ -0,0 +1,125 @@
import React, { useEffect, useRef } from 'react';
import './LevelMeter.css';
interface Props {
/** Left channel level in dB (-60..0) */
levelL: number;
/** Right channel level in dB (-60..0) */
levelR: number;
/** Optional label below the meter */
label?: string;
/** Whether to show as a compact mini meter (for bus strip) */
mini?: boolean;
/** Peak hold duration in ms (default: 1000) */
peakHold?: number;
}
const DB_MIN = -60;
const DB_MAX = 0;
/**
* Converts a dB value to a fill percentage (0-100).
* Uses a perceptual curve so -20dB appears around 50%.
*/
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); // 0..1
// Apply a curve: sqrt for VU-like response
return Math.sqrt(normalized) * 100;
}
/**
* Reusable stereo level meter with LED-style bars and peak hold dots.
*/
export const LevelMeter: React.FC<Props> = ({
levelL,
levelR,
label,
mini = false,
peakHold = 1500,
}) => {
const peakLRef = useRef<number>(DB_MIN);
const peakRRef = useRef<number>(DB_MIN);
const peakTimerL = useRef<number | null>(null);
const peakTimerR = useRef<number | null>(null);
const [, forceUpdate] = React.useState(0);
// Update peak holds
useEffect(() => {
if (levelL > peakLRef.current) {
peakLRef.current = levelL;
if (peakTimerL.current) clearTimeout(peakTimerL.current);
peakTimerL.current = window.setTimeout(() => {
peakLRef.current = Math.max(levelL, DB_MIN);
forceUpdate((n) => n + 1);
}, peakHold);
}
if (levelR > peakRRef.current) {
peakRRef.current = levelR;
if (peakTimerR.current) clearTimeout(peakTimerR.current);
peakTimerR.current = window.setTimeout(() => {
peakRRef.current = Math.max(levelR, DB_MIN);
forceUpdate((n) => n + 1);
}, peakHold);
}
}, [levelL, levelR, peakHold]);
const pctL = dbToPercent(levelL);
const pctR = dbToPercent(levelR);
const peakPctL = dbToPercent(peakLRef.current);
const peakPctR = dbToPercent(peakRRef.current);
const getColor = (pct: number): string => {
// Green < 60%, Yellow 60-85%, Red > 85%
// (60% ≈ -20dB, 85% ≈ -6dB with sqrt curve)
if (pct < 60) return 'var(--meter-green, #4caf50)';
if (pct < 85) return 'var(--meter-yellow, #ff9800)';
return 'var(--meter-red, #f44336)';
};
const renderMeter = (pct: number, peakPct: number, side: 'L' | 'R') => (
<div className={`level-meter-channel ${mini ? 'mini' : ''}`}>
{!mini && <span className="level-meter-side-label">{side}</span>}
<div className="level-meter-bar-track">
<div
className="level-meter-bar-fill"
style={{
height: `${Math.min(100, pct)}%`,
backgroundColor: getColor(pct),
}}
/>
{/* Peak hold dot */}
<div
className="level-meter-peak"
style={{
bottom: `${Math.min(100, peakPct)}%`,
}}
/>
{/* Tick marks at -6, -20, -40 dB */}
{!mini && (
<>
<div className="level-meter-tick" style={{ bottom: `${dbToPercent(-6)}%` }} />
<div className="level-meter-tick mark-yellow" style={{ bottom: `${dbToPercent(-20)}%` }} />
<div className="level-meter-tick mark-green" style={{ bottom: `${dbToPercent(-40)}%` }} />
</>
)}
</div>
</div>
);
return (
<div className={`level-meter ${mini ? 'mini' : ''}`}>
<div className="level-meter-stereo-pair">
{renderMeter(pctL, peakPctL, 'L')}
{renderMeter(pctR, peakPctR, 'R')}
</div>
{label && <div className="level-meter-label">{label}</div>}
{!mini && (
<div className="level-meter-db-values">
<span className={levelL > -10 ? 'hot' : ''}>{levelL.toFixed(1)}</span>
<span className={levelR > -10 ? 'hot' : ''}>{levelR.toFixed(1)}</span>
</div>
)}
</div>
);
};
@@ -0,0 +1,254 @@
.mixer-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg, #1a1a2e);
/* Override the app-main max-width for mixer */
max-width: 100% !important;
}
/* ---- Header ---- */
.mixer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--surface, #16213e);
border-bottom: 1px solid var(--border, #2a2a4a);
gap: 12px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 100;
}
.mixer-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.mixer-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--text, #eaeaea);
letter-spacing: 0.3px;
}
.mixer-header-center {
display: flex;
align-items: center;
gap: 12px;
}
.mixer-pedalboard-name {
font-size: 0.8rem;
color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
padding: 3px 10px;
border-radius: var(--radius, 8px);
}
.mixer-ws-indicator {
font-size: 0.7rem;
padding: 3px 8px;
border-radius: 4px;
font-weight: 500;
}
.mixer-ws-indicator.ready {
color: var(--success, #4caf50);
background: rgba(76, 175, 80, 0.12);
}
.mixer-ws-indicator.pending {
color: var(--warning, #ff9800);
background: rgba(255, 152, 0, 0.12);
}
.mixer-header-right {
display: flex;
align-items: center;
gap: 12px;
}
.mixer-update-time {
font-size: 0.7rem;
color: var(--text-dim, #9e9e9e);
}
.mixer-refresh-btn {
background: var(--accent, #e94560);
color: #fff;
border: none;
padding: 6px 14px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: opacity 0.2s;
}
.mixer-refresh-btn:hover {
opacity: 0.85;
}
.mixer-refresh-btn.large {
padding: 10px 24px;
font-size: 0.95rem;
margin-top: 16px;
}
/* ---- Scenes Button ---- */
.mixer-scenes-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--surface-alt, #0f3460);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
padding: 6px 12px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
}
.mixer-scenes-btn:hover {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
}
.mixer-scenes-btn.active {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.15);
color: var(--accent, #e94560);
}
.mixer-scenes-count {
font-size: 0.65rem;
background: var(--accent, #e94560);
color: #fff;
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
}
/* ---- Content ---- */
.mixer-content {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* ---- Section Headers ---- */
.mixer-section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.mixer-section-header h2 {
font-size: 0.95rem;
font-weight: 600;
color: var(--text, #eaeaea);
}
.mixer-count {
font-size: 0.7rem;
background: var(--surface-alt, #0f3460);
color: var(--text-dim, #9e9e9e);
padding: 1px 7px;
border-radius: 10px;
}
/* ---- Channels Grid ---- */
.mixer-channels-grid {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 8px;
}
/* ---- Add Channel ---- */
.mixer-add-channel {
display: flex;
align-items: center;
}
.mixer-add-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px dashed var(--border, #2a2a4a);
background: transparent;
color: var(--text-dim, #9e9e9e);
font-size: 1.3rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.mixer-add-btn:hover {
border-color: var(--accent, #e94560);
color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.08);
}
/* ---- Buses Grid ---- */
.mixer-buses-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ---- Empty State ---- */
.mixer-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 24px;
text-align: center;
color: var(--text-dim, #9e9e9e);
flex: 1;
}
.mixer-empty-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.6;
}
.mixer-empty h2 {
font-size: 1.2rem;
color: var(--text, #eaeaea);
margin-bottom: 8px;
}
.mixer-empty p {
font-size: 0.9rem;
max-width: 400px;
line-height: 1.5;
margin-bottom: 4px;
}
.mixer-empty-hint {
font-size: 0.8rem !important;
opacity: 0.7;
margin-top: 8px !important;
}
@@ -0,0 +1,218 @@
import React, { useMemo, useState, useCallback } from 'react';
import type { PluginInstance } from '../../hooks/usePiPedalWS';
import type { SceneState } from '../../hooks/useScenes';
import { useMixerControls } from '../../hooks/useMixerControls';
import { useScenes } from '../../hooks/useScenes';
import { ChannelStrip } from './ChannelStrip';
import { BusStrip } from './BusStrip';
import { SceneManager } from '../Scenes/SceneManager';
import { ConnectionStatus } from '../ConnectionStatus';
import type { ConnectionState } from '../../hooks/usePiPedalWS';
import './MixerPage.css';
import './ChannelStrip.css';
import './BusStrip.css';
import './LevelMeter.css';
import './InstrumentBadge.css';
interface Props {
plugins: PluginInstance[];
connectionState: ConnectionState;
pedalboardName: string | null;
lastUpdate: number;
onRefresh: () => void;
}
/**
* Main mixer page layout.
* - Header with connection status and scene selector
* - Sidebar of channel strips (left)
* - Bus meter bridge (right)
* - Scene management sidebar
*/
export const MixerPage: React.FC<Props> = ({
plugins,
connectionState,
pedalboardName,
lastUpdate,
onRefresh,
}) => {
const { ready, setControlValue } = useMixerControls();
const {
scenes,
loading: scenesLoading,
error: scenesError,
saveScene,
deleteScene,
recallScene,
} = useScenes();
const [scenePanelOpen, setScenePanelOpen] = useState(false);
// Separate plugins by type
const channels = useMemo(
() =>
plugins.filter(
(p) =>
!p.uri.includes('band-bus') && p.uri.includes('band-channel')
),
[plugins]
);
const buses = useMemo(
() => plugins.filter((p) => p.uri.includes('band-bus')),
[plugins]
);
const hasChannels = channels.length > 0;
const hasBuses = buses.length > 0;
const hasAny = plugins.length > 0;
/**
* Capture current plugin state for saving as a scene.
* Returns channels and buses with their instanceIds and current controlValues.
*/
const getCurrentState = useCallback((): SceneState | null => {
if (plugins.length === 0) return null;
return {
channels: channels.map((ch) => ({
instanceId: ch.instanceId,
params: { ...ch.controlValues },
})),
buses: buses.map((bus) => ({
instanceId: bus.instanceId,
params: { ...bus.controlValues },
})),
};
}, [plugins, channels, buses]);
const toggleScenePanel = useCallback(() => {
setScenePanelOpen((prev) => !prev);
}, []);
return (
<div className="mixer-page">
{/* Header */}
<header className="mixer-header">
<div className="mixer-header-left">
<h1 className="mixer-title">OPLabs Mixer</h1>
<ConnectionStatus state={connectionState} onRetry={onRefresh} />
</div>
<div className="mixer-header-center">
{pedalboardName && (
<span className="mixer-pedalboard-name">
Pedalboard: {pedalboardName}
</span>
)}
{connectionState.status === 'connected' && (
<span className={`mixer-ws-indicator ${ready ? 'ready' : 'pending'}`}>
{ready ? 'Controls Ready' : 'Connecting Controls...'}
</span>
)}
</div>
<div className="mixer-header-right">
<span className="mixer-update-time">
{lastUpdate > 0
? `Updated ${new Date(lastUpdate).toLocaleTimeString()}`
: ''}
</span>
<button
className={`mixer-scenes-btn ${scenePanelOpen ? 'active' : ''}`}
onClick={toggleScenePanel}
title="Scene Manager"
>
🎬 Scenes
{scenes.length > 0 && (
<span className="mixer-scenes-count">{scenes.length}</span>
)}
</button>
<button className="mixer-refresh-btn" onClick={onRefresh}>
Refresh
</button>
</div>
</header>
{/* Main content */}
<div className="mixer-content">
{/* Empty state */}
{!hasAny && (
<div className="mixer-empty">
<div className="mixer-empty-icon">🎛</div>
<h2>No OPLabs Plugins Found</h2>
<p>
Load OPLabsBandChannel or OPLabsBandBus plugins on the PiPedal
pedalboard, then click Refresh.
</p>
{connectionState.status !== 'connected' && (
<p className="mixer-empty-hint">
Status: <strong>{connectionState.status}</strong>
{connectionState.error && `${connectionState.error}`}
</p>
)}
<button className="mixer-refresh-btn large" onClick={onRefresh}>
Refresh Now
</button>
</div>
)}
{/* Channel strips */}
{hasChannels && (
<div className="mixer-channels">
<div className="mixer-section-header">
<h2>Channels</h2>
<span className="mixer-count">{channels.length}</span>
</div>
<div className="mixer-channels-grid">
{channels.map((plugin) => (
<ChannelStrip
key={plugin.instanceId}
plugin={plugin}
onSetControl={setControlValue}
/>
))}
{/* Add channel button */}
<div className="mixer-add-channel">
<button className="mixer-add-btn" title="Add channel (future)">
+
</button>
</div>
</div>
</div>
)}
{/* Bus strips */}
{hasBuses && (
<div className="mixer-buses">
<div className="mixer-section-header">
<h2>Bus</h2>
<span className="mixer-count">{buses.length}</span>
</div>
<div className="mixer-buses-grid">
{buses.map((plugin) => (
<BusStrip
key={plugin.instanceId}
plugin={plugin}
onSetControl={setControlValue}
/>
))}
</div>
</div>
)}
</div>
{/* Scene Manager sidebar */}
{scenePanelOpen && (
<SceneManager
scenes={scenes}
loading={scenesLoading}
error={scenesError}
onClose={() => setScenePanelOpen(false)}
onSaveScene={saveScene}
onDeleteScene={deleteScene}
onRecallScene={recallScene}
getCurrentState={getCurrentState}
/>
)}
</div>
);
};
+114
View File
@@ -0,0 +1,114 @@
import React from 'react';
import type { PluginInstance } from '../hooks/usePiPedalWS';
interface Props {
plugins: PluginInstance[];
lastUpdate: number;
}
// Port metadata for our OPLabs plugins
const PORT_META: Record<string, Record<string, { label: string; unit?: string }>> = {
'http://ourpad.casa/plugins/oplabs-band-channel': {
volume: { label: 'Volume', unit: 'dB' },
pan: { label: 'Pan' },
mute: { label: 'Mute' },
solo: { label: 'Solo' },
instrument: { label: 'Instrument' },
levelL: { label: 'Level L', unit: 'dB' },
levelR: { label: 'Level R', unit: 'dB' },
},
};
const BUS_PORTS: Record<string, { label: string; unit?: string }> = {
masterVol: { label: 'Master Vol', unit: 'dB' },
masterMute: { label: 'Master Mute' },
};
for (let ch = 1; ch <= 8; ch++) {
BUS_PORTS[`ch${ch}Vol`] = { label: `Ch ${ch} Vol`, unit: 'dB' };
BUS_PORTS[`ch${ch}Pan`] = { label: `Ch ${ch} Pan` };
BUS_PORTS[`ch${ch}Mute`] = { label: `Ch ${ch} Mute` };
}
function getPortLabel(uri: string, symbol: string): string {
const meta = PORT_META[uri];
if (meta && meta[symbol]) return meta[symbol].label;
if (BUS_PORTS[symbol]) return BUS_PORTS[symbol].label;
return symbol;
}
function formatValue(uri: string, symbol: string, value: number): string {
const meta = PORT_META[uri];
const info = meta?.[symbol] || BUS_PORTS[symbol];
if (symbol.endsWith('Mute') || symbol === 'mute' || symbol === 'solo') {
return value >= 0.5 ? 'ON' : 'off';
}
if (symbol === 'instrument') {
const names = ['Guitar', 'Bass', 'Keys', 'Vocals', 'Backing'];
const idx = Math.round(value);
return names[idx] || String(value);
}
if (info?.unit === 'dB') {
return `${value >= 0 ? '+' : ''}${value.toFixed(1)} dB`;
}
return value.toFixed(3);
}
export const PluginList: React.FC<Props> = ({ plugins, lastUpdate }) => {
if (plugins.length === 0) {
return (
<div className="plugin-list empty">
<p>No OPLabs plugin instances found in the current pedalboard.</p>
<p className="hint">
Load OPLabsBandChannel or OPLabsBandBus plugins on the PiPedal pedalboard,
then refresh.
</p>
</div>
);
}
return (
<div className="plugin-list">
<div className="plugin-list-header">
<h2>Discovered OPLabs Plugins</h2>
<span className="plugin-count">{plugins.length} instance{plugins.length !== 1 ? 's' : ''}</span>
<span className="update-time">
Last updated: {new Date(lastUpdate).toLocaleTimeString()}
</span>
</div>
{plugins.map((plugin) => {
const portSymbols = Object.keys(plugin.controlValues);
const typeLabel = plugin.uri.includes('band-bus') ? 'Band Bus' : 'Band Channel';
return (
<div key={plugin.instanceId} className="plugin-card">
<div className="plugin-card-header">
<span className={`plugin-type-badge ${plugin.uri.includes('band-bus') ? 'bus' : 'channel'}`}>
{typeLabel}
</span>
<span className="plugin-name">{plugin.title || plugin.pluginName || 'Unnamed'}</span>
<span className="plugin-instance-id">ID: {plugin.instanceId}</span>
<span className={`plugin-enabled ${plugin.isEnabled ? 'enabled' : 'disabled'}`}>
{plugin.isEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="plugin-uri">{plugin.uri}</div>
<div className="plugin-ports">
{portSymbols.map((symbol) => (
<div key={symbol} className="port-row">
<span className="port-label">{getPortLabel(plugin.uri, symbol)}</span>
<span className="port-symbol">{symbol}</span>
<span className="port-value">
{formatValue(plugin.uri, symbol, plugin.controlValues[symbol])}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,352 @@
.scene-manager-overlay {
position: fixed;
top: 0;
right: 0;
width: 360px;
height: 100vh;
background: var(--surface, #16213e);
border-left: 1px solid var(--border, #2a2a4a);
z-index: 200;
display: flex;
flex-direction: column;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
animation: scene-slide-in 0.2s ease-out;
}
@keyframes scene-slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.scene-manager-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #2a2a4a);
}
.scene-manager-header h2 {
font-size: 1rem;
font-weight: 600;
color: var(--text, #eaeaea);
margin: 0;
}
.scene-manager-count {
font-size: 0.7rem;
background: var(--surface-alt, #0f3460);
color: var(--text-dim, #9e9e9e);
padding: 1px 7px;
border-radius: 10px;
margin-left: 8px;
}
.scene-close-btn {
background: transparent;
border: none;
color: var(--text-dim, #9e9e9e);
font-size: 1.2rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
line-height: 1;
}
.scene-close-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text, #eaeaea);
}
/* ---- Scene List ---- */
.scene-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.scene-list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
color: var(--text-dim, #9e9e9e);
font-size: 0.85rem;
gap: 8px;
}
.scene-list-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: var(--text-dim, #9e9e9e);
font-size: 0.85rem;
}
/* ---- Scene Card ---- */
.scene-card {
background: var(--bg, #1a1a2e);
border-radius: var(--radius, 8px);
margin-bottom: 8px;
border: 1px solid var(--border, #2a2a4a);
overflow: hidden;
transition: border-color 0.2s;
}
.scene-card:hover {
border-color: var(--accent, #e94560);
}
.scene-card-content {
display: flex;
align-items: center;
padding: 10px 12px;
gap: 10px;
cursor: pointer;
}
.scene-card-content:hover {
background: rgba(233, 69, 96, 0.04);
}
.scene-card-name {
flex: 1;
font-size: 0.9rem;
font-weight: 500;
color: var(--text, #eaeaea);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scene-card-midi {
font-size: 0.65rem;
color: var(--text-dim, #9e9e9e);
background: var(--surface-alt, #0f3460);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.scene-card-actions {
display: flex;
gap: 4px;
}
.scene-card-btn {
background: transparent;
border: none;
color: var(--text-dim, #9e9e9e);
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.15s;
}
.scene-card-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text, #eaeaea);
}
.scene-card-btn.delete:hover {
background: rgba(244, 67, 54, 0.15);
color: var(--error, #f44336);
}
.scene-card-btn.load:hover {
background: rgba(76, 175, 80, 0.15);
color: var(--success, #4caf50);
}
.scene-card-btn.recall {
font-weight: 600;
}
.scene-card-btn.recall:hover {
background: rgba(233, 69, 96, 0.15);
color: var(--accent, #e94560);
}
/* ---- Save New Scene Form ---- */
.scene-save-form {
padding: 12px 16px;
border-top: 1px solid var(--border, #2a2a4a);
display: flex;
flex-direction: column;
gap: 8px;
}
.scene-save-row {
display: flex;
gap: 8px;
align-items: center;
}
.scene-name-input {
flex: 1;
background: var(--bg, #1a1a2e);
border: 1px solid var(--border, #2a2a4a);
border-radius: 6px;
padding: 8px 10px;
color: var(--text, #eaeaea);
font-size: 0.85rem;
outline: none;
transition: border-color 0.2s;
}
.scene-name-input:focus {
border-color: var(--accent, #e94560);
}
.scene-name-input::placeholder {
color: var(--text-dim, #9e9e9e);
}
.scene-save-btn {
background: var(--accent, #e94560);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
transition: opacity 0.15s;
}
.scene-save-btn:hover {
opacity: 0.85;
}
.scene-save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.scene-midi-input-group {
display: flex;
align-items: center;
gap: 6px;
}
.scene-midi-label {
font-size: 0.7rem;
color: var(--text-dim, #9e9e9e);
white-space: nowrap;
}
.scene-midi-input {
width: 48px;
background: var(--bg, #1a1a2e);
border: 1px solid var(--border, #2a2a4a);
border-radius: 4px;
padding: 4px 6px;
color: var(--text, #eaeaea);
font-size: 0.8rem;
text-align: center;
outline: none;
}
.scene-midi-input:focus {
border-color: var(--accent, #e94560);
}
/* ---- Error display ---- */
.scene-error {
padding: 8px 16px;
background: rgba(244, 67, 54, 0.1);
color: var(--error, #f44336);
font-size: 0.75rem;
border-top: 1px solid rgba(244, 67, 54, 0.2);
}
/* ---- Confirmation Dialog ---- */
.scene-confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
.scene-confirm-dialog {
background: var(--surface, #16213e);
border: 1px solid var(--border, #2a2a4a);
border-radius: var(--radius, 8px);
padding: 20px 24px;
max-width: 320px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.scene-confirm-dialog p {
font-size: 0.9rem;
color: var(--text, #eaeaea);
margin-bottom: 16px;
line-height: 1.4;
}
.scene-confirm-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.scene-confirm-cancel {
background: transparent;
border: 1px solid var(--border, #2a2a4a);
color: var(--text-dim, #9e9e9e);
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.scene-confirm-cancel:hover {
background: rgba(255, 255, 255, 0.05);
}
.scene-confirm-delete {
background: var(--error, #f44336);
border: none;
color: #fff;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
}
.scene-confirm-delete:hover {
opacity: 0.85;
}
/* ---- Recall feedback ---- */
.scene-recalling {
opacity: 0.6;
pointer-events: none;
}
.scene-recalled-flash {
animation: scene-flash 0.6s ease-out;
}
@keyframes scene-flash {
0% { border-color: var(--success, #4caf50); box-shadow: 0 0 8px rgba(76, 175, 80, 0.3); }
100% { border-color: var(--border, #2a2a4a); box-shadow: none; }
}
@@ -0,0 +1,242 @@
import React, { useState, useCallback } from 'react';
import type { Scene, SceneState } from '../../hooks/useScenes';
import './SceneManager.css';
interface Props {
scenes: Scene[];
loading: boolean;
error: string | null;
onClose: () => void;
onSaveScene: (name: string, state: SceneState, midiPc?: number | null) => Promise<void>;
onDeleteScene: (id: string) => Promise<void>;
onRecallScene: (id: string) => Promise<boolean>;
getCurrentState: () => SceneState | null;
}
/**
* Scene Manager sidebar panel.
* Lists saved scenes, allows saving new ones, loading/recalling, and deleting.
*/
export const SceneManager: React.FC<Props> = ({
scenes,
loading,
error: externalError,
onClose,
onSaveScene,
onDeleteScene,
onRecallScene,
getCurrentState,
}) => {
const [saveName, setSaveName] = useState('');
const [midiPc, setMidiPc] = useState('');
const [saveInProgress, setSaveInProgress] = useState(false);
const [recallingId, setRecallingId] = useState<string | null>(null);
const [recentlyRecalledId, setRecentlyRecalledId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [internalError, setInternalError] = useState<string | null>(null);
const errorMsg = externalError || internalError;
const handleSave = useCallback(async () => {
const trimmed = saveName.trim();
if (!trimmed) return;
const currentState = getCurrentState();
if (!currentState) {
setInternalError('No plugin state available. Refresh plugins first.');
return;
}
setSaveInProgress(true);
setInternalError(null);
try {
const midiPcVal = midiPc.trim() !== '' ? parseInt(midiPc.trim(), 10) : null;
await onSaveScene(trimmed, currentState, midiPcVal);
setSaveName('');
setMidiPc('');
} catch {
setInternalError('Failed to save scene');
} finally {
setSaveInProgress(false);
}
}, [saveName, midiPc, getCurrentState, onSaveScene]);
const handleRecall = useCallback(
async (id: string) => {
setRecallingId(id);
setInternalError(null);
try {
const ok = await onRecallScene(id);
if (ok) {
setRecentlyRecalledId(id);
setTimeout(() => setRecentlyRecalledId(null), 700);
} else {
setInternalError('Recall failed — check PiPedal connection');
}
} catch {
setInternalError('Recall failed');
} finally {
setRecallingId(null);
}
},
[onRecallScene]
);
const handleDelete = useCallback(
async (id: string) => {
setInternalError(null);
setDeleteConfirmId(null);
try {
await onDeleteScene(id);
} catch {
setInternalError('Failed to delete scene');
}
},
[onDeleteScene]
);
const handleSaveKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
}
},
[handleSave]
);
return (
<div className="scene-manager-overlay">
{/* Header */}
<div className="scene-manager-header">
<div style={{ display: 'flex', alignItems: 'center' }}>
<h2>Scenes</h2>
<span className="scene-manager-count">
{scenes.length}
</span>
</div>
<button className="scene-close-btn" onClick={onClose} title="Close">
</button>
</div>
{/* Scene list */}
<div className="scene-list">
{loading && scenes.length === 0 && (
<div className="scene-list-loading">Loading scenes...</div>
)}
{!loading && scenes.length === 0 && (
<div className="scene-list-empty">
<span>No saved scenes yet</span>
<span>Use the form below to save the current mixer state.</span>
</div>
)}
{scenes.map((scene) => (
<div
key={scene.id}
className={`scene-card ${
recentlyRecalledId === scene.id ? 'scene-recalled-flash' : ''
} ${recallingId === scene.id ? 'scene-recalling' : ''}`}
>
<div className="scene-card-content">
<span
className="scene-card-name"
onClick={() => handleRecall(scene.id)}
title={`Recall "${scene.name}"`}
>
{scene.name}
</span>
{scene.midiPc !== null && scene.midiPc !== undefined && (
<span className="scene-card-midi">PC#{scene.midiPc}</span>
)}
<div className="scene-card-actions">
<button
className="scene-card-btn recall"
onClick={() => handleRecall(scene.id)}
disabled={recallingId === scene.id}
title="Recall scene"
>
Recall
</button>
<button
className="scene-card-btn delete"
onClick={() => setDeleteConfirmId(scene.id)}
title="Delete scene"
>
</button>
</div>
</div>
</div>
))}
</div>
{/* Save new scene form */}
<div className="scene-save-form">
<div className="scene-save-row">
<input
className="scene-name-input"
type="text"
placeholder="Scene name..."
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={handleSaveKeyDown}
maxLength={60}
/>
<button
className="scene-save-btn"
onClick={handleSave}
disabled={saveInProgress || !saveName.trim()}
>
{saveInProgress ? 'Saving...' : 'Save'}
</button>
</div>
<div className="scene-save-row">
<div className="scene-midi-input-group">
<span className="scene-midi-label">MIDI PC:</span>
<input
className="scene-midi-input"
type="number"
min="0"
max="127"
placeholder="—"
value={midiPc}
onChange={(e) => setMidiPc(e.target.value)}
/>
</div>
</div>
</div>
{/* Error display */}
{errorMsg && (
<div className="scene-error">{errorMsg}</div>
)}
{/* Delete confirmation dialog */}
{deleteConfirmId !== null && (
<div className="scene-confirm-overlay" onClick={() => setDeleteConfirmId(null)}>
<div className="scene-confirm-dialog" onClick={(e) => e.stopPropagation()}>
<p>Delete this scene permanently?</p>
<div className="scene-confirm-actions">
<button
className="scene-confirm-cancel"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</button>
<button
className="scene-confirm-delete"
onClick={() => handleDelete(deleteConfirmId)}
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
};
+95
View File
@@ -0,0 +1,95 @@
import { useState, useEffect, useRef, useCallback } from 'react';
/**
* Hook that provides a WebSocket connection for sending control values to PiPedal.
* Maintains its own persistent WS connection (separate from usePiPedalWS discovery hook).
*/
export function useMixerControls() {
const wsRef = useRef<WebSocket | null>(null);
const [ready, setReady] = useState(false);
const reconnectTimer = useRef<number | null>(null);
const isUnmounted = useRef(false);
const replyHandlers = useRef<Map<number, (data: unknown) => void>>(new Map());
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/pipedal`;
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setReady(true);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Check if it's a reply to one of our pending requests
if (Array.isArray(data) && data.length >= 1) {
const hdr = data[0];
if (hdr.reply !== undefined) {
const handler = replyHandlers.current.get(hdr.reply);
if (handler) {
handler(data[1] || null);
replyHandlers.current.delete(hdr.reply);
}
}
}
} catch {
// ignore non-JSON messages
}
};
ws.onerror = () => {
setReady(false);
};
ws.onclose = () => {
setReady(false);
replyHandlers.current.clear();
if (!isUnmounted.current) {
reconnectTimer.current = window.setTimeout(() => {
if (!isUnmounted.current) connect();
}, 3000);
}
};
wsRef.current = ws;
} catch {
setReady(false);
}
}, []);
useEffect(() => {
isUnmounted.current = false;
connect();
return () => {
isUnmounted.current = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (wsRef.current) wsRef.current.close();
};
}, [connect]);
/**
* Send a setControlValue message to PiPedal for a specific plugin instance parameter.
*/
const setControlValue = useCallback(
(instanceId: number, key: string, value: number): void => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const replyTo = (Date.now() & 0x7fffffff) + Math.floor(Math.random() * 1000);
const msg = JSON.stringify([
{ message: 'setControlValue', replyTo },
{ instanceId, key, value },
]);
wsRef.current.send(msg);
},
[]
);
return { ready, setControlValue };
}
+155
View File
@@ -0,0 +1,155 @@
import { useState, useEffect, useRef, useCallback } from 'react';
// PiPedal WebSocket protocol types
export interface PiPedalMessageHeader {
replyTo?: number;
reply?: number;
message: string;
}
export interface PluginInstance {
instanceId: number;
uri: string;
pluginName: string;
title: string;
isEnabled: boolean;
controlValues: Record<string, number>;
}
export interface ConnectionState {
status: 'disconnected' | 'connecting' | 'connected' | 'error';
error?: string;
}
export interface DiscoverResponse {
connected: boolean;
oplabs_instances: PluginInstance[];
pedalboard_name: string | null;
}
// OPLabs plugin URIs we're looking for
const OPLABS_URIS = new Set([
'http://ourpad.casa/plugins/oplabs-band-channel',
'http://ourpad.casa/plugins/oplabs-band-bus',
'http://ourpad.casa/plugins/oplabs-band-bus#',
]);
function isOplabsPlugin(uri: string): boolean {
return OPLABS_URIS.has(uri);
}
export interface UsePiPedalWSReturn {
connectionState: ConnectionState;
plugins: PluginInstance[];
pedalboardName: string | null;
discover: () => Promise<void>;
lastUpdate: number;
}
/**
* Hook that connects to the local WS proxy and discovers OPLabs plugin instances.
*/
export function usePiPedalWS(): UsePiPedalWSReturn {
const [connectionState, setConnectionState] = useState<ConnectionState>({
status: 'disconnected',
});
const [plugins, setPlugins] = useState<PluginInstance[]>([]);
const [pedalboardName, setPedalboardName] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<number>(0);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<number | null>(null);
const isUnmounted = useRef(false);
// Connect to the local WebSocket proxy
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/pipedal`;
setConnectionState({ status: 'connecting' });
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setConnectionState({ status: 'connected' });
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle our custom messages
if (data.type === 'connected') {
setConnectionState({ status: 'connected' });
} else if (data.type === 'error') {
setConnectionState({ status: 'error', error: data.message });
}
// PiPedal protocol messages are relayed transparently
} catch {
// Binary or non-JSON data — ignore
}
};
ws.onerror = () => {
setConnectionState({ status: 'error', error: 'WebSocket connection error' });
};
ws.onclose = () => {
if (!isUnmounted.current) {
setConnectionState({ status: 'disconnected' });
// Auto-reconnect after 3 seconds
reconnectTimer.current = window.setTimeout(() => {
if (!isUnmounted.current) connect();
}, 3000);
}
};
wsRef.current = ws;
} catch (e) {
setConnectionState({ status: 'error', error: String(e) });
}
}, []);
// Discover OPLabs plugins via REST API
const discover = useCallback(async () => {
try {
const res = await fetch('/api/discover');
const data: DiscoverResponse = await res.json();
setPlugins(data.oplabs_instances || []);
setPedalboardName(data.pedalboard_name);
setLastUpdate(Date.now());
if (data.connected) {
setConnectionState({ status: 'connected' });
}
} catch (e) {
setConnectionState({ status: 'error', error: `Discover failed: ${String(e)}` });
}
}, []);
// Connect on mount
useEffect(() => {
isUnmounted.current = false;
connect();
return () => {
isUnmounted.current = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (wsRef.current) wsRef.current.close();
};
}, [connect]);
// Auto-discover on connect
useEffect(() => {
if (connectionState.status === 'connected') {
discover();
}
}, [connectionState.status, discover]);
return {
connectionState,
plugins,
pedalboardName,
discover,
lastUpdate,
};
}
+152
View File
@@ -0,0 +1,152 @@
import { useState, useEffect, useCallback } from 'react';
/** A single scene snapshot of all OPLabs plugin states. */
export interface SceneChannelState {
instanceId: number;
params: Record<string, number>;
}
export interface SceneState {
channels: SceneChannelState[];
buses: SceneChannelState[];
}
export interface Scene {
id: string;
name: string;
midiPc: number | null;
state: SceneState;
}
export interface UseScenesReturn {
scenes: Scene[];
loading: boolean;
error: string | null;
saveScene: (name: string, state: SceneState, midiPc?: number | null) => Promise<void>;
updateScene: (id: string, updates: Partial<Pick<Scene, 'name' | 'midiPc' | 'state'>>) => Promise<void>;
deleteScene: (id: string) => Promise<void>;
recallScene: (id: string) => Promise<boolean>;
refreshScenes: () => Promise<void>;
}
/**
* Hook for managing mixer scenes via the backend REST API.
*/
export function useScenes(): UseScenesReturn {
const [scenes, setScenes] = useState<Scene[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshScenes = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/scenes');
if (!res.ok) {
throw new Error(`Failed to load scenes: ${res.statusText}`);
}
const data = await res.json();
setScenes(data.scenes || []);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refreshScenes();
}, [refreshScenes]);
const saveScene = useCallback(
async (name: string, state: SceneState, midiPc?: number | null) => {
setError(null);
try {
const res = await fetch('/api/scenes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, state, midiPc: midiPc ?? null }),
});
if (!res.ok) {
throw new Error(`Failed to save scene: ${res.statusText}`);
}
await refreshScenes();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
throw e;
}
},
[refreshScenes]
);
const updateScene = useCallback(
async (id: string, updates: Partial<Pick<Scene, 'name' | 'midiPc' | 'state'>>) => {
setError(null);
try {
const res = await fetch(`/api/scenes/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!res.ok) {
throw new Error(`Failed to update scene: ${res.statusText}`);
}
await refreshScenes();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
}
},
[refreshScenes]
);
const deleteScene = useCallback(
async (id: string) => {
setError(null);
try {
const res = await fetch(`/api/scenes/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error(`Failed to delete scene: ${res.statusText}`);
}
await refreshScenes();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
}
},
[refreshScenes]
);
const recallScene = useCallback(async (id: string): Promise<boolean> => {
setError(null);
try {
const res = await fetch(`/api/scenes/${encodeURIComponent(id)}/recall`, {
method: 'POST',
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Recall failed: ${res.statusText}`);
}
return true;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
return false;
}
}, []);
return {
scenes,
loading,
error,
saveScene,
updateScene,
deleteScene,
recallScene,
refreshScenes,
};
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './App.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)