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
@@ -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,
};
}