Compare commits
11
Commits
06710b68d4
...
d91bcaa20f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d91bcaa20f | ||
|
|
b572e1c794 | ||
|
|
0f66ed72f4 | ||
|
|
ee3f56fcdf | ||
|
|
fbcaaf31aa | ||
|
|
470b95ace3 | ||
|
|
e802067056 | ||
|
|
56f7aca174 | ||
|
|
78ea04453e | ||
|
|
e110a28f80 | ||
|
|
daf2bf32aa |
@@ -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
@@ -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
@@ -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!())
|
||||||
|
|||||||
@@ -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
@@ -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')}
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+53
-59
@@ -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;
|
||||||
}
|
}
|
||||||
+104
-78
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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
@@ -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
@@ -89,5 +89,5 @@
|
|||||||
--sidebar-width-expanded: 200px;
|
--sidebar-width-expanded: 200px;
|
||||||
|
|
||||||
/* ---------- DSP ---------- */
|
/* ---------- DSP ---------- */
|
||||||
--dsp-control-heigth: 40vh;
|
--dsp-control-heigth: 40%;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user