P4: Production hardening — reconnect, auto-scene-load, service cleanup
This commit is contained in:
+1
File diff suppressed because one or more lines are too long
-42
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+42
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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 (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 >= 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 (0–100).
|
||||
*/
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user