feat: add keep alive

This commit is contained in:
2026-07-26 17:56:25 +02:00
parent a157b5f198
commit 31f645ebb2
21 changed files with 1207 additions and 263 deletions

View File

@ -203,4 +203,33 @@ pub async fn set_channel_inverse_gain(
Ok(r) Ok(r)
}) })
} }
#[tauri::command]
pub async fn recall_preset(
state: State<'_, AppState>,
id: u64,
preset_index: usize,
) -> Result<bool, String> {
state.with_device_mut(id, |device| {
let success = device
.dsp
.recall_preset(preset_index)
.map_err(|e| e.to_string())?;
Ok(success)
})
}
#[tauri::command]
pub async fn get_meter_levels(
state: State<'_, AppState>,
id: u64,
) -> Result<dsp_thomann::dsp408::types::Meters, String> {
state.with_device_mut(id, |device| {
device
.dsp
.get_meter_levels()
.map_err(|e| e.to_string())
})
}

View File

@ -16,7 +16,9 @@ pub fn run() {
commands::get_dsp_state, commands::get_dsp_state,
commands::set_channel_gain, commands::set_channel_gain,
commands::set_mute, commands::set_mute,
commands::set_channel_inverse_gain commands::set_channel_inverse_gain,
commands::recall_preset,
commands::get_meter_levels,
] ]
) )
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View File

