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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (20–400). */
|
||||
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 (0–100) 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 (0–100).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user