P4: Production hardening — reconnect, auto-scene-load, service cleanup

This commit is contained in:
2026-06-23 13:27:06 -04:00
parent d19e0ea7a8
commit 84cad11745
21 changed files with 2458 additions and 363 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OPLabs Mixer</title>
<script type="module" crossorigin src="/assets/index-BixTWz89.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwT15WG7.css">
<script type="module" crossorigin src="/assets/index-CpkXACSZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B9JhTSIS.css">
</head>
<body>
<div id="root"></div>
@@ -3,13 +3,13 @@
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 8px;
padding: 8px 6px;
background: var(--surface, #16213e);
border: 1px solid var(--border, #2a2a4a);
border-top: 3px solid #666;
border-top: 3px solid var(--channel-accent, #666);
border-radius: var(--radius, 8px);
min-width: 80px;
max-width: 100px;
min-width: 90px;
max-width: 110px;
transition: border-top-color 0.3s, box-shadow 0.2s;
}
@@ -17,7 +17,7 @@
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
/* ---- Header ---- */
/* ── Header: label + type badge ── */
.channel-header {
display: flex;
@@ -36,9 +36,152 @@
text-overflow: ellipsis;
max-width: 100%;
color: var(--text, #eaeaea);
cursor: pointer;
padding: 1px 4px;
border-radius: 3px;
transition: background 0.15s;
}
/* ---- Mute / Solo ---- */
.channel-label:hover {
background: rgba(255, 255, 255, 0.08);
}
.channel-label-input {
font-size: 0.7rem;
font-weight: 600;
text-align: center;
width: 100%;
padding: 1px 4px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--channel-accent, #666);
border-radius: 3px;
outline: none;
}
/* ── Channel Type Badge ── */
.channel-type-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid;
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
user-select: none;
line-height: 1.3;
}
.channel-type-icon {
font-size: 0.75em;
line-height: 1;
}
.channel-type-name {
line-height: 1;
}
/* ── HPF Section ── */
.channel-hpf-section {
display: flex;
align-items: center;
gap: 3px;
width: 100%;
min-height: 22px;
}
.channel-hpf-toggle {
flex-shrink: 0;
width: 32px;
height: 18px;
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.5rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
background: var(--bg, #1a1a2e);
color: var(--text-dim, #9e9e9e);
user-select: none;
line-height: 1;
padding: 0;
}
.channel-hpf-toggle.active {
background: var(--channel-accent, #666);
color: #fff;
border-color: var(--channel-accent, #666);
}
.channel-hpf-toggle:hover {
opacity: 0.85;
}
.channel-hpf-slider-group {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
.channel-hpf-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: var(--border, #2a2a4a);
border-radius: 2px;
outline: none;
cursor: pointer;
min-width: 0;
}
.channel-hpf-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 8px;
height: 10px;
border-radius: 1px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.channel-hpf-slider::-moz-range-thumb {
width: 8px;
height: 10px;
border-radius: 1px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.channel-hpf-readout {
font-size: 0.5rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
min-width: 24px;
text-align: right;
flex-shrink: 0;
}
/* ── Level Meter Section ── */
.channel-meter-section {
width: 100%;
display: flex;
justify-content: center;
flex: 1;
min-height: 50px;
}
/* ── Mute / Solo ── */
.channel-mute-solo {
display: flex;
@@ -60,6 +203,7 @@
color: var(--text-dim, #9e9e9e);
user-select: none;
line-height: 1;
padding: 0;
}
.channel-btn.mute.active {
@@ -78,17 +222,7 @@
opacity: 0.85;
}
/* ---- Level Meter Section ---- */
.channel-meter-section {
width: 100%;
display: flex;
justify-content: center;
flex: 1;
min-height: 60px;
}
/* ---- Pan ---- */
/* ── Pan ── */
.channel-pan {
width: 100%;
@@ -147,98 +281,44 @@
font-size: 0.55rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
min-width: 20px;
min-width: 22px;
text-align: center;
}
/* ---- Fader ---- */
/* ── Volume Knob Section ── */
.channel-fader-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
gap: 4px;
width: 100%;
padding: 4px 0;
}
/* ── Channel Type Selector ── */
.channel-type-selector {
width: 100%;
}
.channel-fader {
-webkit-appearance: none;
appearance: none;
.channel-type-select {
width: 100%;
height: 100px;
background: linear-gradient(to top, var(--bg, #1a1a2e) 0%, var(--surface-alt, #0f3460) 75%, #4caf50 85%, #ff9800 95%, #f44336 100%);
border-radius: 4px;
outline: none;
cursor: pointer;
border: 1px solid var(--border, #2a2a4a);
writing-mode: vertical-lr;
direction: rtl;
}
/* WebKit vertical slider */
.channel-fader::-webkit-slider-runnable-track {
height: 100px;
border-radius: 3px;
}
.channel-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 12px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
margin-left: -3px;
}
.channel-fader::-moz-range-track {
height: 100px;
border-radius: 3px;
}
.channel-fader::-moz-range-thumb {
width: 18px;
height: 12px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.channel-fader-readout {
font-size: 0.6rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
text-align: center;
}
/* ---- Instrument Selector ---- */
.channel-instrument-selector {
width: 100%;
}
.channel-instrument-select {
width: 100%;
padding: 3px 4px;
padding: 2px 4px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.6rem;
font-size: 0.55rem;
cursor: pointer;
outline: none;
}
.channel-instrument-select:focus {
.channel-type-select:focus {
border-color: var(--channel-accent, #666);
}
.channel-instrument-select option {
.channel-type-select option {
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
}
@@ -2,8 +2,8 @@
* 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)
* - Volume control (rotary knob, -60..+6 dB, ZoomedDial-style SVG dial)
* - Pan control (-1..1 horizontal slider)
* - Mute / Solo buttons
* - Channel type selector (Instrument / Mic / Line / AuxReturn)
* - High-pass filter toggle + frequency slider
@@ -12,11 +12,16 @@
*
* Designed as a controlled component: all state is passed in via props;
* change callbacks flow upward. No built-in WebSocket or IPC coupling.
*
* The volume RotaryKnob reuses the PiPedal ZoomedDial interaction pattern:
* pointer-based rotation with drag, Shift for fine-tune, double-click reset.
*/
import React, { useCallback, useRef, useState } from 'react';
import { LevelMeter } from './LevelMeter';
import RotaryKnob from '../RotaryKnob/RotaryKnob';
import './ChannelStrip.css';
import '../RotaryKnob/RotaryKnob.css';
// ── Types ──────────────────────────────────────────────────────────
@@ -82,36 +87,12 @@ export interface ChannelStripProps {
onToggleHp: (instanceId: number, enabled: boolean) => void;
onChangeHpFrequency: (instanceId: number, freq: number) => void;
onChangeLabel: (instanceId: number, label: string) => void;
// ── FX Inserts (optional, rendered at bottom of strip) ──
fxInserts?: React.ReactNode;
}
// ── dB ↔ Slider position (logarithmic) ────────────────────────────
/**
* 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 >= 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 dB value (-60..+6) to slider position (0100).
*/
function dbToPos(db: number): number {
if (db <= -60) return 0;
if (db >= 6) return 100;
const linear = Math.pow(10, db / 20);
return Math.min(100, linear * 0.75 * 100);
}
/**
* Format dB for display.
*/
// ── Format helpers ─────────────────────────────────────────────────
function formatDb(db: number): string {
if (db <= -60) return '-∞';
return `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
@@ -149,24 +130,15 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
onToggleHp,
onChangeHpFrequency,
onChangeLabel,
fxInserts,
}) => {
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 db = posToDb(parseFloat(e.target.value));
onChangeVolume(instanceId, parseFloat(db.toFixed(1)));
},
[instanceId, onChangeVolume],
);
const handlePanChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChangePan(instanceId, parseFloat(e.target.value));
@@ -339,20 +311,19 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
</div>
</div>
{/* ── Volume Fader ── */}
{/* ── Volume Knob ── */}
<div className="channel-fader-section">
<input
type="range"
min="0"
max="100"
step="0.5"
value={volumeSliderPos}
onChange={handleVolChange}
className="channel-fader"
title={`Volume: ${formatDb(volume)}`}
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
<RotaryKnob
value={volume}
min={-60}
max={6}
step={0.5}
size={48}
label="Volume"
readout={formatDb(volume)}
accentColor={accentColor}
onChange={(v) => onChangeVolume(instanceId, parseFloat(v.toFixed(1)))}
/>
<span className="channel-fader-readout">{formatDb(volume)}</span>
</div>
{/* ── Channel Type Selector ── */}
@@ -370,6 +341,9 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
))}
</select>
</div>
{/* ── FX Inserts (injected by parent) ── */}
{fxInserts}
</div>
);
};
@@ -0,0 +1,287 @@
/* ── FX Insert Panel (per-channel) ── */
.fx-insert-panel {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
border-top: 1px solid var(--border, #2a2a4a);
padding-top: 4px;
margin-top: 2px;
}
.fx-insert-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.fx-insert-panel-title {
font-size: 0.5rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim, #9e9e9e);
}
/* ── Add button ── */
.fx-insert-add-btn {
background: transparent;
border: 1px solid var(--border, #2a2a4a);
color: var(--text-dim, #9e9e9e);
font-size: 0.5rem;
padding: 1px 6px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s;
line-height: 1.4;
}
.fx-insert-add-btn:hover {
border-color: var(--accent, #e94560);
color: var(--accent, #e94560);
}
.fx-insert-add-btn.ghost {
width: 100%;
border-style: dashed;
border-color: var(--border, #2a2a4a);
opacity: 0.6;
padding: 4px 8px;
font-size: 0.55rem;
}
.fx-insert-add-btn.ghost:hover {
opacity: 1;
border-color: var(--accent, #e94560);
color: var(--accent, #e94560);
}
/* ── Insert Slots ── */
.fx-insert-slots {
display: flex;
flex-direction: column;
gap: 3px;
}
/* ── Individual Insert Slot ── */
.fx-insert-slot {
display: flex;
flex-direction: column;
gap: 2px;
padding: 3px 4px;
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
border-left: 2px solid var(--fx-accent, #666);
transition: opacity 0.2s;
}
.fx-insert-slot.bypassed {
opacity: 0.45;
border-left-color: var(--border, #2a2a4a);
}
/* ── Insert header row ── */
.fx-insert-header {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
/* Bypass toggle button */
.fx-insert-bypass-btn {
flex-shrink: 0;
width: 24px;
height: 14px;
border-radius: 2px;
border: 1px solid var(--border, #2a2a4a);
background: var(--bg, #1a1a2e);
color: var(--text-dim, #9e9e9e);
font-size: 0.45rem;
font-weight: 700;
text-align: center;
line-height: 14px;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.fx-insert-bypass-btn.active {
background: var(--success, #4caf50);
color: #fff;
border-color: var(--success, #4caf50);
}
.fx-insert-bypass-btn:hover {
opacity: 0.85;
}
/* Plugin name */
.fx-insert-name {
flex: 1;
font-size: 0.5rem;
color: var(--text, #eaeaea);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* Remove button */
.fx-insert-remove {
flex-shrink: 0;
width: 14px;
height: 14px;
border: none;
background: transparent;
color: var(--text-dim, #9e9e9e);
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
padding: 0;
border-radius: 2px;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.fx-insert-remove:hover {
background: var(--error, #f44336);
color: #fff;
}
/* ── Wet/Dry Mix ── */
.fx-insert-wetdry {
display: flex;
align-items: center;
gap: 3px;
width: 100%;
}
.fx-insert-wetdry-label {
font-size: 0.45rem;
color: var(--text-dim, #9e9e9e);
flex-shrink: 0;
min-width: 16px;
}
.fx-insert-wetdry-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: var(--border, #2a2a4a);
border-radius: 2px;
outline: none;
cursor: pointer;
min-width: 0;
}
.fx-insert-wetdry-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--fx-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.fx-insert-wetdry-slider::-moz-range-thumb {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--fx-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.fx-insert-wetdry-readout {
font-size: 0.45rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
min-width: 22px;
text-align: right;
flex-shrink: 0;
}
/* ── Empty state ── */
.fx-insert-empty {
display: flex;
width: 100%;
}
/* ── Add form ── */
.fx-insert-add-form {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.fx-insert-plugin-select {
width: 100%;
padding: 2px 4px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.5rem;
cursor: pointer;
outline: none;
}
.fx-insert-plugin-select option {
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
}
.fx-insert-plugin-info {
font-size: 0.45rem;
color: var(--text-dim, #9e9e9e);
}
.fx-insert-add-actions {
display: flex;
gap: 4px;
}
.fx-insert-confirm-btn {
flex: 1;
padding: 2px 6px;
background: var(--accent, #e94560);
color: #fff;
border: none;
border-radius: 3px;
font-size: 0.5rem;
cursor: pointer;
font-weight: 600;
}
.fx-insert-confirm-btn:disabled {
opacity: 0.4;
cursor: default;
}
.fx-insert-cancel-btn {
flex: 1;
padding: 2px 6px;
background: transparent;
color: var(--text-dim, #9e9e9e);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.5rem;
cursor: pointer;
}
@@ -0,0 +1,201 @@
/**
* FxInsertPanel manages all FX inserts for a single mixer channel.
*
* Provides:
* - List of active insert slots (drawn by FxInsertSlot)
* - "Add Insert" button that opens a plugin selector
* - Add/remove/reorder of PiPedal plugins as channel inserts
*/
import React, { useState, useCallback, useMemo } from 'react';
import FxInsertSlot from './FxInsertSlot';
import type { FxInsert, AvailablePlugin } from '../../hooks/useFxInserts';
import './FxInsertPanel.css';
interface FxInsertPanelProps {
channelIndex: number;
inserts: FxInsert[];
availablePlugins: AvailablePlugin[];
accentColor: string;
onCreateInsert: (insert: {
channel_index: number;
plugin_instance_id: number;
plugin_name?: string;
slot_order?: number;
bypassed?: boolean;
wet_dry_mix?: number;
}) => Promise<FxInsert | null>;
onUpdateInsert: (
id: string,
update: Partial<FxInsert>,
) => Promise<FxInsert | null>;
onDeleteInsert: (id: string) => Promise<boolean>;
onToggleBypass: (id: string, currentBypassed: boolean) => void;
}
const FxInsertPanel: React.FC<FxInsertPanelProps> = ({
channelIndex,
inserts,
availablePlugins,
accentColor,
onCreateInsert,
onUpdateInsert,
onDeleteInsert,
onToggleBypass,
}) => {
const [adding, setAdding] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<string>('');
// Sorted inserts by slot_order
const sortedInserts = useMemo(
() => [...inserts].sort((a, b) => a.slot_order - b.slot_order),
[inserts],
);
const handleWetDryChange = useCallback(
(id: string, wetDry: number) => {
onUpdateInsert(id, { wet_dry_mix: wetDry } as Partial<FxInsert>);
},
[onUpdateInsert],
);
const handleRemove = useCallback(
(id: string) => {
onDeleteInsert(id);
},
[onDeleteInsert],
);
const handleAddClick = useCallback(() => {
setAdding(true);
setSelectedPlugin('');
}, []);
const handleCancelAdd = useCallback(() => {
setAdding(false);
setSelectedPlugin('');
}, []);
const handlePluginSelect = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedPlugin(e.target.value);
},
[],
);
const handleConfirmAdd = useCallback(async () => {
if (!selectedPlugin) return;
// Find the selected plugin to get its details
const plugin = availablePlugins.find((p) => p.uri === selectedPlugin);
if (!plugin) return;
const nextSlot = sortedInserts.length;
await onCreateInsert({
channel_index: channelIndex,
plugin_instance_id: plugin.instanceId ?? -(Date.now() % 10000),
plugin_name: plugin.name || plugin.uri.split('/').pop() || 'FX',
slot_order: nextSlot,
bypassed: false,
wet_dry_mix: 1.0,
});
setAdding(false);
setSelectedPlugin('');
}, [
selectedPlugin,
availablePlugins,
sortedInserts.length,
channelIndex,
onCreateInsert,
]);
const selectedPluginInfo = useMemo(
() => availablePlugins.find((p) => p.uri === selectedPlugin),
[availablePlugins, selectedPlugin],
);
return (
<div className="fx-insert-panel">
<div className="fx-insert-panel-header">
<span className="fx-insert-panel-title">FX Inserts</span>
{!adding && sortedInserts.length > 0 && (
<button
className="fx-insert-add-btn"
onClick={handleAddClick}
title="Add FX insert"
>
+ Add
</button>
)}
</div>
{/* Insert slots */}
{sortedInserts.length > 0 && (
<div className="fx-insert-slots">
{sortedInserts.map((ins) => (
<FxInsertSlot
key={ins.id}
insert={ins}
accentColor={accentColor}
onToggleBypass={onToggleBypass}
onWetDryChange={handleWetDryChange}
onRemove={handleRemove}
/>
))}
</div>
)}
{/* Empty state */}
{sortedInserts.length === 0 && !adding && (
<div className="fx-insert-empty">
<button className="fx-insert-add-btn ghost" onClick={handleAddClick}>
+ Add FX Insert
</button>
</div>
)}
{/* Add insert form */}
{adding && (
<div className="fx-insert-add-form">
<select
className="fx-insert-plugin-select"
value={selectedPlugin}
onChange={handlePluginSelect}
>
<option value="">-- Select Plugin --</option>
{availablePlugins.map((p) => (
<option key={p.uri} value={p.uri}>
{p.name || p.uri.split('/').pop() || p.uri}
{p.brand ? ` (${p.brand})` : ''}
</option>
))}
</select>
{selectedPluginInfo && (
<div className="fx-insert-plugin-info">
I/O: {selectedPluginInfo.audioInputs}in /{' '}
{selectedPluginInfo.audioOutputs}out
</div>
)}
<div className="fx-insert-add-actions">
<button
className="fx-insert-confirm-btn"
disabled={!selectedPlugin}
onClick={handleConfirmAdd}
>
Add
</button>
<button className="fx-insert-cancel-btn" onClick={handleCancelAdd}>
Cancel
</button>
</div>
</div>
)}
</div>
);
};
export { FxInsertPanel };
export default FxInsertPanel;
@@ -0,0 +1,98 @@
/**
* FxInsertSlot a single FX insert on a channel strip.
*
* Shows:
* - Plugin name
* - Bypass toggle button
* - Wet/dry mix slider
* - Remove button
*/
import React, { useCallback } from 'react';
import type { FxInsert } from '../../hooks/useFxInserts';
import './FxInsertPanel.css';
interface FxInsertSlotProps {
insert: FxInsert;
accentColor: string;
onToggleBypass: (id: string, currentBypassed: boolean) => void;
onWetDryChange: (id: string, wetDry: number) => void;
onRemove: (id: string) => void;
}
const FxInsertSlot: React.FC<FxInsertSlotProps> = ({
insert,
accentColor,
onToggleBypass,
onWetDryChange,
onRemove,
}) => {
const handleBypass = useCallback(() => {
onToggleBypass(insert.id, insert.bypassed);
}, [insert.id, insert.bypassed, onToggleBypass]);
const handleWetDry = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onWetDryChange(insert.id, parseFloat(e.target.value));
},
[insert.id, onWetDryChange],
);
const handleRemove = useCallback(() => {
onRemove(insert.id);
}, [insert.id, onRemove]);
const wetDryPercent = Math.round(insert.wet_dry_mix * 100);
return (
<div
className={`fx-insert-slot ${insert.bypassed ? 'bypassed' : ''}`}
style={
{
'--fx-accent': accentColor,
} as React.CSSProperties
}
>
{/* Header: plugin name + bypass + remove */}
<div className="fx-insert-header">
<span
className={`fx-insert-bypass-btn ${insert.bypassed ? 'active' : ''}`}
onClick={handleBypass}
title={insert.bypassed ? 'Enable insert' : 'Bypass insert'}
>
{insert.bypassed ? 'OFF' : 'ON'}
</span>
<span className="fx-insert-name" title={insert.plugin_name}>
{insert.plugin_name}
</span>
<button
className="fx-insert-remove"
onClick={handleRemove}
title="Remove insert"
>
×
</button>
</div>
{/* Wet/dry mix slider */}
{!insert.bypassed && (
<div className="fx-insert-wetdry">
<label className="fx-insert-wetdry-label">Mix</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={insert.wet_dry_mix}
onChange={handleWetDry}
className="fx-insert-wetdry-slider"
title={`Wet/Dry: ${wetDryPercent}%`}
/>
<span className="fx-insert-wetdry-readout">{wetDryPercent}%</span>
</div>
)}
</div>
);
};
export { FxInsertSlot };
export default FxInsertSlot;
+86 -12
View File
@@ -4,22 +4,23 @@ import type { SceneState } from '../../hooks/useScenes';
import { useMixerControls } from '../../hooks/useMixerControls';
import { useScenes } from '../../hooks/useScenes';
import { useJackPorts } from '../../hooks/useJackPorts';
import { useFxInserts } from '../../hooks/useFxInserts';
import { PatchBay } from '../PatchBay/PatchBay';
import { ChannelStrip } from './ChannelStrip';
import ChannelStrip, { CHANNEL_TYPE_COLORS } from './ChannelStrip';
import type { ChannelType } from './ChannelStrip';
import { BusStrip } from './BusStrip';
import { MasterBus } from './MasterBus';
import { SceneManager } from '../Scenes/SceneManager';
import { ConnectionStatus } from '../ConnectionStatus';
import { FxInsertPanel } from './FxInsertPanel';
import type { ConnectionState } from '../../hooks/usePiPedalWS';
import './MixerPage.css';
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';
import './FxInsertPanel.css';
import '../PatchBay/PatchBay.css';
interface Props {
plugins: PluginInstance[];
@@ -67,6 +68,16 @@ export const MixerPage: React.FC<Props> = ({
const [showPiPedal, setShowPiPedal] = useState(false);
const [showPatchBay, setShowPatchBay] = useState(false);
// ── FX Inserts ──────────────────────────────────────────────
const {
insertsByChannel,
availablePlugins,
createInsert,
updateInsert,
deleteInsert,
toggleBypass,
} = useFxInserts();
// PiPedal iframe URL — use the Pi's IP since the browser accesses this from another machine
const pipedalUrl = `http://192.168.0.245:8080/`;
@@ -124,6 +135,29 @@ export const MixerPage: React.FC<Props> = ({
};
}, [plugins, channels, buses]);
// ── Channel Strip data mapping (PiPedal → daemon types) ─────
/**
* Map PiPedal instrument value (0-4) to daemon ChannelType.
* Temporary bridge until the daemon protocol replaces PiPedal.
*/
const instrumentToType: Record<number, ChannelType> = {
0: 'instrument', // Guitar
1: 'instrument', // Bass
2: 'line', // Keys
3: 'mic', // Vocals
4: 'line', // Backing
};
const typeToInstrument: Record<ChannelType, number> = {
instrument: 0,
mic: 3,
line: 2,
auxReturn: 4,
};
// ── User interactions ───────────────────────────────────────
const toggleScenePanel = useCallback(() => {
setScenePanelOpen((prev) => !prev);
}, []);
@@ -217,13 +251,53 @@ export const MixerPage: React.FC<Props> = ({
<span className="mixer-count">{channels.length}</span>
</div>
<div className="mixer-channels-grid">
{channels.map((plugin) => (
<ChannelStrip
key={plugin.instanceId}
plugin={plugin}
onSetControl={setControlValue}
/>
))}
{channels.map((plugin, index) => {
const cv = plugin.controlValues;
const instrumentVal = Math.round(cv.instrument ?? 0) || 0;
return (
<ChannelStrip
key={plugin.instanceId}
instanceId={plugin.instanceId}
channelIndex={index}
label={plugin.title || plugin.pluginName || `Ch ${index + 1}`}
volume={cv.volume ?? 0}
pan={cv.pan ?? 0}
mute={(cv.mute ?? 0) >= 0.5}
solo={(cv.solo ?? 0) >= 0.5}
channelType={instrumentToType[instrumentVal] ?? 'instrument'}
hpEnabled={false}
hpFrequency={80}
levelL={cv.levelL ?? -60}
levelR={cv.levelR ?? -60}
onChangeVolume={(id, db) => setControlValue(id, 'volume', db)}
onChangePan={(id, p) => setControlValue(id, 'pan', p)}
onToggleMute={(id, m) => setControlValue(id, 'mute', m ? 1 : 0)}
onToggleSolo={(id, s) => setControlValue(id, 'solo', s ? 1 : 0)}
onChangeChannelType={(id, type) => {
setControlValue(id, 'instrument', typeToInstrument[type] ?? 0);
}}
onToggleHp={() => {}}
onChangeHpFrequency={() => {}}
onChangeLabel={() => {}}
fxInserts={
<FxInsertPanel
channelIndex={index}
inserts={insertsByChannel[index] || []}
availablePlugins={availablePlugins}
accentColor={
CHANNEL_TYPE_COLORS[
instrumentToType[instrumentVal] ?? 'instrument'
] ?? '#666'
}
onCreateInsert={createInsert}
onUpdateInsert={updateInsert}
onDeleteInsert={deleteInsert}
onToggleBypass={toggleBypass}
/>
}
/>
);
})}
{/* Add channel button */}
<div className="mixer-add-channel">
<button className="mixer-add-btn" title="Add channel (future)">
@@ -0,0 +1,99 @@
/* RotaryKnob
*
* SVG rotary control inspired by PiPedal's ZoomedDial.
* All sizing is driven by the component's `size` prop (CSS px on the
* root); the SVG viewBox is a fixed -24..24 coordinate space.
*
* Theming via CSS custom properties:
* --knob-accent: control accent colour (defaults to var(--accent))
* --knob-bg: knob body fill (defaults to surface tone)
* --knob-track: track arc colour (defaults to border tone)
*/
.rotary-knob {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.rotary-knob-svg {
display: block;
cursor: grab;
border-radius: 50%;
transition: filter 0.15s;
outline: none;
}
.rotary-knob-svg:hover {
filter: brightness(1.15);
}
.rotary-knob-svg:focus-visible {
outline: 2px solid var(--knob-accent, var(--accent, #e94560));
outline-offset: 2px;
}
.rotary-knob-svg.dragging {
cursor: grabbing;
filter: brightness(1.25);
}
/* ── SVG elements ─────────────────────────────────────────────── */
.rotary-knob-track {
stroke: var(--knob-track, var(--border, #2a2a4a));
opacity: 0.5;
}
.rotary-knob-fill {
stroke: var(--knob-accent, var(--accent, #e94560));
filter: drop-shadow(0 0 2px var(--knob-accent, var(--accent, #e94560)));
transition: d 0.08s ease-out;
}
.rotary-knob-body {
stroke: var(--knob-track, var(--border, #2a2a4a));
stroke-width: 0.5;
fill: var(--knob-bg, #1e2a4a);
}
.rotary-knob-needle {
stroke: var(--knob-accent, var(--accent, #e94560));
stroke-width: 2;
stroke-linecap: round;
transition: transform 0.08s ease-out;
}
.rotary-knob-centre {
fill: var(--knob-accent, var(--accent, #e94560));
opacity: 0.8;
}
/* ── Label & readout ──────────────────────────────────────────── */
.rotary-knob-label {
font-size: 0.5rem;
font-weight: 600;
color: var(--text-dim, #9e9e9e);
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.rotary-knob-readout {
font-size: 0.55rem;
font-family: 'Cooper Hewitt', 'SF Mono', 'Fira Code', monospace;
color: var(--text, #eaeaea);
text-align: center;
line-height: 1;
white-space: nowrap;
}
@@ -0,0 +1,320 @@
/**
* RotaryKnob SVG rotary control following the PiPedal ZoomedDial pattern.
*
* Interaction model:
* - Pointer-drag: vertical motion maps to value (up = increase)
* - Hold Shift for fine adjustment (10% sensitivity)
* - Double-click resets to default value
* - The rotating needle is the primary value indicator (PiPedal-style)
*
* Angle range: -135° (min) to +135° (max) 270° sweep from 7:30 to 4:30
* via 12:00, matching the ZoomedDial convention.
*/
import React, { useCallback, useRef, useState } from 'react';
import './RotaryKnob.css';
// ── Constants ─────────────────────────────────────────────────────
const MIN_ANGLE = -135;
const MAX_ANGLE = 135;
const ANGLE_RANGE = MAX_ANGLE - MIN_ANGLE;
const FINE_SCALE = 0.1;
const R = 24; // viewport radius (viewBox = -24..24)
const SIZE = R * 2;
const TRACK_R = 20;
const NEEDLE_LEN = 16;
const CENTRE_R = 2.5;
// ── Types ─────────────────────────────────────────────────────────
export interface RotaryKnobProps {
/** Current value. */
value: number;
/** Value at -135° position. */
min: number;
/** Value at +135° position. */
max: number;
/** Snap increment. 0 = continuous. */
step?: number;
/** SVG size in CSS px (default: 48). */
size?: number;
/** Label shown below the knob. */
label?: string;
/** Readout string (e.g. "+2.3 dB"). */
readout?: string;
/** Reset value on double-click (default: min). */
defaultValue?: number;
/** Called during drag with intermediate value. */
onChanging?: (value: number) => void;
/** Committed on drag-end or step. */
onChange: (value: number) => void;
/** Double-click handler. */
onDoubleClick?: () => void;
/** CSS accent colour. */
accentColor?: string;
}
// ── Value / Angle helpers ────────────────────────────────────────
function snap(v: number, mn: number, mx: number, step: number): number {
if (step <= 0) return v;
const clamped = Math.max(mn, Math.min(mx, v));
return Math.max(mn, Math.min(mx, mn + Math.round((clamped - mn) / step) * step));
}
function valToAngle(v: number, mn: number, mx: number): number {
const t = (Math.max(mn, Math.min(mx, v)) - mn) / (mx - mn);
return MIN_ANGLE + t * ANGLE_RANGE;
}
function angleToVal(a: number, mn: number, mx: number): number {
const t = (Math.max(MIN_ANGLE, Math.min(MAX_ANGLE, a)) - MIN_ANGLE) / ANGLE_RANGE;
return mn + t * (mx - mn);
}
/**
* Compute a "rotation from 12-o'clock" angle from pointer coordinates.
* Returns NaN when pointer is inside the centre dead zone.
*/
function pointerAngle(clientX: number, clientY: number, svg: SVGSVGElement): number {
const rc = svg.getBoundingClientRect();
const dx = clientX - (rc.left + rc.width / 2);
const dy = clientY - (rc.top + rc.height / 2);
if (dx * dx + dy * dy < 25) return NaN;
// atan2 gives angle from +x axis; add 90° so 0° = 12 o'clock, positive = CW
const deg = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
return ((deg % 360) + 360) % 360;
}
// ── Component ────────────────────────────────────────────────────
const RotaryKnob: React.FC<RotaryKnobProps> = ({
value,
min,
max,
step = 0,
size = 48,
label,
readout,
defaultValue,
onChanging,
onChange,
onDoubleClick,
accentColor,
}) => {
const svgRef = useRef<SVGSVGElement>(null);
// Mutable drag state (avoids re-render on every move frame)
const drag = useRef({
active: false,
pointerId: -1,
lastAngle: 0,
dragValue: value,
pointerType: '',
});
const [dragging, setDragging] = useState(false);
const currentAngle = valToAngle(value, min, max);
// ── Pointer handlers ─────────────────────────────────────────
const onDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
const svg = svgRef.current;
if (!svg) return;
e.preventDefault();
svg.setPointerCapture(e.pointerId);
const a = pointerAngle(e.clientX, e.clientY, svg);
drag.current = {
active: true,
pointerId: e.pointerId,
lastAngle: isNaN(a) ? 0 : a,
dragValue: value,
pointerType: e.pointerType,
};
setDragging(true);
},
[value]
);
const onMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!drag.current.active || drag.current.pointerId !== e.pointerId) return;
const svg = svgRef.current;
if (!svg) return;
e.preventDefault();
const a = pointerAngle(e.clientX, e.clientY, svg);
if (isNaN(a)) return;
let dAngle = a - drag.current.lastAngle;
while (dAngle > 180) dAngle -= 360;
while (dAngle < -180) dAngle += 360;
const scale = e.shiftKey ? FINE_SCALE : 1.0;
const nAngle = Math.max(MIN_ANGLE, Math.min(MAX_ANGLE, valToAngle(drag.current.dragValue, min, max) + dAngle * scale));
let nVal = angleToVal(nAngle, min, max);
if (step > 0) nVal = snap(nVal, min, max, step);
drag.current.dragValue = nVal;
drag.current.lastAngle = a;
if (onChanging) onChanging(nVal);
onChange(nVal);
},
[min, max, step, onChanging, onChange]
);
const onUp = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!drag.current.active || drag.current.pointerId !== e.pointerId) return;
const svg = svgRef.current;
if (!svg) return;
svg.releasePointerCapture(e.pointerId);
drag.current.active = false;
setDragging(false);
},
[]
);
const onLostCapture = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (drag.current.pointerId === e.pointerId) {
drag.current.active = false;
setDragging(false);
}
},
[]
);
const onDblClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const dflt = defaultValue ?? min;
onChange(dflt);
if (onDoubleClick) onDoubleClick();
},
[defaultValue, min, onChange, onDoubleClick]
);
// ── SVG arc helper ───────────────────────────────────────────
/**
* Compute SVG arc path from our angle convention (0°=12-o'clock, clockwise+).
* Sweep-flag = 1 means increasing angle in SVG-y-down space, which visually
* sweeps from start-angle CW to end-angle.
*/
function arc(cx: number, cy: number, r: number, d0: number, d1: number): string {
const rad = (d: number) => (d * Math.PI) / 180;
const x1 = cx + r * Math.sin(rad(d0));
const y1 = cy - r * Math.cos(rad(d0));
const x2 = cx + r * Math.sin(rad(d1));
const y2 = cy - r * Math.cos(rad(d1));
const large = ((d1 - d0 + 720) % 360) > 180 ? 1 : 0;
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;
}
// Track arc: from MIN_ANGLE to MAX_ANGLE going clockwise (through top)
const trackStart = MIN_ANGLE;
const trackEnd = MAX_ANGLE;
// Fill arc: from MIN_ANGLE to current angle
const fillStart = MIN_ANGLE;
const fillEnd = currentAngle;
return (
<div
className="rotary-knob"
style={{
'--knob-accent': accentColor || 'var(--accent, #e94560)',
width: size,
height: size,
} as React.CSSProperties}
>
<svg
ref={svgRef}
className={`rotary-knob-svg ${dragging ? 'dragging' : ''}`}
viewBox={`${-R} ${-R} ${SIZE} ${SIZE}`}
width={size}
height={size}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onLostPointerCapture={onLostCapture}
onDoubleClick={onDblClick}
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-label={label || 'Knob'}
tabIndex={0}
>
<defs>
<radialGradient id="knob-body" cx="40%" cy="35%" r="60%">
<stop offset="0%" stopColor="rgba(255,255,255,0.15)" />
<stop offset="60%" stopColor="rgba(255,255,255,0.04)" />
<stop offset="100%" stopColor="rgba(0,0,0,0.35)" />
</radialGradient>
</defs>
{/* Track arc (full 270° range) */}
<path
className="rotary-knob-track"
d={arc(0, 0, TRACK_R, trackStart, trackEnd)}
fill="none"
strokeWidth={3}
/>
{/* Fill arc (current value) */}
<path
className="rotary-knob-fill"
d={arc(0, 0, TRACK_R, fillStart, fillEnd)}
fill="none"
strokeWidth={3}
strokeLinecap="round"
/>
{/* Knob body */}
<circle
className="rotary-knob-body"
cx={0}
cy={0}
r={TRACK_R - 5}
fill="url(#knob-body)"
/>
{/* Indicator needle */}
<line
className="rotary-knob-needle"
x1={0}
y1={0}
x2={0}
y2={-NEEDLE_LEN}
transform={`rotate(${currentAngle})`}
/>
{/* Centre dot */}
<circle
className="rotary-knob-centre"
cx={0}
cy={0}
r={CENTRE_R}
/>
</svg>
{label && <span className="rotary-knob-label">{label}</span>}
{readout !== undefined && (
<span className="rotary-knob-readout">{readout}</span>
)}
</div>
);
};
export default RotaryKnob;
+224
View File
@@ -0,0 +1,224 @@
import { useState, useCallback, useEffect, useRef } from 'react';
// ── Types ──────────────────────────────────────────────────────────
export interface FxInsert {
id: string;
channel_index: number;
plugin_instance_id: number;
plugin_name: string;
slot_order: number;
bypassed: boolean;
wet_dry_mix: number;
can_route?: boolean;
}
export interface AvailablePlugin {
uri: string;
name: string;
brand: string;
audioInputs: number;
audioOutputs: number;
instanceId: number | null;
}
export interface UseFxInsertsReturn {
insertsByChannel: Record<number, FxInsert[]>;
availablePlugins: AvailablePlugin[];
loadingAvailable: boolean;
loadingInserts: boolean;
error: string | null;
fetchInserts: (channelIndex?: number) => Promise<void>;
fetchAvailablePlugins: () => Promise<void>;
createInsert: (insert: {
channel_index: number;
plugin_instance_id: number;
plugin_name?: string;
slot_order?: number;
bypassed?: boolean;
wet_dry_mix?: number;
}) => Promise<FxInsert | null>;
updateInsert: (
id: string,
update: Partial<FxInsert>,
) => Promise<FxInsert | null>;
deleteInsert: (id: string) => Promise<boolean>;
toggleBypass: (id: string, currentBypassed: boolean) => Promise<void>;
}
/**
* Hook for managing PiPedal FX inserts via the REST API.
*/
export function useFxInserts(): UseFxInsertsReturn {
const [inserts, setInserts] = useState<FxInsert[]>([]);
const [availablePlugins, setAvailablePlugins] = useState<AvailablePlugin[]>(
[],
);
const [loadingInserts, setLoadingInserts] = useState(false);
const [loadingAvailable, setLoadingAvailable] = useState(false);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
// Build a channel→inserts map
const insertsByChannel = useCallback((): Record<number, FxInsert[]> => {
const map: Record<number, FxInsert[]> = {};
for (const ins of inserts) {
const ch = ins.channel_index;
if (!map[ch]) map[ch] = [];
map[ch].push(ins);
}
// Sort each channel's inserts by slot_order
for (const ch of Object.keys(map)) {
map[Number(ch)].sort((a, b) => a.slot_order - b.slot_order);
}
return map;
}, [inserts]);
// Fetch inserts from the API
const fetchInserts = useCallback(async (channelIndex?: number) => {
setLoadingInserts(true);
setError(null);
try {
const url =
channelIndex !== undefined
? `/api/fx/inserts?channel_index=${channelIndex}`
: '/api/fx/inserts';
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (mountedRef.current) setInserts(data.inserts || []);
} catch (e) {
if (mountedRef.current) setError(`Failed to fetch inserts: ${String(e)}`);
} finally {
if (mountedRef.current) setLoadingInserts(false);
}
}, []);
// Fetch available PiPedal plugins for use as inserts
const fetchAvailablePlugins = useCallback(async () => {
setLoadingAvailable(true);
try {
const res = await fetch('/api/fx/available-plugins');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (mountedRef.current) setAvailablePlugins(data.plugins || []);
} catch (e) {
if (mountedRef.current)
setError(`Failed to fetch available plugins: ${String(e)}`);
} finally {
if (mountedRef.current) setLoadingAvailable(false);
}
}, []);
// Create a new FX insert
const createInsert = useCallback(
async (insert: {
channel_index: number;
plugin_instance_id: number;
plugin_name?: string;
slot_order?: number;
bypassed?: boolean;
wet_dry_mix?: number;
}): Promise<FxInsert | null> => {
try {
const res = await fetch('/api/fx/inserts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(insert),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const newInsert = data.insert as FxInsert;
if (mountedRef.current)
setInserts((prev) => [...prev, newInsert]);
return newInsert;
} catch (e) {
if (mountedRef.current)
setError(`Failed to create insert: ${String(e)}`);
return null;
}
},
[],
);
// Update an existing insert
const updateInsert = useCallback(
async (
id: string,
update: Partial<FxInsert>,
): Promise<FxInsert | null> => {
try {
const res = await fetch(`/api/fx/inserts/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const updated = data.insert as FxInsert;
if (mountedRef.current)
setInserts((prev) => prev.map((i) => (i.id === id ? updated : i)));
return updated;
} catch (e) {
if (mountedRef.current)
setError(`Failed to update insert: ${String(e)}`);
return null;
}
},
[],
);
// Delete an insert
const deleteInsert = useCallback(
async (id: string): Promise<boolean> => {
try {
const res = await fetch(`/api/fx/inserts/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (mountedRef.current)
setInserts((prev) => prev.filter((i) => i.id !== id));
return true;
} catch (e) {
if (mountedRef.current)
setError(`Failed to delete insert: ${String(e)}`);
return false;
}
},
[],
);
// Toggle bypass
const toggleBypass = useCallback(
async (id: string, currentBypassed: boolean) => {
await updateInsert(id, { bypassed: !currentBypassed } as Partial<FxInsert>);
},
[updateInsert],
);
// Auto-fetch inserts and available plugins on mount
useEffect(() => {
fetchInserts();
fetchAvailablePlugins();
}, [fetchInserts, fetchAvailablePlugins]);
return {
insertsByChannel: insertsByChannel(),
availablePlugins,
loadingAvailable,
loadingInserts,
error,
fetchInserts,
fetchAvailablePlugins,
createInsert,
updateInsert,
deleteInsert,
toggleBypass,
};
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ConnectionStatus.tsx","./src/components/PluginList.tsx","./src/components/MixerPage/BusStrip.tsx","./src/components/MixerPage/ChannelStrip.tsx","./src/components/MixerPage/FxInsertPanel.tsx","./src/components/MixerPage/FxInsertSlot.tsx","./src/components/MixerPage/InstrumentBadge.tsx","./src/components/MixerPage/LevelMeter.tsx","./src/components/MixerPage/MasterBus.tsx","./src/components/MixerPage/MixerPage.tsx","./src/components/PatchBay/PatchBay.tsx","./src/components/RotaryKnob/RotaryKnob.tsx","./src/components/Scenes/SceneManager.tsx","./src/hooks/useFxInserts.ts","./src/hooks/useJackPorts.ts","./src/hooks/useMixerControls.ts","./src/hooks/usePiPedalWS.ts","./src/hooks/useScenes.ts"],"version":"5.9.3"}