Compare commits

..
11 Commits
Author SHA1 Message Date
LucasDLTG d91bcaa20f feat: add demo mode 2026-08-11 22:02:46 +02:00
LucasDLTG b572e1c794 feat: start in tab 2026-08-10 17:58:55 +02:00
LucasDLTG 0f66ed72f4 feat: custom input formatting + custom step 2026-08-10 15:29:18 +02:00
LucasDLTG ee3f56fcdf feat: remove useless api file 2026-08-07 16:02:39 +02:00
LucasDLTG fbcaaf31aa feat: remove useless api file 2026-08-06 21:14:41 +02:00
LucasDLTG 470b95ace3 feat: add delay tab 2026-08-06 20:51:38 +02:00
LucasDLTG e802067056 feat: add limiter 2026-08-06 16:15:46 +02:00
LucasDLTG 56f7aca174 fix: warnings 2026-08-06 14:13:37 +02:00
LucasDLTG 78ea04453e feat: add compressor + fix ui for all sections 2026-08-06 14:13:04 +02:00
LucasDLTG e110a28f80 fix: warnings 2026-07-31 13:25:38 +02:00
LucasDLTG daf2bf32aa feat: gate sectioon 2026-07-31 13:24:24 +02:00
51 changed files with 9260 additions and 1851 deletions
+3
View File
@@ -5,3 +5,6 @@ This template should help get you started developing with Tauri, React and Types
## Recommended IDE Setup ## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) - [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
# TODO
- Move meters polling logic outside dsp state because it causes re-render of everything tied to dsp constantly
+318 -1
View File
@@ -1,6 +1,7 @@
use dsp_thomann::dsp408::types::DelayUnit;
use tauri::State; use tauri::State;
use crate::state::{AppState, DSPDevice}; use crate::state::{AppState, DSPDevice};
use dsp_thomann::dsp408::DSP408; use dsp_thomann::dsp408::{DSP408, types};
use std::{net::IpAddr, str::FromStr}; use std::{net::IpAddr, str::FromStr};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -247,3 +248,319 @@ pub async fn set_current_preset_name(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
} }
#[tauri::command]
pub async fn set_gate(
state: State<'_, AppState>,
id: u64,
channel: types::InputChannel,
threshold: f32,
attack: u16,
hold: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_gate(channel, threshold, attack, hold, release)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_compressor(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
ratio: types::Ratio,
attack: u16,
release: u16,
knee: u16,
threshold: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_compressor(
channel,
ratio,
attack,
release,
knee,
threshold,
)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_limiter(
state: State<'_, AppState>,
id: u64,
channel: types::OutputChannel,
threshold: f32,
attack: u16,
release: u16,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_limiter(channel, threshold, attack, release)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_delay_unit(
state: State<'_, AppState>,
id: u64,
unit: types::DelayUnit,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_delay_unit(unit)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_delay(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
delay: f32,
unit: DelayUnit
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_delay(channel, delay, unit)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_matrix_routing(
state: State<'_, AppState>,
id: u64,
output: types::OutputChannel,
inputs: Vec<types::InputChannel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_routing(output, inputs)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_matrix_gain(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
output_channel: types::OutputChannel,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_matrix_gain(input_channel, output_channel, gain)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_geq_band(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
frequency_band: types::DiscreteFrequency,
gain: f32,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_band(input_channel, frequency_band, gain)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_geq_bypass(
state: State<'_, AppState>,
id: u64,
input_channel: types::InputChannel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_geq_bypass(input_channel, bypass)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_low_pass(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
freq: f32,
filter_type: types::CrossoverFilter,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_low_pass(channel, freq, filter_type)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_high_pass(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
freq: f32,
filter_type: types::CrossoverFilter,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_high_pass(channel, freq, filter_type)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_peq_band(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
band: types::PEQBand,
gain_db: f32,
freq_hz: f32,
q: f32,
filter_type: types::PEQFilter,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_peq_band(
channel,
band,
gain_db,
freq_hz,
q,
filter_type,
bypass,
)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn store_current_configuration(
state: State<'_, AppState>,
id: u64,
preset_index: usize,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.store_current_configuration(preset_index, force)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn factory_reset(
state: State<'_, AppState>,
id: u64,
force: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.factory_reset(force)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn copy_channel(
state: State<'_, AppState>,
id: u64,
channel_1: types::Channel,
channel_2: types::Channel,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.copy_channel(channel_1, channel_2)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_input_source(
state: State<'_, AppState>,
id: u64,
input_source: types::InputSource,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_input_source(input_source)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn link_channels(
state: State<'_, AppState>,
id: u64,
source: types::Channel,
destinations: Vec<types::Channel>,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.link_channels(source, destinations)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_bypass_peq(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
bypass: bool,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_bypass_peq(channel, bypass)
.map_err(|e| e.to_string())
})
}
#[tauri::command]
pub async fn set_channel_name(
state: State<'_, AppState>,
id: u64,
channel: types::Channel,
name: String,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
device
.dsp
.set_channel_name(channel, &name)
.map_err(|e| e.to_string())
})
}
+31 -8
View File
@@ -10,16 +10,39 @@ pub fn run() {
.manage(AppState::new()) .manage(AppState::new())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::create_dsp408, commands::create_dsp408,
commands::connect_dsp408,
commands::remove_dsp408, commands::remove_dsp408,
commands::disconnect_dsp408,
commands::get_dsp_state, commands::get_dsp_state,
commands::set_channel_gain,
commands::set_mute, commands::connect_dsp408, // 0x10
commands::set_channel_inverse_gain, commands::disconnect_dsp408, // 0x11
commands::recall_preset, commands::set_delay_unit, // 0x15
commands::get_meter_levels,
commands::set_current_preset_name, commands::recall_preset, // 0x20
commands::store_current_configuration, // 0x21
commands::factory_reset, // 0x23
commands::set_current_preset_name, // 0x26
commands::copy_channel, // 0x2A
commands::set_compressor, // 0x30
commands::set_low_pass, // 0x31
commands::set_high_pass, // 0x32
commands::set_peq_band, // 0x33
commands::set_channel_gain, // 0x34
commands::set_mute, // 0x35
commands::set_channel_inverse_gain, // 0x36
commands::set_delay, // 0x38
commands::set_input_source, // 0x39
commands::set_matrix_routing, // 0x3A
commands::link_channels, // 0x3B
commands::set_bypass_peq, // 0x3C
commands::set_channel_name, // 0x3D
commands::set_gate, // 0x3E
commands::set_limiter, // 0x3F
commands::get_meter_levels, // 0x40
commands::set_matrix_gain, // 0x41
commands::set_geq_band, // 0x48
commands::set_geq_bypass, // 0x49
] ]
) )
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+1 -1
View File
@@ -15,7 +15,7 @@
"title": "thomann-dsp-app", "title": "thomann-dsp-app",
"width": 1200, "width": 1200,
"height": 800, "height": 800,
"minWidth": 900, "minWidth": 1200,
"minHeight": 600 "minHeight": 600
} }
], ],
+30 -17
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import './App.css'; import './App.css';
import { DSP, AppPage } from './types/types'; import { DSP, AppPage } from './types/types';
import TopBar from './components/Topbar/Topbar'; import TopBar from './components/Topbar/Topbar';
@@ -8,17 +8,33 @@ import { useNotifications } from './hooks/useNotifications';
import Notifications from './components/Notifications/Notifications'; import Notifications from './components/Notifications/Notifications';
import CreditsPage from './pages/CreditsPage/CreditsPage'; import CreditsPage from './pages/CreditsPage/CreditsPage';
import HomePage from './pages/HomePage/HomePage'; import HomePage from './pages/HomePage/HomePage';
import { useDSP408 } from './hooks/useDSP408'; import { addDSP, createDSPInstance } from './dsp/dspApi';
import { removeDSP } from './dsp/dspShared';
import { stopAllPolling } from './dsp/pollRegistry';
function App() { function App() {
const [dsps, setDsps] = useState<DSP[]>([]); const [dsps, setDsps] = useState<DSP[]>([]);
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const [page, setPage] = useState<AppPage>('home'); const [page, setPage] = useState<AppPage>('home');
const { notifications, notify, removeNotification, hoverNotification } = useNotifications(); const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
const activeDsp = dsps.find((d) => d.id === selected) ?? null;
const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length; const connectedDspCount = dsps.filter((dsp) => dsp.status === 'connected').length;
const dsp408 = useDSP408(dsps, setDsps, selected, setSelected, setPage, notify); const activeDsp = dsps.find((d) => d.id === selected) ?? null;
const activeInstance = activeDsp ? createDSPInstance(activeDsp, setDsps, notify) : null;
const dspsRef = useRef(dsps);
dspsRef.current = dsps;
useEffect(() => {
return () => stopAllPolling(dspsRef.current);
}, []);
useEffect(() => {
if (activeDsp == null) return;
const instance = createDSPInstance(activeDsp, setDsps, notify);
instance.api.setActive(true);
return () => instance.api.setActive(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDsp?.id]);
return ( return (
<div className="app"> <div className="app">
@@ -34,29 +50,26 @@ function App() {
page={page} page={page}
setPage={setPage} setPage={setPage}
setSelect={setSelected} setSelect={setSelected}
onSelect={(id) => { onSelect={(id) => {
setSelected(id); setSelected(id);
setPage('view-dsp'); setPage('view-dsp');
}} }}
onRemove={(dsp) => removeDSP(setDsps, selected, setSelected, dsp.id, dsp.type)}
onRemove={dsp408.removeDSP} onAddClick={() => setPage('add-dsp')}
onConnect={(dsp) => createDSPInstance(dsp, setDsps, notify).api.connect()}
onAddClick={() => { onDisconnect={(dsp) => createDSPInstance(dsp, setDsps, notify).api.disconnect()}
setPage('add-dsp');
}}
onConnect={dsp408.connectDSP}
onDisconnect={dsp408.disconnectDSP}
/> />
<main className="stage"> <main className="stage">
{page === 'credits' ? ( {page === 'credits' ? (
<CreditsPage /> <CreditsPage />
) : page === 'add-dsp' ? ( ) : page === 'add-dsp' ? (
<AddDSPForm onCancel={() => setPage('home')} onAdd={dsp408.addDSP} /> <AddDSPForm
) : activeDsp ? ( onCancel={() => setPage('home')}
<DSPPage dsp={activeDsp} dsp408={dsp408.forDSP(activeDsp)} /> onAdd={(params) => addDSP(setDsps, setSelected, setPage, notify, params)}
/>
) : activeInstance ? (
<DSPPage instance={activeInstance} />
) : ( ) : (
<HomePage <HomePage
onAddDSP={() => setPage('add-dsp')} onAddDSP={() => setPage('add-dsp')}
-31
View File
@@ -1,31 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { DSPState, Meters } from '../types/dsp408State';
export const createDSP408 = (ip: string, port: number, deviceId: number) =>
invoke<number>('create_dsp408', {
ip,
port,
deviceId,
});
export const removeDSP408 = (id: number) => invoke('remove_dsp408', { id });
export const connectDSP408 = (id: number) => invoke('connect_dsp408', { id });
export const disconnectDSP408 = (id: number) => invoke('disconnect_dsp408', { id });
export const getDSP408State = (id: number) => invoke<DSPState>('get_dsp_state', { id });
export const recallPresetDSP408 = (id: number, presetIndex: number) =>
invoke<boolean>('recall_preset', {
id,
presetIndex,
});
export const DSP408SetCurrentPresetName = (id: number, name: string) =>
invoke<boolean>('set_current_preset_name', {
id,
name,
});
export const getDSP408MeterLevels = (id: number) => invoke<Meters>('get_meter_levels', { id });
+20
View File
@@ -64,3 +64,23 @@ export function KnobGainIcon(props: React.SVGProps<SVGSVGElement>) {
</svg> </svg>
); );
} }
export function TextIcon({
children,
...props
}: React.SVGProps<SVGSVGElement> & { children: React.ReactNode }) {
return (
<svg viewBox="0 0 24 24" {...props}>
<text
x="12"
y="12"
textAnchor="middle"
dominantBaseline="middle"
fontSize="9.5"
fill="currentColor"
>
{children}
</text>
</svg>
);
}
+27
View File
@@ -0,0 +1,27 @@
export function TriangleUpIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 3L13 10H3L8 3Z"
stroke="currentColor"
strokeWidth="1.5"
fill="transparent"
strokeLinejoin="round"
/>
</svg>
);
}
export function TriangleDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8 13L3 6H13L8 13Z"
stroke="currentColor"
strokeWidth="1.5"
fill="transparent"
strokeLinejoin="round"
/>
</svg>
);
}
+11
View File
@@ -45,6 +45,12 @@
} }
/* Active toggle states */ /* Active toggle states */
.btn-toggle.active-info {
background: var(--accent-brand);
border-color: var(--accent-brand);
color: var(--bg-void);
}
.btn-toggle.active-danger { .btn-toggle.active-danger {
background: var(--accent-down); background: var(--accent-down);
border-color: var(--accent-down); border-color: var(--accent-down);
@@ -58,6 +64,11 @@
} }
/* Optional: active state hover */ /* Optional: active state hover */
.btn-toggle.active-info:hover:not(:disabled) {
background: var(--accent-brand);
border-color: var(--accent-brand);
}
.btn-toggle.active-danger:hover:not(:disabled) { .btn-toggle.active-danger:hover:not(:disabled) {
background: var(--accent-down); background: var(--accent-down);
border-color: var(--accent-down); border-color: var(--accent-down);
+464
View File
@@ -0,0 +1,464 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
export interface LinearGraphHelpers {
toPx: (x: number, y: number) => [number, number];
toPxX: (x: number) => number;
toPxY: (y: number) => number;
toData: (px: number, py: number) => [number, number];
plot: { x: number; y: number; width: number; height: number };
ctx: CanvasRenderingContext2D;
}
export type DrawFn = (helpers: LinearGraphHelpers) => void;
export interface LinearGraphHandle {
redraw: () => void;
getCanvas: () => HTMLCanvasElement | null;
}
export type AxisScale = 'linear' | 'log';
export interface LinearGraphProps {
xMin: number;
xMax: number;
xStep?: number;
yMin: number;
yMax: number;
yStep?: number;
/** 'log' requires xMin > 0. Ticks are drawn at 1/2/5-per-decade mantissas, with decade (power-of-10) lines highlighted. */
xScale?: AxisScale;
/** 'log' requires yMin > 0. */
yScale?: AxisScale;
draw?: DrawFn;
width?: number;
height?: number;
padding?: { top?: number; right?: number; bottom?: number; left?: number };
showGrid?: boolean;
showAxes?: boolean;
showTicks?: boolean;
xLabel?: string;
yLabel?: string;
formatX?: (v: number) => string;
formatY?: (v: number) => string;
className?: string;
style?: React.CSSProperties;
}
const defaultFormat = (v: number) => {
if (Math.abs(v) < 1e-9) return '0';
const abs = Math.abs(v);
if (abs >= 1000 || abs < 0.01) return v.toExponential(1);
return parseFloat(v.toFixed(4)).toString();
};
function niceStep(min: number, max: number, targetTicks = 8): number {
const range = Math.abs(max - min) || 1;
const raw = range / targetTicks;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10;
return step * mag;
}
function range(min: number, max: number, step: number): number[] {
if (step <= 0) return [];
const out: number[] = [];
const start = Math.ceil((min - 1e-9) / step) * step;
for (let v = start; v <= max + 1e-9; v += step) {
out.push(Math.round(v / step) * step);
}
return out;
}
interface Tick {
value: number;
/** Decade lines (powers of 10) get major=true for a bolder/brighter grid line. */
major: boolean;
}
// Linear ticks: every step is equally weighted, so mark every Nth (default 5th) as "major"
// purely for label emphasis — grid highlighting logic only really matters for log mode.
function linearTicks(min: number, max: number, step: number): Tick[] {
return range(min, max, step).map((value) => ({ value, major: false }));
}
// Log ticks: standard 1/2/3/4/5/6/7/8/9 mantissas within each decade between min and max.
// Mantissa === 1 (i.e. the decade itself: ...0.1, 1, 10, 100, 1000...) is flagged major
// so it can be drawn as a highlighted line distinct from the minor in-decade ticks.
function logTicks(min: number, max: number): Tick[] {
if (min <= 0 || max <= 0 || max <= min) return [];
const startDecade = Math.floor(Math.log10(min));
const endDecade = Math.ceil(Math.log10(max));
const out: Tick[] = [];
for (let d = startDecade; d <= endDecade; d++) {
const decadeValue = Math.pow(10, d);
for (const mantissa of [1, 2, 3, 4, 5, 6, 7, 8, 9]) {
const value = decadeValue * mantissa;
if (value < min - 1e-9 || value > max + 1e-9) continue;
out.push({ value, major: mantissa === 1 });
}
}
return out;
}
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
{
xMin,
xMax,
xStep,
yMin,
yMax,
yStep,
xScale = 'linear',
yScale = 'linear',
draw,
width,
height,
padding,
showGrid = true,
showAxes = true,
showTicks = true,
xLabel,
yLabel,
formatX = defaultFormat,
formatY = defaultFormat,
style,
},
ref
) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
// "container query" units, computed from the actual rendered size —
// 1 cqw = 1% of width, 1 cqh = 1% of height, cqmin = smaller of the two.
const cq = useMemo(() => {
const w = size.width || 1;
const h = size.height || 1;
return { w: w / 100, h: h / 100, min: Math.min(w, h) / 100 };
}, [size.width, size.height]);
const pad = useMemo(() => {
const topBase = cq.h * 3;
const rightBase = cq.w * 5;
const bottomBase = cq.h * 4;
const leftBase = cq.w * 2;
const xLabelSpace = xLabel ? cq.h * 7 : 0;
const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0;
return {
top: padding?.top ?? topBase,
right: padding?.right ?? rightBase,
bottom: padding?.bottom ?? bottomBase + xLabelSpace,
left: padding?.left ?? leftBase + yLabelSpace,
};
}, [padding, xLabel, yLabel, cq]);
// font / stroke / tick metrics, all derived from container size
const metrics = useMemo(
() => ({
tickFont: cq.min * 3,
labelFont: cq.min * 4,
tickLen: cq.min * 1.2,
axisLineWidth: cq.min * 0.4,
yLabelOffset: cq.w * 2,
}),
[cq]
);
const effXStep = xStep ?? niceStep(xMin, xMax);
const effYStep = yStep ?? niceStep(yMin, yMax);
const xLogValid = xScale === 'log' && xMin > 0 && xMax > xMin;
const yLogValid = yScale === 'log' && yMin > 0 && yMax > yMin;
const xTicks = useMemo(
() => (xLogValid ? logTicks(xMin, xMax) : linearTicks(xMin, xMax, effXStep)),
[xLogValid, xMin, xMax, effXStep]
);
const yTicks = useMemo(
() => (yLogValid ? logTicks(yMin, yMax) : linearTicks(yMin, yMax, effYStep)),
[yLogValid, yMin, yMax, effYStep]
);
useEffect(() => {
if (width && height) return;
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width: w, height: h } = entry.contentRect;
if (w > 0 && h > 0) {
setSize({ width: width ?? w, height: height ?? h });
}
});
ro.observe(el);
return () => ro.disconnect();
}, [width, height]);
const paint = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = size.width;
const cssH = size.height;
if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) {
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const plot = {
x: pad.left + cq.w * 2,
y: pad.top,
width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2),
height: Math.max(1, cssH - pad.top - pad.bottom),
};
const logXMin = xLogValid ? Math.log10(xMin) : 0;
const logXMax = xLogValid ? Math.log10(xMax) : 0;
const logYMin = yLogValid ? Math.log10(yMin) : 0;
const logYMax = yLogValid ? Math.log10(yMax) : 0;
const toPxX = (x: number) => {
if (xLogValid) {
const lx = Math.log10(Math.max(x, 1e-9));
return plot.x + ((lx - logXMin) / (logXMax - logXMin || 1)) * plot.width;
}
return plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
};
const toPxY = (y: number) => {
if (yLogValid) {
const ly = Math.log10(Math.max(y, 1e-9));
return plot.y + plot.height - ((ly - logYMin) / (logYMax - logYMin || 1)) * plot.height;
}
return plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height;
};
const toPx = (x: number, y: number): [number, number] => [toPxX(x), toPxY(y)];
const toData = (px: number, py: number): [number, number] => {
const xRatio = (px - plot.x) / plot.width;
const yRatio = 1 - (py - plot.y) / plot.height;
const x = xLogValid
? Math.pow(10, logXMin + xRatio * (logXMax - logXMin))
: xMin + xRatio * (xMax - xMin);
const y = yLogValid
? Math.pow(10, logYMin + yRatio * (logYMax - logYMin))
: yMin + yRatio * (yMax - yMin);
return [x, y];
};
const css = getComputedStyle(canvas);
const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a';
const gridMajorColor = css.getPropertyValue('--graph-grid-major').trim() || '#4b5563';
const axisColor = css.getPropertyValue('--graph-axis').trim() || '#6b7280';
const textColor = css.getPropertyValue('--graph-text').trim() || '#9ca3af';
const bgColor = css.getPropertyValue('--graph-bg').trim();
if (bgColor) {
ctx.fillStyle = bgColor;
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
}
const clipPad = 2;
ctx.save();
ctx.beginPath();
ctx.rect(
plot.x - clipPad,
plot.y - clipPad,
plot.width + clipPad * 2,
plot.height + clipPad * 2
);
ctx.clip();
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
if (showGrid) {
// minor grid lines first (thin), then major/decade lines drawn on top so
// they aren't obscured and read clearly as the "multiples of 10" markers.
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (const t of xTicks) {
if (t.major) continue;
const px = Math.round(toPxX(t.value)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const t of yTicks) {
if (t.major) continue;
const py = Math.round(toPxY(t.value)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
const majorX = xTicks.filter((t) => t.major);
const majorY = yTicks.filter((t) => t.major);
if (majorX.length || majorY.length) {
ctx.strokeStyle = gridMajorColor;
ctx.lineWidth = Math.max(1, metrics.axisLineWidth * 0.6);
ctx.beginPath();
for (const t of majorX) {
const px = Math.round(toPxX(t.value)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const t of majorY) {
const py = Math.round(toPxY(t.value)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
}
}
if (showAxes) {
ctx.strokeStyle = axisColor;
ctx.lineWidth = metrics.axisLineWidth;
ctx.beginPath();
if (!yLogValid && yMin <= 0 && yMax >= 0) {
const py = Math.round(toPxY(0)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
if (!xLogValid && xMin <= 0 && xMax >= 0) {
const px = Math.round(toPxX(0)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
ctx.stroke();
}
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
ctx.restore();
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
if (showTicks) {
ctx.font = `${metrics.tickFont}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (const t of xTicks) {
// In log mode, only label the decade (major) ticks — labeling every
// 2,3,4...9 mantissa gets unreadably dense.
if (xLogValid && !t.major) continue;
const px = toPxX(t.value);
ctx.beginPath();
ctx.moveTo(px, plot.y + plot.height);
ctx.lineTo(px, plot.y + plot.height + metrics.tickLen);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillStyle = t.major && xLogValid ? textColor : textColor;
ctx.fillText(formatX(t.value), px, plot.y + plot.height + metrics.tickLen + 2);
}
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const t of yTicks) {
if (yLogValid && !t.major) continue;
const py = toPxY(t.value);
ctx.beginPath();
ctx.moveTo(plot.x - metrics.tickLen, py);
ctx.lineTo(plot.x, py);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillStyle = textColor;
ctx.fillText(formatY(t.value), plot.x - metrics.tickLen - 2, py);
}
}
if (xLabel) {
ctx.fillStyle = textColor;
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4);
}
if (yLabel) {
ctx.save();
ctx.fillStyle = textColor;
ctx.font = `${metrics.labelFont}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.translate(metrics.yLabelOffset, plot.y + plot.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(yLabel, 0, 0);
ctx.restore();
}
}, [
size,
pad,
metrics,
xMin,
xMax,
xLogValid,
yMin,
yMax,
yLogValid,
xTicks,
yTicks,
showGrid,
showAxes,
showTicks,
xLabel,
yLabel,
formatX,
formatY,
draw,
]);
useEffect(() => {
paint();
}, [paint]);
useImperativeHandle(ref, () => ({
redraw: paint,
getCanvas: () => canvasRef.current,
}));
return (
<div
ref={containerRef}
className={'container'}
style={{
width: width ?? '100%',
height: height ?? '100%',
...style,
}}
>
<canvas
ref={canvasRef}
className={'canvas'}
style={{ width: size.width, height: size.height }}
/>
</div>
);
});
export default LinearGraph;
+111
View File
@@ -0,0 +1,111 @@
.meter-bar {
display: flex;
flex-direction: column;
align-items: left;
gap: 0.3cqh;
}
.meter-bar__title {
font-size: 1.5cqh;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
white-space: nowrap;
text-align: left;
justify-content: left;
}
.meter-bar__body {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.3cqh;
flex: 1 1 auto;
}
.meter-bar__leds-wrap {
position: relative;
width: 3cqh;
flex: 1 1 auto;
}
/* All rows — the 2 indicator LEDs and every level segment — are direct
siblings in this one flex column with equal flex weight, so they're
guaranteed the exact same height. No row gets a hardcoded pixel height. */
.meter-bar__leds {
display: flex;
flex-direction: column;
gap: 0.5cqh;
width: 100%;
height: 100%;
}
.meter-bar__led {
flex: 1 1 0;
min-height: 0;
width: 100%;
border-radius: 0.5cqh;
box-sizing: border-box;
transition: background-color 60ms linear;
}
.meter-bar__led--indicator {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
/* Peak-hold marker: a thin line overlaid on the ladder at the peak's position,
independent of which LEDs are lit by rms. */
.meter-bar__peak-marker {
position: absolute;
left: -0.5cqh;
right: -0.5cqh;
height: 0.3cqh;
min-height: 1px;
background-color: #e5e7eb;
box-shadow: 0 0 3px rgba(255, 255, 255, 0.8);
transform: translateY(0.25cqh);
pointer-events: none;
}
/* Scale sits in the same row as leds-wrap, same height, so ticks positioned
by percentage line up with the exact dB position on the ladder. */
.meter-bar__scale {
position: relative;
width: 3.5cqh;
font-size: 1.5cqh;
color: rgba(255, 255, 255, 0.4);
}
.meter-bar__scale-tick {
position: absolute;
left: 0;
transform: translateY(50%);
white-space: nowrap;
}
.meter-bar__scale-tick--end {
color: rgba(255, 255, 255, 0.55);
}
.meter-bar__value {
padding-top: 0.5cqh;
font-size: 1.2cqh;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.7);
}
.meter-bar__indicator-row {
display: flex;
align-items: center;
gap: 0.1cqh;
flex: 1 1 0;
min-height: 0;
font-size: 1.5cqh;
color: rgba(255, 255, 255, 0.45);
white-space: nowrap;
}
.meter-bar__indicator-row .meter-bar__led {
flex: 0 0 auto;
width: 3cqh;
height: 100%;
}
+165
View File
@@ -0,0 +1,165 @@
import './MeterBar.css';
export interface Meter {
rms: number;
peak: number;
clip?: boolean;
}
type MeterBarProps = {
label: string;
meter: Meter;
min?: number;
max?: number;
segments?: number;
/** Intermediate dB values to mark on the scale, e.g. [0] or [-20, -6, 0]. */
ticks?: number[];
};
function segmentColor(posRatioFromBottom: number) {
if (posRatioFromBottom > 0.9) return '#f87171'; // red — near clip
if (posRatioFromBottom > 0.7) return '#fbbf24'; // yellow — hot
return '#4ade80'; // green — normal
}
// Formats a dB value with an explicit '+' sign for positive values (e.g. +12, -60, 0)
function formatDb(v: number, decimals = 0) {
if (!Number.isFinite(v)) return '—';
const fixed = v.toFixed(decimals);
return v > 0 ? `+${fixed}` : fixed;
}
function MeterBar({
label,
meter,
min = -60,
max = +10,
segments = 7,
ticks = [0, -50, -40, -30, -20, -10],
}: MeterBarProps) {
const { rms, peak, clip } = meter;
// Maps a raw dB value to a 0..1 ratio from the bottom of the ladder, clamped to range.
const ratioOf = (v: number) => {
const clamped = Math.min(max, Math.max(min, v));
return (clamped - min) / (max - min);
};
// --- RMS ladder (drives which LEDs are lit) ---
const rmsRatio = ratioOf(rms);
const litCount = Math.round(rmsRatio * segments);
// --- Peak marker (position only, doesn't affect lit count) ---
const peakRatio = ratioOf(peak);
// --- Intermediate tick marks on the scale, e.g. a 0 dB reference line ---
const visibleTicks = ticks.filter((t) => t > min && t < max);
// --- Red "over" LED: lit whenever rms or peak crosses max ---
const isOverMax = rms >= max || peak >= max;
// --- Orange "limit" LED: lit strictly from the meter's own clip flag.
// clip is optional — some meters simply don't report a limiter/clip state.
const hasLimiter = clip !== undefined;
const isClipping = clip === true;
// The over-led and limit-led are rendered as two extra rows in the very
// same flex column as the level segments (see meter-bar__leds below), so
// they get identical row height automatically instead of a hardcoded one.
// Because of that, the ladder itself only occupies `segments / totalRows`
// of the column — the rest of the math (peak marker, scale ticks) has to
// scale down to match.
const totalRows = segments + 2;
const ladderFraction = segments / totalRows;
const toColumnPercent = (ratio: number) => ratio * ladderFraction * 100;
return (
<div className="meter-bar">
<div className="meter-bar__title">{label}</div>
<div className="meter-bar__body">
<div className="meter-bar__leds-wrap">
<div className="meter-bar__leds">
<div
className="meter-bar__indicator-row"
style={{ visibility: hasLimiter ? 'visible' : 'hidden' }}
>
<div
className="meter-bar__led meter-bar__led--indicator"
style={{
backgroundColor: isClipping ? '#fb923c' : 'rgba(255,255,255,0.08)',
}}
title="Limit"
/>
<span>Limit</span>
</div>
<div className="meter-bar__indicator-row">
<div
className="meter-bar__led meter-bar__led--indicator"
style={{
backgroundColor: isOverMax ? '#f87171' : 'rgba(255,255,255,0.08)',
}}
title="Clip"
/>
<span>Clip</span>
</div>
{Array.from({ length: segments }).map((_, i) => {
// i=0 renders at the top; convert to "position from bottom" for lighting + color
const posFromBottom = segments - 1 - i;
const lit = posFromBottom < litCount;
return (
<div
key={i}
className="meter-bar__led"
style={{
backgroundColor: lit
? segmentColor(posFromBottom / segments)
: 'rgba(255,255,255,0.08)',
}}
/>
);
})}
</div>
<div
className="meter-bar__peak-marker"
style={{ bottom: `${toColumnPercent(peakRatio)}%` }}
title={`Peak: ${formatDb(peak, 1)} dB`}
/>
</div>
<div className="meter-bar__scale">
<span
className="meter-bar__scale-tick meter-bar__scale-tick--end"
style={{ bottom: `${ladderFraction * 100}%` }}
>
{formatDb(max)}
</span>
{visibleTicks.map((t) => (
<span
key={t}
className="meter-bar__scale-tick"
style={{ bottom: `${toColumnPercent(ratioOf(t))}%` }}
>
{formatDb(t)}
</span>
))}
<span
className="meter-bar__scale-tick meter-bar__scale-tick--end"
style={{ bottom: '0%' }}
>
{formatDb(min)}
</span>
</div>
</div>
<div className="meter-bar__value">
{Number.isFinite(rms) ? `${formatDb(rms, 1)} dB` : '—'}
</div>
</div>
);
}
export default MeterBar;
@@ -1,50 +1,60 @@
.gauge-viewport { .gauge-viewport {
container-type: size;
container-name: gauge;
position: relative; position: relative;
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
width: auto; width: 100%;
overflow: hidden;
}
.gauge-container {
--s: min(calc(100cqw / 70), calc(100cqh / 240));
position: absolute;
bottom: 0;
left: 50%;
width: calc(70 * var(--s));
height: calc(240 * var(--s));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
transform: translateX(-50%);
box-sizing: border-box; box-sizing: border-box;
} }
.gauge-track { .gauge-track {
position: relative; position: relative;
flex: 1 1 auto;
width: calc(70 * var(--s)); min-height: 0;
height: calc(210 * var(--s)); width: 100%;
display: flex; display: flex;
align-items: stretch;
justify-content: center; justify-content: center;
gap: 1.2cqh;
padding-bottom: 1.5cqh;
padding-top: 1.2cqh;
}
.gauge-track.no-ticks {
padding-left: calc(2.75cqh);
padding-right: calc(2.75cqh);
}
.ticks {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: calc(8 * var(--s)); pointer-events: none;
}
.ticks span {
width: 2.8cqh;
height: 0.55cqh;
min-height: 1px;
background: var(--border-hairline);
border-radius: 999px;
} }
.slider { .slider {
position: relative; position: relative;
flex: 0 0 auto;
width: calc(24 * var(--s)); width: 6.5cqh;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
@@ -59,56 +69,35 @@
transform: translateX(-50%); transform: translateX(-50%);
width: calc(2 * var(--s)); width: 0.55cqh;
min-width: 1px;
height: 100%; height: 100%;
background: var(--border-hairline); background: var(--border-hairline);
border-radius: var(--radius-sm); border-radius: 999px;
} }
.thumb { .thumb {
position: absolute; position: absolute;
left: 50%; left: 50%;
width: calc(18 * var(--s)); width: 5cqh;
aspect-ratio: 1; aspect-ratio: 1;
transform: translate(-50%, 50%); transform: translate(-50%, 50%);
border-radius: 50%; border-radius: 50%;
background: var(--bg-panel); background: var(--bg-panel);
border: var(--border-width-active) solid var(--accent-brand); border: 0.55cqh solid var(--accent-brand);
box-shadow: var(--shadow-sm); box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
} }
.ticks {
position: absolute;
top: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.ticks span {
width: calc(10 * var(--s));
height: calc(2 * var(--s));
background: var(--border-hairline);
border-radius: var(--radius-sm);
}
.left { left: calc(8 * var(--s)); }
.right { right: calc(8 * var(--s)); }
.value { .value {
flex: 0 0 auto;
width: 100%; width: 100%;
margin-top: calc(4 * var(--s)); margin-top: 0.8cqh;
padding: calc(2 * var(--s)) calc(4 * var(--s)); padding: 0.5cqh 1cqh;
box-sizing: border-box; box-sizing: border-box;
@@ -120,16 +109,21 @@
white-space: nowrap; white-space: nowrap;
color: var(--accent-brand); color: var(--accent-brand);
font-size: var(--font-xxs); font-size: 3.2cqh;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-sm); box-shadow: 0 0.55cqh 1.6cqh rgba(0, 0, 0, 0.2);
border-radius: var(--radius-md); border-radius: 1.6cqh;
overflow: hidden; overflow: hidden;
} }
.value.no-ticks {
padding-left: 0;
padding-right: 0;
}
.value input { .value input {
width: auto; width: auto;
min-width: 0; min-width: 0;
@@ -138,8 +132,8 @@
text-align: center; text-align: center;
background: transparent; background: transparent;
border: var(--border-width) solid var(--border-hairline); border: 0.3cqh solid var(--border-hairline);
border-radius: var(--radius-sm); border-radius: 1.2cqh;
appearance: none; appearance: none;
} }
@@ -1,24 +1,36 @@
import { useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import './VerticalSlider.css'; import './VerticalSlider.css';
type StepFn = (value: number) => number;
type VerticalSliderProps = { type VerticalSliderProps = {
min: number; min: number;
max: number; max: number;
step: number;
switchStepValue?: number; step?: StepFn;
switchStep?: number;
value: number; value: number;
onChange?: (value: number) => Promise<boolean>; onChange?: (value: number) => Promise<boolean>;
onInput?: (value: number) => void;
unit?: string; unit?: string;
formatValue?: (value: number, decimals: number) => string;
showTicks?: boolean;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
}; };
const defaultStep: StepFn = () => 0.1;
function formatValueDefault(v: number, decimals: number) {
return v.toFixed(decimals);
}
const MIN_TICKS = 3;
const MAX_TICK_SPACING_PX = 16;
function getDecimals(n: number) { function getDecimals(n: number) {
const s = n.toString(); const s = n.toString();
const i = s.indexOf('.'); const i = s.indexOf('.');
@@ -28,53 +40,75 @@ function getDecimals(n: number) {
export default function VerticalSlider({ export default function VerticalSlider({
min, min,
max, max,
step, step = defaultStep,
switchStepValue,
switchStep,
value, value,
onChange, onChange,
onInput,
unit = '', unit = '',
formatValue,
showTicks = true,
className, className,
style, style,
}: VerticalSliderProps) { }: VerticalSliderProps) {
const [editingText, setEditingText] = useState<string | null>(null); const [editingText, setEditingText] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const sliderRef = useRef<HTMLDivElement>(null); const sliderRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const keyboardValue = useRef<number | null>(null); const keyboardValue = useRef<number | null>(null);
const [displayValue, setDisplayValue] = useState(value); const [displayValue, setDisplayValue] = useState(value);
const dragValue = useRef(value); const dragValue = useRef(value);
const dragging = useRef(false); const dragging = useRef(false);
const thumbRef = useRef<HTMLDivElement>(null); const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false); const keyboardEditing = useRef(false);
const [tickCount, setTickCount] = useState(MIN_TICKS);
function getStep(value: number) { useEffect(() => {
if (switchStepValue !== undefined && switchStep !== undefined && value >= switchStepValue) { if (dragging.current || keyboardEditing.current || editingText !== null) {
return switchStep; return;
} }
setDisplayValue(value);
dragValue.current = value;
}, [value]);
return step; useEffect(() => {
} const el = trackRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const recompute = () => {
const h = el.clientHeight;
if (!h) return;
const n = Math.max(MIN_TICKS, Math.ceil(h / MAX_TICK_SPACING_PX) + 1);
setTickCount(n);
};
recompute();
const observer = new ResizeObserver(recompute);
observer.observe(el);
return () => observer.disconnect();
}, []);
const decimals = useMemo( const decimals = useMemo(
() => Math.max(getDecimals(step), switchStep !== undefined ? getDecimals(switchStep) : 0), () => Math.min(3, getDecimals(step(displayValue))),
[step, switchStep] [step, displayValue]
); );
const formattedValue = (formatValue ?? formatValueDefault)(displayValue, decimals);
function normalizeValue(input: number) { function normalizeValue(input: number) {
const clamped = Math.min(max, Math.max(min, input)); const clamped = Math.min(max, Math.max(min, input));
const currentStep = step(clamped);
const currentStep = getStep(clamped);
const snapped = min + Math.round((clamped - min) / currentStep) * currentStep; const snapped = min + Math.round((clamped - min) / currentStep) * currentStep;
return Number(Math.min(max, Math.max(min, snapped)).toFixed(6)); return Number(Math.min(max, Math.max(min, snapped)).toFixed(6));
} }
function updateValue(input: number) { function updateValue(input: number) {
const next = normalizeValue(input); const next = normalizeValue(input);
dragValue.current = next; dragValue.current = next;
setDisplayValue(next); setDisplayValue(next);
onInput?.(next);
} }
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
@@ -83,27 +117,17 @@ export default function VerticalSlider({
} }
keyboardEditing.current = true; keyboardEditing.current = true;
e.preventDefault(); e.preventDefault();
const base = keyboardValue.current ?? value; const base = keyboardValue.current ?? value;
const currentStep = step(base);
const currentStep = getStep(base);
let next = base; let next = base;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') next += currentStep;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') next -= currentStep;
next += currentStep;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
next -= currentStep;
}
next = normalizeValue(next); next = normalizeValue(next);
keyboardValue.current = next; keyboardValue.current = next;
updateValue(next); updateValue(next);
} }
@@ -111,7 +135,6 @@ export default function VerticalSlider({
if (keyboardValue.current == null) return; if (keyboardValue.current == null) return;
const next = keyboardValue.current; const next = keyboardValue.current;
const success = await onChange?.(next); const success = await onChange?.(next);
keyboardEditing.current = false; keyboardEditing.current = false;
@@ -120,6 +143,7 @@ export default function VerticalSlider({
if (success === false) { if (success === false) {
setDisplayValue(value); setDisplayValue(value);
dragValue.current = value; dragValue.current = value;
onInput?.(value);
} }
} }
@@ -147,7 +171,6 @@ export default function VerticalSlider({
if (!dragging.current) return; if (!dragging.current) return;
const finalValue = dragValue.current; const finalValue = dragValue.current;
const success = await onChange?.(finalValue); const success = await onChange?.(finalValue);
dragging.current = false; dragging.current = false;
@@ -155,6 +178,7 @@ export default function VerticalSlider({
if (success === false) { if (success === false) {
setDisplayValue(value); setDisplayValue(value);
dragValue.current = value; dragValue.current = value;
onInput?.(value);
} }
if (e.currentTarget.hasPointerCapture(e.pointerId)) { if (e.currentTarget.hasPointerCapture(e.pointerId)) {
@@ -170,12 +194,14 @@ export default function VerticalSlider({
const next = normalizeValue(parsed); const next = normalizeValue(parsed);
setDisplayValue(next); setDisplayValue(next);
onInput?.(next);
const success = await onChange?.(next); const success = await onChange?.(next);
if (success === false) { if (success === false) {
setDisplayValue(value); setDisplayValue(value);
dragValue.current = value; dragValue.current = value;
onInput?.(value);
} }
} }
} }
@@ -186,62 +212,62 @@ export default function VerticalSlider({
const percent = (displayValue - min) / (max - min); const percent = (displayValue - min) / (max - min);
return ( return (
<div <div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}>
className={['gauge-viewport', className].filter(Boolean).join(' ')}
style={style}
>
<div <div
className="gauge-container" className={['gauge-track', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}
ref={trackRef}
> >
<div className="gauge-track"> {showTicks && (
<div className="ticks left"> <div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => ( {Array.from({ length: tickCount }).map((_, i) => (
<span key={i} /> <span key={i} />
))} ))}
</div> </div>
)}
<div <div
className="slider" className="slider"
ref={sliderRef} ref={sliderRef}
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onPointerDown={startDrag} onPointerDown={startDrag}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={endDrag} onPointerUp={endDrag}
onPointerCancel={endDrag} onPointerCancel={endDrag}
> >
<div className="track" /> <div className="track" />
<div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} /> <div ref={thumbRef} className="thumb" style={{ bottom: `${percent * 100}%` }} />
</div> </div>
{showTicks && (
<div className="ticks right"> <div className="ticks right">
{Array.from({ length: 15 }).map((_, i) => ( {Array.from({ length: tickCount }).map((_, i) => (
<span key={i} /> <span key={i} />
))} ))}
</div> </div>
</div> )}
</div>
<div className="value"> <div className={['value', !showTicks && 'no-ticks'].filter(Boolean).join(' ')}>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={editingText ?? displayValue.toFixed(decimals)} value={editingText ?? formattedValue}
style={{ width: '100%' }} style={{ width: '100%' }}
onFocus={() => setEditingText(value.toFixed(decimals))} onFocus={() => setEditingText(value.toFixed(decimals))} // plain number while editing
onChange={(e) => setEditingText(e.target.value)} onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing} onBlur={commitEditing}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') inputRef.current?.blur(); if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') { if (e.key === 'Escape') {
setEditingText(null); setEditingText(null);
inputRef.current?.blur(); inputRef.current?.blur();
} }
}} }}
/> />
<span className="value-unit">{unit}</span> {unit && <span className="value-unit">{unit}</span>}
</div>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,289 @@
.compressor-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.compressor-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.compressor-panel {
position: relative;
aspect-ratio: 0.5;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.compressor-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.compressor-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
grid-template-areas:
'threshold attack'
'steppers release';
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.compressor-panel__cell--threshold {
grid-area: threshold;
}
.compressor-panel__cell--attack {
grid-area: attack;
}
.compressor-panel__cell--release {
grid-area: release;
}
.compressor-panel__cell--steppers {
grid-area: steppers;
}
.compressor-panel__cell--steppers {
flex-direction: column;
justify-content: center;
gap: 1cqh;
}
.compressor-panel__stepper-item {
flex: 1 1 0;
min-height: 0;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1cqh;
}
.compressor-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.compressor-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.choice-stepper {
width: 100%;
flex: 1 1 auto;
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
background: transparent;
}
.choice-stepper__input {
flex: 1 1 auto;
min-width: 0;
width: 100%;
font-family: var(--font-ui);
font-size: 3cqh;
font-weight: var(--font-weight-semibold);
text-align: center;
color: var(--accent-brand);
background: transparent;
border: none;
border-radius: var(--radius-sm);
padding: 0.5ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.choice-stepper__input:focus {
outline: none;
}
.choice-stepper__arrows {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5cqh;
height: 100%;
box-sizing: border-box;
padding: 1cqh;
padding-left: 0;
}
.choice-stepper__arrow-btn {
aspect-ratio: 1;
height: 4.5cqh;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--accent-brand);
background: transparent;
border-radius: 1cqh;
cursor: pointer;
}
.choice-stepper__arrow-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.choice-stepper__arrow-btn svg {
width: 100%;
height: 100%;
}
.compressor-graph__channel {
font-size: var(--font-xs);
color: var(--text-muted);
}
.compressor-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.compressor-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.compressor-meter-panel {
flex: 0 0 auto;
width: fit-content;
min-width: max-content;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.compressor-graph__header {
flex: 0 0 auto;
display: flex;
align-items: baseline;
justify-content: space-between;
font-family: var(--font-ui);
padding: 1cqh;
}
.compressor-graph__title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.compressor-graph__meters {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 1cqh;
padding: 1cqh;
}
.compressor-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -0,0 +1,436 @@
// CompressorSection.tsx
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { OutputChannel, Ratio } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState, useEffect } from 'react';
import { TriangleUpIcon, TriangleDownIcon } from '../../../assets/icons/Triangle';
import './CompressorSection.css';
// Enum order defines stepper order (1:1 -> Limit).
const RATIO_ORDER: Ratio[] = [
Ratio.Ratio1_1,
Ratio.Ratio1_1_1,
Ratio.Ratio1_1_3,
Ratio.Ratio1_1_5,
Ratio.Ratio1_1_7,
Ratio.Ratio1_2,
Ratio.Ratio1_2_5,
Ratio.Ratio1_3,
Ratio.Ratio1_3_5,
Ratio.Ratio1_4,
Ratio.Ratio1_5,
Ratio.Ratio1_6,
Ratio.Ratio1_8,
Ratio.Ratio1_10,
Ratio.Ratio1_20,
Ratio.Limit,
];
const RATIO_LABEL: Record<Ratio, string> = {
[Ratio.Ratio1_1]: '1:1',
[Ratio.Ratio1_1_1]: '1.1:1',
[Ratio.Ratio1_1_3]: '1.3:1',
[Ratio.Ratio1_1_5]: '1.5:1',
[Ratio.Ratio1_1_7]: '1.7:1',
[Ratio.Ratio1_2]: '2:1',
[Ratio.Ratio1_2_5]: '2.5:1',
[Ratio.Ratio1_3]: '3:1',
[Ratio.Ratio1_3_5]: '3.5:1',
[Ratio.Ratio1_4]: '4:1',
[Ratio.Ratio1_5]: '5:1',
[Ratio.Ratio1_6]: '6:1',
[Ratio.Ratio1_8]: '8:1',
[Ratio.Ratio1_10]: '10:1',
[Ratio.Ratio1_20]: '20:1',
[Ratio.Limit]: 'Limit',
};
// Numeric ratio used only for drawing the transfer curve; Limit ~= brickwall.
const RATIO_NUMERIC: Record<Ratio, number> = {
[Ratio.Ratio1_1]: 1,
[Ratio.Ratio1_1_1]: 1.1,
[Ratio.Ratio1_1_3]: 1.3,
[Ratio.Ratio1_1_5]: 1.5,
[Ratio.Ratio1_1_7]: 1.7,
[Ratio.Ratio1_2]: 2,
[Ratio.Ratio1_2_5]: 2.5,
[Ratio.Ratio1_3]: 3,
[Ratio.Ratio1_3_5]: 3.5,
[Ratio.Ratio1_4]: 4,
[Ratio.Ratio1_5]: 5,
[Ratio.Ratio1_6]: 6,
[Ratio.Ratio1_8]: 8,
[Ratio.Ratio1_10]: 10,
[Ratio.Ratio1_20]: 20,
[Ratio.Limit]: 1000,
};
const KNEE_VALUES = Array.from({ length: 13 }, (_, i) => i); // 0..12 dB, integer steps
const COMPRESSOR_SLIDER_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
{ key: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const;
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
// Out1 -> 4, Out2 -> 5, etc. Adjust to match dsp.meters.channels ordering.
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
[OutputChannel.Out1]: 4,
[OutputChannel.Out2]: 5,
[OutputChannel.Out3]: 6,
[OutputChannel.Out4]: 7,
[OutputChannel.Out5]: 8,
[OutputChannel.Out6]: 9,
[OutputChannel.Out7]: 10,
[OutputChannel.Out8]: 11,
} as const;
type CompressorParamKey = 'ratio' | 'attack' | 'release' | 'knee' | 'threshold';
type CompressorValues = {
ratio: Ratio;
attack: number;
release: number;
knee: number;
threshold: number;
};
type SetCompressorFn = (
outputChannel: OutputChannel,
ratio: Ratio,
attack: number,
release: number,
knee: number,
threshold: number
) => Promise<boolean>;
function ChoiceStepper<T>({
value,
values,
formatLabel,
onChange,
}: {
value: T;
values: readonly T[];
formatLabel: (value: T) => string;
onChange: (value: T) => void;
}) {
const index = values.indexOf(value);
const canUp = index === -1 || index < values.length - 1;
const canDown = index > 0;
const [text, setText] = useState(formatLabel(value));
useEffect(() => {
setText(formatLabel(value));
}, [value, formatLabel]);
const step = (dir: 1 | -1) => {
const currentIndex = index === -1 ? 0 : index;
const nextIndex = Math.min(values.length - 1, Math.max(0, currentIndex + dir));
if (nextIndex !== currentIndex) onChange(values[nextIndex]);
};
const commitText = () => {
const match = values.find(
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
);
if (match !== undefined) {
onChange(match);
} else {
setText(formatLabel(value));
}
};
return (
<div className="choice-stepper">
<input
className="choice-stepper__input"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
step(1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
step(-1);
}
}}
/>
<div className="choice-stepper__arrows">
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={() => step(1)}
disabled={!canUp}
>
<TriangleUpIcon />
</Button>
<Button
variant="toggle"
className="choice-stepper__arrow-btn"
onClick={() => step(-1)}
disabled={!canDown}
>
<TriangleDownIcon />
</Button>
</div>
</div>
);
}
function CompressorPanel({
channel,
dsp,
setCompressor,
onLiveValue,
}: {
channel: OutputChannel;
dsp: DSP;
setCompressor: SetCompressorFn;
onLiveValue: (channel: OutputChannel, key: CompressorParamKey, value: number | Ratio) => void;
}) {
const outputState = dsp.state.current_config.output_states[channel];
const comp = outputState.compressor as CompressorValues;
const commit = (overrides: Partial<CompressorValues>) => {
const next = { ...comp, ...overrides };
return setCompressor(channel, next.ratio, next.attack, next.release, next.knee, next.threshold);
};
return (
<div className="compressor-panel">
<div className="compressor-panel__title">{outputState.name}</div>
<div className="compressor-panel__grid">
{COMPRESSOR_SLIDER_PARAMS.map((param) => (
<div
className={`compressor-panel__cell compressor-panel__cell--${param.key}`}
key={param.key}
>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={comp[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<CompressorValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="compressor-panel__cell-label">{param.label}</div>
</div>
))}
<div className="compressor-panel__cell compressor-panel__cell--steppers">
<div className="compressor-panel__stepper-item">
<ChoiceStepper
value={comp.ratio}
values={RATIO_ORDER}
formatLabel={(v) => RATIO_LABEL[v]}
onChange={(value) => {
onLiveValue(channel, 'ratio', value);
commit({ ratio: value });
}}
/>
<div className="compressor-panel__cell-label">Ratio</div>
</div>
<div className="compressor-panel__stepper-item">
<ChoiceStepper
value={comp.knee}
values={KNEE_VALUES}
formatLabel={(v) => `${v} dB`}
onChange={(value) => {
onLiveValue(channel, 'knee', value);
commit({ knee: value });
}}
/>
<div className="compressor-panel__cell-label">Knee</div>
</div>
</div>
</div>
</div>
);
}
// Soft-knee compressor transfer curve, in dB.
function compressorOutputDb(input: number, threshold: number, ratio: number, knee: number): number {
const kneeStart = threshold - knee / 2;
const kneeEnd = threshold + knee / 2;
if (knee <= 0 || input <= kneeStart) {
return input <= threshold ? input : threshold + (input - threshold) / ratio;
}
if (input >= kneeEnd) {
return threshold + (input - threshold) / ratio;
}
const x = input - kneeStart;
return input + ((1 / ratio - 1) * x * x) / (2 * knee);
}
function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor: SetCompressorFn }) {
const outputs = [
OutputChannel.Out1,
OutputChannel.Out2,
OutputChannel.Out3,
OutputChannel.Out4,
OutputChannel.Out5,
OutputChannel.Out6,
OutputChannel.Out7,
OutputChannel.Out8,
];
const [activeChannel, setActiveChannel] = useState<OutputChannel>(outputs[0]);
const [liveComp, setLiveComp] = useState<Partial<Record<OutputChannel, CompressorValues>>>({});
const handleLiveValue = (
channel: OutputChannel,
key: CompressorParamKey,
value: number | Ratio
) => {
setActiveChannel(channel);
setLiveComp((prev) => {
const base =
prev[channel] ??
(dsp.state.current_config.output_states[channel].compressor as CompressorValues);
return { ...prev, [channel]: { ...base, [key]: value } };
});
};
const handleSetCompressor: SetCompressorFn = async (
channel,
ratio,
attack,
release,
knee,
threshold
) => {
setActiveChannel(channel);
const success = await set_compressor(channel, ratio, attack, release, knee, threshold);
if (success) {
setLiveComp((prev) => {
if (!(channel in prev)) return prev;
const next = { ...prev };
delete next[channel];
return next;
});
}
return success;
};
const activeOutputState = dsp.state.current_config.output_states[activeChannel];
const activeComp = liveComp[activeChannel] ?? (activeOutputState.compressor as CompressorValues);
const curvePoints = useMemo(() => {
const { threshold, ratio, knee } = activeComp;
const ratioNumeric = RATIO_NUMERIC[ratio];
const steps = 120;
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const x = -60 + (60 * i) / steps;
points.push([x, compressorOutputDb(x, threshold, ratioNumeric, knee)]);
}
return points;
}, [activeComp.threshold, activeComp.ratio, activeComp.knee]);
return (
<>
<div className="compressor-linear-graph-section">
<div className="compressor-graph-panel">
<div className="compressor-graph__title">{activeOutputState.name} - RMS Compressor</div>
<LinearGraph
xMin={-60}
xMax={0}
yMin={-60}
yMax={0}
xLabel="in dB"
yLabel="out dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => {
// Unity reference line
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-60, -60);
const [ux1, uy1] = toPx(0, 0);
ctx.beginPath();
ctx.moveTo(ux0, uy0);
ctx.lineTo(ux1, uy1);
ctx.stroke();
ctx.setLineDash([]);
// Threshold marker
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeComp.threshold, -60);
const [tx1, ty1] = toPx(activeComp.threshold, 0);
ctx.beginPath();
ctx.moveTo(tx0, ty0);
ctx.lineTo(tx1, ty1);
ctx.stroke();
ctx.setLineDash([]);
// Compression curve
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
curvePoints.forEach(([x, y], i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}}
/>
</div>
<div className="compressor-meter-panel">
<div className="compressor-graph__meters">
{outputs.map((output) => {
const outputState = dsp.state.current_config.output_states[output];
const idx = OUTPUT_METER_INDEX[output];
return (
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
);
})}
</div>
</div>
</div>
<div className="compressor-section">
<div className="compressor-panels">
<ChannelGroup title="Output">
{outputs.map((output) => (
<CompressorPanel
key={String(output)}
channel={output}
dsp={dsp}
setCompressor={handleSetCompressor}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default CompressorSection;
@@ -0,0 +1,117 @@
.delay-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.delay-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.delay-panel {
position: relative;
height: 100%;
aspect-ratio: 0.26;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: 2cqh;
background: var(--bg-void);
padding-bottom: 2cqh;
}
.delay-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.delay-unit-selector {
height: 100%;
width: 20cqh;
display: flex;
flex-direction: column;
gap: 5cqh;
justify-content: center;
box-sizing: border-box;
}
.delay-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.delay-graph-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.delay-graph-title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.delay-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -0,0 +1,368 @@
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel, DelayUnit } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useMemo, useState } from 'react';
import './DelaySection.css';
const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
[DelayUnit.Millisecond]: [0, 680.0],
[DelayUnit.Meter]: [0, 233.58],
[DelayUnit.Feet]: [0, 766.329],
};
const UNIT_LABEL: Record<DelayUnit, string> = {
[DelayUnit.Millisecond]: 'ms',
[DelayUnit.Meter]: 'm',
[DelayUnit.Feet]: 'ft',
};
const UNIT_ORDER: DelayUnit[] = [DelayUnit.Millisecond, DelayUnit.Meter, DelayUnit.Feet];
const DELAY_STEPS = 255;
const DELAY_STEP: Record<DelayUnit, number> = {
[DelayUnit.Millisecond]: (680.0 - 0) / (DELAY_STEPS - 1),
[DelayUnit.Meter]: (233.58 - 0) / (DELAY_STEPS - 1),
[DelayUnit.Feet]: (766.329 - 0) / (DELAY_STEPS - 1),
};
type SetDelayFn = (channel: Channel, delay: number) => Promise<boolean>;
function channelKey(channel: Channel): string {
return 'Input' in channel ? `Input:${channel.Input}` : `Output:${channel.Output}`;
}
function DelayPanel({
channel,
dsp,
setDelay,
onLiveValue,
}: {
channel: Channel;
dsp: DSP;
setDelay: SetDelayFn;
onLiveValue: (channel: Channel, value: number) => void;
}) {
function getChannelState(channel: Channel) {
if ('Input' in channel) {
return dsp.state.current_config.input_states[channel.Input];
} else {
return dsp.state.current_config.output_states[channel.Output];
}
}
const state = getChannelState(channel);
const unit = dsp.state.current_config.delay_unit;
const [min, max] = DELAY_RANGES[unit];
return (
<div className="delay-panel">
<div className="delay-panel__title">{state.name}</div>
<VerticalSlider
min={min}
max={max}
step={() => DELAY_STEP[unit]}
unit={' ' + UNIT_LABEL[unit]}
value={state.delay}
onInput={(value) => onLiveValue(channel, value)}
onChange={(value) => setDelay(channel, value)}
/>
</div>
);
}
function DelayGraph({
title,
rows,
unit,
}: {
title: string;
rows: { label: string; delay: number }[];
unit: DelayUnit;
}) {
const [min, max] = DELAY_RANGES[unit];
const labelByX = useMemo(() => {
const map = new Map<number, string>();
rows.forEach((row, i) => map.set(i + 1, row.label));
return map;
}, [rows]);
return (
<div className="delay-graph-panel">
<div className="delay-graph-title">{title}</div>
<LinearGraph
xMin={0.5}
xMax={rows.length + 0.5}
xStep={1}
yMin={min}
yMax={max}
xLabel="Channel"
yLabel={UNIT_LABEL[unit]}
showGrid={true}
formatX={(v) => labelByX.get(Math.round(v)) ?? ''}
formatY={(v) => v.toFixed(0)}
draw={({ ctx, toPx, plot }) => {
const barWidth = (plot.width / rows.length) * 0.55;
rows.forEach((row, i) => {
const x = i + 1;
const [xPx, baseY] = toPx(x, min);
const [, topY] = toPx(x, row.delay);
ctx.fillStyle = '#4ade80';
ctx.fillRect(xPx - barWidth / 2, topY, barWidth, baseY - topY);
});
}}
/>
</div>
);
}
function DelayUnitSelector({
unit,
onSelect,
}: {
unit: DelayUnit;
onSelect: (unit: DelayUnit) => void;
}) {
return (
<div className="delay-unit-selector">
{UNIT_ORDER.map((u) => (
<Button
key={u}
variant="toggle"
className={u === unit ? 'active-info' : ''}
onClick={() => onSelect(u)}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{UNIT_LABEL[u]}
</Button>
))}
</div>
);
}
function DelaySection({
dsp,
setDelay,
setDelayUnit,
setDSPState,
}: {
dsp: DSP;
setDelay: SetDelayFn;
setDelayUnit: (unit: DelayUnit) => Promise<boolean>;
setDSPState: (updater: (dsp: DSP) => DSP) => void;
}) {
const unit = dsp.state?.current_config.delay_unit ?? DelayUnit.Millisecond;
const inputs: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const outputs: { channel: Channel; title: string }[] = [
{ channel: { Output: OutputChannel.Out1 }, title: 'Out 1' },
{ channel: { Output: OutputChannel.Out2 }, title: 'Out 2' },
{ channel: { Output: OutputChannel.Out3 }, title: 'Out 3' },
{ channel: { Output: OutputChannel.Out4 }, title: 'Out 4' },
{ channel: { Output: OutputChannel.Out5 }, title: 'Out 5' },
{ channel: { Output: OutputChannel.Out6 }, title: 'Out 6' },
{ channel: { Output: OutputChannel.Out7 }, title: 'Out 7' },
{ channel: { Output: OutputChannel.Out8 }, title: 'Out 8' },
];
const [liveDelay, setLiveDelay] = useState<Partial<Record<string, number>>>({});
const handleLiveValue = (channel: Channel, value: number) => {
setLiveDelay((prev) => ({
...prev,
[channelKey(channel)]: value,
}));
};
const handleSetDelay: SetDelayFn = async (channel, delay) => {
const success = await setDelay(channel, delay);
if (success) {
const key = channelKey(channel);
setLiveDelay((prev) => {
if (!(key in prev)) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}
return success;
};
const committedDelay = (channel: Channel): number =>
'Input' in channel
? dsp.state.current_config.input_states[channel.Input].delay
: dsp.state.current_config.output_states[channel.Output].delay;
const getDelay = (channel: Channel): number => {
const key = channelKey(channel);
if (key in liveDelay) {
return liveDelay[key]!;
}
return committedDelay(channel);
};
function convertDelayUnit(value: number, from: DelayUnit, to: DelayUnit): number {
const [, fromMax] = DELAY_RANGES[from];
const [, toMax] = DELAY_RANGES[to];
if (fromMax === 0) return 0;
const ratio = value / fromMax;
return ratio * toMax;
}
const handleUnitChange = async (newUnit: DelayUnit) => {
if (newUnit === unit) return;
const success = await setDelayUnit(newUnit);
if (!success) return;
setDSPState((d) => {
if (!d.state) return d;
const currentConfig = d.state.current_config;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
delay_unit: newUnit,
input_states: {
...currentConfig.input_states,
[InputChannel.InA]: {
...currentConfig.input_states[InputChannel.InA],
delay: convertDelayUnit(
currentConfig.input_states[InputChannel.InA].delay,
unit,
newUnit
),
},
[InputChannel.InB]: {
...currentConfig.input_states[InputChannel.InB],
delay: convertDelayUnit(
currentConfig.input_states[InputChannel.InB].delay,
unit,
newUnit
),
},
[InputChannel.InC]: {
...currentConfig.input_states[InputChannel.InC],
delay: convertDelayUnit(
currentConfig.input_states[InputChannel.InC].delay,
unit,
newUnit
),
},
[InputChannel.InD]: {
...currentConfig.input_states[InputChannel.InD],
delay: convertDelayUnit(
currentConfig.input_states[InputChannel.InD].delay,
unit,
newUnit
),
},
},
output_states: {
...currentConfig.output_states,
...Object.fromEntries(
Object.values(OutputChannel).map((channel) => [
channel,
{
...currentConfig.output_states[channel],
delay: convertDelayUnit(
currentConfig.output_states[channel].delay,
unit,
newUnit
),
},
])
),
},
},
},
};
});
};
const inputRows = inputs.map((channel) => ({
label: dsp.state.current_config.input_states[channel].name,
delay: getDelay({ Input: channel }),
}));
const outputRows = outputs.map(({ channel, title }) => ({
label: title,
delay: getDelay(channel),
}));
return (
<>
<div className="delay-linear-graph-section">
<DelayGraph title="Input Delay" rows={inputRows} unit={unit} />
<DelayGraph title="Output Delay" rows={outputRows} unit={unit} />
</div>
<div className="delay-section">
<div className="delay-panels">
<ChannelGroup title="Input">
{inputs.map((channel) => (
<DelayPanel
key={channel}
channel={{ Input: channel }}
dsp={dsp}
setDelay={handleSetDelay}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<DelayPanel
key={title}
channel={channel}
dsp={dsp}
setDelay={handleSetDelay}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
<ChannelGroup title="Unit">
<DelayUnitSelector unit={unit} onSelect={handleUnitChange} />
</ChannelGroup>
</div>
</div>
</>
);
}
export default DelaySection;
@@ -1,45 +1,6 @@
.gain-panel-viewport {
position: relative;
aspect-ratio: 90 / 400;
overflow: hidden;
box-sizing: border-box;
}
.gain-panel {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: var(--space-2);
background: var(--bg-void);
padding-bottom: var(--space-2);
}
.gain-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
padding-top: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline);
}
/* Container */
.gain-section { .gain-section {
container-type: size; container-type: size;
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
@@ -67,20 +28,78 @@
overflow-x: auto; overflow-x: auto;
} }
/* --- graph strip, sits above the gain section --- .gain-panel {
Requires the shared parent (whatever renders <GainSection />) to be position: relative;
position: relative — it already must be, for .gain-section's absolute height: 100%;
bottom:0 to anchor correctly. */ aspect-ratio: 1 / 4;
.linear-graph-section { flex: 0 0 auto;
position: absolute;
top: 0; display: flex;
left: 0; flex-direction: column;
right: 0; align-items: center;
bottom: var(--dsp-control-heigth); justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: 2cqh;
background: var(--bg-void);
padding-bottom: 2cqh;
}
.gain-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.gain-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box; box-sizing: border-box;
padding: var(--space-4);
background: var(--bg-panel); background: var(--bg-panel);
z-index: 10; /* stays below .gain-section's z-index: 20 */ }
.gain-graph-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.gain-graph-title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.gain-graph-panel > .container {
flex: 1;
min-height: 0;
} }
+105 -91
View File
@@ -1,15 +1,11 @@
import VerticalSlider from '../Slider/VerticalSlider'; import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State'; import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408';
import { DSP } from '../../../types/types'; import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button'; import Button from '../../Button/Button';
import ChannelGroup from '../ChannelGroup/ChannelGroup'; import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../LinearGraph/LinearGraph'; import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useEffect, useRef, useState } from 'react';
import './GainSection.css'; import './GainSection.css';
const PANEL_DESIGN_WIDTH = 90;
const PANEL_DESIGN_HEIGHT = 400;
function GainPanel({ function GainPanel({
dsp, dsp,
channel, channel,
@@ -30,12 +26,9 @@ function GainPanel({
? dsp.state?.current_config.input_states[channel.Input] ? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output]; : dsp.state?.current_config.output_states[channel.Output];
const muted = channelState?.mute ?? false; const muted = channelState.mute;
const inverted = channelState?.phase_inverted ?? false; const inverted = channelState.phase_inverted;
const gain = channelState?.gain ?? 0; const gain = channelState.gain;
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const toggleMute = async () => { const toggleMute = async () => {
await setMute(channel, !muted); await setMute(channel, !muted);
@@ -45,54 +38,51 @@ function GainPanel({
await setInverse(channel, !inverted); await setInverse(channel, !inverted);
}; };
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width === 0 || height === 0) return;
setScale(Math.min(width / PANEL_DESIGN_WIDTH, height / PANEL_DESIGN_HEIGHT));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return ( return (
<div className="gain-panel-viewport" ref={viewportRef}> <div className="gain-panel">
<div <div className="gain-panel__title">{title}</div>
className="gain-panel"
<VerticalSlider
min={-60}
max={12}
step={(v) => (v >= -20 ? 0.1 : 0.5)}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
/>
<Button
variant="toggle"
className={muted ? 'active-danger' : ''}
onClick={toggleMute}
style={{ style={{
width: PANEL_DESIGN_WIDTH, width: '85%',
height: PANEL_DESIGN_HEIGHT, paddingLeft: 0,
transform: `translate(-50%, -50%) scale(${scale})`, paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}} }}
> >
<div className="gain-panel__title">{title}</div> {muted ? 'Muted' : 'Mute '}
</Button>
<VerticalSlider <Button
min={-60} variant="toggle"
max={12} className={inverted ? 'active-warning' : ''}
step={0.5} onClick={toggleInverse}
switchStepValue={-10} style={{
switchStep={0.1} width: '85%',
unit=" dB" paddingLeft: 0,
value={gain} paddingRight: 0,
onChange={(value) => setGain(channel, value)} fontSize: '4cqh',
style={{ width: 70, height: 250, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink paddingTop: '2cqh',
/> paddingBottom: '2cqh',
minHeight: 0,
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute} style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}> }}
{muted ? 'Muted' : 'Mute '} >
</Button> {inverted ? 'Inverted' : 'Invert'}
<Button </Button>
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{ width: '70px', flex: '1 1 auto', minHeight: 0 }}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
</div> </div>
); );
} }
@@ -108,11 +98,11 @@ function GainSection({
setMute: (channel: Channel, muted: boolean) => Promise<boolean>; setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>; setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) { }) {
const inputs: { channel: Channel; title: string }[] = [ const inputs: InputChannel[] = [
{ channel: { Input: InputChannel.InA }, title: 'In A' }, InputChannel.InA,
{ channel: { Input: InputChannel.InB }, title: 'In B' }, InputChannel.InB,
{ channel: { Input: InputChannel.InC }, title: 'In C' }, InputChannel.InC,
{ channel: { Input: InputChannel.InD }, title: 'In D' }, InputChannel.InD,
]; ];
const outputs: { channel: Channel; title: string }[] = [ const outputs: { channel: Channel; title: string }[] = [
@@ -128,40 +118,64 @@ function GainSection({
return ( return (
<> <>
<div className="linear-graph-section"> <div className="gain-linear-graph-section">
<LinearGraph <div className="gain-graph-panel">
xMin={20} <div className="gain-graph-title">Frequency Response</div>
xMax={20000} <LinearGraph
yMin={-24} xMin={10}
yMax={24} xMax={30000}
yStep={6} xScale="log"
xLabel="Hz" yMin={-18}
yLabel="dB" yMax={18}
draw={({ ctx, toPx }) => { yStep={3}
// placeholder flat 0 dB response — swap for real EQ/filter data. xLabel="Hz"
// NOTE: this maps x linearly; for a real frequency response you'll yLabel="dB"
// likely want a log-frequency x-axis instead (see chat notes). draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80'; ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2; ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0); const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0); const [x1, y1] = toPx(20000, 0);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x0, y0); ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1); ctx.lineTo(x1, y1);
ctx.stroke(); ctx.stroke();
}} }}
/> />
</div>
<div className="gain-graph-panel">
<div className="gain-graph-title">Phase Response</div>
<LinearGraph
xMin={20}
xMax={20000}
yMin={-180}
yMax={180}
yStep={45}
xLabel="Hz"
yLabel="deg"
draw={({ ctx, toPx }) => {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
const [x0, y0] = toPx(20, 0);
const [x1, y1] = toPx(20000, 0);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}}
/>
</div>
</div> </div>
<div className="gain-section"> <div className="gain-section">
<div className="gain-panels"> <div className="gain-panels">
<ChannelGroup title="Input"> <ChannelGroup title="Input">
{inputs.map(({ channel, title }) => ( {inputs.map((channel) => (
<GainPanel <GainPanel
key={title} key={channel}
dsp={dsp} dsp={dsp}
channel={channel} channel={{ Input: channel }}
title={title} title={dsp.state.current_config.input_states[channel].name}
setGain={setGain} setGain={setGain}
setMute={setMute} setMute={setMute}
setInverse={setInverse} setInverse={setInverse}
@@ -1,4 +1,159 @@
.rerere { /* GateSection.css */
.gate-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 40%;
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.gate-panels {
height: 100%; height: 100%;
width: 10%; display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.gate-panel {
position: relative;
height: 100%;
aspect-ratio: 1 / 2;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.gate-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.gate-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.gate-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.gate-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.gate-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.gate-graph-panel {
flex: 1 1 auto; /* take remaining space */
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.gate-meter-panel {
flex: 0 0 auto; /* only required width */
min-width: max-content;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.gate-graph-title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.gate-graph-panel > .container {
flex: 1;
min-height: 0;
}
.gate-graph__meters {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: center;
gap: 1cqh;
padding: 1cqh;
} }
+202 -19
View File
@@ -1,31 +1,214 @@
import VerticalSlider from '../Slider/VerticalSlider'; // GateSection.tsx
import { invoke } from '@tauri-apps/api/core'; import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State'; import { InputChannel } from '../../../types/dsp408';
import { DSP } from '../../../types/types'; import { DSP408 as DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types'; import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import ChannelGroup from '../ChannelGroup/ChannelGroup'; import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState } from 'react';
import './GateSection.css'; import './GateSection.css';
function GateSection({ const GATE_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 0, step: 0.5, unit: ' dB' },
{ key: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
{ key: 'hold', label: 'Hold', min: 10, max: 999, step: 1, unit: ' ms' },
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const;
const INPUT_METER_INDEX: Record<InputChannel, number> = {
[InputChannel.InA]: 0,
[InputChannel.InB]: 1,
[InputChannel.InC]: 2,
[InputChannel.InD]: 3,
} as const;
type GateParamKey = (typeof GATE_PARAMS)[number]['key'];
type GateValues = {
threshold: number;
attack: number;
hold: number;
release: number;
};
type SetGateFn = (
inputChannel: InputChannel,
threshold: number,
attack: number,
hold: number,
release: number
) => Promise<boolean>;
function GatePanel({
channel,
dsp, dsp,
setGate,
onLiveValue,
}: { }: {
channel: InputChannel;
dsp: DSP; dsp: DSP;
setGate: SetGateFn;
onLiveValue: (channel: InputChannel, key: GateParamKey, value: number) => void;
}) { }) {
const inputState = dsp.state.current_config.input_states[channel];
const gate = inputState.gate;
return ( return (
<div className="rerere"> <div className="gate-panel">
<VerticalSlider <div className="gate-panel__title">{inputState.name}</div>
min={-60}
max={12} <div className="gate-panel__grid">
step={0.5} {GATE_PARAMS.map((param) => (
switchStepValue={-10} <div className="gate-panel__cell" key={param.key}>
switchStep={0.1} <VerticalSlider
unit=" dB" min={param.min}
value={0} max={param.max}
style={{ width: 70, height: 250, flex: '0 0 auto' }} step={() => param.step}
/> unit={param.unit}
value={gate[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => {
return setGate(
channel,
param.key === 'threshold' ? value : gate.threshold,
param.key === 'attack' ? value : gate.attack,
param.key === 'hold' ? value : gate.hold,
param.key === 'release' ? value : gate.release
);
}}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="gate-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div> </div>
); );
} }
function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
const inputs = [InputChannel.InA, InputChannel.InB, InputChannel.InC, InputChannel.InD];
const [activeChannel, setActiveChannel] = useState<InputChannel>(inputs[0]);
const [liveGate, setLiveGate] = useState<Partial<Record<InputChannel, GateValues>>>({});
const handleLiveValue = (channel: InputChannel, key: GateParamKey, value: number) => {
setActiveChannel(channel);
setLiveGate((prev) => {
const base = prev[channel] ?? dsp.state.current_config.input_states[channel].gate;
return { ...prev, [channel]: { ...base, [key]: value } };
});
};
const handleSetGate: SetGateFn = async (channel, threshold, attack, hold, release) => {
setActiveChannel(channel);
const success = await set_gate(channel, threshold, attack, hold, release);
if (success) {
setLiveGate((prev) => {
if (!(channel in prev)) return prev;
const next = { ...prev };
delete next[channel];
return next;
});
}
return success;
};
const activeInputState = dsp.state.current_config.input_states[activeChannel];
const activeGate = liveGate[activeChannel] ?? activeInputState.gate;
const envelope = useMemo(() => {
const { threshold, attack, hold, release } = activeGate;
const roll = Math.max(20, (attack + hold + release) * 0.08);
const t0 = 0;
const t1 = t0 + roll;
const t2 = t1 + attack;
const t3 = t2 + hold;
const t4 = t3 + release;
const t5 = t4 + roll;
return { threshold, t0, t1, t2, t3, t4, t5 };
}, [activeGate.threshold, activeGate.attack, activeGate.hold, activeGate.release]);
return (
<>
<div className="gate-linear-graph-section">
<div className="gate-graph-panel">
<div className="gate-graph-title">Frequency Response</div>
<LinearGraph
xMin={envelope.t0}
xMax={envelope.t5}
yMin={-90}
yMax={0}
xLabel="ms"
yLabel="dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ toPx, ctx }) => {
const { threshold, t0, t1, t2, t3, t4, t5 } = envelope;
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [tx0, ty] = toPx(t0, threshold);
const [tx1] = toPx(t5, threshold);
ctx.beginPath();
ctx.moveTo(tx0, ty);
ctx.lineTo(tx1, ty);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
const points: [number, number][] = [
[t0, -90],
[t1, -90],
[t2, 0],
[t3, 0],
[t4, -90],
[t5, -90],
];
ctx.beginPath();
points.forEach(([t, g], i) => {
const [px, py] = toPx(t, g);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}}
/>
</div>
<div className="gate-meter-panel">
<div className="gate-graph__meters">
{inputs.map((input) => {
const inputState = dsp.state.current_config.input_states[input];
const idx = INPUT_METER_INDEX[input];
const meter = dsp.meters.channels[idx];
return <MeterBar label={inputState.name} meter={meter} />;
})}
</div>
</div>
</div>
<div className="gate-section">
<div className="gate-panels">
<ChannelGroup title="Input">
{inputs.map((input) => (
<GatePanel
key={String(input)}
channel={input}
dsp={dsp}
setGate={handleSetGate}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default GateSection; export default GateSection;
@@ -0,0 +1,143 @@
.geq-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.geq-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.geq-band-panel {
position: relative;
height: 100%;
aspect-ratio: 4.2;
flex: 0 0 auto;
min-width: 0;
display: flex;
flex-direction: row;
align-items: stretch;
/* padding: 2cqh 1.5cqh; */
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: 0cqh;
background: var(--bg-void);
padding-bottom: 2cqh;
padding-top: 2cqh;
}
.geq-band-cell {
flex: 1 1 0;
min-width: 2cqh;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.6cqh;
}
.geq-band-cell__label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 3.2cqh;
color: var(--text-muted);
white-space: nowrap;
}
.geq-channel-selector {
height: 100%;
width: 20cqh;
display: flex;
flex-direction: column;
gap: 5cqh;
justify-content: center;
box-sizing: border-box;
}
.geq-side-panels {
height: 100%;
display: flex;
flex-direction: column;
gap: 2cqh;
}
.geq-side-panels > :first-child {
flex: 0 0 60%;
}
.geq-side-panels > :last-child {
flex: 1 1 auto;
min-height: 0;
}
.geq-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.geq-graph-panel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.geq-graph-title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.geq-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -0,0 +1,347 @@
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { InputChannel, DiscreteFrequency } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import Button from '../../Button/Button';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useMemo, useState } from 'react';
import './GeqSection.css';
const GEQ_BANDS = [
{ frequency: DiscreteFrequency.F20, label: '20' },
{ frequency: DiscreteFrequency.F25, label: '25' },
{ frequency: DiscreteFrequency.F31_5, label: '31.5' },
{ frequency: DiscreteFrequency.F40, label: '40' },
{ frequency: DiscreteFrequency.F50, label: '50' },
{ frequency: DiscreteFrequency.F63, label: '63' },
{ frequency: DiscreteFrequency.F80, label: '80' },
{ frequency: DiscreteFrequency.F100, label: '100' },
{ frequency: DiscreteFrequency.F125, label: '125' },
{ frequency: DiscreteFrequency.F160, label: '160' },
{ frequency: DiscreteFrequency.F200, label: '200' },
{ frequency: DiscreteFrequency.F250, label: '250' },
{ frequency: DiscreteFrequency.F315, label: '315' },
{ frequency: DiscreteFrequency.F400, label: '400' },
{ frequency: DiscreteFrequency.F500, label: '500' },
{ frequency: DiscreteFrequency.F630, label: '630' },
{ frequency: DiscreteFrequency.F800, label: '800' },
{ frequency: DiscreteFrequency.F1000, label: '1k' },
{ frequency: DiscreteFrequency.F1250, label: '1.25k' },
{ frequency: DiscreteFrequency.F1600, label: '1.6k' },
{ frequency: DiscreteFrequency.F2000, label: '2k' },
{ frequency: DiscreteFrequency.F2500, label: '2.5k' },
{ frequency: DiscreteFrequency.F3150, label: '3.15k' },
{ frequency: DiscreteFrequency.F4000, label: '4k' },
{ frequency: DiscreteFrequency.F5000, label: '5k' },
{ frequency: DiscreteFrequency.F6300, label: '6.3k' },
{ frequency: DiscreteFrequency.F8000, label: '8k' },
{ frequency: DiscreteFrequency.F10000, label: '10k' },
{ frequency: DiscreteFrequency.F12500, label: '12.5k' },
{ frequency: DiscreteFrequency.F16000, label: '16k' },
{ frequency: DiscreteFrequency.F20000, label: '20k' },
] as const;
const GEQ_GAIN_MIN = -12;
const GEQ_GAIN_MAX = 12;
const GEQ_GAIN_STEP = 0.1;
const INPUT_ORDER: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
type LiveBands = Partial<Record<DiscreteFrequency, number>>;
type SetGeqBandFn = (
input: InputChannel,
band: DiscreteFrequency,
gain: number
) => Promise<boolean>;
type SetGeqBypassFn = (input: InputChannel, bypass: boolean) => Promise<boolean>;
function GeqBandPanel({
channel,
dsp,
setBand,
onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
setBand: SetGeqBandFn;
onLiveValue: (channel: InputChannel, band: DiscreteFrequency, value: number) => void;
}) {
const inputState = dsp.state.current_config.input_states[channel];
const geq = inputState.geq;
return (
<div className="geq-band-panel">
{GEQ_BANDS.map(({ frequency, label }) => (
<div className="geq-band-cell" key={frequency}>
<VerticalSlider
min={GEQ_GAIN_MIN}
max={GEQ_GAIN_MAX}
step={() => GEQ_GAIN_STEP}
unit=""
showTicks={false}
value={geq.gains[frequency]}
formatValue={(v, d) => (v > 0 ? `+${v.toFixed(d)}` : v.toFixed(d))}
onInput={(value) => onLiveValue(channel, frequency, value)}
onChange={(value) => setBand(channel, frequency, value)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="geq-band-cell__label">{label}</div>
</div>
))}
</div>
);
}
function GeqGraph({
title,
channel,
dsp,
liveBands,
}: {
title: string;
channel: InputChannel;
dsp: DSP;
liveBands: LiveBands | undefined;
}) {
const geq = dsp.state.current_config.input_states[channel].geq;
const labelByX = useMemo(() => {
const map = new Map<number, string>();
GEQ_BANDS.forEach((band, i) => map.set(i + 1, band.label));
return map;
}, []);
const values = GEQ_BANDS.map(
({ frequency }) => liveBands?.[frequency] ?? geq.gains[frequency] ?? 0
);
return (
<div className="geq-graph-panel">
<div className="geq-graph-title">{title}</div>
<LinearGraph
xMin={0.5}
xMax={GEQ_BANDS.length + 0.5}
xStep={1}
yMin={GEQ_GAIN_MIN}
yMax={GEQ_GAIN_MAX}
xLabel="Band Hz"
yLabel="dB"
showGrid={true}
formatX={(v) => labelByX.get(Math.round(v)) ?? ''}
formatY={(v) => v.toFixed(0)}
draw={({ ctx, toPx, plot }) => {
const barWidth = (plot.width / GEQ_BANDS.length) * 0.6;
values.forEach((gain, i) => {
const x = i + 1;
const [xPx, zeroY] = toPx(x, 0);
const [, gainY] = toPx(x, gain);
ctx.fillStyle = gain >= 0 ? '#4ade80' : '#f87171';
const top = Math.min(zeroY, gainY);
const height = Math.abs(gainY - zeroY);
ctx.fillRect(xPx - barWidth / 2, top, barWidth, height);
});
}}
/>
</div>
);
}
function GeqChannelSelector({
channel,
inputs,
onSelect,
}: {
channel: InputChannel;
inputs: { channel: InputChannel; label: string }[];
onSelect: (channel: InputChannel) => void;
}) {
return (
<div className="geq-channel-selector">
{inputs.map(({ channel: c, label }) => (
<Button
key={c}
variant="toggle"
className={c === channel ? 'active-info' : ''}
onClick={() => onSelect(c)}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
whiteSpace: 'nowrap',
}}
>
{label}
</Button>
))}
</div>
);
}
function GeqActions({
bypass,
resetting,
onBypass,
onReset,
}: {
bypass: boolean;
resetting: boolean;
onBypass: () => void;
onReset: () => void;
}) {
return (
<div className="geq-channel-selector">
<Button
variant="toggle"
className={bypass ? 'active-info' : ''}
onClick={onBypass}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
whiteSpace: 'nowrap',
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
<Button
variant="toggle"
onClick={onReset}
disabled={resetting}
style={{
width: '100%',
fontSize: '3.5cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{resetting ? 'Resetting…' : 'Reset'} {/* NEW */}
</Button>
</div>
);
}
function GeqSection({
dsp,
setGeqBand,
setGeqBypass,
}: {
dsp: DSP;
setGeqBand: SetGeqBandFn;
setGeqBypass: SetGeqBypassFn;
}) {
const [activeChannel, setActiveChannel] = useState<InputChannel>(INPUT_ORDER[0]);
const [liveGeq, setLiveGeq] = useState<Partial<Record<InputChannel, LiveBands>>>({});
const [resetting, setResetting] = useState(false); // NEW
const handleLiveValue = (channel: InputChannel, band: DiscreteFrequency, value: number) => {
setLiveGeq((prev) => ({
...prev,
[channel]: { ...prev[channel], [band]: value },
}));
};
const handleSetBand: SetGeqBandFn = async (channel, band, gain) => {
const success = await setGeqBand(channel, band, gain);
if (success) {
setLiveGeq((prev) => {
const channelLive = prev[channel];
if (!channelLive || !(band in channelLive)) return prev;
const next = { ...channelLive };
delete next[band];
return { ...prev, [channel]: next };
});
}
return success;
};
// NEW: sequential per-band reset, since hardware has no bulk reset command.
const handleReset = async (channel: InputChannel) => {
if (resetting) return;
setResetting(true);
for (const { frequency } of GEQ_BANDS) {
// Skip bands already at 0 to save a round trip.
const current = dsp.state.current_config.input_states[channel].geq.gains[frequency] ?? 0;
if (current === 0) continue;
await handleSetBand(channel, frequency, 0);
}
setResetting(false);
};
const activeInputState = dsp.state.current_config.input_states[activeChannel];
const activeGeq = activeInputState.geq;
const activeLiveBands = liveGeq[activeChannel];
const handleBypassClick = () => {
setGeqBypass(activeChannel, !activeGeq.bypass);
};
const channelOptions = INPUT_ORDER.map((channel) => ({
channel,
label: dsp.state.current_config.input_states[channel].name,
}));
return (
<>
<div className="geq-linear-graph-section">
<GeqGraph
title={`${activeInputState.name} - GEQ`}
channel={activeChannel}
dsp={dsp}
liveBands={activeLiveBands}
/>
</div>
<div className="geq-section">
<div className="geq-panels">
<ChannelGroup title="Bands">
<GeqBandPanel
channel={activeChannel}
dsp={dsp}
setBand={handleSetBand}
onLiveValue={handleLiveValue}
/>
</ChannelGroup>
<div className="geq-side-panels">
<ChannelGroup title="Input">
<GeqChannelSelector
channel={activeChannel}
inputs={channelOptions}
onSelect={setActiveChannel}
/>
</ChannelGroup>
<ChannelGroup title="Actions">
<GeqActions
bypass={activeGeq.bypass}
resetting={resetting}
onBypass={handleBypassClick}
onReset={() => handleReset(activeChannel)}
/>
</ChannelGroup>
</div>
</div>
</div>
</>
);
}
export default GeqSection;
@@ -0,0 +1,342 @@
/* InputSection.css */
.input-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.input-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.input-graph__title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.input-graph-panel > .container {
flex: 1;
min-height: 0;
}
.input-control-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.input-control-panel {
height: 100%;
width: 100%;
max-width: 60cqw;
display: flex;
align-items: stretch;
gap: var(--space-4);
box-sizing: border-box;
padding: 2cqh;
}
.input-control-panel__sliders {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4cqh;
}
.input-control-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.input-control-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.input-control-panel__side {
flex: 0 0 auto;
width: 16cqh;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 2cqh;
}
.input-control-panel__select {
display: flex;
flex-direction: column;
gap: 0.5cqh;
}
.input-control-panel__select-label {
font-family: var(--font-ui);
font-size: 2.2cqh;
color: var(--text-muted);
}
.input-control-panel__select select {
font-family: var(--font-ui);
font-size: 2.5cqh;
padding: 0.8cqh;
background: var(--bg-void);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.input-control-panel__select select:disabled {
opacity: 0.5;
}
.input-control-panel__bypass-btn {
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
padding: 1cqh;
background: var(--bg-void);
color: var(--text-primary);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
cursor: pointer;
}
.input-control-panel__bypass-btn--active {
background: var(--accent, #f87171);
color: var(--bg-void);
border-color: var(--accent, #f87171);
}
/* Peq Sliders */
.input-panel {
position: relative;
height: 10;
aspect-ratio: 0.75;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.input-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.input-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: 1fr;
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.input-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.input-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.crossover-panel {
position: relative;
height: 100%;
aspect-ratio: 0.28;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: 2cqh;
background: var(--bg-void);
padding-bottom: 2cqh;
}
.crossover-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.crossover-panel__select {
flex: 0 0 auto;
width: 85%;
font-family: var(--font-ui);
font-size: 3cqh;
padding: 1cqh;
background: var(--bg-panel);
color: inherit;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
}
.gain-meter-panel {
container-type: size;
position: relative;
height: 100%;
aspect-ratio: 1 / 2;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.gain-meter-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.gain-meter-panel__grid {
flex: 1 1 auto;
min-height: 0;
width: 100%;
padding: 2cqh;
box-sizing: border-box;
display: grid;
grid-template-columns: 1fr 1fr;
/* slider/meter row gets the bulk of the height, button row stays compact */
grid-template-rows: 1fr auto;
gap: 1.5cqh;
}
.gain-meter-panel__cell {
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.gain-meter-panel__cell--slider,
.gain-meter-panel__cell--meter {
height: 100%;
}
.gain-meter-panel__cell--btn {
flex: 0 0 auto;
}
.gain-meter-panel__cell--meter {
align-items: stretch;
justify-content: stretch;
}
@@ -0,0 +1,504 @@
// InputSection.tsx
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel, mapping
// input pages 1-8 similarly (In1 -> 0, In2 -> 1, ...). Adjust import/values
// if the actual enum differs.
import { InputChannel, Channel, PEQBand, PEQFilter, PEQ, Meter } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import { CrossoverFilter } from '../../../types/dsp408';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import { useRef, useState } from 'react';
import './InputSection.css';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import Button from '../../Button/Button';
import MeterBar from '../../MeterBar/MeterBar';
const FREQ_MIN = 20;
const FREQ_MAX = 20000;
const FREQ_STEP = 1;
const FILTER_TYPE_OPTIONS: { value: CrossoverFilter; label: string }[] = [
{ value: CrossoverFilter.Bw6, label: 'BW-6' },
{ value: CrossoverFilter.Bw12, label: 'BW-12' },
{ value: CrossoverFilter.Bw18, label: 'BW-18' },
{ value: CrossoverFilter.Bw24, label: 'BW-24' },
{ value: CrossoverFilter.Bw30, label: 'BW-30' },
{ value: CrossoverFilter.Bw36, label: 'BW-36' },
{ value: CrossoverFilter.Bw42, label: 'BW-42' },
{ value: CrossoverFilter.Bw48, label: 'BW-48' },
{ value: CrossoverFilter.Lk12, label: 'LR-12' },
{ value: CrossoverFilter.Lk24, label: 'LR-24' },
{ value: CrossoverFilter.Lk36, label: 'LR-36' },
{ value: CrossoverFilter.Lk48, label: 'LR-48' },
];
const PEQ_SLIDER_PARAMS = [
{ key: 'frequency', label: 'Frequency', min: 19.7, max: 20160, step: 0.5, unit: ' Hz' },
{ key: 'q', label: 'Q', min: 1, max: 999, step: 1, unit: '' },
{ key: 'gain', label: 'Gain', min: -12, max: +12, step: 0.1, unit: ' dB' },
] as const;
type SetPEQFn = (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
type PeqParamKey = 'frequency' | 'q' | 'gain';
type PEQValues = {
frequency: number;
q: number;
gain: number;
filter_type: PEQFilter;
bypass: boolean;
};
function PeqPanelSliders({
channel,
dsp,
band,
setPEQ,
// onLiveValue,
}: {
channel: InputChannel;
dsp: DSP;
band: PEQBand;
setPEQ: SetPEQFn;
// onLiveValue: (channel: InputChannel, key: PeqParamKey, value: number) => void;
}) {
const inputState = dsp.state.current_config.input_states[channel];
const peq = inputState.peq_chain.bands[band] as PEQ;
const commit = (overrides: Partial<PEQValues>) => {
const next = { ...peq, ...overrides };
return setPEQ(
{ Input: channel },
band,
next.gain,
next.frequency,
next.q,
next.filter_type,
next.bypass
);
};
return (
<div className="input-panel">
<div className="input-panel__title">{'PEQ Parameters'}</div>
<div className="input-panel__grid">
{PEQ_SLIDER_PARAMS.map((param) => (
<div className={`input-panel__cell input-panel__cell--${param.key}`} key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={peq[param.key]}
// onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<PEQValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div>
);
}
function CrossoverPanel({
channel,
dsp,
title,
setCrossover,
}: {
channel: Channel;
dsp: DSP;
title: string;
setCrossover: (
channel: Channel,
frequency: number,
filter_type: CrossoverFilter,
bypass: boolean
) => Promise<boolean>;
}) {
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const crossover = channelState?.crossover;
const frequency = crossover.high_pass.frequency;
const filter_type = crossover.high_pass.slope;
const bypass = false;
const commit = (overrides: {
frequency?: number;
filter_type?: CrossoverFilter;
bypass?: boolean;
}) => {
const next = { frequency, filter_type, bypass, ...overrides };
return setCrossover(channel, next.frequency, next.filter_type, next.bypass);
};
return (
<div className="crossover-panel">
<div className="crossover-panel__title">{title}</div>
<VerticalSlider
min={20}
max={20000}
step={() => 1}
unit=" Hz"
value={frequency}
onChange={(value) => commit({ frequency: value })}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<select
className="crossover-panel__select"
value={filter_type}
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
variant="toggle"
className={bypass ? 'active-warning' : ''}
onClick={() => commit({ bypass: !bypass })}
style={{
width: '85%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '4cqh',
paddingTop: '2cqh',
paddingBottom: '2cqh',
minHeight: 0,
}}
>
{bypass ? 'Bypassed' : 'Bypass'}
</Button>
</div>
);
}
function GainMeterPanel({
dsp,
channel,
title,
meter,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
channel: Channel;
title: string;
meter: Meter;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const channelState =
'Input' in channel
? dsp.state?.current_config.input_states[channel.Input]
: dsp.state?.current_config.output_states[channel.Output];
const muted = channelState?.mute ?? false;
const inverted = channelState?.phase_inverted ?? false;
const gain = channelState?.gain ?? 0;
const toggleMute = async () => {
await setMute(channel, !muted);
};
const toggleInverse = async () => {
await setInverse(channel, !inverted);
};
return (
<div className="gain-meter-panel">
<div className="gain-meter-panel__title">{title}</div>
<div className="gain-meter-panel__grid">
<div className="gain-meter-panel__cell gain-meter-panel__cell--slider">
<VerticalSlider
min={-60}
max={12}
step={(v) => (v >= -20 ? 0.1 : 0.5)}
unit=" dB"
value={gain}
onChange={(value) => setGain(channel, value)}
style={{ width: '100%', height: '100%' }}
/>
</div>
<div className="gain-meter-panel__cell gain-meter-panel__cell--meter">
<MeterBar label="" meter={meter} />
</div>
<div className="gain-meter-panel__cell gain-meter-panel__cell--btn">
<Button
variant="toggle"
className={inverted ? 'active-warning' : ''}
onClick={toggleInverse}
style={{
width: '100%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '3.5cqh',
paddingTop: '1cqh',
paddingBottom: '1cqh',
minHeight: 0,
}}
>
{inverted ? 'Inverted' : 'Invert'}
</Button>
</div>
<div className="gain-meter-panel__cell gain-meter-panel__cell--btn">
<Button
variant="toggle"
className={muted ? 'active-danger' : ''}
onClick={toggleMute}
style={{
width: '100%',
paddingLeft: 0,
paddingRight: 0,
fontSize: '3.5cqh',
paddingTop: '1cqh',
paddingBottom: '1cqh',
minHeight: 0,
}}
>
{muted ? 'Muted' : 'Mute'}
</Button>
</div>
</div>
</div>
);
}
type SetFilterFn = (
channel: Channel,
freq: number,
filterType: CrossoverFilter
) => Promise<boolean>;
type setPeqBandFn = (
channel: Channel,
band: PEQBand,
gainDb: number,
freqHz: number,
q: number,
filterType: PEQFilter,
bypass: boolean
) => Promise<boolean>;
function InputSection({
dsp,
channel,
set_high_pass: setHighPass,
set_low_pass,
setPeq,
// setPeqBypass,
setGain,
setMute,
setInverse,
}: {
dsp: DSP;
channel: InputChannel;
set_high_pass: SetFilterFn;
set_low_pass: SetFilterFn;
setPeq: setPeqBandFn;
// setPeqBypass;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
}) {
const [activeBand, setActiveBand] = useState<PEQBand>(PEQBand.B1);
const [liveHpFreq, setLiveHpFreq] = useState<number>(19.7);
const [liveLpFreq, setLiveLpFreq] = useState<number>(20.16);
const [livePeq, setLivePeq] = useState<Partial<Record<PEQBand, PEQValues>>>({});
const inputState = dsp.state.current_config.input_states[channel];
const hp = inputState.crossover.high_pass;
const lp = inputState.crossover.low_pass;
const activePeq = livePeq[activeBand] ?? (inputState.peq_chain.bands[activeBand] as PEQValues);
const bypassed = hp.slope === CrossoverFilter.Bypass;
const activeFilterType = bypassed && lp.slope !== CrossoverFilter.Bypass ? lp.slope : hp.slope;
// Remembers the last non-bypass slope so the Bypass button can restore it.
// const lastNonBypassType = useRef<CrossoverFilter>(
// activeFilterType === CrossoverFilter.Bypass ? DEFAULT_FILTER_TYPE : activeFilterType
// );
// if (activeFilterType !== CrossoverFilter.Bypass) {
// lastNonBypassType.current = activeFilterType;
// }
const hpFreq = liveHpFreq ?? hp.frequency;
const lpFreq = liveLpFreq ?? lp.frequency;
// const commitHighPass = async (freq: number, filterType: CrossoverFilter) => {
// const success = await set_high_pass({ Input: channel }, freq, filterType);
// if (success) setLiveHpFreq(null);
// return success;
// };
// const commitLowPass = async (freq: number, filterType: CrossoverFilter) => {
// const success = await set_low_pass({ Input: channel }, freq, filterType);
// if (success) setLiveLpFreq(null);
// return success;
// };
// const handleFilterTypeChange = (filterType: CrossoverFilter) => {
// // Applying a slope change always takes the crossover out of bypass.
// void commitHighPass(hpFreq, filterType);
// void commitLowPass(lpFreq, filterType);
// };
// const handleBypassToggle = () => {
// if (bypassed) {
// const restore = lastNonBypassType.current;
// void commitHighPass(hpFreq, restore);
// void commitLowPass(lpFreq, restore);
// } else {
// void commitHighPass(hpFreq, CrossoverFilter.Bypass);
// void commitLowPass(lpFreq, CrossoverFilter.Bypass);
// }
// };
return (
<>
<div className="input-graph-section">
<div className="input-graph-panel">
<div className="input-graph__title">{inputState.name} - Filters</div>
{/* TODO: draw HP/LP transfer curves here once filter math is defined,
same pattern as LimiterSection's `draw` prop. */}
<LinearGraph
xMin={20}
xMax={20000}
yMin={-30}
yMax={6}
xLabel="Hz"
yLabel="dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={() => {}}
/>
</div>
</div>
<div className="input-control-section">
<div className="input-control-panel">
<ChannelGroup title="PEQ">
<PeqPanelSliders channel={channel} dsp={dsp} band={PEQBand.B1} setPEQ={setPeq} />
</ChannelGroup>
<ChannelGroup title="CrossOver">
<CrossoverPanel
channel={{ Input: channel }}
dsp={dsp}
title="LowPass"
setCrossover={setHighPass}
/>
<CrossoverPanel
channel={{ Input: channel }}
dsp={dsp}
title="HighPass"
setCrossover={setHighPass}
/>
</ChannelGroup>
<ChannelGroup title="Gain">
<GainMeterPanel
dsp={dsp}
channel={{ Input: channel }}
title={dsp.state.current_config.input_states.InA.name}
meter={dsp.meters.channels[0]}
setGain={setGain}
setMute={setMute}
setInverse={setInverse}
/>
</ChannelGroup>
{/* <div className="input-control-panel__sliders">
<div className="input-control-panel__cell input-control-panel__cell--high-pass">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={hpFreq}
// disabled={bypassed}
onInput={(value) => setLiveHpFreq(value)}
onChange={(value) => commitHighPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">High Pass</div>
</div>
<div className="input-control-panel__cell input-control-panel__cell--low-pass">
<VerticalSlider
min={FREQ_MIN}
max={FREQ_MAX}
step={FREQ_STEP}
unit=" Hz"
value={lpFreq}
// disabled={bypassed}
onInput={(value) => setLiveLpFreq(value)}
onChange={(value) => commitLowPass(value, activeFilterType)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="input-control-panel__cell-label">Low Pass</div>
</div>
</div>
<div className="input-control-panel__side">
<label className="input-control-panel__select">
<span className="input-control-panel__select-label">Filter Type</span>
<select
value={activeFilterType}
disabled={bypassed}
onChange={(e) => handleFilterTypeChange(e.target.value as CrossoverFilter)}
>
{FILTER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<button
type="button"
className={`input-control-panel__bypass-btn${
bypassed ? ' input-control-panel__bypass-btn--active' : ''
}`}
onClick={handleBypassToggle}
>
{bypassed ? 'Bypassed' : 'Bypass'}
</button>
</div> */}
</div>
</div>
</>
);
}
export default InputSection;
@@ -0,0 +1,171 @@
.limiter-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.limiter-panels {
height: 100%;
display: flex;
gap: var(--space-2);
overflow-x: auto;
}
.limiter-panel {
position: relative;
height: 10;
aspect-ratio: 0.5;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--bg-void);
}
.limiter-panel__title {
flex: 0 0 auto;
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 4cqh;
font-weight: var(--font-weight-semibold);
padding-top: 2cqh;
padding-bottom: 2cqh;
border-bottom: var(--border-width) solid var(--border-hairline);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.limiter-panel__grid {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
grid-template-areas:
'threshold attack'
'threshold release';
gap: 2cqh;
padding: 2cqh;
box-sizing: border-box;
}
.limiter-panel__cell--threshold {
grid-area: threshold;
}
.limiter-panel__cell--attack {
grid-area: attack;
}
.limiter-panel__cell--release {
grid-area: release;
}
.limiter-panel__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
min-height: 0;
min-width: 0;
gap: 1cqh;
}
.limiter-panel__cell-label {
flex: 0 0 auto;
font-family: var(--font-ui);
font-size: 2.5cqh;
color: var(--text-muted);
white-space: nowrap;
}
.limiter-linear-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.limiter-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.limiter-meter-panel {
flex: 0 0 auto;
width: fit-content;
min-width: max-content;
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
}
.limiter-graph__title {
flex: 0 0 auto;
text-align: center;
padding: 1cqh;
font-family: var(--font-ui);
font-size: 2.5cqh;
font-weight: var(--font-weight-semibold);
}
.limiter-graph__meters {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
gap: 1cqh;
padding: 1cqh;
}
.limiter-graph-panel > .container {
flex: 1;
min-height: 0;
}
@@ -0,0 +1,238 @@
// LimiterSection.tsx
import VerticalSlider from '../../VerticalSlider/VerticalSlider';
import { OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import ChannelGroup from '../../ChannelGroup/ChannelGroup';
import { LinearGraph } from '../../LinearGraph/LinearGraph';
import MeterBar from '../../MeterBar/MeterBar';
import { useMemo, useState } from 'react';
import './LimiterSection.css';
const LIMITER_SLIDER_PARAMS = [
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
{ key: 'attack', label: 'Attack', min: 1, max: 999, step: 1, unit: ' ms' },
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
] as const;
// ASSUMPTION: output meter indices follow the 4 input channels (0-3), i.e.
// Out1 -> 4, Out2 -> 5, etc. Same mapping as CompressorSection — adjust if
// dsp.meters.channels ordering differs.
const OUTPUT_METER_INDEX: Record<OutputChannel, number> = {
[OutputChannel.Out1]: 4,
[OutputChannel.Out2]: 5,
[OutputChannel.Out3]: 6,
[OutputChannel.Out4]: 7,
[OutputChannel.Out5]: 8,
[OutputChannel.Out6]: 9,
[OutputChannel.Out7]: 10,
[OutputChannel.Out8]: 11,
} as const;
type LimiterParamKey = 'threshold' | 'attack' | 'release';
type LimiterValues = {
threshold: number;
attack: number;
release: number;
};
type SetLimiterFn = (
outputChannel: OutputChannel,
threshold: number,
attack: number,
release: number
) => Promise<boolean>;
function LimiterPanel({
channel,
dsp,
setLimiter,
onLiveValue,
}: {
channel: OutputChannel;
dsp: DSP;
setLimiter: SetLimiterFn;
onLiveValue: (channel: OutputChannel, key: LimiterParamKey, value: number) => void;
}) {
const outputState = dsp.state.current_config.output_states[channel];
// ASSUMPTION: limiter settings live at output_states[channel].limiter
const lim = outputState.limiter as LimiterValues;
const commit = (overrides: Partial<LimiterValues>) => {
const next = { ...lim, ...overrides };
return setLimiter(channel, next.threshold, next.attack, next.release);
};
return (
<div className="limiter-panel">
<div className="limiter-panel__title">{outputState.name}</div>
<div className="limiter-panel__grid">
{LIMITER_SLIDER_PARAMS.map((param) => (
<div className={`limiter-panel__cell limiter-panel__cell--${param.key}`} key={param.key}>
<VerticalSlider
min={param.min}
max={param.max}
step={() => param.step}
unit={param.unit}
value={lim[param.key]}
onInput={(value) => onLiveValue(channel, param.key, value)}
onChange={(value) => commit({ [param.key]: value } as Partial<LimiterValues>)}
style={{ width: '100%', flex: '1 1 auto' }}
/>
<div className="limiter-panel__cell-label">{param.label}</div>
</div>
))}
</div>
</div>
);
}
// Hard-knee brickwall limiter transfer curve, in dB.
function limiterOutputDb(input: number, threshold: number): number {
return input <= threshold ? input : threshold;
}
function LimiterSection({ dsp, set_limiter }: { dsp: DSP; set_limiter: SetLimiterFn }) {
const outputs = [
OutputChannel.Out1,
OutputChannel.Out2,
OutputChannel.Out3,
OutputChannel.Out4,
OutputChannel.Out5,
OutputChannel.Out6,
OutputChannel.Out7,
OutputChannel.Out8,
];
const [activeChannel, setActiveChannel] = useState<OutputChannel>(outputs[0]);
const [liveLim, setLiveLim] = useState<Partial<Record<OutputChannel, LimiterValues>>>({});
const handleLiveValue = (channel: OutputChannel, key: LimiterParamKey, value: number) => {
setActiveChannel(channel);
setLiveLim((prev) => {
const base =
prev[channel] ?? (dsp.state.current_config.output_states[channel].limiter as LimiterValues);
return { ...prev, [channel]: { ...base, [key]: value } };
});
};
const handleSetLimiter: SetLimiterFn = async (channel, threshold, attack, release) => {
setActiveChannel(channel);
const success = await set_limiter(channel, threshold, attack, release);
if (success) {
setLiveLim((prev) => {
if (!(channel in prev)) return prev;
const next = { ...prev };
delete next[channel];
return next;
});
}
return success;
};
const activeOutputState = dsp.state.current_config.output_states[activeChannel];
const activeLim = liveLim[activeChannel] ?? (activeOutputState.limiter as LimiterValues);
const curvePoints = useMemo(() => {
const { threshold } = activeLim;
const steps = 120;
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const x = -60 + (60 * i) / steps;
points.push([x, limiterOutputDb(x, threshold)]);
}
return points;
}, [activeLim.threshold]);
return (
<>
<div className="limiter-linear-graph-section">
<div className="limiter-graph-panel">
<div className="limiter-graph__title">{activeOutputState.name} - Limiter</div>
<LinearGraph
xMin={-60}
xMax={0}
yMin={-60}
yMax={0}
xLabel="in dB"
yLabel="out dB"
formatX={(v) => Math.round(v).toString()}
formatY={(v) => Math.round(v).toString()}
draw={({ ctx, toPx }) => {
// Unity reference line
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
const [ux0, uy0] = toPx(-60, -60);
const [ux1, uy1] = toPx(0, 0);
ctx.beginPath();
ctx.moveTo(ux0, uy0);
ctx.lineTo(ux1, uy1);
ctx.stroke();
ctx.setLineDash([]);
// Threshold marker
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
const [tx0, ty0] = toPx(activeLim.threshold, -60);
const [tx1, ty1] = toPx(activeLim.threshold, 0);
ctx.beginPath();
ctx.moveTo(tx0, ty0);
ctx.lineTo(tx1, ty1);
ctx.stroke();
ctx.setLineDash([]);
// Limiter curve
ctx.strokeStyle = '#f87171';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
curvePoints.forEach(([x, y], i) => {
const [px, py] = toPx(x, y);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
}}
/>
</div>
<div className="limiter-meter-panel">
<div className="limiter-graph__meters">
{outputs.map((output) => {
const outputState = dsp.state.current_config.output_states[output];
const idx = OUTPUT_METER_INDEX[output];
return (
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
);
})}
</div>
</div>
</div>
<div className="limiter-section">
<div className="limiter-panels">
<ChannelGroup title="Output">
{outputs.map((output) => (
<LimiterPanel
key={String(output)}
channel={output}
dsp={dsp}
setLimiter={handleSetLimiter}
onLiveValue={handleLiveValue}
/>
))}
</ChannelGroup>
</div>
</div>
</>
);
}
export default LimiterSection;
@@ -1,89 +0,0 @@
.gain-section {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
padding: 0 var(--space-4);
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
.gain-panels {
height: 100%;
display: flex;
gap: var(--space-4);
overflow-x: auto;
}
.gain-panel-frame {
flex: 0 0 auto;
height: calc(100% - 2 * var(--space-1));
margin: var(--space-1) 0;
}
.gain-panel-viewport {
position: relative;
aspect-ratio: 90 / 400;
overflow: hidden;
box-sizing: border-box;
}
.gain-panel {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
gap: var(--space-2);
background: var(--bg-void);
}
.gain-panel__title {
flex: 0 0 auto; /* NEW: never let this shrink */
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: var(--font-sm);
font-weight: var(--font-weight-semibold);
padding-top: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline);
}
/* --- graph strip, sits above the gain section ---
Requires the shared parent (whatever renders <GainSection />) to be
position: relative — it already must be, for .gain-section's absolute
bottom:0 to anchor correctly. */
.linear-graph-section {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: var(--dsp-control-heigth);
box-sizing: border-box;
padding: var(--space-4);
background: var(--bg-panel);
z-index: 10; /* stays below .gain-section's z-index: 20 */
}
@@ -1,346 +0,0 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import styles from './LinearGraph.css';
/**
* Coordinate-space helpers passed into your draw callback.
* Everything here works in DATA units (e.g. seconds, Hz, volts) —
* you never have to think about pixels or devicePixelRatio.
*/
export interface LinearGraphHelpers {
/** data (x, y) -> canvas pixel (px, py) */
toPx: (x: number, y: number) => [number, number];
/** data x -> pixel x */
toPxX: (x: number) => number;
/** data y -> pixel y */
toPxY: (y: number) => number;
/** pixel (px, py) -> data (x, y). Useful for handling clicks/drags. */
toData: (px: number, py: number) => [number, number];
/** size of the plot area in CSS pixels (inside the axes/padding) */
plot: { x: number; y: number; width: number; height: number };
ctx: CanvasRenderingContext2D;
}
export type DrawFn = (helpers: LinearGraphHelpers) => void;
export interface LinearGraphHandle {
/** Force a redraw (e.g. after mutating a ref-held buffer without a re-render) */
redraw: () => void;
/** Access the raw canvas, e.g. for exporting a PNG */
getCanvas: () => HTMLCanvasElement | null;
}
export interface LinearGraphProps {
xMin: number;
xMax: number;
/** grid + tick spacing on x. Omit to auto-pick a "nice" step. */
xStep?: number;
yMin: number;
yMax: number;
yStep?: number;
/** Your drawing code. Called every time the graph needs to repaint. */
draw?: DrawFn;
width?: number;
height?: number;
/** padding (px) reserved for axis labels, in CSS pixels */
padding?: { top?: number; right?: number; bottom?: number; left?: number };
showGrid?: boolean;
showAxes?: boolean;
showTicks?: boolean;
xLabel?: string;
yLabel?: string;
/** format a tick value, e.g. (v) => v.toFixed(1) or Hz->kHz */
formatX?: (v: number) => string;
formatY?: (v: number) => string;
className?: string;
style?: React.CSSProperties;
}
const defaultFormat = (v: number) => {
if (Math.abs(v) < 1e-9) return '0';
const abs = Math.abs(v);
if (abs >= 1000 || abs < 0.01) return v.toExponential(1);
return parseFloat(v.toFixed(4)).toString();
};
/** Pick a "nice" step (1/2/5 * 10^n) that yields ~targetTicks divisions */
function niceStep(min: number, max: number, targetTicks = 8): number {
const range = Math.abs(max - min) || 1;
const raw = range / targetTicks;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
const step = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10;
return step * mag;
}
function range(min: number, max: number, step: number): number[] {
if (step <= 0) return [];
const out: number[] = [];
const start = Math.ceil((min - 1e-9) / step) * step;
for (let v = start; v <= max + 1e-9; v += step) {
// snap to avoid float drift like 0.30000000000000004
out.push(Math.round(v / step) * step);
}
return out;
}
export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(function LinearGraph(
{
xMin,
xMax,
xStep,
yMin,
yMax,
yStep,
draw,
width,
height,
padding,
showGrid = true,
showAxes = true,
showTicks = true,
xLabel,
yLabel,
formatX = defaultFormat,
formatY = defaultFormat,
className,
style,
},
ref
) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [size, setSize] = useState({ width: width ?? 600, height: height ?? 360 });
const pad = useMemo(
() => ({
top: padding?.top ?? 16,
right: padding?.right ?? 16,
bottom: padding?.bottom ?? (xLabel ? 44 : 28),
left: padding?.left ?? (yLabel ? 56 : 44),
}),
[padding, xLabel, yLabel]
);
const effXStep = xStep ?? niceStep(xMin, xMax);
const effYStep = yStep ?? niceStep(yMin, yMax);
// Responsive sizing when width/height aren't fixed
useEffect(() => {
if (width && height) return;
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width: w, height: h } = entry.contentRect;
if (w > 0 && h > 0) {
setSize({ width: width ?? w, height: height ?? h });
}
});
ro.observe(el);
return () => ro.disconnect();
}, [width, height]);
const paint = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = size.width;
const cssH = size.height;
if (canvas.width !== cssW * dpr || canvas.height !== cssH * dpr) {
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const plot = {
x: pad.left,
y: pad.top,
width: Math.max(1, cssW - pad.left - pad.right),
height: Math.max(1, cssH - pad.top - pad.bottom),
};
const toPxX = (x: number) => plot.x + ((x - xMin) / (xMax - xMin || 1)) * plot.width;
const toPxY = (y: number) =>
plot.y + plot.height - ((y - yMin) / (yMax - yMin || 1)) * plot.height;
const toPx = (x: number, y: number): [number, number] => [toPxX(x), toPxY(y)];
const toData = (px: number, py: number): [number, number] => [
xMin + ((px - plot.x) / plot.width) * (xMax - xMin),
yMin + (1 - (py - plot.y) / plot.height) * (yMax - yMin),
];
const css = getComputedStyle(canvas);
const gridColor = css.getPropertyValue('--graph-grid').trim() || '#2a2f3a';
const axisColor = css.getPropertyValue('--graph-axis').trim() || '#6b7280';
const textColor = css.getPropertyValue('--graph-text').trim() || '#9ca3af';
const bgColor = css.getPropertyValue('--graph-bg').trim();
if (bgColor) {
ctx.fillStyle = bgColor;
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
}
// clip to plot area so user drawing can't bleed into labels
ctx.save();
ctx.beginPath();
ctx.rect(plot.x, plot.y, plot.width, plot.height);
ctx.clip();
if (showGrid) {
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (const gx of range(xMin, xMax, effXStep)) {
const px = Math.round(toPxX(gx)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
for (const gy of range(yMin, yMax, effYStep)) {
const py = Math.round(toPxY(gy)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
ctx.stroke();
}
if (showAxes) {
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
if (yMin <= 0 && yMax >= 0) {
const py = Math.round(toPxY(0)) + 0.5;
ctx.moveTo(plot.x, py);
ctx.lineTo(plot.x + plot.width, py);
}
if (xMin <= 0 && xMax >= 0) {
const px = Math.round(toPxX(0)) + 0.5;
ctx.moveTo(px, plot.y);
ctx.lineTo(px, plot.y + plot.height);
}
ctx.stroke();
}
// hand off to user drawing code, still clipped to plot area
draw?.({ toPx, toPxX, toPxY, toData, plot, ctx });
ctx.restore();
// border
ctx.strokeStyle = axisColor;
ctx.lineWidth = 1;
ctx.strokeRect(
Math.round(plot.x) + 0.5,
Math.round(plot.y) + 0.5,
Math.round(plot.width) - 1,
Math.round(plot.height) - 1
);
// ticks + labels (outside clip)
if (showTicks) {
ctx.fillStyle = textColor;
ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (const gx of range(xMin, xMax, effXStep)) {
const px = toPxX(gx);
ctx.beginPath();
ctx.moveTo(px, plot.y + plot.height);
ctx.lineTo(px, plot.y + plot.height + 4);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillText(formatX(gx), px, plot.y + plot.height + 6);
}
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const gy of range(yMin, yMax, effYStep)) {
const py = toPxY(gy);
ctx.beginPath();
ctx.moveTo(plot.x - 4, py);
ctx.lineTo(plot.x, py);
ctx.strokeStyle = axisColor;
ctx.stroke();
ctx.fillText(formatY(gy), plot.x - 6, py);
}
}
if (xLabel) {
ctx.fillStyle = textColor;
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(xLabel, plot.x + plot.width / 2, cssH - 4);
}
if (yLabel) {
ctx.save();
ctx.fillStyle = textColor;
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.translate(12, plot.y + plot.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText(yLabel, 0, 0);
ctx.restore();
}
}, [
size,
pad,
xMin,
xMax,
effXStep,
yMin,
yMax,
effYStep,
showGrid,
showAxes,
showTicks,
xLabel,
yLabel,
formatX,
formatY,
draw,
]);
useEffect(() => {
paint();
}, [paint]);
useImperativeHandle(ref, () => ({
redraw: paint,
getCanvas: () => canvasRef.current,
}));
return (
<div
ref={containerRef}
className={'container'}
style={{
width: width ?? '100%',
height: height ?? '100%',
...style,
}}
>
<canvas
ref={canvasRef}
className={'canvas'}
style={{ width: size.width, height: size.height }}
/>
</div>
);
});
export default LinearGraph;
@@ -0,0 +1,171 @@
.matrix-section {
container-type: size;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--dsp-control-heigth);
display: flex;
align-items: stretch;
justify-content: center;
box-sizing: border-box;
background: var(--bg-panel);
z-index: 20;
border-top: var(--border-width) solid var(--border-hairline);
}
/* ---------- Placeholder graph area (top) ---------- */
/* Intentionally minimal for now — see chat for ideas on what belongs here. */
.matrix-graph-section {
flex: 0 0 calc(100% - var(--dsp-control-heigth));
min-height: 0;
display: flex;
flex-direction: row;
gap: 1cqh;
padding: 0.5cqh;
box-sizing: border-box;
background: var(--bg-panel);
}
.matrix-graph-panel {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border: var(--border-width) dashed var(--border-hairline);
border-radius: var(--radius-md);
font-family: var(--font-ui);
font-size: var(--font-xs);
color: var(--text-muted);
}
/* ---------- Matrix grid (bottom) ---------- */
.matrix-grid-panel {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
padding: var(--space-2);
box-sizing: border-box;
overflow: auto;
}
.matrix-grid {
display: grid;
/* 1 corner cell + 1 column per input */
grid-template-columns: minmax(8ch, 12cqh) repeat(var(--matrix-input-count, 4), minmax(9cqh, 1fr));
/* 1 header row + 1 row per output */
grid-auto-rows: minmax(6cqh, 1fr);
gap: var(--border-width);
background: var(--border-hairline);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md);
overflow: hidden;
}
.matrix-grid__corner,
.matrix-grid__col-header,
.matrix-grid__row-header {
background: var(--bg-void);
font-family: var(--font-ui);
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 2.5cqh;
padding: 0.5cqh;
min-width: 0;
min-height: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.matrix-grid__row-header {
justify-content: flex-start;
padding-left: 1cqh;
}
.matrix-cell {
position: relative;
background: var(--bg-panel);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5cqh;
min-width: 0;
min-height: 0;
border: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
touch-action: none;
}
.matrix-cell:focus-visible {
outline: 2px solid var(--accent-brand);
outline-offset: -2px;
}
.matrix-cell--off {
color: var(--text-muted);
}
.matrix-cell--off .matrix-cell__dot {
width: 0.8cqh;
height: 0.8cqh;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
}
.matrix-cell--on {
background: color-mix(in srgb, var(--accent-brand) 14%, var(--bg-panel));
color: var(--accent-brand);
}
.matrix-cell--dragging {
background: color-mix(in srgb, var(--accent-brand) 26%, var(--bg-panel));
}
.matrix-cell__gain {
font-size: 2.6cqh;
font-weight: var(--font-weight-semibold);
font-family: var(--font-ui);
white-space: nowrap;
}
.matrix-cell__link-icon {
width: 2.2cqh;
height: 2.2cqh;
}
@@ -0,0 +1,257 @@
// MatrixSection.tsx
import { Fragment, useRef, useState } from 'react';
import { InputChannel, OutputChannel } from '../../../types/dsp408';
import { DSP408 as DSP } from '../../../types/types';
import './MatrixSection.css';
// ASSUMPTION: an InputChannel enum exists alongside OutputChannel in
// dsp408State, e.g. In1..In4. Adjust the import/labels below if the real
// enum differs.
const INPUTS: InputChannel[] = [
InputChannel.InA,
InputChannel.InB,
InputChannel.InC,
InputChannel.InD,
];
const OUTPUTS: OutputChannel[] = [
OutputChannel.Out1,
OutputChannel.Out2,
OutputChannel.Out3,
OutputChannel.Out4,
OutputChannel.Out5,
OutputChannel.Out6,
OutputChannel.Out7,
OutputChannel.Out8,
];
const INPUT_LABEL: Record<InputChannel, string> = {
[InputChannel.InA]: 'In 1',
[InputChannel.InB]: 'In 2',
[InputChannel.InC]: 'In 3',
[InputChannel.InD]: 'In 4',
};
const GAIN_MIN = -60;
const GAIN_MAX = 12;
const GAIN_DEFAULT = 0; // gain a link is set to the moment it's first routed
const GAIN_STEP = 0.5; // wheel / arrow-key nudge
const DRAG_PX_PER_DB = 4; // vertical drag sensitivity
function clampGain(v: number) {
return Math.min(GAIN_MAX, Math.max(GAIN_MIN, v));
}
function formatGain(v: number) {
return `${v > 0 ? '+' : ''}${v.toFixed(1)}`;
}
// ASSUMPTION: each output tracks which inputs are currently routed to it
// (output_states[out].matrix_inputs: InputChannel[]) and the last-known gain
// per input (output_states[out].matrix_gains: Partial<Record<InputChannel,
// number>>, in dB, kept around even if the link is later removed so it can
// be restored). Adjust these two accessors if the real shape differs —
// everything else only depends on the two functions below.
type MatrixOutputState = {
matrix_inputs?: InputChannel[];
matrix_gains?: Partial<Record<InputChannel, number>>;
};
function isRouted(dsp: DSP, output: OutputChannel, input: InputChannel): boolean {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_inputs?.includes(input) ?? false;
}
function getLinkGain(dsp: DSP, output: OutputChannel, input: InputChannel): number {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_gains?.[input] ?? GAIN_DEFAULT;
}
function getRoutedInputs(dsp: DSP, output: OutputChannel): InputChannel[] {
const state = dsp.state.current_config.output_states[output] as unknown as MatrixOutputState;
return state.matrix_inputs ?? [];
}
function MatrixCell({
output,
input,
connected,
gain,
onToggle,
onGainChange,
}: {
output: OutputChannel;
input: InputChannel;
connected: boolean;
gain: number;
onToggle: () => void;
onGainChange: (gain: number) => void;
}) {
const [liveGain, setLiveGain] = useState<number | null>(null);
const [dragging, setDragging] = useState(false);
const dragStart = useRef<{ y: number; base: number } | null>(null);
const displayGain = liveGain ?? gain;
const handlePointerDown = (e: React.PointerEvent) => {
if (!connected) return; // plain click on an off cell just routes it (see onClick)
e.currentTarget.setPointerCapture(e.pointerId);
dragStart.current = { y: e.clientY, base: gain };
setDragging(true);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragStart.current) return;
const deltaPx = dragStart.current.y - e.clientY; // up = louder
const next = clampGain(dragStart.current.base + deltaPx / DRAG_PX_PER_DB);
setLiveGain(next);
};
const endDrag = () => {
if (dragStart.current && liveGain !== null) {
onGainChange(liveGain);
}
dragStart.current = null;
setDragging(false);
setLiveGain(null);
};
const handleWheel = (e: React.WheelEvent) => {
if (!connected) return;
e.preventDefault();
onGainChange(clampGain(gain + (e.deltaY < 0 ? GAIN_STEP : -GAIN_STEP)));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
} else if (connected && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
const dir = e.key === 'ArrowUp' ? 1 : -1;
onGainChange(clampGain(gain + dir * GAIN_STEP));
} else if (connected && (e.key === 'Backspace' || e.key === 'Delete')) {
e.preventDefault();
onToggle();
}
};
return (
<div
role="button"
tabIndex={0}
aria-pressed={connected}
aria-label={`${INPUT_LABEL[input]} to ${String(output)}${
connected ? `, ${formatGain(displayGain)} dB` : ', not routed'
}`}
className={[
'matrix-cell',
connected ? 'matrix-cell--on' : 'matrix-cell--off',
dragging ? 'matrix-cell--dragging' : '',
]
.filter(Boolean)
.join(' ')}
onClick={connected ? undefined : onToggle}
onDoubleClick={connected ? onToggle : undefined}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onWheel={handleWheel}
onKeyDown={handleKeyDown}
title={
connected
? `${formatGain(displayGain)} dB — drag/scroll to adjust, double-click to remove`
: 'Click to route'
}
>
{connected ? (
<span className="matrix-cell__gain">{formatGain(displayGain)}</span>
) : (
<span className="matrix-cell__dot" />
)}
</div>
);
}
function MatrixSection({
dsp,
setMatrixRouting,
setMatrixGain,
}: {
dsp: DSP;
setMatrixRouting: (output: OutputChannel, inputs: InputChannel[]) => Promise<boolean>;
setMatrixGain: (
inputChannel: InputChannel,
outputChannel: OutputChannel,
gain: number
) => Promise<boolean>;
}) {
// Toggling a cell sends the *whole* next input list for that output —
// setMatrixRouting replaces, it doesn't add/remove a single link.
const handleToggle = (output: OutputChannel, input: InputChannel) => {
const current = getRoutedInputs(dsp, output);
const nowConnecting = !current.includes(input);
const next = nowConnecting ? [...current, input] : current.filter((i) => i !== input);
void setMatrixRouting(output, next).then((ok) => {
// Give a freshly-connected link a defined starting gain rather than
// leaving it at whatever stale value the backend last had for it.
if (ok && nowConnecting) {
void setMatrixGain(input, output, GAIN_DEFAULT);
}
});
};
const handleGainChange = (output: OutputChannel, input: InputChannel, gain: number) => {
void setMatrixGain(input, output, gain);
};
return (
<>
<div className="matrix-graph-section">
<div className="matrix-graph-panel">Routing overview — see chat for ideas</div>
</div>
<div className="matrix-section">
<div
className="matrix-grid-panel"
style={{ ['--matrix-input-count' as string]: INPUTS.length }}
>
<div className="matrix-grid">
<div className="matrix-grid__corner" />
{INPUTS.map((input) => (
<div className="matrix-grid__col-header" key={String(input)}>
{INPUT_LABEL[input]}
</div>
))}
{OUTPUTS.map((output) => (
<Fragment key={String(output)}>
<div className="matrix-grid__row-header">
{dsp.state.current_config.output_states[output].name}
</div>
{INPUTS.map((input) => {
const connected = isRouted(dsp, output, input);
return (
<MatrixCell
key={`${String(output)}-${String(input)}`}
output={output}
input={input}
connected={connected}
gain={getLinkGain(dsp, output, input)}
onToggle={() => handleToggle(output, input)}
onGainChange={(gain) => handleGainChange(output, input, gain)}
/>
);
})}
</Fragment>
))}
</div>
</div>
</div>
</>
);
}
export default MatrixSection;
+1 -1
View File
@@ -139,7 +139,7 @@
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: var(--font-md); font-size: var(--font-xs);
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
scroll-snap-align: start; scroll-snap-align: start;
+2 -10
View File
@@ -45,11 +45,7 @@ function Tab({
return ( return (
<div <div
className={[ className={['tab', active && 'tab--active', dimmed && 'tab--dimmed']
'tab',
active && 'tab--active',
dimmed && 'tab--dimmed',
]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
data-step={step} data-step={step}
@@ -62,11 +58,7 @@ function Tab({
<button <button
type="button" type="button"
className="tab-led" className="tab-led"
aria-label={ aria-label={isConnected ? `Disconnect ${dsp.name}` : `Connect ${dsp.name}`}
isConnected
? `Disconnect ${dsp.name}`
: `Connect ${dsp.name}`
}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
+1719
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
import { DSP, DSPTypes, AppPage } from '../types/types';
import { SetDsps, NotifyFn } from './dspShared';
import { forDSP408, create as createDSP408 } from './dsp408';
import { DSP408Api } from './dsp408';
// import { forDSP204 } from './dsp204';
export type DSPInstance = { dsp: Extract<DSP, { type: 'DSP408' }>; api: DSP408Api };
// | { dsp: Extract<DSP, { type: 'DSP204' }>; api: DSP408Api };
export function createDSPInstance(dsp: DSP, setDsps: SetDsps, notify: NotifyFn): DSPInstance {
switch (dsp.type) {
case 'DSP408':
return { dsp, api: forDSP408(dsp, setDsps, notify) };
case 'DSP204':
throw new Error('DSP204 not yet implemented');
}
}
export async function addDSP(
setDsps: SetDsps,
setSelected: React.Dispatch<React.SetStateAction<number | null>>,
setPage: React.Dispatch<React.SetStateAction<AppPage>>,
notify: NotifyFn,
params: {
name: string;
type: DSPTypes;
ip: string;
port: number;
deviceId: number;
demo: boolean;
}
) {
try {
const newDsp: DSP =
params.type === 'DSP408'
? await createDSP408(params)
: await Promise.reject('DSP204 not implemented');
setDsps((prev) => [...prev, newDsp]);
setSelected(newDsp.id);
setPage('view-dsp');
} catch (err) {
notify(`Failed to add DSP: ${String(err)}`, 'error', params.name);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { DSP, DSPTypes } from '../types/types';
import { stopPollingFor } from './pollRegistry';
export type SetDsps = React.Dispatch<React.SetStateAction<DSP[]>>;
export type NotifyFn = (message: string, type: 'success' | 'error', dspName?: string) => void;
export function updateDSP(setDsps: SetDsps, id: number, updater: (dsp: DSP) => DSP) {
setDsps((prev) => prev.map((d) => (d.id === id ? updater(d) : d)));
}
export function setPollingPaused(setDsps: SetDsps, id: number, paused: boolean) {
updateDSP(setDsps, id, (d) => ({ ...d, pollingPaused: paused }));
}
export function removeDSP(
setDsps: SetDsps,
selected: number | null,
setSelected: React.Dispatch<React.SetStateAction<number | null>>,
id: number,
type: DSPTypes
) {
stopPollingFor(type, id);
setDsps((prev) => prev.filter((d) => d.id !== id));
if (selected === id) setSelected(null);
}
+15
View File
@@ -0,0 +1,15 @@
import { DSPTypes } from '../types/types';
const stoppers = new Map<DSPTypes, (id: number) => void>();
export function registerPollStopper(type: DSPTypes, stop: (id: number) => void) {
stoppers.set(type, stop);
}
export function stopPollingFor(type: DSPTypes, id: number) {
stoppers.get(type)?.(id);
}
export function stopAllPolling(dsps: { id: number; type: DSPTypes }[]) {
for (const dsp of dsps) stopPollingFor(dsp.type, dsp.id);
}
-592
View File
@@ -1,592 +0,0 @@
// hooks/useDSP408.ts
import { useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { DSP, AppPage } from '../types/types';
import { Channel } from '../types/dsp408State';
import {
createDSP408,
removeDSP408,
connectDSP408,
disconnectDSP408,
getDSP408State,
recallPresetDSP408,
DSP408SetCurrentPresetName,
getDSP408MeterLevels,
} from '../api/dsp408';
type NotifyFunction = (message: string, type: 'success' | 'error', dspName?: string) => void;
type SetDsps = React.Dispatch<React.SetStateAction<DSP[]>>;
// ============================================================
// Helpers
// ============================================================
function updateDSP(setDsps: SetDsps, dspId: number, updater: (dsp: DSP) => DSP) {
setDsps((prev) => prev.map((dsp) => (dsp.id === dspId ? updater(dsp) : dsp)));
}
export type DSP408Device = {
connect: () => Promise<void>;
disconnect: () => Promise<void>;
recallPreset: (index: number) => Promise<boolean>;
setCurrentPresetName: (name: string) => Promise<boolean>;
setGain: (channel: Channel, gain: number) => Promise<boolean>;
setMute: (channel: Channel, muted: boolean) => Promise<boolean>;
setInverse: (channel: Channel, inverted: boolean) => Promise<boolean>;
};
// ============================================================
// Polling
// ============================================================
function useDSPPolling(
dsps: DSP[],
selected: number | null,
setDsps: React.Dispatch<React.SetStateAction<DSP[]>>
) {
const dspsRef = useRef(dsps);
const selectedRef = useRef(selected);
useEffect(() => {
dspsRef.current = dsps;
}, [dsps]);
useEffect(() => {
selectedRef.current = selected;
}, [selected]);
useEffect(() => {
let stopped = false;
let activeTimeout: ReturnType<typeof setTimeout> | undefined;
let inactiveTimeout: ReturnType<typeof setTimeout> | undefined;
const pollDSP = async (dsp: DSP) => {
// Don't poll paused DSPs
if (dsp.pollingPaused) {
return;
}
try {
const meters = await getDSP408MeterLevels(dsp.id);
if (stopped) return;
setDsps((prev) => prev.map((d) => (d.id === dsp.id ? { ...d, meters } : d)));
} catch (err) {
if (!stopped) {
console.error(`Meter polling failed for ${dsp.name}:`, err);
}
}
};
const activeLoop = async () => {
if (stopped) return;
const activeId = selectedRef.current;
if (activeId !== null) {
const dsp = dspsRef.current.find(
(d) => d.id === activeId && d.status === 'connected' && !d.pollingPaused
);
if (dsp) {
await pollDSP(dsp);
}
}
if (!stopped) {
activeTimeout = setTimeout(activeLoop, 500);
}
};
const inactiveLoop = async () => {
if (stopped) return;
const activeId = selectedRef.current;
const inactiveDsps = dspsRef.current.filter(
(dsp) => dsp.status === 'connected' && dsp.id !== activeId && !dsp.pollingPaused
);
await Promise.all(inactiveDsps.map((dsp) => pollDSP(dsp)));
if (!stopped) {
inactiveTimeout = setTimeout(inactiveLoop, 5000);
}
};
// Start polling
activeLoop();
inactiveLoop();
return () => {
stopped = true;
if (activeTimeout !== undefined) {
clearTimeout(activeTimeout);
}
if (inactiveTimeout !== undefined) {
clearTimeout(inactiveTimeout);
}
};
}, [setDsps]);
}
// ============================================================
// Main hook
// ============================================================
export function useDSP408(
dsps: DSP[],
setDsps: SetDsps,
selected: number | null,
setSelected: React.Dispatch<React.SetStateAction<number | null>>,
setPage: React.Dispatch<React.SetStateAction<AppPage>>,
notify: NotifyFunction
) {
// ----------------------------------------------------------
// Polling control
// ----------------------------------------------------------
const setPollingPaused = (id: number, paused: boolean) => {
updateDSP(setDsps, id, (dsp) => ({
...dsp,
pollingPaused: paused,
}));
};
// Polling is part of the hook.
useDSPPolling(dsps, selected, setDsps);
// ----------------------------------------------------------
// Generic operation wrapper
// ----------------------------------------------------------
async function withPollingPaused<T>(dsp: DSP, operation: () => Promise<T>): Promise<T> {
setPollingPaused(dsp.id, true);
try {
return await operation();
} finally {
setPollingPaused(dsp.id, false);
}
}
// ==========================================================
// Connection
// ==========================================================
async function connectDSP(dsp: DSP) {
setPollingPaused(dsp.id, true);
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'connecting',
}));
try {
await connectDSP408(dsp.id);
const state = await getDSP408State(dsp.id);
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'connected',
state,
}));
notify('Connected', 'success', dsp.name);
} catch (err) {
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'disconnected',
}));
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
} finally {
setPollingPaused(dsp.id, false);
}
}
async function disconnectDSP(dsp: DSP) {
setPollingPaused(dsp.id, true);
try {
await disconnectDSP408(dsp.id);
updateDSP(setDsps, dsp.id, (d) => ({
...d,
status: 'disconnected',
}));
notify('Disconnected', 'success', dsp.name);
} catch (err) {
// If disconnect failed, it is probably still connected.
setPollingPaused(dsp.id, false);
notify(`Disconnect failed: ${String(err)}`, 'error', dsp.name);
}
}
// ==========================================================
// Add / Remove
// ==========================================================
async function addDSP(dsp: Omit<DSP, 'id' | 'status' | 'state' | 'meters' | 'pollingPaused'>) {
try {
const id = await createDSP408(dsp.ip, dsp.port, dsp.deviceId);
const newDsp: DSP = {
id,
...dsp,
status: 'disconnected',
state: null,
meters: null,
pollingPaused: true,
};
setDsps((prev) => [...prev, newDsp]);
setSelected(id);
setPage('view-dsp');
notify('Added successfully', 'success', dsp.name);
} catch (err) {
notify(`Connection failed: ${String(err)}`, 'error', dsp.name);
}
}
async function removeDSP(dsp: DSP) {
setPollingPaused(dsp.id, true);
try {
await removeDSP408(dsp.id);
setDsps((prev) => {
const next = prev.filter((d) => d.id !== dsp.id);
if (selected === dsp.id) {
setSelected(next.length ? next[0].id : null);
}
return next;
});
notify('Removed successfully', 'success', dsp.name);
} catch (err) {
setPollingPaused(dsp.id, false);
notify(`Remove failed: ${String(err)}`, 'error', dsp.name);
}
}
// ==========================================================
// Presets
// ==========================================================
async function recallPreset(dsp: DSP, presetIndex: number) {
return withPollingPaused(dsp, async () => {
try {
const success = await recallPresetDSP408(dsp.id, presetIndex);
if (!success) {
notify('Failed to recall preset', 'error', dsp.name);
return false;
}
updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d;
return {
...d,
state: {
...d.state,
presets: {
...d.state.presets,
current_index: presetIndex - 1,
},
},
};
});
notify(`Preset ${presetIndex} recalled`, 'success', dsp.name);
return true;
} catch (err) {
notify(`Preset recall failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
async function setCurrentPresetName(dsp: DSP, name: string) {
return withPollingPaused(dsp, async () => {
try {
const paddedName = name.slice(0, 14).padEnd(14, ' ');
const success = await DSP408SetCurrentPresetName(dsp.id, paddedName);
if (!success) {
notify('Failed to rename preset', 'error', dsp.name);
return false;
}
updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d;
const presets = d.state.presets;
const index = presets.current_index;
return {
...d,
state: {
...d.state,
presets: {
...presets,
names: presets.names.map((presetName, i) =>
i === index ? paddedName : presetName
),
modified: presets.modified.map((modified, i) => (i === index ? true : modified)),
},
},
};
});
notify('Preset renamed', 'success', dsp.name);
return true;
} catch (err) {
notify(`Preset rename failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Gain
// ==========================================================
async function setGain(dsp: DSP, channel: Channel, gain: number): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('set_channel_gain', {
id: dsp.id,
channel,
db: gain,
});
if (!success) {
notify('Failed to set gain', 'error', dsp.name);
return false;
}
const state = await getDSP408State(dsp.id);
updateDSP(setDsps, dsp.id, (d) => ({
...d,
state,
}));
notify('Gain updated', 'success', dsp.name);
return true;
} catch (err) {
notify(`Gain update failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Mute
// ==========================================================
async function setMute(dsp: DSP, channel: Channel, muted: boolean): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('set_mute', {
id: dsp.id,
channel,
muted,
});
if (!success) {
notify('Failed to change mute state', 'error', dsp.name);
return false;
}
updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d;
const currentConfig = d.state.current_config;
if ('Input' in channel) {
const inputChannel = channel.Input;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
input_states: {
...currentConfig.input_states,
[inputChannel]: {
...currentConfig.input_states[inputChannel],
mute: muted,
},
},
},
},
};
}
const outputChannel = channel.Output;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
output_states: {
...currentConfig.output_states,
[outputChannel]: {
...currentConfig.output_states[outputChannel],
mute: muted,
},
},
},
},
};
});
notify(muted ? 'Muted' : 'Unmuted', 'success', dsp.name);
return true;
} catch (err) {
notify(`Mute update failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Inverse
// ==========================================================
async function setInverse(dsp: DSP, channel: Channel, inverted: boolean): Promise<boolean> {
return withPollingPaused(dsp, async () => {
try {
const success = await invoke<boolean>('set_channel_inverse_gain', {
id: dsp.id,
channel,
inverted,
});
if (!success) {
notify('Failed to change phase', 'error', dsp.name);
return false;
}
updateDSP(setDsps, dsp.id, (d) => {
if (!d.state) return d;
const currentConfig = d.state.current_config;
if ('Input' in channel) {
const inputChannel = channel.Input;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
input_states: {
...currentConfig.input_states,
[inputChannel]: {
...currentConfig.input_states[inputChannel],
phase_inverted: inverted,
},
},
},
},
};
}
const outputChannel = channel.Output;
return {
...d,
state: {
...d.state,
current_config: {
...currentConfig,
output_states: {
...currentConfig.output_states,
[outputChannel]: {
...currentConfig.output_states[outputChannel],
phase_inverted: inverted,
},
},
},
},
};
});
notify(inverted ? 'Phase inverted' : 'Phase normal', 'success', dsp.name);
return true;
} catch (err) {
notify(`Phase update failed: ${String(err)}`, 'error', dsp.name);
return false;
}
});
}
// ==========================================================
// Return API
// ==========================================================
function forDSP(dsp: DSP): DSP408Device {
return {
connect: () => connectDSP(dsp),
disconnect: () => disconnectDSP(dsp),
recallPreset: (index: number) => recallPreset(dsp, index),
setCurrentPresetName: (name: string) => setCurrentPresetName(dsp, name),
setGain: (channel: Channel, gain: number) => setGain(dsp, channel, gain),
setMute: (channel: Channel, muted: boolean) => setMute(dsp, channel, muted),
setInverse: (channel: Channel, inverted: boolean) => setInverse(dsp, channel, inverted),
};
}
return {
forDSP,
addDSP,
removeDSP,
connectDSP,
disconnectDSP,
recallPreset,
setCurrentPresetName,
setGain,
};
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -67,3 +67,8 @@
gap: var(--space-3); gap: var(--space-3);
margin-top: var(--space-2); margin-top: var(--space-2);
} }
.add-dsp-form__checkbox-label {
display: flex;
align-items: center;
}
+36 -13
View File
@@ -1,10 +1,19 @@
import { useState } from 'react'; import { useState } from 'react';
import { DSP, DSP_TYPES } from '../../types/types'; import { DSP, DSP_DEFAULT_PARAMETERS, DSPTypes } from '../../types/types';
import Button from '../../components/Button/Button'; import Button from '../../components/Button/Button';
import Card from '../../components/Card/Card'; import Card from '../../components/Card/Card';
import './AddDspPage.css'; import './AddDspPage.css';
type DSPForm = {
name: string;
type: DSPTypes;
ip: string;
port: number;
deviceId: number;
demo: boolean;
};
function AddDSPForm({ function AddDSPForm({
onCancel, onCancel,
onAdd, onAdd,
@@ -12,17 +21,18 @@ function AddDSPForm({
onCancel: () => void; onCancel: () => void;
onAdd: (d: Omit<DSP, 'status' | 'id' | 'state' | 'meters' | 'pollingPaused'>) => void; onAdd: (d: Omit<DSP, 'status' | 'id' | 'state' | 'meters' | 'pollingPaused'>) => void;
}) { }) {
const [form, setForm] = useState({ const [form, setForm] = useState<DSPForm>({
name: '', name: '',
type: DSP_TYPES[0].name, type: 'DSP408',
ip: '192.168.1.100', ip: DSP_DEFAULT_PARAMETERS['DSP408'].ip,
port: DSP_TYPES[0].defaultPort, port: DSP_DEFAULT_PARAMETERS['DSP408'].defaultPort,
deviceId: 1, deviceId: DSP_DEFAULT_PARAMETERS['DSP408'].device_id,
demo: false,
}); });
const canSubmit = const canSubmit =
form.name.trim().length > 0 && form.name.trim().length > 0 &&
form.ip.trim().length > 0 && (form.demo || form.ip.trim().length > 0) &&
Number.isInteger(form.deviceId) && Number.isInteger(form.deviceId) &&
form.deviceId >= 1 && form.deviceId >= 1 &&
form.deviceId <= 255; form.deviceId <= 255;
@@ -58,18 +68,17 @@ function AddDSPForm({
id="f-type" id="f-type"
value={form.type} value={form.type}
onChange={(e) => { onChange={(e) => {
const selected = DSP_TYPES.find((d) => d.name === e.target.value); const type = e.target.value as DSPTypes;
setForm({ setForm({
...form, ...form,
type: e.target.value, type,
port: selected?.defaultPort ?? form.port,
}); });
}} }}
> >
{DSP_TYPES.map((dsp) => ( {Object.keys(DSP_DEFAULT_PARAMETERS).map((type) => (
<option key={dsp.name} value={dsp.name}> <option key={type} value={type}>
{dsp.name} {type}
</option> </option>
))} ))}
</select> </select>
@@ -98,6 +107,7 @@ function AddDSPForm({
id="f-ip" id="f-ip"
className="mono" className="mono"
value={form.ip} value={form.ip}
disabled={form.demo}
onChange={(e) => setForm({ ...form, ip: e.target.value })} onChange={(e) => setForm({ ...form, ip: e.target.value })}
/> />
</div> </div>
@@ -109,11 +119,24 @@ function AddDSPForm({
className="mono" className="mono"
type="number" type="number"
value={form.port} value={form.port}
disabled={form.demo}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })}
/> />
</div> </div>
</div> </div>
<div className="add-dsp-form__field add-dsp-form__field--checkbox">
<label htmlFor="f-demo" className="add-dsp-form__checkbox-label">
<input
id="f-demo"
type="checkbox"
checked={form.demo}
onChange={(e) => setForm({ ...form, demo: e.target.checked })}
/>
Demo mode (simulate device, no network connection)
</label>
</div>
<div className="add-dsp-form__actions"> <div className="add-dsp-form__actions">
<Button variant="ghost" onClick={onCancel}> <Button variant="ghost" onClick={onCancel}>
Cancel Cancel
+202
View File
@@ -0,0 +1,202 @@
import { useState } from 'react';
import { DSP } from '../../types/types';
import Sidebar from '../../components/Sidebar/Sidebar';
import { InfoIcon, KnobGainIcon, TextIcon } from '../../assets/icons/AudioIcons';
import ConnectionPanel from '../../components/dsp408/ConnectionSection/ConnectionSection';
import GainSection from '../../components/dsp408/GainSection/GainSection';
import CompressorSection from '../../components/dsp408/CompressorSection/CompressorSection';
import GateSection from '../../components/dsp408/GateSection/GateSection';
import LimiterSection from '../../components/dsp408/LimiterSection/LimiterSection';
import DelaySection from '../../components/dsp408/DelaySection/DelaySection';
import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay';
import { InputChannel, OutputChannel } from '../../types/dsp408';
import MatrixSection from '../../components/dsp408/Matrixsection/Matrixsection';
import GeqSection from '../../components/dsp408/GeqSection/GeqSection';
import InputSection from '../../components/dsp408/InputSection/InputSection';
import { DSP408Api } from '../../dsp/dsp408';
import '../DspPage/DspPage.css';
type DSP408 = Extract<DSP, { type: 'DSP408' }>;
function DSP408Panel({ dsp, api }: { dsp: DSP408; api: DSP408Api }) {
const [activeSidebar, setActiveSidebar] = useState('overview');
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0);
const [presetLoading, setPresetLoading] = useState(false);
const inputStates = dsp.state?.current_config.input_states;
const outputStates = dsp.state?.current_config.output_states;
const sidebarItems = [
{ id: 'overview', label: 'Overview', icon: <InfoIcon /> },
{ id: 'gain', label: 'Gain', icon: <KnobGainIcon /> },
{ id: 'gate', label: 'Gate', icon: <KnobGainIcon /> },
{ id: 'compressor', label: 'Compressor', icon: <KnobGainIcon /> },
{ id: 'limiter', label: 'Limiter', icon: <KnobGainIcon /> },
{ id: 'delay', label: 'Delay', icon: <KnobGainIcon /> },
{ id: 'matrix', label: 'Matrix', icon: <KnobGainIcon /> },
{ id: 'geq', label: 'Graphic Equalizer', icon: <KnobGainIcon /> },
{ id: 'ina', label: `${inputStates[InputChannel.InA].name}`, icon: <TextIcon>In A</TextIcon> },
{ id: 'inb', label: `${inputStates[InputChannel.InB].name}`, icon: <TextIcon>In B</TextIcon> },
{ id: 'inc', label: `${inputStates[InputChannel.InC].name}`, icon: <TextIcon>In C</TextIcon> },
{ id: 'ind', label: `${inputStates[InputChannel.InD].name}`, icon: <TextIcon>In D</TextIcon> },
{
id: 'out1',
label: `${outputStates[OutputChannel.Out1].name}`,
icon: <TextIcon>Out 1</TextIcon>,
},
{
id: 'out2',
label: `${outputStates[OutputChannel.Out2].name}`,
icon: <TextIcon>Out 2</TextIcon>,
},
{
id: 'out3',
label: `${outputStates[OutputChannel.Out3].name}`,
icon: <TextIcon>Out 3</TextIcon>,
},
{
id: 'out4',
label: `${outputStates[OutputChannel.Out4].name}`,
icon: <TextIcon>Out 4</TextIcon>,
},
{
id: 'out5',
label: `${outputStates[OutputChannel.Out5].name}`,
icon: <TextIcon>Out 5</TextIcon>,
},
{
id: 'out6',
label: `${outputStates[OutputChannel.Out6].name}`,
icon: <TextIcon>Out 6</TextIcon>,
},
{
id: 'out7',
label: `${outputStates[OutputChannel.Out7].name}`,
icon: <TextIcon>Out 7</TextIcon>,
},
{
id: 'out8',
label: `${outputStates[OutputChannel.Out8].name}`,
icon: <TextIcon>Out 8</TextIcon>,
},
{ id: 'miscellaneous', label: 'Miscellaneous', icon: <TextIcon>Out 1</TextIcon> },
];
const presets = dsp.state?.presets.names
? [
{ id: 'f00', name: 'Factory Preset' },
...dsp.state.presets.names.map((name, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name,
})),
]
: [
{ id: 'f00', name: 'Factory Preset' },
...Array.from({ length: 20 }, (_, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name: 'Default Preset',
})),
];
const onAppChange = async (id: string) => {
const presetIndex = presets.findIndex((preset) => preset.id === id);
if (presetIndex === -1) return;
setPresetLoading(true);
const success = await api.recallPreset(presetIndex);
if (success) setSelectedPreset(presetIndex);
setPresetLoading(false);
};
const renderTab = () => {
switch (activeSidebar) {
case 'overview':
return <ConnectionPanel dsp={dsp} onConnect={api.connect} onDisconnect={api.disconnect} />;
case 'gain':
return (
<GainSection
dsp={dsp}
setGain={api.setGain}
setMute={api.setMute}
setInverse={api.setInverse}
/>
);
case 'gate':
return <GateSection dsp={dsp} set_gate={api.setGate} />;
case 'compressor':
return <CompressorSection dsp={dsp} set_compressor={api.setCompressor} />;
case 'limiter':
return <LimiterSection dsp={dsp} set_limiter={api.setLimiter} />;
// case 'delay':
// return (
// <DelaySection
// dsp={dsp}
// setDelay={api.setDelay}
// setDelayUnit={api.setDelayUnit}
// setDSPState={api.setDSPState}
// />
// );
case 'ina':
return (
<InputSection
dsp={dsp}
channel={InputChannel.InA}
set_low_pass={api.setLowPass}
set_high_pass={api.setHighPass}
setPeq={api.setPeqBand}
setGain={api.setGain}
setMute={api.setMute}
setInverse={api.setInverse}
/>
);
case 'matrix':
return (
<MatrixSection
dsp={dsp}
setMatrixRouting={api.setMatrixRouting}
setMatrixGain={api.setMatrixGain}
/>
);
case 'geq':
return <GeqSection dsp={dsp} setGeqBand={api.setGeqBand} setGeqBypass={api.setGeqBypass} />;
default:
return null;
}
};
return (
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
<Sidebar
items={sidebarItems}
activeId={activeSidebar}
onSelect={setActiveSidebar}
appName={dsp.name}
appOptions={presets}
selectedAppId={presets[selectedPreset]?.id}
onAppChange={onAppChange}
onAppRename={api.setCurrentPresetName}
expanded={sidebarExpanded}
onToggle={() => setSidebarExpanded((v) => !v)}
/>
<LoadingOverlay
active={presetLoading}
label="Recalling preset…"
overlayClassName="dsp-loading-overlay"
>
<div className="dsp-content">{renderTab()}</div>
</LoadingOverlay>
</div>
);
}
export default DSP408Panel;
+8 -2
View File
@@ -2,9 +2,15 @@
position: fixed; position: fixed;
left: var(--sidebar-width-collapsed); left: var(--sidebar-width-collapsed);
right: 0; right: 0;
bottom: 0;
transition: left var(--dur-base) var(--ease-standard);
top: var(--header-height); top: var(--header-height);
bottom: 0;
display: flex;
flex-direction: column;
min-height: 0;
transition: left var(--dur-base) var(--ease-standard);
} }
.app-root:has(.sidebar-rail--expanded) .dsp-content { .app-root:has(.sidebar-rail--expanded) .dsp-content {
+7 -209
View File
@@ -1,213 +1,11 @@
import { useState } from 'react'; import { DSPInstance } from '../../dsp/dspApi';
import { DSP } from '../../types/types'; import DSP408Panel from '../DSP408Panel/DSP408Panel';
import Sidebar from '../../components/Sidebar/Sidebar';
import { InfoIcon, KnobGainIcon } from '../../assets/icons/AudioIcons';
import ConnectionPanel from '../../components/dsp408/ConnectionSection/ConnectionSection';
import GainSection from '../../components/dsp408/GainSection/GainSection';
import { DSP408Device } from '../../hooks/useDSP408';
import './DspPage.css';
import GateSection from '../../components/dsp408/GateSection/GateSection';
import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay';
import { InputChannel, OutputChannel } from '../../types/dsp408State';
function DSPPage({ dsp, dsp408 }: { dsp: DSP; dsp408: DSP408Device }) { function DSPPage({ instance }: { instance: DSPInstance }) {
const [activeSidebar, setActiveSidebar] = useState('overview'); if (instance.dsp.type === 'DSP408') {
const [sidebarExpanded, setSidebarExpanded] = useState(false); return <DSP408Panel dsp={instance.dsp} api={instance.api} />;
const [selectedPreset, setSelectedPreset] = useState(dsp.state?.presets.current_index ?? 0); }
const [presetLoading, setPresetLoading] = useState(false); return null;
const inputStates = dsp.state?.current_config.input_states;
const outputStates = dsp.state?.current_config.output_states;
const sidebarItems = [
{
id: 'overview',
label: 'Overview',
icon: <InfoIcon />,
},
{
id: 'gain',
label: 'Gain',
icon: <KnobGainIcon />,
},
{
id: 'gate',
label: 'Gate',
icon: <KnobGainIcon />,
},
{
id: 'compressor',
label: 'Compressor',
icon: <KnobGainIcon />,
},
{
id: 'limiter',
label: 'Limiter',
icon: <KnobGainIcon />,
},
{
id: 'delay',
label: 'Delay',
icon: <KnobGainIcon />,
},
{
id: 'matrix',
label: 'Matrix',
icon: <KnobGainIcon />,
},
{
id: 'geq',
label: 'Graphic Equalizer',
icon: <KnobGainIcon />,
},
{
id: 'ina',
label: `In A (${inputStates?.[InputChannel.InA]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'inb',
label: `In B (${inputStates?.[InputChannel.InB]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'inc',
label: `In C (${inputStates?.[InputChannel.InC]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'ind',
label: `In D (${inputStates?.[InputChannel.InD]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out1',
label: `Out 1 (${outputStates?.[OutputChannel.Out1]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out2',
label: `Out 2 (${outputStates?.[OutputChannel.Out2]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out3',
label: `Out 3 (${outputStates?.[OutputChannel.Out3]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out4',
label: `Out 4 (${outputStates?.[OutputChannel.Out4]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out5',
label: `Out 5 (${outputStates?.[OutputChannel.Out5]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out6',
label: `Out 6 (${outputStates?.[OutputChannel.Out6]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out7',
label: `Out 7 (${outputStates?.[OutputChannel.Out7]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'out8',
label: `Out 8 (${outputStates?.[OutputChannel.Out8]?.name})`,
icon: <KnobGainIcon />,
},
{
id: 'miscellaneous',
label: 'Miscellaneous',
icon: <KnobGainIcon />,
},
];
const presets = dsp.state?.presets.names
? [
{
id: 'f00',
name: 'Factory Preset',
},
...dsp.state.presets.names.map((name, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name,
})),
]
: [
{
id: 'f00',
name: 'Factory Preset',
},
...Array.from({ length: 20 }, (_, index) => ({
id: `u${String(index + 1).padStart(2, '0')}`,
name: 'Default Preset',
})),
];
const onAppChange = async (id: string) => {
const presetIndex = presets.findIndex((preset) => preset.id === id);
if (presetIndex === -1) return;
setPresetLoading(true);
const success = await dsp408.recallPreset(presetIndex);
if (success) {
setSelectedPreset(presetIndex);
}
setPresetLoading(false);
};
const renderTab = () => {
switch (activeSidebar) {
case 'overview':
return (
<ConnectionPanel dsp={dsp} onConnect={dsp408.connect} onDisconnect={dsp408.disconnect} />
);
case 'gain':
return (
<GainSection
dsp={dsp}
setGain={dsp408.setGain}
setMute={dsp408.setMute}
setInverse={dsp408.setInverse}
/>
);
case 'gate':
return <GateSection dsp={dsp} />;
default:
return null;
}
};
return (
<div className={`app-root ${sidebarExpanded ? 'app-root--sidebar-expanded' : ''}`}>
<Sidebar
items={sidebarItems}
activeId={activeSidebar}
onSelect={setActiveSidebar}
appName={dsp.name}
appOptions={presets}
selectedAppId={presets[selectedPreset]?.id}
onAppChange={onAppChange}
onAppRename={dsp408.setCurrentPresetName}
expanded={sidebarExpanded}
onToggle={() => setSidebarExpanded((v) => !v)}
/>
<LoadingOverlay
active={presetLoading}
label="Recalling preset…"
overlayClassName="dsp-loading-overlay"
>
<div className="dsp-content">{renderTab()}</div>
</LoadingOverlay>
</div>
);
} }
export default DSPPage; export default DSPPage;
+422
View File
@@ -0,0 +1,422 @@
export enum DelayUnit {
Millisecond = 'Millisecond',
Meter = 'Meter',
Feet = 'Feet',
}
export enum InputChannel {
InA = 'InA',
InB = 'InB',
InC = 'InC',
InD = 'InD',
}
export enum OutputChannel {
Out1 = 'Out1',
Out2 = 'Out2',
Out3 = 'Out3',
Out4 = 'Out4',
Out5 = 'Out5',
Out6 = 'Out6',
Out7 = 'Out7',
Out8 = 'Out8',
}
export type Channel = { Input: InputChannel } | { Output: OutputChannel };
export enum Ratio {
Ratio1_1 = 'Ratio1_1',
Ratio1_1_1 = 'Ratio1_1_1',
Ratio1_1_3 = 'Ratio1_1_3',
Ratio1_1_5 = 'Ratio1_1_5',
Ratio1_1_7 = 'Ratio1_1_7',
Ratio1_2 = 'Ratio1_2',
Ratio1_2_5 = 'Ratio1_2_5',
Ratio1_3 = 'Ratio1_3',
Ratio1_3_5 = 'Ratio1_3_5',
Ratio1_4 = 'Ratio1_4',
Ratio1_5 = 'Ratio1_5',
Ratio1_6 = 'Ratio1_6',
Ratio1_8 = 'Ratio1_8',
Ratio1_10 = 'Ratio1_10',
Ratio1_20 = 'Ratio1_20',
Limit = 'Limit',
}
export enum CrossoverFilter {
Bypass = 'Bypass',
Bw6 = 'Bw6',
Bw12 = 'Bw12',
Bw18 = 'Bw18',
Bw24 = 'Bw24',
Bw30 = 'Bw30',
Bw36 = 'Bw36',
Bw42 = 'Bw42',
Bw48 = 'Bw48',
Lk12 = 'Lk12',
Lk24 = 'Lk24',
Lk36 = 'Lk36',
Lk48 = 'Lk48',
}
export enum PEQFilter {
Peak = 'Peak',
LowShelf = 'LowShelf',
HighShelf = 'HighShelf',
Lp6Db = 'Lp6Db',
Lp12Db = 'Lp12Db',
Hp6Db = 'Hp6Db',
Hp12Db = 'Hp12Db',
AllPass1 = 'AllPass1',
AllPass2 = 'AllPass2',
}
export enum DiscreteFrequency {
F20 = 'F20',
F25 = 'F25',
F31_5 = 'F31_5',
F40 = 'F40',
F50 = 'F50',
F63 = 'F63',
F80 = 'F80',
F100 = 'F100',
F125 = 'F125',
F160 = 'F160',
F200 = 'F200',
F250 = 'F250',
F315 = 'F315',
F400 = 'F400',
F500 = 'F500',
F630 = 'F630',
F800 = 'F800',
F1000 = 'F1000',
F1250 = 'F1250',
F1600 = 'F1600',
F2000 = 'F2000',
F2500 = 'F2500',
F3150 = 'F3150',
F4000 = 'F4000',
F5000 = 'F5000',
F6300 = 'F6300',
F8000 = 'F8000',
F10000 = 'F10000',
F12500 = 'F12500',
F16000 = 'F16000',
F20000 = 'F20000',
}
export enum PEQBand {
B1 = 1,
B2 = 2,
B3 = 3,
B4 = 4,
B5 = 5,
B6 = 6,
B7 = 7,
B8 = 8,
B9 = 9,
}
// ---------------- STATE ----------------
export type Gate = {
attack: number;
release: number;
hold: number;
threshold: number;
};
export type Compressor = {
threshold: number;
ratio: Ratio;
attack: number;
release: number;
knee: number;
};
export type Limiter = {
threshold: number;
attack: number;
release: number;
};
export type GraphicEQ = {
gains: Record<DiscreteFrequency, number>;
bypass: boolean;
};
export type PEQ = {
gain: number;
frequency: number;
q: number;
filter_type: PEQFilter;
bypass: boolean;
};
export type Crossover = {
frequency: number;
slope: CrossoverFilter;
};
export type CrossoverFilters = {
high_pass: Crossover;
low_pass: Crossover;
};
export type MatrixRoutes = {
connected: InputChannel[];
gains: number[];
};
export type PEQChain = {
bands: Record<PEQBand, PEQ>;
bypass: boolean;
};
export type PresetBank = {
current_index: number;
names: string[];
modified: boolean[];
};
export type DeviceFlags = {
is_locked: boolean;
};
export type InputSource = {
type: string;
frequency: string;
};
export type InputChannelState = {
name: string;
gain: number;
phase_inverted: boolean;
delay: number;
gate: Gate;
geq: GraphicEQ;
peq_chain: PEQChain;
crossover: CrossoverFilters;
linked_channels: Channel[];
mute: boolean;
};
export type OutputChannelState = {
name: string;
matrix_routes: MatrixRoutes;
crossover: CrossoverFilters;
peq_chain: PEQChain;
compressor: Compressor;
limiter: Limiter;
gain: number;
phase_inverted: boolean;
delay: number;
linked_channels: Channel[];
mute: boolean;
};
export type DSPConfigState = {
input_states: Record<InputChannel, InputChannelState>;
output_states: Record<OutputChannel, OutputChannelState>;
input_source: InputSource;
delay_unit: DelayUnit;
};
export type DSPState = {
name: string;
flags: DeviceFlags;
presets: PresetBank;
current_config: DSPConfigState;
};
export interface Meter {
rms: number;
peak: number;
clip: boolean;
}
export interface Meters {
channels: Meter[];
}
/* -- Default Value -- */
const createDefaultGate = (): Gate => ({
attack: 10,
release: 10,
hold: 10,
threshold: 0,
});
const createDefaultGraphicEQ = (): GraphicEQ => ({
gains: {
[DiscreteFrequency.F20]: 0,
[DiscreteFrequency.F25]: 0,
[DiscreteFrequency.F31_5]: 0,
[DiscreteFrequency.F40]: 0,
[DiscreteFrequency.F50]: 0,
[DiscreteFrequency.F63]: 0,
[DiscreteFrequency.F80]: 0,
[DiscreteFrequency.F100]: 0,
[DiscreteFrequency.F125]: 0,
[DiscreteFrequency.F160]: 0,
[DiscreteFrequency.F200]: 0,
[DiscreteFrequency.F250]: 0,
[DiscreteFrequency.F315]: 0,
[DiscreteFrequency.F400]: 0,
[DiscreteFrequency.F500]: 0,
[DiscreteFrequency.F630]: 0,
[DiscreteFrequency.F800]: 0,
[DiscreteFrequency.F1000]: 0,
[DiscreteFrequency.F1250]: 0,
[DiscreteFrequency.F1600]: 0,
[DiscreteFrequency.F2000]: 0,
[DiscreteFrequency.F2500]: 0,
[DiscreteFrequency.F3150]: 0,
[DiscreteFrequency.F4000]: 0,
[DiscreteFrequency.F5000]: 0,
[DiscreteFrequency.F6300]: 0,
[DiscreteFrequency.F8000]: 0,
[DiscreteFrequency.F10000]: 0,
[DiscreteFrequency.F12500]: 0,
[DiscreteFrequency.F16000]: 0,
[DiscreteFrequency.F20000]: 0,
},
bypass: false,
});
const createDefaultPEQ = (): PEQ => ({
gain: 0,
frequency: 1000,
q: 1,
filter_type: PEQFilter.Peak,
bypass: false,
});
const createDefaultPEQChain = (): PEQChain => ({
bands: Object.fromEntries(
Object.values(PEQBand)
.filter((band): band is PEQBand => typeof band === 'number')
.map((band) => [band, createDefaultPEQ()])
) as Record<PEQBand, PEQ>,
bypass: false,
});
const createDefaultCrossover = (): Crossover => ({
frequency: 20000,
slope: CrossoverFilter.Bypass,
});
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
high_pass: createDefaultCrossover(),
low_pass: createDefaultCrossover(),
});
const createDefaultInputChannelState = (name: string): InputChannelState => ({
name,
gain: 0,
phase_inverted: false,
delay: 0,
gate: createDefaultGate(),
geq: createDefaultGraphicEQ(),
peq_chain: createDefaultPEQChain(),
crossover: createDefaultCrossoverFilters(),
linked_channels: [],
mute: false,
});
const createDefaultOutputChannelState = (name: string): OutputChannelState => ({
name,
matrix_routes: {
connected: [],
gains: [],
},
crossover: createDefaultCrossoverFilters(),
peq_chain: createDefaultPEQChain(),
compressor: {
threshold: -90,
ratio: Ratio.Ratio1_1,
attack: 1,
release: 10,
knee: 0,
},
limiter: {
threshold: -90,
attack: 1,
release: 10,
},
gain: 0,
phase_inverted: false,
delay: 0,
linked_channels: [],
mute: false,
});
export const defaultDSPState: DSPState = {
name: 'Default',
flags: {
is_locked: false,
},
presets: {
current_index: 0,
names: Array(20).fill('Default'),
modified: Array(20).fill(false),
},
current_config: {
input_source: {
type: 'Analog',
frequency: '48kHz',
},
delay_unit: DelayUnit.Millisecond,
input_states: {
[InputChannel.InA]: createDefaultInputChannelState('InInInIn'),
[InputChannel.InB]: createDefaultInputChannelState('In B'),
[InputChannel.InC]: createDefaultInputChannelState('In C'),
[InputChannel.InD]: createDefaultInputChannelState('In D'),
},
output_states: {
[OutputChannel.Out1]: createDefaultOutputChannelState('Out 1'),
[OutputChannel.Out2]: createDefaultOutputChannelState('Out 2'),
[OutputChannel.Out3]: createDefaultOutputChannelState('Out 3'),
[OutputChannel.Out4]: createDefaultOutputChannelState('Out 4'),
[OutputChannel.Out5]: createDefaultOutputChannelState('Out 5'),
[OutputChannel.Out6]: createDefaultOutputChannelState('Out 6'),
[OutputChannel.Out7]: createDefaultOutputChannelState('Out 7'),
[OutputChannel.Out8]: createDefaultOutputChannelState('Out 8'),
},
},
};
const createDefaultMeter = (): Meter => ({
rms: -60,
peak: -60,
clip: false,
});
export const defaultMeters: Meters = {
channels: Array.from({ length: 12 }, createDefaultMeter),
};
-197
View File
@@ -1,197 +0,0 @@
export enum DelayUnit {
Millisecond = 'Millisecond',
Meter = 'Meter',
Feet = 'Feet',
}
export enum InputChannel {
InA = 'InA',
InB = 'InB',
InC = 'InC',
InD = 'InD',
}
export enum OutputChannel {
Out1 = 'Out1',
Out2 = 'Out2',
Out3 = 'Out3',
Out4 = 'Out4',
Out5 = 'Out5',
Out6 = 'Out6',
Out7 = 'Out7',
Out8 = 'Out8',
}
export type Channel = { Input: InputChannel } | { Output: OutputChannel };
export enum Ratio {
Ratio1_1 = 'Ratio1_1',
Ratio1_1_1 = 'Ratio1_1_1',
Ratio1_1_3 = 'Ratio1_1_3',
Ratio1_1_5 = 'Ratio1_1_5',
Ratio1_1_7 = 'Ratio1_1_7',
Ratio1_2 = 'Ratio1_2',
Ratio1_2_5 = 'Ratio1_2_5',
Ratio1_3 = 'Ratio1_3',
Ratio1_3_5 = 'Ratio1_3_5',
Ratio1_4 = 'Ratio1_4',
Ratio1_5 = 'Ratio1_5',
Ratio1_6 = 'Ratio1_6',
Ratio1_8 = 'Ratio1_8',
Ratio1_10 = 'Ratio1_10',
Ratio1_20 = 'Ratio1_20',
Limit = 'Limit',
}
export enum CrossoverFilter {
Bypass = 'Bypass',
Bw6 = 'Bw6',
Bw12 = 'Bw12',
Bw18 = 'Bw18',
Bw24 = 'Bw24',
Bw30 = 'Bw30',
Bw36 = 'Bw36',
Bw42 = 'Bw42',
Bw48 = 'Bw48',
Lk12 = 'Lk12',
Lk24 = 'Lk24',
Lk36 = 'Lk36',
Lk48 = 'Lk48',
}
export enum PEQFilter {
Peak = 'Peak',
LowShelf = 'LowShelf',
HighShelf = 'HighShelf',
Lp6Db = 'Lp6Db',
Lp12Db = 'Lp12Db',
Hp6Db = 'Hp6Db',
Hp12Db = 'Hp12Db',
AllPass1 = 'AllPass1',
AllPass2 = 'AllPass2',
}
// ---------------- STATE ----------------
export type Gate = {
attack: number;
release: number;
hold: number;
threshold: number;
};
export type Compressor = {
threshold: number;
ratio: Ratio;
attack: number;
release: number;
knee: number;
};
export type Limiter = {
threshold: number;
attack: number;
release: number;
};
export type GraphicEQ = {
gains: number[];
bypass: boolean;
};
export type PEQ = {
gain: number;
frequency: number;
q: number;
filter_type: PEQFilter;
bypass: boolean;
};
export type Crossover = {
frequency: number;
slope: CrossoverFilter;
};
export type CrossoverFilters = {
high_pass: Crossover;
low_pass: Crossover;
};
export type MatrixRoutes = {
connected: InputChannel[];
gains: number[];
};
export type PEQChain = {
bands: PEQ[];
bypass: boolean;
};
export type PresetBank = {
current_index: number;
names: string[];
modified: boolean[];
};
export type DeviceFlags = {
is_locked: boolean;
};
export type InputSource = {
type: string;
frequency: string;
};
export type InputChannelState = {
name: string;
gain: number;
phase_inverted: boolean;
delay: number;
gate: Gate;
geq: GraphicEQ;
peq_chain: PEQChain;
crossover: CrossoverFilters;
linked_channels: Channel[];
mute: boolean;
};
export type OutputChannelState = {
name: string;
matrix_routes: MatrixRoutes;
crossover: CrossoverFilters;
peq_chain: PEQChain;
compressor: Compressor;
limiter: Limiter;
gain: number;
phase_inverted: boolean;
delay: number;
linked_channels: Channel[];
mute: boolean;
};
export type DSPConfigState = {
input_states: Record<InputChannel, InputChannelState>;
output_states: Record<OutputChannel, OutputChannelState>;
input_source: InputSource;
delay_unit: DelayUnit;
};
export type DSPState = {
name: string;
flags: DeviceFlags;
presets: PresetBank;
current_config: DSPConfigState;
};
export interface Meters {
levels: number[];
}
+41 -26
View File
@@ -1,33 +1,8 @@
import { DSPState, Meters } from './dsp408State'; import { DSPState as DSP408State, Meters as DSP408Meters } from './dsp408';
export type DSPStatus = 'connected' | 'connecting' | 'disconnected'; export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
export type DSP = {
id: number;
name: string;
type: string;
ip: string;
port: number;
deviceId: number;
status: DSPStatus;
state: DSPState | null;
meters: Meters | null;
pollingPaused: boolean;
};
export const DSP_TYPES = [
{
name: 'DSP408',
defaultPort: 9761,
},
{
name: 'DSP204',
defaultPort: 94,
},
];
export type NotificationType = 'success' | 'error' | 'warning'; export type NotificationType = 'success' | 'error' | 'warning';
export type Notification = { export type Notification = {
id: number; id: number;
message: string; message: string;
@@ -36,3 +11,43 @@ export type Notification = {
}; };
export type AppPage = 'home' | 'add-dsp' | 'view-dsp' | 'credits'; export type AppPage = 'home' | 'add-dsp' | 'view-dsp' | 'credits';
type DSPDefaultParameters = {
ip: string;
defaultPort: number;
device_id: number;
};
export type DSPTypes = 'DSP408' | 'DSP204';
export const DSP_DEFAULT_PARAMETERS: Record<DSPTypes, DSPDefaultParameters> = {
DSP408: { ip: '192.168.49.30', defaultPort: 9761, device_id: 1 },
DSP204: { ip: '192.168.49.30', defaultPort: 94, device_id: 1 },
};
interface DSPBase {
id: number;
name: string;
ip: string;
port: number;
deviceId: number;
demo: boolean;
status: DSPStatus;
pollingPaused: boolean;
}
export type DSP408 = DSPBase & {
type: 'DSP408';
state: DSP408State;
meters: DSP408Meters;
};
export type DSP204 = DSPBase & {
type: 'DSP204';
state: null;
meters: null;
};
export type DSP = DSP408 | DSP204;
+1 -1
View File
@@ -89,5 +89,5 @@
--sidebar-width-expanded: 200px; --sidebar-width-expanded: 200px;
/* ---------- DSP ---------- */ /* ---------- DSP ---------- */
--dsp-control-heigth: 40vh; --dsp-control-heigth: 40%;
} }