@ -8,9 +8,10 @@ import DSPPage from './pages/DspPage/DspPage';
import AddDSPForm from './pages/AddDspPage/AddDspPage'; import AddDSPForm from './pages/AddDspPage/AddDspPage';
import { useNotifications } from './hooks/useNotifications'; import { useNotifications } from './hooks/useNotifications';
import Notifications from './components/Notifications/Notifications'; import Notifications from './components/Notifications/Notifications';
import { DSPState } from './types/dsp408State';
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 { getDSP408State, connectDSP408, disconnectDSP408 } from './api/dsp408';
import { useDSPPolling } from './hooks/useDSPPolling';
function App() { function App() {
const [dsps, setDsps] = useState<DSP[]>([]); const [dsps, setDsps] = useState<DSP[]>([]);
@ -18,7 +19,7 @@ function App() {
const [page, setPage] = useState<AppPage>('home'); const [page, setPage] = useState<AppPage>('home');
const { notifications, notify, removeNotification, hoverNotification } = useNotifications(); const { notifications, notify, removeNotification, hoverNotification } = useNotifications();
async function addDSP(dsp: Omit<DSP, 'id' | 'status'>) { async function addDSP(dsp: Omit<DSP, 'id' | 'status' | 'state' | 'meters'>) {
try { try {
const id = await invoke<number>('create_dsp408', { const id = await invoke<number>('create_dsp408', {
ip: dsp.ip, ip: dsp.ip,
@ -30,6 +31,8 @@ function App() {
id, id,
...dsp, ...dsp,
status: 'disconnected', status: 'disconnected',
state: null,
meters: null,
}; };
setDsps((prev) => [...prev, newDsp]); setDsps((prev) => [...prev, newDsp]);
@ -69,17 +72,13 @@ function App() {
setStatus(dsp.id, 'connecting'); setStatus(dsp.id, 'connecting');
try { try {
await invoke('connect_dsp408', { await connectDSP408(dsp.id);
id: dsp.id,
});
console.log('Connected');
setStatus(dsp.id, 'connected'); setStatus(dsp.id, 'connected');
const state = await getDSPState(dsp); const state = await getDSP408State(dsp.id);
console.log(state); setDsps((prev) => prev.map((d) => (d.id === dsp.id ? { ...d, state } : d)));
notify('Connected', 'success', dsp.name); notify('Connected', 'success', dsp.name);
} catch (err) { } catch (err) {
@ -91,9 +90,7 @@ function App() {
async function disconnectDSP(dsp: DSP) { async function disconnectDSP(dsp: DSP) {
try { try {
await invoke('disconnect_dsp408', { await disconnectDSP408(dsp.id);
id: dsp.id,
});
setStatus(dsp.id, 'disconnected'); setStatus(dsp.id, 'disconnected');
@ -103,26 +100,13 @@ function App() {
} }
} }
async function getDSPState(dsp: DSP) {
try {
const state = await invoke<DSPState>('get_dsp_state', {
id: dsp.id,
});
return state;
} catch (err) {
notify(`Failed to get state: ${String(err)}`, 'error', dsp.name);
return null;
}
}
function setStatus(id: number, status: DSPStatus) { function setStatus(id: number, status: DSPStatus) {
setDsps((prev) => prev.map((d) => (d.id === id ? { ...d, status } : d))); setDsps((prev) => prev.map((d) => (d.id === id ? { ...d, status } : d)));
} }
const activeDsp = dsps.find((d) => d.id === selected) ?? null; 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;
useDSPPolling(dsps, selected, setDsps);
return ( return (
<div className="app"> <div className="app">

25
src/api/dsp408.ts Normal file
View File

@ -0,0 +1,25 @@
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 recallPreset = (id: number, presetIndex: number) =>
invoke<boolean>('recall_preset', {
id,
presetIndex,
});
export const getDSP408MeterLevels = (id: number) => invoke<Meters>('get_meter_levels', { id });

View File

@ -264,3 +264,132 @@
.app-root--sidebar-expanded .stage--with-sidebar { .app-root--sidebar-expanded .stage--with-sidebar {
margin-left: var(--sidebar-width-expanded); margin-left: var(--sidebar-width-expanded);
} }
.sidebar-app-combobox {
position: relative;
flex: 1 1 auto;
min-width: 0;
opacity: 0;
max-width: 0;
pointer-events: none;
transition:
opacity var(--dur-fast) var(--ease-standard),
max-width var(--dur-base) var(--ease-standard);
}
.sidebar-rail--expanded .sidebar-app-combobox {
opacity: 1;
max-width: 200px;
pointer-events: auto;
transition-delay: var(--dur-fast);
}
.sidebar-app-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
height: calc(var(--header-height) * 0.6);
padding: 0 var(--space-2);
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: border-color var(--dur-base) var(--ease-standard);
}
.sidebar-app-trigger:hover,
.sidebar-app-trigger--open {
border-color: var(--accent-brand);
}
.sidebar-app-trigger-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-app-trigger-caret {
flex-shrink: 0;
color: var(--text-muted);
transition: transform var(--dur-base) var(--ease-standard);
}
.sidebar-app-trigger--open .sidebar-app-trigger-caret {
transform: rotate(180deg);
}
.sidebar-app-input {
width: 100%;
height: calc(var(--header-height) * 0.6);
padding: 0 var(--space-2);
background: var(--bg-panel);
border: var(--border-width) solid var(--accent-brand);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
outline: none;
}
.sidebar-app-listbox {
position: absolute;
top: calc(100% + var(--space-1));
left: 0;
right: 0;
z-index: 40;
margin: 0;
padding: var(--space-1);
list-style: none;
background: var(--bg-panel);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-height: 240px;
overflow-y: auto;
}
.sidebar-app-option {
padding: var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-app-option--highlight {
background: var(--bg-raised);
}
.sidebar-app-option--selected {
color: var(--accent-brand);
font-weight: var(--font-weight-semibold);
}
.sidebar-rail--expanded .sidebar-app-select {
opacity: 1;
max-width: 200px;
padding-inline: var(--space-2);
pointer-events: auto;
transition-delay: var(--dur-fast);
}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import './Sidebar.css'; import './Sidebar.css';
import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon'; import { ChevronLeftIcon } from '../../assets/icons/ChevronLeftIcon';
@ -8,6 +8,11 @@ export type SidebarItem = {
icon: React.ReactNode; icon: React.ReactNode;
}; };
export type SidebarAppOption = {
id: string;
name: string;
};
function SidebarButton({ function SidebarButton({
item, item,
active, active,
@ -31,11 +36,178 @@ function SidebarButton({
); );
} }
function AppCombobox({
appOptions,
selectedAppId,
appName,
onAppChange,
onAppRename,
}: {
appOptions: SidebarAppOption[];
selectedAppId?: string;
appName: string;
onAppChange?: (id: string) => void;
onAppRename?: (name: string) => void;
}) {
const currentOption = appOptions.find((o) => o.id === selectedAppId) ?? appOptions[0];
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(currentOption?.name ?? appName);
const [highlight, setHighlight] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!editing) setInputValue(currentOption?.name ?? appName);
}, [currentOption?.id, currentOption?.name, appName, editing]);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
if (editing) commitRename();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, inputValue]);
function openList() {
setOpen(true);
const idx = appOptions.findIndex((o) => o.id === currentOption?.id);
setHighlight(idx >= 0 ? idx : 0);
}
function pick(opt: SidebarAppOption) {
if (opt.id !== selectedAppId) onAppChange?.(opt.id);
setInputValue(opt.name);
setOpen(false);
setEditing(false);
}
function startEditing(e: React.MouseEvent) {
e.stopPropagation();
setEditing(true);
setOpen(false);
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}
function commitRename() {
const trimmed = inputValue.trim();
setEditing(false);
if (!trimmed) {
setInputValue(currentOption?.name ?? appName);
return;
}
const matched = appOptions.find((o) => o.name === trimmed);
if (matched) {
if (matched.id !== selectedAppId) onAppChange?.(matched.id);
return;
}
if (trimmed !== currentOption?.name) {
onAppRename?.(trimmed);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (editing) {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') {
setInputValue(currentOption?.name ?? appName);
setEditing(false);
}
return;
}
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
openList();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlight((h) => Math.min(h + 1, appOptions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlight((h) => Math.max(h - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
pick(appOptions[highlight]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<div className="sidebar-app-combobox" ref={rootRef}>
{editing ? (
<input
ref={inputRef}
className="sidebar-app-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onBlur={commitRename}
onKeyDown={handleKeyDown}
aria-label="Rename app"
/>
) : (
<button
type="button"
className={'sidebar-app-trigger' + (open ? ' sidebar-app-trigger--open' : '')}
onClick={() => (open ? setOpen(false) : openList())}
onDoubleClick={startEditing}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="sidebar-app-trigger-label">{currentOption?.name ?? appName}</span>
<svg className="sidebar-app-trigger-caret" viewBox="0 0 24 24" width="14" height="14">
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
</button>
)}
{open && (
<ul className="sidebar-app-listbox" role="listbox">
{appOptions.map((opt, i) => (
<li
key={opt.id}
role="option"
aria-selected={opt.id === selectedAppId}
className={
'sidebar-app-option' +
(opt.id === selectedAppId ? ' sidebar-app-option--selected' : '') +
(i === highlight ? ' sidebar-app-option--highlight' : '')
}
onMouseEnter={() => setHighlight(i)}
onClick={() => pick(opt)}
>
{opt.name}
</li>
))}
</ul>
)}
</div>
);
}
export default function Sidebar({ export default function Sidebar({
items, items,
activeId, activeId,
onSelect, onSelect,
appName, appName,
appOptions,
selectedAppId,
onAppChange,
onAppRename,
expanded, expanded,
onToggle, onToggle,
}: { }: {
@ -43,10 +215,15 @@ export default function Sidebar({
activeId: string | null; activeId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
appName: string; appName: string;
appOptions?: SidebarAppOption[];
selectedAppId?: string;
onAppChange?: (id: string) => void;
onAppRename?: (name: string) => void;
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const hasOptions = appOptions && appOptions.length > 0;
const updateFade = () => { const updateFade = () => {
const el = scrollRef.current; const el = scrollRef.current;
@ -71,7 +248,17 @@ export default function Sidebar({
return ( return (
<nav className={'sidebar-rail' + (expanded ? ' sidebar-rail--expanded' : '')}> <nav className={'sidebar-rail' + (expanded ? ' sidebar-rail--expanded' : '')}>
<div className="sidebar-header"> <div className="sidebar-header">
<span className="sidebar-app-name">{appName}</span> {hasOptions ? (
<AppCombobox
appOptions={appOptions!}
selectedAppId={selectedAppId}
appName={appName}
onAppChange={onAppChange}
onAppRename={onAppRename}
/>
) : (
<span className="sidebar-app-name">{appName}</span>
)}
<button <button
className="sidebar-toggle" className="sidebar-toggle"
onClick={onToggle} onClick={onToggle}

View File

@ -39,5 +39,5 @@
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
height: 100%; height: 100%;
gap: var(--space-4); gap: var(--space-2);
} }

View File

@ -1,11 +1,10 @@
.gain-section { .gain-section {
position: absolute; /* was: fixed — now relative to .dsp-content, which is the fixed+inset parent */ position: absolute;
left: 0; /* was: var(--sidebar-width-collapsed) — .dsp-content already excludes the sidebar */ left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
height: var(--gain-section-height); height: var(--dsp-control-heigth);
min-height: 0;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@ -18,65 +17,77 @@
z-index: 20; z-index: 20;
border-top: 1px solid var(--border-hairline); border-top: var(--border-width) solid var(--border-hairline);
} }
.gain-panels { .gain-panels {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: safe center; /* centers when it fits, falls back to start-aligned scroll when it overflows */
gap: var(--space-4);
width: 100%;
height: 100%; height: 100%;
min-width: 0; display: flex;
min-height: 0; gap: var(--space-4);
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
} }
.gain-panel-frame { .gain-panel-frame {
flex: 0 0 auto; flex: 0 0 auto;
height: calc(100% - 8px); height: calc(100% - 2 * var(--space-1));
aspect-ratio: var(--panel-w) / var(--panel-h); /* now driven by JS, not duplicated */ margin: var(--space-1) 0;
margin: 4px 0; }
.gain-panel-viewport {
position: relative;
aspect-ratio: 90 / 400;
overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
} }
.gain-panels::-webkit-scrollbar {
height: 6px;
}
.gain-panels::-webkit-scrollbar-thumb {
background: var(--border-hairline);
border-radius: var(--radius-pill);
}
.gain-panels::-webkit-scrollbar-track {
background: transparent;
}
.gain-panel { .gain-panel {
/* width/height now set inline from DESIGN_WIDTH/HEIGHT, drop the fixed px here */ position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
border: 1px solid var(--border-hairline);
border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-sizing: border-box; box-sizing: border-box;
padding: 8px; gap: var(--space-2);
background: var(--bg-void); background: var(--bg-void);
} }
.gain-panel__title { .gain-panel__title {
flex: 0 0 auto; /* NEW: never let this shrink */
width: 100%; width: 100%;
text-align: center; text-align: center;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 12px; font-size: var(--font-sm);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
padding-top: var(--space-2);
padding-bottom: 6px; padding-bottom: var(--space-2);
border-bottom: var(--border-width) solid var(--border-hairline);
border-bottom: 1px solid var(--border-hairline); }
.channel-group__label {
font-size: calc(var(--tab-close-font) * var(--panel-scale, 1));
}
/* --- 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 */
} }

View File

@ -3,13 +3,37 @@ import { invoke } from '@tauri-apps/api/core';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State'; import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types'; import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types'; import { NotificationType } from '../../../types/types';
import { useState } from 'react';
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 { useEffect, useRef, useState } from 'react';
import './GainSection.css'; import './GainSection.css';
const DESIGN_WIDTH = 90; const PANEL_DESIGN_WIDTH = 90;
const DESIGN_HEIGHT = 380; const PANEL_DESIGN_HEIGHT = 400;
function ChannelGroupScaled({ title, children }: { title: string; children: React.ReactNode }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const { height } = entries[0].contentRect;
if (height === 0) return;
setScale(height / PANEL_DESIGN_HEIGHT); // height is the only free axis here
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={wrapperRef} style={{ height: '100%', '--panel-scale': scale } as React.CSSProperties}>
<ChannelGroup title={title}>{children}</ChannelGroup>
</div>
);
}
function GainPanel({ function GainPanel({
dsp, dsp,
@ -78,6 +102,8 @@ function GainPanel({
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);
const [inverted, setInverted] = useState(false); const [inverted, setInverted] = useState(false);
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const toggleMute = async () => { const toggleMute = async () => {
const next = !muted; const next = !muted;
@ -91,9 +117,28 @@ function GainPanel({
await setInverse(next); await setInverse(next);
}; };
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-frame"> <div className="gain-panel-viewport" ref={viewportRef}>
<div className="gain-panel" style={{ width: DESIGN_WIDTH, height: DESIGN_HEIGHT }}> <div
className="gain-panel"
style={{
width: PANEL_DESIGN_WIDTH,
height: PANEL_DESIGN_HEIGHT,
transform: `translate(-50%, -50%) scale(${scale})`,
}}
>
<div className="gain-panel__title">{title}</div> <div className="gain-panel__title">{title}</div>
<VerticalSlider <VerticalSlider
@ -104,12 +149,12 @@ function GainPanel({
switchStep={0.1} switchStep={0.1}
unit=" dB" unit=" dB"
onChange={setGain} onChange={setGain}
style={{ width: 70, height: 260, flex: '0 0 auto' }} // pin to design size, disable its own auto-shrink
/> />
<Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute}> <Button variant="toggle" className={muted ? 'active-danger' : ''} onClick={toggleMute}>
{muted ? 'Muted' : 'Mute'} {muted ? 'Muted' : 'Mute'}
</Button> </Button>
<Button <Button
variant="toggle" variant="toggle"
className={inverted ? 'active-warning' : ''} className={inverted ? 'active-warning' : ''}
@ -148,21 +193,48 @@ function GainSection({
]; ];
return ( return (
<div className="gain-section"> <>
<div className="gain-panels"> <div className="linear-graph-section">
<ChannelGroup title="Input"> <LinearGraph
{inputs.map(({ channel, title }) => ( xMin={20}
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} /> xMax={20000}
))} yMin={-24}
</ChannelGroup> yMax={24}
yStep={6}
<ChannelGroup title="Output"> xLabel="Hz"
{outputs.map(({ channel, title }) => ( yLabel="dB"
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} /> draw={({ ctx, toPx }) => {
))} // placeholder flat 0 dB response — swap for real EQ/filter data.
</ChannelGroup> // NOTE: this maps x linearly; for a real frequency response you'll
// likely want a log-frequency x-axis instead (see chat notes).
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-panels">
<ChannelGroup title="Input">
{inputs.map(({ channel, title }) => (
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
))}
</ChannelGroup>
<ChannelGroup title="Output">
{outputs.map(({ channel, title }) => (
<GainPanel key={title} dsp={dsp} notify={notify} channel={channel} title={title} />
))}
</ChannelGroup>
</div>
</div>
</>
); );
} }

View File

@ -1,118 +1,4 @@
.gate-section { .rerere {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--gain-section-height);
min-height: 0;
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: 1px solid var(--border-hairline);
}
.gate-panels {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: safe center;
gap: var(--space-4);
width: 100%;
height: 100%; height: 100%;
min-width: 0; width: 10%;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
}
.gate-panel-frame {
flex: 0 0 auto;
height: calc(100% - 8px);
aspect-ratio: var(--panel-w) / var(--panel-h);
margin: 4px 0;
box-sizing: border-box;
}
.gate-panels::-webkit-scrollbar {
height: 6px;
}
.gate-panels::-webkit-scrollbar-thumb {
background: var(--border-hairline);
border-radius: var(--radius-pill);
}
.gate-panels::-webkit-scrollbar-track {
background: transparent;
}
.gate-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md);
box-sizing: border-box;
padding: 8px;
background: var(--bg-void);
}
.gate-panel__title {
width: 100%;
text-align: center;
font-family: var(--font-ui);
font-size: 12px;
font-weight: var(--font-weight-semibold);
padding-bottom: 6px;
border-bottom: 1px solid var(--border-hairline);
}
.gate-panel__sliders {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: center;
gap: var(--space-2);
width: 100%;
flex: 1;
min-height: 0;
}
.gate-panel__slider-col {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 0;
min-width: 0;
}
.gate-panel__slider-label {
font-family: var(--font-ui);
font-size: 10px;
font-weight: var(--font-weight-medium);
text-align: center;
padding-top: 4px;
color: var(--text-secondary);
}
/* GateSection.css */
.gate-panels .channel-group__items {
display: grid;
grid-template-columns: repeat(2, auto);
grid-template-rows: repeat(2, 1fr);
gap: var(--space-2);
align-items: stretch;
} }

View File

@ -0,0 +1,35 @@
import VerticalSlider from '../Slider/VerticalSlider';
import { invoke } from '@tauri-apps/api/core';
import { InputChannel, Channel, OutputChannel } from '../../../types/dsp408State';
import { DSP } from '../../../types/types';
import { NotificationType } from '../../../types/types';
import ChannelGroup from '../ChannelGroup/ChannelGroup';
import './GateSection.css';
function GateSection({
dsp,
notify,
}: {
dsp: DSP;
notify: (message: string, type?: NotificationType, dspName?: string) => void;
}) {
console.log(dsp, notify);
return (
<div className="rerere">
<VerticalSlider
min={-60}
max={12}
step={0.5}
switchStepValue={-10}
switchStep={0.1}
unit=" dB"
onChange={(_f: number) => {
return;
}}
/>
</div>
);
}
export default GateSection;

View File

@ -0,0 +1,93 @@
.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);
}
.channel-group__label {
font-size: calc(var(--tab-close-font) * var(--panel-scale, 1));
}
/* --- 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 */
}

View File

@ -0,0 +1,346 @@
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;

View File

@ -1,25 +1,27 @@
.gauge-viewport { .gauge-viewport {
container-type: inline-size; position: relative;
container-name: gauge;
width: 100%; flex: 1 1 0;
min-height: 0;
width: auto;
aspect-ratio: 70 / 260; aspect-ratio: 70 / 260;
box-sizing: border-box; overflow: hidden;
} }
.gauge-container { .gauge-container {
width: 100%; position: absolute;
height: 100%; top: 50%;
left: 50%;
padding-top: 28.571cqw; /* 20px */ width: 70px;
padding-bottom: 14.286cqw; /* 10px */ height: 240px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
transform-origin: top center; transform-origin: center center;
box-sizing: border-box; box-sizing: border-box;
} }
@ -27,23 +29,21 @@
.gauge-track { .gauge-track {
position: relative; position: relative;
width: 100%; width: 70px;
height: 300cqw; /* 210px */ height: 210px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-bottom: 11.429cqw; /* 8px */ margin-bottom: 8px;
box-sizing: border-box;
} }
.slider { .slider {
position: relative; position: relative;
width: 34.286cqw; /* 24px */ width: 24px;
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
@ -60,7 +60,7 @@
transform: translateX(-50%); transform: translateX(-50%);
width: 2.857cqw; /* 2px */ width: 2px;
height: 100%; height: 100%;
@ -74,7 +74,7 @@
left: 50%; left: 50%;
width: 25.714cqw; /* 18px */ width: 18px;
aspect-ratio: 1; aspect-ratio: 1;
@ -106,9 +106,9 @@
} }
.ticks span { .ticks span {
width: 14.286cqw; /* 10px */ width: 10px;
height: 2.857cqw; /* 2px */ height: 2px;
background: var(--border-hairline); background: var(--border-hairline);
@ -116,22 +116,22 @@
} }
.left { .left {
left: 11.429cqw; /* 8px */ left: 8px;
} }
.right { .right {
right: 11.429cqw; /* 8px */ right: 8px;
} }
.value { .value {
width: 100%; width: 100%;
margin-top: 5.714cqw; /* 4px */ margin-top: 4px;
padding-top: 2.857cqw; /* 2px */ padding-top: 2px;
padding-bottom: 2.857cqw; /* 2px */ padding-bottom: 2px;
padding-right: 5.714cqw; /* 4px */ padding-right: 4px;
padding-left: 5.714cqw; /* 4px */ padding-left: 4px;
box-sizing: border-box; box-sizing: border-box;
@ -146,9 +146,10 @@
color: var(--accent-brand); color: var(--accent-brand);
font-size: 22.857cqw; font-size: var(--font-xxs);
font-family: var(--font-ui); font-family: var(--font-mono);
font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
@ -167,7 +168,7 @@
background: transparent; background: transparent;
border: 1px solid var(--border-hairline); border: var(--border-width) solid var(--border-hairline);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);

View File

@ -2,12 +2,8 @@ import { useMemo, useRef, useState, useEffect } from 'react';
import './VerticalSlider.css'; import './VerticalSlider.css';
// Design size 260*70 (height*width) — kept as the reference ratio.
// All CSS dimensions are expressed in container-query width units (cqw)
// relative to this DESIGN_WIDTH, so the whole component scales together
// and preserves this aspect ratio at any rendered size.
const DESIGN_WIDTH = 70; const DESIGN_WIDTH = 70;
const DESIGN_HEIGHT = 240;
type VerticalSliderProps = { type VerticalSliderProps = {
min: number; min: number;
@ -59,6 +55,24 @@ export default function VerticalSlider({
const thumbRef = useRef<HTMLDivElement>(null); const thumbRef = useRef<HTMLDivElement>(null);
const keyboardEditing = useRef(false); const keyboardEditing = useRef(false);
const viewportRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
const { width, height } = entry.contentRect;
if (width === 0 || height === 0) return;
setScale(Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => { useEffect(() => {
if (keyboardEditing.current) return; if (keyboardEditing.current) return;
@ -76,26 +90,6 @@ export default function VerticalSlider({
[step, switchStep] [step, switchStep]
); );
const maxChars = useMemo(() => {
const minStr = min.toFixed(decimals);
const maxStr = max.toFixed(decimals);
return Math.max(minStr.length, maxStr.length) + unit.length;
}, [min, max, decimals, unit]);
// Computed in "design pixels" (i.e. assuming the component is rendered at
// DESIGN_WIDTH px wide), then converted below to cqw so it scales with
// the actual rendered size instead of staying a fixed pixel value.
const valueFontSize = useMemo(() => {
const availableWidth = DESIGN_WIDTH - 8;
const AVG_CHAR_WIDTH_RATIO = 0.6;
const sizeByWidth = availableWidth / (maxChars * AVG_CHAR_WIDTH_RATIO);
return Math.min(16, Math.max(8, sizeByWidth));
}, [maxChars]);
// Convert the "design px" font size into cqw units relative to the
// component's own width, so it scales proportionally with everything else.
const valueFontSizeCqw = useMemo(() => (valueFontSize / DESIGN_WIDTH) * 100, [valueFontSize]);
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 snapped = Math.round(clamped / currentStep) * currentStep; const snapped = Math.round(clamped / currentStep) * currentStep;
@ -202,8 +196,15 @@ export default function VerticalSlider({
const percent = (value - min) / (max - min); const percent = (value - min) / (max - min);
return ( return (
<div className={['gauge-viewport', className].filter(Boolean).join(' ')} style={style}> <div
<div className="gauge-container"> className={['gauge-viewport', className].filter(Boolean).join(' ')}
style={style}
ref={viewportRef}
>
<div
className="gauge-container"
style={{ transform: `translate(-50%, -50%) scale(${scale})` }}
>
<div className="gauge-track"> <div className="gauge-track">
<div className="ticks left"> <div className="ticks left">
{Array.from({ length: 15 }).map((_, i) => ( {Array.from({ length: 15 }).map((_, i) => (
@ -233,7 +234,7 @@ export default function VerticalSlider({
</div> </div>
</div> </div>
<div className="value" style={{ fontSize: `${valueFontSizeCqw}cqw` }}> <div className="value">
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"

View File

@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react';
import { DSP } from '../types/types';
import { getDSP408MeterLevels } from '../api/dsp408';
export function useDSPPolling(
dsps: DSP[],
selected: number | null,
setDsps: React.Dispatch<React.SetStateAction<DSP[]>>
) {
const dspsRef = useRef(dsps);
const selectedRef = useRef(selected);
// Keep refs up to date without restarting the polling loops
useEffect(() => {
dspsRef.current = dsps;
}, [dsps]);
useEffect(() => {
selectedRef.current = selected;
}, [selected]);
useEffect(() => {
let stopped = false;
const pollDSP = async (dsp: DSP) => {
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');
if (dsp) {
await pollDSP(dsp);
}
}
if (!stopped) {
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
);
// Poll inactive connected DSPs
await Promise.all(inactiveDsps.map((dsp) => pollDSP(dsp)));
if (!stopped) {
setTimeout(inactiveLoop, 5000);
}
};
// Start both loops
activeLoop();
inactiveLoop();
return () => {
stopped = true;
};
}, [setDsps]);
}

View File

@ -10,7 +10,7 @@ function AddDSPForm({
onAdd, onAdd,
}: { }: {
onCancel: () => void; onCancel: () => void;
onAdd: (d: Omit<DSP, 'status' | 'id'>) => void; onAdd: (d: Omit<DSP, 'status' | 'id' | 'state' | 'meters'>) => void;
}) { }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',

View File

@ -6,6 +6,8 @@ import ConnectionPanel from '../../components/dsp408/ConnectionSection/Connectio
import GainSection from '../../components/dsp408/GainSection/GainSection'; import GainSection from '../../components/dsp408/GainSection/GainSection';
import { NotificationType } from '../../types/types'; import { NotificationType } from '../../types/types';
import './DspPage.css'; import './DspPage.css';
import GateSection from '../../components/dsp408/GateSection/GateSection';
import { invoke } from '@tauri-apps/api/core';
function DSPPage({ function DSPPage({
dsp, dsp,
@ -129,6 +131,41 @@ function DSPPage({
}, },
]; ];
const presets = dsp.state?.presets.names?.map((name, index) => ({
id: index === 0 ? 'f00' : `u${String(index).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 selectedPreset = dsp.state?.presets.current_index ?? 0;
const recallPreset = async (presetIndex: number) => {
try {
const success = await invoke<boolean>('recall_preset', {
id: dsp.id,
presetIndex,
});
if (success) {
notify(`Preset ${presetIndex} recalled`, 'success', dsp.name);
} else {
notify('Failed to recall preset', 'error', dsp.name);
}
return success;
} catch (err) {
notify(`Preset recall failed: ${String(err)}`, 'error', dsp.name);
return false;
}
};
const renderTab = () => { const renderTab = () => {
switch (activeSidebar) { switch (activeSidebar) {
case 'overview': case 'overview':
@ -137,6 +174,9 @@ function DSPPage({
case 'gain': case 'gain':
return <GainSection dsp={dsp} notify={notify} />; return <GainSection dsp={dsp} notify={notify} />;
case 'gate':
return <GateSection dsp={dsp} notify={notify} />;
default: default:
return null; return null;
} }
@ -149,6 +189,15 @@ function DSPPage({
activeId={activeSidebar} activeId={activeSidebar}
onSelect={setActiveSidebar} onSelect={setActiveSidebar}
appName={dsp.name} appName={dsp.name}
appOptions={presets}
selectedAppId={presets[selectedPreset]?.id}
onAppChange={(id) => {
const presetIndex = presets.findIndex((preset) => preset.id === id);
if (presetIndex !== -1) {
recallPreset(presetIndex);
}
}}
expanded={sidebarExpanded} expanded={sidebarExpanded}
onToggle={() => setSidebarExpanded((v) => !v)} onToggle={() => setSidebarExpanded((v) => !v)}
/> />

View File

@ -191,3 +191,7 @@ export type DSPState = {
presets: PresetBank; presets: PresetBank;
current_config: DSPConfigState; current_config: DSPConfigState;
}; };
export interface Meters {
levels: number[];
}

View File

@ -1,3 +1,5 @@
import { DSPState, Meters } from './dsp408State';
export type DSPStatus = 'connected' | 'connecting' | 'disconnected'; export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
export type DSP = { export type DSP = {
@ -8,6 +10,8 @@ export type DSP = {
port: number; port: number;
deviceId: string; deviceId: string;
status: DSPStatus; status: DSPStatus;
state: DSPState | null;
meters: Meters | null;
}; };
export const DSP_TYPES = [ export const DSP_TYPES = [

View File

@ -20,6 +20,7 @@
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace; --font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
/* ---------- Font ---------- */ /* ---------- Font ---------- */
--font-xxs: 0.66rem;
--font-xs: 0.75rem; --font-xs: 0.75rem;
--font-sm: 0.875rem; --font-sm: 0.875rem;
--font-md: 1rem; --font-md: 1rem;
@ -86,4 +87,7 @@
/* ---------- SideBar ---------- */ /* ---------- SideBar ---------- */
--sidebar-width-collapsed: calc(var(--header-height) * 0.85 + 2 * var(--space-3)); --sidebar-width-collapsed: calc(var(--header-height) * 0.85 + 2 * var(--space-3));
--sidebar-width-expanded: 200px; --sidebar-width-expanded: 200px;
/* ---------- DSP ---------- */
--dsp-control-heigth: 40vh;
} }