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