Add MasterBus component + enhance BusStrip with routing selector

MasterBus: stereo master fader, mute, level meters in PiPedal theme
(purple accent per mixer-engine branch reference).

BusStrip: added per-channel routing selector (ch1Route..ch8Route)
for selecting output bus target (Main, Bus 2-8).

MixerPage: integrated MasterBus with levels derived from bus
master data. Local state for volume/mute until backend exposes
master output.
This commit is contained in:
2026-06-23 12:15:09 -04:00
parent 17d423b21f
commit d19e0ea7a8
25 changed files with 4419 additions and 288 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OPLabs Mixer</title>
<script type="module" crossorigin src="/assets/index-DDgWDHTC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-58I2yFEM.css">
<script type="module" crossorigin src="/assets/index-BixTWz89.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwT15WG7.css">
</head>
<body>
<div id="root"></div>
@@ -180,3 +180,33 @@
width: 8px;
height: 10px;
}
/* ---- Routing Selector ---- */
.bus-channel-route {
width: 100%;
padding: 1px 2px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.5rem;
font-weight: 600;
cursor: pointer;
outline: none;
text-align: center;
text-align-last: center;
-moz-text-align-last: center;
min-height: 16px;
max-height: 18px;
}
.bus-channel-route:focus {
border-color: var(--accent, #e94560);
}
.bus-channel-route option {
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
font-size: 0.55rem;
}
+40 -3
View File
@@ -10,6 +10,18 @@ interface Props {
onSetControl: (instanceId: number, key: string, value: number) => void;
}
/** Available routing targets for bus channels */
const ROUTE_TARGETS = [
{ value: 1, label: 'Main' },
{ value: 2, label: 'Bus 2' },
{ value: 3, label: 'Bus 3' },
{ value: 4, label: 'Bus 4' },
{ value: 5, label: 'Bus 5' },
{ value: 6, label: 'Bus 6' },
{ value: 7, label: 'Bus 7' },
{ value: 8, label: 'Bus 8' },
];
/**
* Converts a dB value to a mini-fader position (0-100).
*/
@@ -23,10 +35,12 @@ function sliderPosToDb(pos: number): number {
}
/**
* Bus meter bridge: per-channel mini faders and mute buttons + master section.
* Bus meter bridge: per-channel mini faders, mute buttons, routing selector,
* and master section.
*
* The OPLabsBandBus plugin has 62 ports:
* The OPLabsBandBus plugin has 70 ports:
* - ch1Vol..ch8Vol, ch1Pan..ch8Pan, ch1Mute..ch8Mute
* - ch1Route..ch8Route (routing: 1=Main, 2-8=sub-buses)
* - ch1LevelL..ch8LevelL, ch1LevelR..ch8LevelR
* - masterVol, masterMute, masterLevelL, masterLevelR
*/
@@ -44,10 +58,12 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
index: idx,
vol: cv[`ch${idx}Vol`] ?? 0,
mute: (cv[`ch${idx}Mute`] ?? 0) >= 0.5,
route: Math.round(cv[`ch${idx}Route`] ?? 1) as 1|2|3|4|5|6|7|8,
levelL: cv[`ch${idx}LevelL`] ?? -60,
levelR: cv[`ch${idx}LevelR`] ?? -60,
volKey: `ch${idx}Vol` as const,
muteKey: `ch${idx}Mute` as const,
routeKey: `ch${idx}Route` as const,
};
});
@@ -78,6 +94,14 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
[plugin.instanceId, onSetControl]
);
const handleChannelRoute = useCallback(
(chIdx: number, routeKey: string, e: React.ChangeEvent<HTMLSelectElement>) => {
const val = parseInt(e.target.value, 10);
onSetControl(plugin.instanceId, routeKey, val);
},
[plugin.instanceId, onSetControl]
);
return (
<div className="bus-strip">
{/* Master section */}
@@ -122,7 +146,7 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
{channels.map((ch) => (
<div key={ch.index} className="bus-channel">
<span className="bus-channel-label">
{ch.index}
Ch {ch.index}
</span>
<button
className={`bus-btn mute mini ${ch.mute ? 'active' : ''}`}
@@ -131,6 +155,19 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
>
M
</button>
{/* Routing selector */}
<select
className="bus-channel-route"
value={ch.route}
onChange={(e) => handleChannelRoute(ch.index, ch.routeKey, e)}
title={`Ch ${ch.index} route`}
>
{ROUTE_TARGETS.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<div className="bus-channel-meter">
<LevelMeter
levelL={ch.levelL}
@@ -1,134 +1,327 @@
import React, { useCallback } from 'react';
import type { PluginInstance } from '../../hooks/usePiPedalWS';
import type { Instrument } from './InstrumentBadge';
import { InstrumentBadge, INSTRUMENT_NAMES, INSTRUMENT_COLORS } from './InstrumentBadge';
/**
* ChannelStrip per-input channel control for the OPLabs Mixer Console.
*
* Mirrors the daemon's MixerChannelStrip parameter set:
* - Volume fader (-96..+12 dB, logarithmic taper)
* - Pan control (-1..1)
* - Mute / Solo buttons
* - Channel type selector (Instrument / Mic / Line / AuxReturn)
* - High-pass filter toggle + frequency slider
* - Stereo level meter (VU)
* - Editable label
*
* Designed as a controlled component: all state is passed in via props;
* change callbacks flow upward. No built-in WebSocket or IPC coupling.
*/
import React, { useCallback, useRef, useState } from 'react';
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;
// ── Types ──────────────────────────────────────────────────────────
/** Channel type matching the daemon's MixerChannelType enum. */
export type ChannelType = 'instrument' | 'mic' | 'line' | 'auxReturn';
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
instrument: 'Instrument',
mic: 'Mic',
line: 'Line',
auxReturn: 'Aux Return',
};
export const CHANNEL_TYPE_COLORS: Record<ChannelType, string> = {
instrument: '#ff8c00', // orange
mic: '#e91e63', // pink
line: '#2196f3', // blue
auxReturn: '#9c27b0', // purple
};
export const CHANNEL_TYPE_ICONS: Record<ChannelType, string> = {
instrument: '🎸',
mic: '🎤',
line: '🔌',
auxReturn: '📥',
};
export const CHANNEL_TYPES: ChannelType[] = ['instrument', 'mic', 'line', 'auxReturn'];
export interface ChannelStripProps {
/** Unique mixer-engine instance id. */
instanceId: number;
/** 0-based channel index (displayed as N+1). */
channelIndex: number;
/** User-assignable label shown at the top of the strip. */
label: string;
/** Volume in dB (-96 to +12). */
volume: number;
/** Pan: -1.0 (L) to +1.0 (R). 0.0 = centre. */
pan: number;
/** Mute state. */
mute: boolean;
/** Solo state. */
solo: boolean;
/** Channel type classification (controls accent colour). */
channelType: ChannelType;
/** High-pass filter enabled. */
hpEnabled: boolean;
/** High-pass filter corner frequency in Hz (20400). */
hpFrequency: number;
/** Left-channel VU level in dB (-96 to 0). */
levelL: number;
/** Right-channel VU level in dB (-96 to 0). */
levelR: number;
// ── Callbacks ─────────────────────────────────────────────────
onChangeVolume: (instanceId: number, db: number) => void;
onChangePan: (instanceId: number, pan: number) => void;
onToggleMute: (instanceId: number, mute: boolean) => void;
onToggleSolo: (instanceId: number, solo: boolean) => void;
onChangeChannelType: (instanceId: number, type: ChannelType) => void;
onToggleHp: (instanceId: number, enabled: boolean) => void;
onChangeHpFrequency: (instanceId: number, freq: number) => void;
onChangeLabel: (instanceId: number, label: string) => void;
}
const INSTRUMENT_VALUES: Instrument[] = [0, 1, 2, 3, 4];
// ── dB ↔ Slider position (logarithmic) ────────────────────────────
/**
* Map a dB slider position (0-1) to a dB value (-60..+6).
* 75% position = 0 dB (unity).
* Map slider position (0100) to dB value (-60..+6).
* 75% position = 0 dB (unity), logarithmic taper.
*/
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 (pos >= 100) return 6;
const scaled = (pos / 100) / 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).
* Map dB value (-60..+6) to slider position (0100).
*/
function dbToPos(db: number): number {
if (db <= -60) return 0;
if (db >= 6) return 1;
// Inverse of posToDb
if (db >= 6) return 100;
const linear = Math.pow(10, db / 20);
return Math.min(1, linear * 0.75);
return Math.min(100, linear * 0.75 * 100);
}
/**
* Full channel strip with fader, pan, mute/solo, instrument selector, level meter.
* Format dB for display.
*/
export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
const cv = plugin.controlValues;
function formatDb(db: number): string {
if (db <= -60) return '-∞';
return `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
}
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;
/**
* Format pan for display.
*/
function formatPan(pan: number): string {
if (pan === 0) return 'C';
if (pan < 0) return `L${Math.abs(Math.round(pan * 100))}`;
return `R${Math.round(pan * 100)}`;
}
const instrumentColor = INSTRUMENT_COLORS[instrument] ?? '#666';
// ── Component ──────────────────────────────────────────────────────
const handleVolumeChange = useCallback(
const ChannelStrip: React.FC<ChannelStripProps> = ({
instanceId,
channelIndex,
label,
volume,
pan,
mute,
solo,
channelType,
hpEnabled,
hpFrequency,
levelL,
levelR,
onChangeVolume,
onChangePan,
onToggleMute,
onToggleSolo,
onChangeChannelType,
onToggleHp,
onChangeHpFrequency,
onChangeLabel,
}) => {
const accentColor = CHANNEL_TYPE_COLORS[channelType] ?? '#666';
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState(label);
const labelInputRef = useRef<HTMLInputElement>(null);
const volumeSliderPos = Math.round(dbToPos(volume));
// ── Handlers ─────────────────────────────────────────────────
const handleVolChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const pos = parseFloat(e.target.value) / 100;
const db = posToDb(pos);
onSetControl(plugin.instanceId, 'volume', parseFloat(db.toFixed(1)));
const db = posToDb(parseFloat(e.target.value));
onChangeVolume(instanceId, parseFloat(db.toFixed(1)));
},
[plugin.instanceId, onSetControl]
[instanceId, onChangeVolume],
);
const handlePanChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
onSetControl(plugin.instanceId, 'pan', val);
onChangePan(instanceId, parseFloat(e.target.value));
},
[plugin.instanceId, onSetControl]
[instanceId, onChangePan],
);
const toggleMute = useCallback(() => {
onSetControl(plugin.instanceId, 'mute', mute ? 0 : 1);
}, [plugin.instanceId, mute, onSetControl]);
const handleMuteClick = useCallback(() => {
onToggleMute(instanceId, !mute);
}, [instanceId, mute, onToggleMute]);
const toggleSolo = useCallback(() => {
onSetControl(plugin.instanceId, 'solo', solo ? 0 : 1);
}, [plugin.instanceId, solo, onSetControl]);
const handleSoloClick = useCallback(() => {
onToggleSolo(instanceId, !solo);
}, [instanceId, solo, onToggleSolo]);
const handleInstrumentChange = useCallback(
const handleTypeChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const val = parseInt(e.target.value, 10);
onSetControl(plugin.instanceId, 'instrument', val);
onChangeChannelType(instanceId, e.target.value as ChannelType);
},
[plugin.instanceId, onSetControl]
[instanceId, onChangeChannelType],
);
const volumeSliderPos = dbToPos(volume) * 100;
const handleHpToggle = useCallback(() => {
onToggleHp(instanceId, !hpEnabled);
}, [instanceId, hpEnabled, onToggleHp]);
const handleHpFreqChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChangeHpFrequency(instanceId, parseFloat(e.target.value));
},
[instanceId, onChangeHpFrequency],
);
const handleLabelClick = useCallback(() => {
setLabelDraft(label);
setEditingLabel(true);
// Focus input on next tick
requestAnimationFrame(() => labelInputRef.current?.focus());
}, [label]);
const handleLabelSubmit = useCallback(() => {
const trimmed = labelDraft.trim();
if (trimmed && trimmed !== label) {
onChangeLabel(instanceId, trimmed);
}
setEditingLabel(false);
}, [labelDraft, label, instanceId, onChangeLabel]);
const handleLabelKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleLabelSubmit();
} else if (e.key === 'Escape') {
setEditingLabel(false);
}
},
[handleLabelSubmit],
);
// Format HPF frequency for display
const hzLabel = hpFrequency >= 100
? `${Math.round(hpFrequency)} Hz`
: `${hpFrequency.toFixed(0)} Hz`;
return (
<div
className="channel-strip"
style={{
borderTopColor: instrumentColor,
'--channel-accent': instrumentColor,
} as React.CSSProperties}
style={{ '--channel-accent': accentColor } as React.CSSProperties}
>
{/* Header: channel label + instrument */}
{/* ── Header: editable label + type badge ── */}
<div className="channel-header">
<span className="channel-label" title={plugin.title || plugin.pluginName}>
{plugin.title || plugin.pluginName || `Ch ${plugin.instanceId}`}
{editingLabel ? (
<input
ref={labelInputRef}
className="channel-label-input"
type="text"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onBlur={handleLabelSubmit}
onKeyDown={handleLabelKeyDown}
maxLength={32}
/>
) : (
<span
className="channel-label"
onClick={handleLabelClick}
title="Click to rename"
>
{label || `Ch ${channelIndex + 1}`}
</span>
)}
<span
className="channel-type-badge"
style={{
backgroundColor: `${accentColor}22`,
color: accentColor,
borderColor: accentColor,
}}
title={CHANNEL_TYPE_LABELS[channelType]}
>
<span className="channel-type-icon">{CHANNEL_TYPE_ICONS[channelType]}</span>
<span className="channel-type-name">{CHANNEL_TYPE_LABELS[channelType]}</span>
</span>
<InstrumentBadge instrument={instrument} size="small" />
</div>
{/* Mute & Solo buttons */}
{/* ── HPF Section ── */}
<div className="channel-hpf-section">
<button
className={`channel-hpf-toggle ${hpEnabled ? 'active' : ''}`}
onClick={handleHpToggle}
title={`High-pass filter: ${hpEnabled ? 'ON' : 'OFF'}`}
>
HPF
</button>
{hpEnabled && (
<div className="channel-hpf-slider-group">
<input
type="range"
min="20"
max="400"
step="1"
value={hpFrequency}
onChange={handleHpFreqChange}
className="channel-hpf-slider"
title={`HPF frequency: ${hzLabel}`}
/>
<span className="channel-hpf-readout">{hzLabel}</span>
</div>
)}
</div>
{/* ── Level Meter ── */}
<div className="channel-meter-section">
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
</div>
{/* ── Mute & Solo ── */}
<div className="channel-mute-solo">
<button
className={`channel-btn mute ${mute ? 'active' : ''}`}
onClick={toggleMute}
title="Mute"
onClick={handleMuteClick}
title={mute ? 'Unmute' : 'Mute'}
>
M
</button>
<button
className={`channel-btn solo ${solo ? 'active' : ''}`}
onClick={toggleSolo}
title="Solo"
onClick={handleSoloClick}
title={solo ? 'Un-solo' : 'Solo'}
>
S
</button>
</div>
{/* Level Meter */}
<div className="channel-meter-section">
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
</div>
{/* Pan control */}
{/* ── Pan Control ── */}
<div className="channel-pan">
<label className="channel-pan-label">Pan</label>
<div className="channel-pan-control">
@@ -140,15 +333,13 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
value={pan}
onChange={handlePanChange}
className="channel-pan-slider"
title={`Pan: ${pan.toFixed(2)}`}
title={`Pan: ${formatPan(pan)}`}
/>
<span className="channel-pan-value">
{pan === 0 ? 'C' : pan < 0 ? `L${Math.abs(Math.round(pan * 100))}` : `R${Math.round(pan * 100)}`}
</span>
<span className="channel-pan-value">{formatPan(pan)}</span>
</div>
</div>
{/* Volume fader */}
{/* ── Volume Fader ── */}
<div className="channel-fader-section">
<input
type="range"
@@ -156,27 +347,25 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
max="100"
step="0.5"
value={volumeSliderPos}
onChange={handleVolumeChange}
onChange={handleVolChange}
className="channel-fader"
title={`Volume: ${volume.toFixed(1)} dB`}
title={`Volume: ${formatDb(volume)}`}
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
/>
<span className="channel-fader-readout">
{volume > -60 ? `${volume >= 0 ? '+' : ''}${volume.toFixed(1)}` : '-∞'}
</span>
<span className="channel-fader-readout">{formatDb(volume)}</span>
</div>
{/* Instrument selector */}
<div className="channel-instrument-selector">
{/* ── Channel Type Selector ── */}
<div className="channel-type-selector">
<select
value={instrument}
onChange={handleInstrumentChange}
className="channel-instrument-select"
style={{ borderColor: instrumentColor }}
value={channelType}
onChange={handleTypeChange}
className="channel-type-select"
style={{ borderColor: accentColor }}
>
{INSTRUMENT_VALUES.map((val) => (
<option key={val} value={val}>
{INSTRUMENT_NAMES[val]}
{CHANNEL_TYPES.map((ct) => (
<option key={ct} value={ct}>
{CHANNEL_TYPE_LABELS[ct]}
</option>
))}
</select>
@@ -184,3 +373,5 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
</div>
);
};
export default ChannelStrip;
@@ -0,0 +1,183 @@
/* ---- Master Bus ---- */
/* Styled in PiPedal theme with purple accent to differentiate from channels */
.master-bus {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 10px;
background: var(--surface, #16213e);
border: 1px solid var(--border, #2a2a4a);
border-top: 3px solid #a770e4;
border-radius: var(--radius, 8px);
min-width: 90px;
max-width: 110px;
position: relative;
transition: border-top-color 0.3s, opacity 0.2s;
}
.master-bus:hover {
box-shadow: 0 2px 16px rgba(167, 112, 228, 0.15);
}
.master-bus.muted {
opacity: 0.6;
}
/* ---- Header ---- */
.master-bus-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 100%;
}
.master-bus-label {
font-size: 0.75rem;
font-weight: 700;
text-align: center;
color: var(--text, #eaeaea);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.master-bus-type {
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #a770e4;
font-weight: 600;
}
/* ---- Level Meters ---- */
.master-bus-meters {
width: 100%;
display: flex;
justify-content: center;
min-height: 70px;
}
/* ---- Controls ---- */
.master-bus-controls {
display: flex;
gap: 3px;
width: 100%;
justify-content: center;
}
.master-bus-btn {
width: 32px;
height: 24px;
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;
}
.master-bus-btn.mute.active {
background: var(--error, #f44336);
color: #fff;
border-color: var(--error, #f44336);
}
.master-bus-btn:hover {
opacity: 0.85;
}
/* ---- Fader ---- */
.master-bus-fader-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 100%;
}
.master-bus-fader {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 110px;
background: linear-gradient(
to top,
var(--bg, #1a1a2e) 0%,
#a770e4 75%,
#c084fc 85%,
#e94560 95%,
#f44336 100%
);
border-radius: 4px;
outline: none;
cursor: pointer;
border: 1px solid var(--border, #2a2a4a);
writing-mode: vertical-lr;
direction: rtl;
}
.master-bus-fader::-webkit-slider-runnable-track {
height: 110px;
border-radius: 3px;
}
.master-bus-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 14px;
background: #a770e4;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
margin-left: -4px;
}
.master-bus-fader::-moz-range-track {
height: 110px;
border-radius: 3px;
}
.master-bus-fader::-moz-range-thumb {
width: 20px;
height: 14px;
background: #a770e4;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.master-bus-fader-readout {
font-size: 0.6rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
text-align: center;
min-height: 1em;
}
/* ---- dB values ---- */
.master-bus-db-values {
display: flex;
gap: 4px;
font-size: 0.6rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
}
.master-bus-db-values .hot {
color: var(--meter-red, #f44336);
font-weight: 700;
}
@@ -0,0 +1,126 @@
import React, { useCallback } from 'react';
import { LevelMeter } from './LevelMeter';
import './MasterBus.css';
interface Props {
/** Current volume in dB (-60 .. +6) */
volume: number;
/** Whether master is muted */
mute: boolean;
/** Left channel level in dB */
levelL: number;
/** Right channel level in dB */
levelR: number;
/** Callback when volume changes */
onVolumeChange: (db: number) => void;
/** Callback when mute toggles */
onMuteToggle: (muted: boolean) => void;
/** Optional label (defaults to "Master") */
label?: string;
}
/**
* 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;
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;
const linear = Math.pow(10, db / 20);
return Math.min(1, linear * 0.75);
}
/**
* Master bus final stereo mix output with fader, mute, and level meters.
* Styled in PiPedal theme, visually distinct from channel strips.
*/
export const MasterBus: React.FC<Props> = ({
volume,
mute,
levelL,
levelR,
onVolumeChange,
onMuteToggle,
label = 'Master',
}) => {
const volumeSliderPos = dbToPos(volume) * 100;
const handleVolumeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const pos = parseFloat(e.target.value) / 100;
const db = posToDb(pos);
onVolumeChange(parseFloat(db.toFixed(1)));
},
[onVolumeChange]
);
const handleMuteClick = useCallback(() => {
onMuteToggle(!mute);
}, [mute, onMuteToggle]);
return (
<div className={`master-bus ${mute ? 'muted' : ''}`}>
{/* Header */}
<div className="master-bus-header">
<span className="master-bus-label">{label}</span>
<span className="master-bus-type">Main Out</span>
</div>
{/* Level meters */}
<div className="master-bus-meters">
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
</div>
{/* Mute button */}
<div className="master-bus-controls">
<button
className={`master-bus-btn mute ${mute ? 'active' : ''}`}
onClick={handleMuteClick}
title="Master Mute"
>
M
</button>
</div>
{/* Volume fader */}
<div className="master-bus-fader-section">
<input
type="range"
min="0"
max="100"
step="0.5"
value={volumeSliderPos}
onChange={handleVolumeChange}
className="master-bus-fader"
title={`Master Volume: ${volume.toFixed(1)} dB`}
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
/>
<span className="master-bus-fader-readout">
{mute
? 'MUTED'
: volume > -60
? `${volume >= 0 ? '+' : ''}${volume.toFixed(1)}`
: '-∞'}
</span>
</div>
{/* dB values under meter */}
<div className="master-bus-db-values">
<span className={levelL > -10 ? 'hot' : ''}>{levelL.toFixed(1)}</span>
<span className={levelR > -10 ? 'hot' : ''}>{levelR.toFixed(1)}</span>
</div>
</div>
);
};
@@ -252,3 +252,79 @@
opacity: 0.7;
margin-top: 8px !important;
}
/* ---- Split Layout (when PiPedal iframe is shown) ---- */
.mixer-content-split {
display: flex;
flex-direction: row;
gap: 0;
padding: 0;
height: calc(100vh - 60px);
overflow: hidden;
}
.mixer-content-split .mixer-content-main {
flex: 1;
overflow-y: auto;
padding: 20px;
border-right: 1px solid var(--border, #2a2a4a);
}
/* ---- PiPedal Iframe Pane ---- */
.mixer-pipedal-pane {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg, #1a1a2e);
min-width: 400px;
}
.mixer-pipedal-iframe {
width: 100%;
height: 100%;
border: none;
background: #fff;
}
/* PiPedal toggle button */
.mixer-pipedal-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-pipedal-btn:hover {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
}
.mixer-pipedal-btn.active {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.15);
color: var(--accent, #e94560);
}
/* ---- Master Section ---- */
.mixer-master-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border, #2a2a4a);
}
.mixer-master-layout {
display: flex;
gap: 10px;
justify-content: flex-start;
}
+162 -61
View File
@@ -3,8 +3,11 @@ import type { PluginInstance } from '../../hooks/usePiPedalWS';
import type { SceneState } from '../../hooks/useScenes';
import { useMixerControls } from '../../hooks/useMixerControls';
import { useScenes } from '../../hooks/useScenes';
import { useJackPorts } from '../../hooks/useJackPorts';
import { PatchBay } from '../PatchBay/PatchBay';
import { ChannelStrip } from './ChannelStrip';
import { BusStrip } from './BusStrip';
import { MasterBus } from './MasterBus';
import { SceneManager } from '../Scenes/SceneManager';
import { ConnectionStatus } from '../ConnectionStatus';
import type { ConnectionState } from '../../hooks/usePiPedalWS';
@@ -13,6 +16,10 @@ import './ChannelStrip.css';
import './BusStrip.css';
import './LevelMeter.css';
import './InstrumentBadge.css';
import '../PatchBay/PatchBay.css';
import './LevelMeter.css';
import './InstrumentBadge.css';
import './MasterBus.css';
interface Props {
plugins: PluginInstance[];
@@ -24,9 +31,9 @@ interface Props {
/**
* Main mixer page layout.
* - Header with connection status and scene selector
* - Sidebar of channel strips (left)
* - Bus meter bridge (right)
* - Header with connection status, PiPedal toggle, scene selector
* - Left pane: channel strips + bus strips
* - Right pane: PiPedal iframe (when toggled)
* - Scene management sidebar
*/
export const MixerPage: React.FC<Props> = ({
@@ -46,7 +53,22 @@ export const MixerPage: React.FC<Props> = ({
recallScene,
} = useScenes();
const {
portsState,
refresh: refreshJack,
connect,
disconnect,
disconnectAll,
actionState,
actionError,
} = useJackPorts();
const [scenePanelOpen, setScenePanelOpen] = useState(false);
const [showPiPedal, setShowPiPedal] = useState(false);
const [showPatchBay, setShowPatchBay] = useState(false);
// PiPedal iframe URL — use the Pi's IP since the browser accesses this from another machine
const pipedalUrl = `http://192.168.0.245:8080/`;
// Separate plugins by type
const channels = useMemo(
@@ -67,6 +89,22 @@ export const MixerPage: React.FC<Props> = ({
const hasBuses = buses.length > 0;
const hasAny = plugins.length > 0;
// Derive master bus state from bus plugin data
// For now, use average of bus master levels as master levels;
// volume/mute are local-state placeholders until backend exposes master output.
const masterLevelL = useMemo(() => {
if (!hasBuses) return -60;
return Math.max(...buses.map((b) => b.controlValues.masterLevelL ?? -60));
}, [buses, hasBuses]);
const masterLevelR = useMemo(() => {
if (!hasBuses) return -60;
return Math.max(...buses.map((b) => b.controlValues.masterLevelR ?? -60));
}, [buses, hasBuses]);
const [masterVolume, setMasterVolume] = useState(0);
const [masterMute, setMasterMute] = useState(false);
/**
* Capture current plugin state for saving as a scene.
* Returns channels and buses with their instanceIds and current controlValues.
@@ -126,79 +164,142 @@ export const MixerPage: React.FC<Props> = ({
<span className="mixer-scenes-count">{scenes.length}</span>
)}
</button>
<button className="mixer-refresh-btn" onClick={onRefresh}>
<button className="mixer-scenes-btn" onClick={onRefresh}>
Refresh
</button>
<button
className={`mixer-pipedal-btn ${showPiPedal ? 'active' : ''}`}
onClick={() => setShowPiPedal((v) => !v)}
title="Toggle PiPedal view"
>
🎛 PiPedal
</button>
<button
className={`mixer-patch-btn ${showPatchBay ? 'active' : ''}`}
onClick={() => setShowPatchBay((v) => !v)}
title="Toggle Patch Bay"
>
🔌 Patch
</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}`}
{/* Main content — splits into two panes when PiPedal is shown */}
<div className={`mixer-content ${showPiPedal ? 'mixer-content-split' : ''}`}>
{/* Left pane: mixer controls */}
<div className="mixer-content-main">
{/* 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>
)}
<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>
{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>
<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>
)}
{/* 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>
</div>
)}
)}
{/* Bus strips */}
{hasBuses && (
<div className="mixer-buses">
<div className="mixer-section-header">
<h2>Bus</h2>
<span className="mixer-count">{buses.length}</span>
{/* 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 className="mixer-buses-grid">
{buses.map((plugin) => (
<BusStrip
key={plugin.instanceId}
plugin={plugin}
onSetControl={setControlValue}
)}
{/* Master bus — final mix output */}
{hasAny && (
<div className="mixer-master-section">
<div className="mixer-section-header">
<h2>Master</h2>
</div>
<div className="mixer-master-layout">
<MasterBus
volume={masterVolume}
mute={masterMute}
levelL={masterLevelL}
levelR={masterLevelR}
onVolumeChange={(db: number) => setMasterVolume(db)}
onMuteToggle={(muted: boolean) => setMasterMute(muted)}
/>
))}
</div>
</div>
)}
</div> {/* end mixer-content-main */}
{/* Right pane: PiPedal iframe (when toggled) */}
{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>
</div> {/* end mixer-content */}
{/* Patch Bay pane (full-width, below mixer content) */}
{showPatchBay && (
<div className="mixer-patch-pane">
<PatchBay
portsState={portsState}
onConnect={connect}
onDisconnect={disconnect}
onDisconnectAll={disconnectAll}
onRefresh={refreshJack}
actionState={actionState}
actionError={actionError}
/>
</div>
)}
{/* Scene Manager sidebar */}
{scenePanelOpen && (
@@ -0,0 +1,408 @@
/* ---- Patch Bay Container ---- */
.patch-bay {
background: var(--bg, #1a1a2e);
border: 1px solid var(--border, #2a2a4a);
border-radius: var(--radius, 8px);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ---- Header ---- */
.patch-bay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--surface, #16213e);
border-bottom: 1px solid var(--border, #2a2a4a);
gap: 12px;
flex-wrap: wrap;
}
.patch-bay-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.95rem;
font-weight: 600;
color: var(--text, #eaeaea);
}
.patch-icon {
font-size: 1rem;
}
.patch-conn-count {
font-size: 0.65rem;
background: var(--accent, #e94560);
color: #fff;
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
min-width: 18px;
text-align: center;
}
.patch-bay-actions {
display: flex;
align-items: center;
gap: 8px;
}
.patch-armed-info {
font-size: 0.72rem;
color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
}
.patch-armed-info code {
font-weight: 600;
}
.patch-action-state {
font-size: 0.7rem;
padding: 3px 8px;
border-radius: 4px;
}
.patch-action-state.connecting,
.patch-action-state.disconnecting {
color: var(--warning, #ff9800);
background: rgba(255, 152, 0, 0.12);
}
.patch-action-state.error {
color: var(--error, #f44336);
background: rgba(244, 67, 54, 0.12);
}
.patch-action-btn {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
color: var(--text-dim, #9e9e9e);
border: 1px solid var(--border, #2a2a4a);
padding: 4px 10px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-size: 0.72rem;
transition: all 0.15s;
}
.patch-action-btn:hover:not(:disabled) {
border-color: var(--error, #f44336);
color: var(--error, #f44336);
background: rgba(244, 67, 54, 0.08);
}
.patch-action-btn:disabled {
opacity: 0.4;
cursor: default;
}
.patch-refresh-btn {
background: var(--surface-alt, #0f3460);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
padding: 4px 10px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s;
}
.patch-refresh-btn:hover:not(:disabled) {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
}
.patch-refresh-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* ---- Body (port columns) ---- */
.patch-bay-body {
position: relative;
display: flex;
gap: 0;
padding: 0;
overflow-x: auto;
}
/* ---- Column ---- */
.patch-column {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 4px;
z-index: 2;
min-width: 240px;
max-width: 300px;
}
.patch-column-left {
padding-right: 8px;
border-right: 1px solid var(--border, #2a2a4a);
}
.patch-column-right {
padding-left: 8px;
}
/* ---- Port Group ---- */
.patch-group {
margin-bottom: 1px;
}
.patch-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-dim, #9e9e9e);
background: rgba(255, 255, 255, 0.02);
border-radius: 4px;
height: 28px;
}
.patch-group-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim, #9e9e9e);
}
.patch-group-dot.physical {
background: #4fc3f7;
}
.patch-group-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Port Row ---- */
.patch-port {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
height: 32px;
border-radius: 4px;
cursor: pointer;
transition: all 0.12s;
border-left: 2px solid transparent;
position: relative;
}
.patch-port:hover {
background: rgba(255, 255, 255, 0.05);
}
.patch-port.connected {
border-left-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.06);
}
.patch-port.armed {
border-left-color: #4fc3f7;
background: rgba(79, 195, 247, 0.1);
box-shadow: inset 0 0 0 1px rgba(79, 195, 247, 0.3);
}
.patch-port.available {
border-left-color: transparent;
}
.patch-port.available:hover {
border-left-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.08);
}
.patch-port-indicator {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent, #e94560);
flex-shrink: 0;
}
.patch-port-name {
font-size: 0.78rem;
color: var(--text, #eaeaea);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
.patch-port-name.physical {
color: #4fc3f7;
}
.patch-port-conn-count {
font-size: 0.6rem;
background: var(--accent, #e94560);
color: #fff;
padding: 0 5px;
border-radius: 6px;
font-weight: 600;
min-width: 14px;
text-align: center;
flex-shrink: 0;
}
/* ---- SVG Cable Layer ---- */
.patch-cables-svg {
z-index: 1;
pointer-events: none;
}
/* The invisible hit area paths have pointer-events: stroke set inline */
/* ---- Source note ---- */
.patch-source-note {
position: absolute;
bottom: 4px;
right: 8px;
font-size: 0.6rem;
color: var(--text-dim, #9e9e9e);
opacity: 0.5;
z-index: 3;
}
/* ---- Error banner ---- */
.patch-bay-error {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: rgba(244, 67, 54, 0.08);
border-top: 1px solid rgba(244, 67, 54, 0.2);
font-size: 0.72rem;
color: var(--error, #f44336);
}
.patch-bay-error.action {
background: rgba(244, 67, 54, 0.12);
}
.patch-dismiss-btn {
background: transparent;
border: none;
color: var(--error, #f44336);
cursor: pointer;
font-size: 0.85rem;
padding: 2px 6px;
border-radius: 4px;
}
.patch-dismiss-btn:hover {
background: rgba(244, 67, 54, 0.15);
}
/* ---- Loading ---- */
.patch-bay-loading,
.patch-bay-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
text-align: center;
color: var(--text-dim, #9e9e9e);
gap: 12px;
}
.patch-empty-icon {
font-size: 2.5rem;
opacity: 0.5;
}
.patch-bay-empty h3 {
font-size: 1rem;
color: var(--text, #eaeaea);
}
.patch-bay-empty p {
font-size: 0.82rem;
max-width: 400px;
line-height: 1.5;
}
.patch-empty-note {
font-size: 0.72rem;
opacity: 0.6;
}
.patch-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border, #2a2a4a);
border-top-color: var(--accent, #e94560);
border-radius: 50%;
animation: patch-spin 0.8s linear infinite;
}
@keyframes patch-spin {
to {
transform: rotate(360deg);
}
}
/* ---- MixerPage integration ---- */
.mixer-patch-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-patch-btn:hover {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
}
.mixer-patch-btn.active {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.15);
color: var(--accent, #e94560);
}
/* Patch bay pane inside mixer content split */
.mixer-patch-pane {
width: 100%;
padding: 12px;
overflow-y: auto;
}
@@ -0,0 +1,513 @@
/**
* Patch Bay Visual JACK connection matrix.
*
* Layout:
* Left column: output ports (audio sources) grouped by JACK client.
* Right column: input ports (audio destinations) grouped by JACK client.
* SVG overlay: bezier curves showing active connections.
*
* Interaction:
* Click an output port it becomes "armed" for connection.
* Click an input port connects/disconnects the armed output.
* Click a connected port disconnects it.
*/
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import type { JackPort, JackPortsState } from '../../hooks/useJackPorts';
import './PatchBay.css';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PORT_ROW_HEIGHT = 32;
const GROUP_HEADER_HEIGHT = 28;
const GROUP_GAP = 4;
const CABLE_COLOR = '#e94560';
const CABLE_WIDTH = 2.5;
const HOVER_CABLE_WIDTH = 4;
// ---------------------------------------------------------------------------
// Props + Types
// ---------------------------------------------------------------------------
interface Props {
portsState: JackPortsState;
onConnect: (source: string, destination: string) => Promise<boolean>;
onDisconnect: (source: string, destination: string) => Promise<boolean>;
onDisconnectAll: () => Promise<boolean>;
onRefresh: () => void;
actionState: string;
actionError: string | null;
}
interface PortGroup {
client: string;
ports: JackPort[];
totalHeight: number;
}
interface PortPosition {
name: string;
y: number;
side: 'left' | 'right';
}
interface Cable {
source: string;
dest: string;
y1: number;
y2: number;
}
// ---------------------------------------------------------------------------
// Helper: group ports by client
// ---------------------------------------------------------------------------
function groupPorts(ports: JackPort[], direction: 'output' | 'input'): PortGroup[] {
const filtered = ports
.filter((p) => p.direction === direction && p.port_type === 'audio')
.sort((a, b) => {
// Physical ports first, then alphabetical by client
if (a.is_physical !== b.is_physical) return a.is_physical ? -1 : 1;
const clientCmp = a.client.localeCompare(b.client);
if (clientCmp !== 0) return clientCmp;
return a.port.localeCompare(b.port);
});
const groups: PortGroup[] = [];
let currentGroup: PortGroup | null = null;
for (const port of filtered) {
if (!currentGroup || currentGroup.client !== port.client) {
currentGroup = {
client: port.client,
ports: [],
totalHeight: GROUP_HEADER_HEIGHT + GROUP_GAP,
};
groups.push(currentGroup);
}
currentGroup.ports.push(port);
currentGroup.totalHeight += PORT_ROW_HEIGHT;
}
return groups;
}
// ---------------------------------------------------------------------------
// Compute positions and cables
// ---------------------------------------------------------------------------
function computeCables(
leftGroups: PortGroup[],
rightGroups: PortGroup[],
connections: Record<string, string[]>,
leftOffset: number
): Cable[] {
const outputPositions: Record<string, number> = {};
const inputPositions: Record<string, number> = {};
let ly = leftOffset;
for (const group of leftGroups) {
ly += GROUP_HEADER_HEIGHT + GROUP_GAP / 2;
for (const port of group.ports) {
outputPositions[port.name] = ly + PORT_ROW_HEIGHT / 2;
ly += PORT_ROW_HEIGHT;
}
}
let ry = leftOffset;
for (const group of rightGroups) {
ry += GROUP_HEADER_HEIGHT + GROUP_GAP / 2;
for (const port of group.ports) {
inputPositions[port.name] = ry + PORT_ROW_HEIGHT / 2;
ry += PORT_ROW_HEIGHT;
}
}
const cables: Cable[] = [];
const seen = new Set<string>();
for (const [srcName, srcY] of Object.entries(outputPositions)) {
const conns = connections[srcName] || [];
for (const dstName of conns) {
const key = `${srcName}${dstName}`;
if (seen.has(key)) continue;
seen.add(key);
const dstY = inputPositions[dstName];
if (dstY !== undefined) {
cables.push({ source: srcName, dest: dstName, y1: srcY, y2: dstY });
}
}
}
return cables;
}
// ---------------------------------------------------------------------------
// SVG Cable Path
// ---------------------------------------------------------------------------
function cablePath(
y1: number,
y2: number,
containerWidth: number,
margin: number
): string {
const x1 = margin;
const x2 = containerWidth - margin;
const midX = (x1 + x2) / 2;
const cpOffset = Math.max(60, (x2 - x1) * 0.4);
return `M ${x1} ${y1} C ${x1 + cpOffset} ${y1}, ${x2 - cpOffset} ${y2}, ${x2} ${y2}`;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const PatchBay: React.FC<Props> = ({
portsState,
onConnect,
onDisconnect,
onDisconnectAll,
onRefresh,
actionState,
actionError,
}) => {
const { ports, connections, loading, error, source } = portsState;
const [armedPort, setArmedPort] = useState<string | null>(null);
const [hoveredCable, setHoveredCable] = useState<{
source: string;
dest: string;
} | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(800);
// Group ports
const leftGroups = useMemo(() => groupPorts(ports, 'output'), [ports]);
const rightGroups = useMemo(() => groupPorts(ports, 'input'), [ports]);
// Calculate layout dimensions
const layout = useMemo(() => {
const headerH = 40;
const leftH = leftGroups.reduce((sum, g) => sum + g.totalHeight, GROUP_GAP);
const rightH = rightGroups.reduce((sum, g) => sum + g.totalHeight, GROUP_GAP);
const totalH = Math.max(leftH, rightH) + headerH + 20;
return { headerH, leftH, rightH, totalH };
}, [leftGroups, rightGroups]);
// Cable positions
const cableMargin = 280;
const cables = useMemo(
() => computeCables(leftGroups, rightGroups, connections, layout.headerH + 12),
[leftGroups, rightGroups, connections, layout.headerH]
);
// Track container width
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// Clear armed port after successful action
useEffect(() => {
if (actionState === 'idle') {
setArmedPort(null);
}
}, [actionState]);
const handlePortClick = useCallback(
async (port: JackPort) => {
if (port.direction === 'output') {
// Clicking an output port arms it for connection
if (port.connections.length > 0) {
// If already connected, disconnect all
for (const dst of port.connections) {
await onDisconnect(port.name, dst);
}
} else {
setArmedPort(armedPort === port.name ? null : port.name);
}
} else {
// Clicking an input port
if (armedPort) {
// Connect armed output to this input
await onConnect(armedPort, port.name);
setArmedPort(null);
} else if (port.connections.length > 0) {
// Disconnect if already connected
for (const src of port.connections) {
await onDisconnect(src, port.name);
}
}
}
},
[armedPort, onConnect, onDisconnect]
);
const getPortClass = useCallback(
(port: JackPort): string => {
const classes = ['patch-port'];
if (port.connections.length > 0) classes.push('connected');
if (armedPort === port.name) classes.push('armed');
if (port.direction === 'output' && armedPort && armedPort !== port.name) classes.push('available');
return classes.join(' ');
},
[armedPort]
);
// Count connections
const connectionCount = useMemo(() => {
let count = 0;
for (const [, conns] of Object.entries(connections)) {
count += conns.length;
}
return count;
}, [connections]);
// Render a column of port groups
const renderColumn = (groups: PortGroup[], side: 'left' | 'right') => (
<div className={`patch-column patch-column-${side}`}>
{groups.map((group) => (
<div key={group.client} className="patch-group">
<div className="patch-group-header">
<span
className={`patch-group-dot ${
group.client === 'system' ? 'physical' : ''
}`}
/>
<span className="patch-group-name">
{group.client === 'system'
? group.client
: group.client.replace(/^pipedal:/, '').replace(/^pipedal/, 'PiPedal')}
</span>
</div>
{group.ports.map((port) => (
<div
key={port.name}
className={getPortClass(port)}
onClick={() => handlePortClick(port)}
title={`${port.name}${
port.connections.length > 0
? `\nConnected to: ${port.connections.join(', ')}`
: '\nClick to connect'
}`}
>
{side === 'left' && (
<span className="patch-port-indicator" />
)}
<span className={`patch-port-name ${port.is_physical ? 'physical' : ''}`}>
{port.port}
</span>
{port.connections.length > 0 && (
<span className="patch-port-conn-count">
{port.connections.length}
</span>
)}
</div>
))}
</div>
))}
</div>
);
// --- Empty / loading states ---
if (loading) {
return (
<div className="patch-bay">
<div className="patch-bay-loading">
<div className="patch-spinner" />
<span>Loading JACK ports...</span>
</div>
</div>
);
}
if (ports.length === 0) {
return (
<div className="patch-bay">
<div className="patch-bay-empty">
<div className="patch-empty-icon">🔌</div>
<h3>No JACK Ports Available</h3>
<p>
{source === 'none'
? 'PiPedal is unreachable. Ensure the PiPedal server is running on 192.168.0.245:8080.'
: 'No ports found in the current pedalboard.'}
</p>
{error && <p className="patch-empty-note">{error}</p>}
<button className="patch-refresh-btn" onClick={onRefresh}>
Retry
</button>
</div>
</div>
);
}
return (
<div className="patch-bay" ref={containerRef}>
{/* Header */}
<div className="patch-bay-header">
<div className="patch-bay-title">
<span className="patch-icon">🔌</span>
<span>Patch Bay</span>
<span className="patch-conn-count">{connectionCount}</span>
</div>
<div className="patch-bay-actions">
{armedPort && (
<span className="patch-armed-info">
Armed: <code>{armedPort.split(':').pop()}</code>
</span>
)}
{actionState !== 'idle' && (
<span className={`patch-action-state ${actionState}`}>
{actionState === 'connecting'
? 'Connecting...'
: actionState === 'disconnecting'
? 'Disconnecting...'
: actionError || 'Error'}
</span>
)}
<button
className="patch-action-btn"
onClick={onDisconnectAll}
disabled={connectionCount === 0 || actionState !== 'idle'}
title="Disconnect all"
>
Disconnect All
</button>
<button
className="patch-refresh-btn"
onClick={onRefresh}
disabled={actionState !== 'idle'}
title="Refresh ports"
>
</button>
</div>
</div>
{/* Content area */}
<div
className="patch-bay-body"
style={{ minHeight: layout.totalH }}
>
{/* SVG Cable Layer */}
<svg
className="patch-cables-svg"
width={containerWidth}
height={layout.totalH}
style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
>
<defs>
<linearGradient id="cableGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={CABLE_COLOR} stopOpacity={0.15} />
<stop offset="50%" stopColor={CABLE_COLOR} stopOpacity={0.6} />
<stop offset="100%" stopColor={CABLE_COLOR} stopOpacity={0.15} />
</linearGradient>
<filter id="cableGlow">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Background cable paths (for hover hit area) */}
{cables.map((c) => (
<path
key={`bg-${c.source}${c.dest}`}
d={cablePath(c.y1, c.y2, containerWidth, cableMargin)}
fill="none"
stroke="transparent"
strokeWidth={14}
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
onMouseEnter={() => setHoveredCable({ source: c.source, dest: c.dest })}
onMouseLeave={() => setHoveredCable(null)}
onClick={() => {
onDisconnect(c.source, c.dest);
}}
/>
))}
{/* Visible cable paths */}
{cables.map((c) => {
const isHovered =
hoveredCable?.source === c.source && hoveredCable?.dest === c.dest;
return (
<path
key={`cable-${c.source}${c.dest}`}
d={cablePath(c.y1, c.y2, containerWidth, cableMargin)}
fill="none"
stroke={isHovered ? '#ff6b81' : CABLE_COLOR}
strokeWidth={isHovered ? HOVER_CABLE_WIDTH : CABLE_WIDTH}
strokeLinecap="round"
opacity={isHovered ? 0.9 : 0.5}
filter={isHovered ? 'url(#cableGlow)' : undefined}
style={{ pointerEvents: 'none' }}
/>
);
})}
{/* Source dots on cables */}
{cables.map((c) => (
<circle
key={`dot-${c.source}${c.dest}`}
cx={cableMargin}
cy={c.y1}
r={3}
fill={CABLE_COLOR}
opacity={0.8}
/>
))}
{/* Destination dots */}
{cables.map((c) => (
<circle
key={`dot2-${c.source}${c.dest}`}
cx={containerWidth - cableMargin}
cy={c.y2}
r={3}
fill={CABLE_COLOR}
opacity={0.8}
/>
))}
</svg>
{/* Columns */}
{renderColumn(leftGroups, 'left')}
{renderColumn(rightGroups, 'right')}
{/* Source indicator */}
{source !== 'pipedal' && source !== 'none' && (
<div className="patch-source-note">
Port data from pedalboard state (source: {source})
</div>
)}
</div>
{/* Error banner */}
{error && (
<div className="patch-bay-error">
<span> {error}</span>
<button className="patch-dismiss-btn" onClick={onRefresh}>
</button>
</div>
)}
{actionError && (
<div className="patch-bay-error action">
<span> {actionError}</span>
</div>
)}
</div>
);
};
+182
View File
@@ -0,0 +1,182 @@
/**
* Hook for fetching and managing JACK audio port connections via the backend API.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface JackPort {
name: string;
client: string;
port: string;
port_type: string; // "audio" | "midi"
direction: string; // "output" = audio source, "input" = audio destination
is_physical: boolean;
connections: string[];
}
export interface JackPortsResponse {
ports: JackPort[];
connections: Record<string, string[]>;
source: 'pipedal' | 'fallback' | 'none';
note?: string;
}
export type ConnectionAction = 'idle' | 'connecting' | 'disconnecting' | 'error';
export interface JackPortsState {
ports: JackPort[];
connections: Record<string, string[]>;
loading: boolean;
error: string | null;
source: string;
}
export interface UseJackPortsReturn {
portsState: JackPortsState;
refresh: () => Promise<void>;
connect: (source: string, destination: string) => Promise<boolean>;
disconnect: (source: string, destination: string) => Promise<boolean>;
disconnectAll: () => Promise<boolean>;
actionState: ConnectionAction;
actionError: string | null;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useJackPorts(): UseJackPortsReturn {
const [portsState, setPortsState] = useState<JackPortsState>({
ports: [],
connections: {},
loading: true,
error: null,
source: 'none',
});
const [actionState, setActionState] = useState<ConnectionAction>('idle');
const [actionError, setActionError] = useState<string | null>(null);
const isMounted = useRef(true);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
const refresh = useCallback(async () => {
setPortsState((prev) => ({ ...prev, loading: true, error: null }));
try {
const res = await fetch('/api/jack/ports');
if (!res.ok) {
throw new Error(`Failed to load JACK ports: ${res.statusText}`);
}
const data: JackPortsResponse = await res.json();
if (isMounted.current) {
setPortsState({
ports: data.ports || [],
connections: data.connections || {},
loading: false,
error: data.note || null,
source: data.source,
});
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (isMounted.current) {
setPortsState((prev) => ({ ...prev, loading: false, error: msg }));
}
}
}, []);
const connect = useCallback(
async (source: string, destination: string): Promise<boolean> => {
setActionState('connecting');
setActionError(null);
try {
const res = await fetch('/api/jack/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, destination }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Connect failed: ${res.statusText}`);
}
await refresh();
setActionState('idle');
return true;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setActionError(msg);
setActionState('error');
return false;
}
},
[refresh]
);
const disconnect = useCallback(
async (source: string, destination: string): Promise<boolean> => {
setActionState('disconnecting');
setActionError(null);
try {
const res = await fetch('/api/jack/disconnect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, destination }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Disconnect failed: ${res.statusText}`);
}
await refresh();
setActionState('idle');
return true;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setActionError(msg);
setActionState('error');
return false;
}
},
[refresh]
);
const disconnectAll = useCallback(async (): Promise<boolean> => {
setActionState('disconnecting');
setActionError(null);
try {
const res = await fetch('/api/jack/disconnect-all', { method: 'POST' });
if (!res.ok) {
throw new Error(`Disconnect all failed: ${res.statusText}`);
}
await refresh();
setActionState('idle');
return true;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setActionError(msg);
setActionState('error');
return false;
}
}, [refresh]);
// Initial fetch
useEffect(() => {
refresh();
}, [refresh]);
return {
portsState,
refresh,
connect,
disconnect,
disconnectAll,
actionState,
actionError,
};
}
-1
View File
@@ -1 +0,0 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ConnectionStatus.tsx","./src/components/PluginList.tsx","./src/components/MixerPage/BusStrip.tsx","./src/components/MixerPage/ChannelStrip.tsx","./src/components/MixerPage/InstrumentBadge.tsx","./src/components/MixerPage/LevelMeter.tsx","./src/components/MixerPage/MixerPage.tsx","./src/components/Scenes/SceneManager.tsx","./src/hooks/useMixerControls.ts","./src/hooks/usePiPedalWS.ts","./src/hooks/useScenes.ts"],"version":"5.9.3"}