Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1792910ca1 | ||
|
|
faa019d52b | ||
|
|
f6bc46dd98 | ||
|
|
30e1c0b0a9 | ||
|
|
9f2ace9698 | ||
|
|
1e00ca3a7c | ||
|
|
2717dfe99a | ||
|
|
fd545fb2a8 | ||
|
|
3793ee9f5b | ||
|
|
014ccf5197 | ||
|
|
0f198692ad | ||
|
|
19c3aedf80 |
Generated
+18
-17
@@ -397,7 +397,7 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -798,9 +798,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_with",
|
||||||
"strum",
|
"strum",
|
||||||
"strum_macros",
|
"strum_macros",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2004,7 +2005,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2628,7 +2629,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3338,7 +3339,7 @@ dependencies = [
|
|||||||
"tauri-runtime",
|
"tauri-runtime",
|
||||||
"tauri-runtime-wry",
|
"tauri-runtime-wry",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
"url",
|
"url",
|
||||||
@@ -3389,7 +3390,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"time",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3442,7 +3443,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -3466,7 +3467,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
@@ -3529,7 +3530,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"toml 1.1.3+spec-1.1.0",
|
"toml 1.1.3+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
@@ -3581,11 +3582,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.19",
|
"thiserror-impl 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3601,9 +3602,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3918,7 +3919,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4278,7 +4279,7 @@ version = "0.38.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -4713,7 +4714,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"soup3",
|
"soup3",
|
||||||
"tao-macros",
|
"tao-macros",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.20",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
|
|||||||
@@ -41,16 +41,29 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct DSP408Snapshot {
|
||||||
|
pub state: types::DSPState,
|
||||||
|
pub input_meters: types::InputMeters,
|
||||||
|
pub output_meters: types::OutputMeters,
|
||||||
|
pub mode: types::DSPMode,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_dsp408(
|
pub fn create_dsp408(
|
||||||
state: State<AppState>,
|
state: State<AppState>,
|
||||||
ip: String,
|
ip: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
device_id: u8,
|
device_id: u8,
|
||||||
|
demo: bool,
|
||||||
) -> Result<u64, String> {
|
) -> Result<u64, String> {
|
||||||
let ip = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
|
let ip = IpAddr::from_str(&ip).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let dsp = DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?;
|
let dsp = if demo {
|
||||||
|
DSP408::new_demo()
|
||||||
|
} else {
|
||||||
|
DSP408::new(ip, port, device_id).map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
let mut next_id = state.next_id.lock().map_err(|e| e.to_string())?;
|
let mut next_id = state.next_id.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
@@ -81,6 +94,21 @@ pub async fn remove_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), St
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_dsp_snapshot(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: u64,
|
||||||
|
) -> Result<DSP408Snapshot, String> {
|
||||||
|
state.with_device_mut(id, |device| {
|
||||||
|
Ok(DSP408Snapshot {
|
||||||
|
state: device.dsp.state().clone(),
|
||||||
|
input_meters: device.dsp.input_meters().clone(),
|
||||||
|
output_meters: device.dsp.output_meters().clone(),
|
||||||
|
mode: device.dsp.mode(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
pub async fn connect_dsp408(state: State<'_, AppState>, id: u64) -> Result<(), String> {
|
||||||
state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
|
state.with_device_mut(id, |device| device.dsp.connect().map_err(|e| e.to_string()))
|
||||||
@@ -99,7 +127,7 @@ pub async fn get_dsp_state(
|
|||||||
id: u64,
|
id: u64,
|
||||||
) -> Result<dsp_thomann::dsp408::types::DSPState, String> {
|
) -> Result<dsp_thomann::dsp408::types::DSPState, String> {
|
||||||
state.with_device_mut(id, |device| {
|
state.with_device_mut(id, |device| {
|
||||||
let dsp_state = device.dsp.state().map_err(|e| e.to_string())?;
|
let dsp_state = device.dsp.state();
|
||||||
|
|
||||||
Ok(dsp_state.clone())
|
Ok(dsp_state.clone())
|
||||||
})
|
})
|
||||||
@@ -176,7 +204,7 @@ pub async fn recall_preset(
|
|||||||
pub async fn get_meter_levels(
|
pub async fn get_meter_levels(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: u64,
|
id: u64,
|
||||||
) -> Result<dsp_thomann::dsp408::types::Meters, String> {
|
) -> Result<(types::InputMeters, types::OutputMeters), String> {
|
||||||
state.with_device_mut(id, |device| {
|
state.with_device_mut(id, |device| {
|
||||||
device.dsp.get_meter_levels().map_err(|e| e.to_string())
|
device.dsp.get_meter_levels().map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::create_dsp408,
|
commands::create_dsp408,
|
||||||
commands::remove_dsp408,
|
commands::remove_dsp408,
|
||||||
|
commands::get_dsp_snapshot,
|
||||||
commands::get_dsp_state,
|
commands::get_dsp_state,
|
||||||
commands::connect_dsp408, // 0x10
|
commands::connect_dsp408, // 0x10
|
||||||
commands::disconnect_dsp408, // 0x11
|
commands::disconnect_dsp408, // 0x11
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export function TriangleUpIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path
|
<path
|
||||||
d="M8 5L4 10H12L8 5Z"
|
d="M8 3L2 12H14L8 3Z"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
@@ -16,7 +16,7 @@ export function TriangleDownIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
<path
|
<path
|
||||||
d="M8 11L4 6H12L8 11Z"
|
d="M8 13L2 4H14L8 13Z"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
@@ -50,6 +52,11 @@
|
|||||||
/* Hover */
|
/* Hover */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--accent-brand) 95%, white);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled),
|
.btn-ghost:hover:not(:disabled),
|
||||||
.btn-toggle:hover:not(:disabled) {
|
.btn-toggle:hover:not(:disabled) {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--space-2);
|
right: var(--space-2);
|
||||||
top: 50%;
|
top: 50%;
|
||||||
height: 70%;
|
height: 45%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type ChoiceSelectProps<T extends string> = {
|
|||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
id?: string;
|
id?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ChoiceSelect<T extends string>({
|
function ChoiceSelect<T extends string>({
|
||||||
@@ -18,15 +20,42 @@ function ChoiceSelect<T extends string>({
|
|||||||
onChange,
|
onChange,
|
||||||
id,
|
id,
|
||||||
disabled,
|
disabled,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
}: ChoiceSelectProps<T>) {
|
}: ChoiceSelectProps<T>) {
|
||||||
|
const step = (direction: 1 | -1) => {
|
||||||
|
const currentIndex = options.indexOf(value);
|
||||||
|
|
||||||
|
if (currentIndex === -1 || options.length === 0) return;
|
||||||
|
|
||||||
|
const nextIndex = (currentIndex + direction + options.length) % options.length;
|
||||||
|
|
||||||
|
onChange(options[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="choice-select" data-disabled={disabled || undefined}>
|
<div className={`choice-select ${className}`} data-disabled={disabled || undefined}>
|
||||||
<select
|
<select
|
||||||
id={id}
|
id={id}
|
||||||
className="choice-select__input"
|
className="choice-select__input"
|
||||||
|
style={style}
|
||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => onChange(e.target.value as T)}
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value as T);
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
step(1);
|
||||||
|
} else if (e.deltaY > 0) {
|
||||||
|
step(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
@@ -34,6 +63,7 @@ function ChoiceSelect<T extends string>({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<span className="choice-select__chevron" aria-hidden="true">
|
<span className="choice-select__chevron" aria-hidden="true">
|
||||||
<TriangleDownIcon />
|
<TriangleDownIcon />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
border: var(--border-width) solid var(--border-hairline);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -18,15 +17,13 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 3cqh;
|
font-size: var(--font--sm);
|
||||||
font-weight: var(--font-weight-normal);
|
font-weight: var(--font-weight-normal);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
color: var(--accent-brand);
|
color: var(--accent-brand);
|
||||||
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
/* padding: 0.5ch; */
|
|
||||||
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -39,20 +36,21 @@
|
|||||||
|
|
||||||
.choice-stepper__arrows {
|
.choice-stepper__arrows {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5cqh;
|
|
||||||
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 1cqh;
|
|
||||||
padding-left: 0;
|
padding-right: 1%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choice-stepper__arrow-btn {
|
.choice-stepper__arrow-btn {
|
||||||
aspect-ratio: 1;
|
height: 50%;
|
||||||
height: 4.5cqh;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -60,10 +58,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--accent-brand);
|
|
||||||
|
|
||||||
|
color: var(--accent-brand);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 1cqh;
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -74,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.choice-stepper__arrow-btn svg {
|
.choice-stepper__arrow-btn svg {
|
||||||
width: 100%;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { JSX, useEffect, useState } from 'react';
|
import { JSX, useEffect, useRef, useState } from 'react';
|
||||||
import Button from '../Button/Button';
|
import Button from '../Button/Button';
|
||||||
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
|
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
|
||||||
import './ChoiceStepper.css';
|
import './ChoiceStepper.css';
|
||||||
|
|
||||||
|
const WHEEL_COMMIT_DELAY = 250;
|
||||||
|
// Safety net: if the parent's `value` prop never comes back exactly equal
|
||||||
|
// to what we committed (rounding, an intermediate transform, etc.), stop
|
||||||
|
// waiting after this long so the stepper doesn't get stuck ignoring
|
||||||
|
// external updates.
|
||||||
|
const PENDING_FALLBACK_MS = 1000;
|
||||||
|
|
||||||
|
type CommitResult = Promise<boolean | void> | boolean | void;
|
||||||
|
|
||||||
function StepperArrows({
|
function StepperArrows({
|
||||||
onUp,
|
onUp,
|
||||||
onDown,
|
onDown,
|
||||||
@@ -44,7 +53,7 @@ type ChoiceStepperListProps<T> = {
|
|||||||
value: T;
|
value: T;
|
||||||
values: readonly T[];
|
values: readonly T[];
|
||||||
formatLabel: (value: T) => string;
|
formatLabel: (value: T) => string;
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => CommitResult;
|
||||||
id?: string;
|
id?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -59,25 +68,113 @@ function ChoiceStepperList<T>({
|
|||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
}: ChoiceStepperListProps<T>) {
|
}: ChoiceStepperListProps<T>) {
|
||||||
const index = values.indexOf(value);
|
const [localValue, setLocalValue] = useState(value);
|
||||||
|
const [text, setText] = useState(formatLabel(value));
|
||||||
|
|
||||||
|
// True from the moment a commit starts until the prop `value` we get from
|
||||||
|
// the parent actually reflects it (not just until onChange resolves —
|
||||||
|
// those are two different moments and the gap between them is what was
|
||||||
|
// causing the old-value blink).
|
||||||
|
const interacting = useRef(false);
|
||||||
|
// The value we're waiting to see echoed back via the `value` prop.
|
||||||
|
const pendingValue = useRef<T | null>(null);
|
||||||
|
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (interacting.current) {
|
||||||
|
// Only release control once the prop actually catches up to what we
|
||||||
|
// committed — otherwise keep showing localValue as-is (no flash back
|
||||||
|
// to the stale old value in between).
|
||||||
|
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLocalValue(value);
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}, [value, formatLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const index = values.indexOf(localValue);
|
||||||
const canUp = index === -1 || index < values.length - 1;
|
const canUp = index === -1 || index < values.length - 1;
|
||||||
const canDown = index > 0;
|
const canDown = index > 0;
|
||||||
|
|
||||||
const [text, setText] = useState(formatLabel(value));
|
const stepLocal = (direction: 1 | -1): T | null => {
|
||||||
useEffect(() => setText(formatLabel(value)), [value, formatLabel]);
|
const currentIndex = values.indexOf(localValue);
|
||||||
|
if (currentIndex === -1) return null;
|
||||||
|
|
||||||
const step = (direction: 1 | -1) => {
|
const nextIndex = currentIndex + direction;
|
||||||
const currentIndex = index === -1 ? 0 : index;
|
if (nextIndex < 0 || nextIndex >= values.length) return null;
|
||||||
const nextIndex = Math.min(values.length - 1, Math.max(0, currentIndex + direction));
|
|
||||||
if (nextIndex !== currentIndex) onChange(values[nextIndex]);
|
const next = values[nextIndex];
|
||||||
|
setLocalValue(next);
|
||||||
|
setText(formatLabel(next));
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function commit(next: T) {
|
||||||
|
interacting.current = true;
|
||||||
|
pendingValue.current = next;
|
||||||
|
|
||||||
|
const result = await onChange(next);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
setLocalValue(value);
|
||||||
|
setText(formatLabel(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepted: don't clear `interacting` here. Wait for the prop-sync
|
||||||
|
// effect above to see `value === next` and release it, so we never
|
||||||
|
// hand control back to a still-stale prop. Guard with a timeout in
|
||||||
|
// case it never matches exactly.
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
fallbackTimeout.current = setTimeout(() => {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
}, PENDING_FALLBACK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepAndCommit = (direction: 1 | -1) => {
|
||||||
|
const next = stepLocal(direction);
|
||||||
|
if (next !== null) void commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepWheel = (direction: 1 | -1) => {
|
||||||
|
const next = stepLocal(direction);
|
||||||
|
if (next === null) return;
|
||||||
|
|
||||||
|
interacting.current = true;
|
||||||
|
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||||
|
wheelTimeout.current = setTimeout(() => {
|
||||||
|
wheelTimeout.current = null;
|
||||||
|
void commit(next);
|
||||||
|
}, WHEEL_COMMIT_DELAY);
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitText = () => {
|
const commitText = () => {
|
||||||
const match = values.find(
|
const match = values.find(
|
||||||
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
|
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
|
||||||
);
|
);
|
||||||
if (match !== undefined) onChange(match);
|
if (match !== undefined) {
|
||||||
else setText(formatLabel(value));
|
setLocalValue(match);
|
||||||
|
void commit(match);
|
||||||
|
} else {
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,20 +186,36 @@ function ChoiceStepperList<T>({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onBlur={commitText}
|
onBlur={commitText}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.deltaY < 0 && canUp) {
|
||||||
|
stepWheel(1);
|
||||||
|
} else if (e.deltaY > 0 && canDown) {
|
||||||
|
stepWheel(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') e.currentTarget.blur();
|
if (e.key === 'Enter') {
|
||||||
else if (e.key === 'ArrowUp') {
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
step(1);
|
interacting.current = true;
|
||||||
|
stepLocal(1);
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
step(-1);
|
interacting.current = true;
|
||||||
|
stepLocal(-1);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
void commit(localValue);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<StepperArrows
|
<StepperArrows
|
||||||
onUp={() => step(1)}
|
onUp={() => stepAndCommit(1)}
|
||||||
onDown={() => step(-1)}
|
onDown={() => stepAndCommit(-1)}
|
||||||
canUp={canUp}
|
canUp={canUp}
|
||||||
canDown={canDown}
|
canDown={canDown}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -119,7 +232,7 @@ type ChoiceStepperRangeProps = {
|
|||||||
max: number;
|
max: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
formatLabel: (value: number) => string;
|
formatLabel: (value: number) => string;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => CommitResult;
|
||||||
id?: string;
|
id?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -136,23 +249,96 @@ function ChoiceStepperRange({
|
|||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
}: ChoiceStepperRangeProps) {
|
}: ChoiceStepperRangeProps) {
|
||||||
|
const [localValue, setLocalValue] = useState(value);
|
||||||
const [text, setText] = useState(formatLabel(value));
|
const [text, setText] = useState(formatLabel(value));
|
||||||
useEffect(() => setText(formatLabel(value)), [value, formatLabel]);
|
|
||||||
|
const interacting = useRef(false);
|
||||||
|
const pendingValue = useRef<number | null>(null);
|
||||||
|
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (interacting.current) {
|
||||||
|
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLocalValue(value);
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}, [value, formatLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clamp = (n: number) => Math.min(max, Math.max(min, n));
|
const clamp = (n: number) => Math.min(max, Math.max(min, n));
|
||||||
|
|
||||||
const canUp = value < max;
|
const canUp = localValue < max;
|
||||||
const canDown = value > min;
|
const canDown = localValue > min;
|
||||||
|
|
||||||
const step = (direction: 1 | -1) => {
|
const stepLocal = (direction: 1 | -1): number | null => {
|
||||||
const next = clamp(value + direction * stepSize);
|
const next = clamp(localValue + direction * stepSize);
|
||||||
if (next !== value) onChange(next);
|
if (next === localValue) return null;
|
||||||
|
setLocalValue(next);
|
||||||
|
setText(formatLabel(next));
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function commit(next: number) {
|
||||||
|
interacting.current = true;
|
||||||
|
pendingValue.current = next;
|
||||||
|
|
||||||
|
const result = await onChange(next);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
setLocalValue(value);
|
||||||
|
setText(formatLabel(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
|
||||||
|
fallbackTimeout.current = setTimeout(() => {
|
||||||
|
interacting.current = false;
|
||||||
|
pendingValue.current = null;
|
||||||
|
}, PENDING_FALLBACK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepAndCommit = (direction: 1 | -1) => {
|
||||||
|
const next = stepLocal(direction);
|
||||||
|
if (next !== null) void commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepWheel = (direction: 1 | -1) => {
|
||||||
|
const next = stepLocal(direction);
|
||||||
|
if (next === null) return;
|
||||||
|
|
||||||
|
interacting.current = true;
|
||||||
|
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
|
||||||
|
wheelTimeout.current = setTimeout(() => {
|
||||||
|
wheelTimeout.current = null;
|
||||||
|
void commit(next);
|
||||||
|
}, WHEEL_COMMIT_DELAY);
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitText = () => {
|
const commitText = () => {
|
||||||
const parsed = Number(text);
|
const parsed = Number(text);
|
||||||
if (Number.isFinite(parsed)) onChange(clamp(parsed));
|
if (Number.isFinite(parsed)) {
|
||||||
else setText(formatLabel(value));
|
const next = clamp(parsed);
|
||||||
|
setLocalValue(next);
|
||||||
|
void commit(next);
|
||||||
|
} else {
|
||||||
|
setText(formatLabel(value));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -164,20 +350,40 @@ function ChoiceStepperRange({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onBlur={commitText}
|
onBlur={commitText}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.deltaY < 0 && canUp) {
|
||||||
|
stepWheel(1);
|
||||||
|
} else if (e.deltaY > 0 && canDown) {
|
||||||
|
stepWheel(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') e.currentTarget.blur();
|
if (e.key === 'Enter') {
|
||||||
else if (e.key === 'ArrowUp') {
|
e.currentTarget.blur();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
step(1);
|
if (canUp) {
|
||||||
|
interacting.current = true;
|
||||||
|
stepLocal(1);
|
||||||
|
}
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
step(-1);
|
if (canDown) {
|
||||||
|
interacting.current = true;
|
||||||
|
stepLocal(-1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
void commit(localValue);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<StepperArrows
|
<StepperArrows
|
||||||
onUp={() => step(1)}
|
onUp={() => stepAndCommit(1)}
|
||||||
onDown={() => step(-1)}
|
onDown={() => stepAndCommit(-1)}
|
||||||
canUp={canUp}
|
canUp={canUp}
|
||||||
canDown={canDown}
|
canDown={canDown}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export interface LinearGraphProps {
|
|||||||
yMin: number;
|
yMin: number;
|
||||||
yMax: number;
|
yMax: number;
|
||||||
yStep?: number;
|
yStep?: number;
|
||||||
/** 'log' requires xMin > 0. Ticks are drawn at 1/2/5-per-decade mantissas, with decade (power-of-10) lines highlighted. */
|
/** 'log' requires xMin > 0. */
|
||||||
xScale?: AxisScale;
|
xScale?: AxisScale;
|
||||||
/** 'log' requires yMin > 0. */
|
/** 'log' requires yMin > 0. */
|
||||||
yScale?: AxisScale;
|
yScale?: AxisScale;
|
||||||
@@ -55,7 +55,7 @@ export interface LinearGraphProps {
|
|||||||
const defaultFormat = (v: number) => {
|
const defaultFormat = (v: number) => {
|
||||||
if (Math.abs(v) < 1e-9) return '0';
|
if (Math.abs(v) < 1e-9) return '0';
|
||||||
const abs = Math.abs(v);
|
const abs = Math.abs(v);
|
||||||
if (abs >= 1000 || abs < 0.01) return v.toExponential(1);
|
if (abs >= 100000 || abs < 0.01) return v.toExponential(1);
|
||||||
return parseFloat(v.toFixed(4)).toString();
|
return parseFloat(v.toFixed(4)).toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
|||||||
const topBase = cq.h * 3;
|
const topBase = cq.h * 3;
|
||||||
const rightBase = cq.w * 5;
|
const rightBase = cq.w * 5;
|
||||||
const bottomBase = cq.h * 4;
|
const bottomBase = cq.h * 4;
|
||||||
const leftBase = cq.w * 2;
|
const leftBase = cq.w * 7;
|
||||||
const xLabelSpace = xLabel ? cq.h * 7 : 0;
|
const xLabelSpace = xLabel ? cq.h * 7 : 0;
|
||||||
const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0;
|
const yLabelSpace = yLabel ? Math.min(cq.w * 6, 24) : 0;
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
|||||||
ctx.clearRect(0, 0, cssW, cssH);
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
const plot = {
|
const plot = {
|
||||||
x: pad.left + cq.w * 2,
|
x: pad.left,
|
||||||
y: pad.top,
|
y: pad.top,
|
||||||
width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2),
|
width: Math.max(1, cssW - pad.left - pad.right - cq.w * 2),
|
||||||
height: Math.max(1, cssH - pad.top - pad.bottom),
|
height: Math.max(1, cssH - pad.top - pad.bottom),
|
||||||
@@ -271,14 +271,14 @@ export const LinearGraph = forwardRef<LinearGraphHandle, LinearGraphProps>(funct
|
|||||||
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
|
ctx.fillRect(plot.x, plot.y, plot.width, plot.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clipPad = 2;
|
const borderInset = 1; // Avoid clipping outside graph bounds
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.rect(
|
ctx.rect(
|
||||||
plot.x - clipPad,
|
plot.x + borderInset,
|
||||||
plot.y - clipPad,
|
plot.y + borderInset,
|
||||||
plot.width + clipPad * 2,
|
plot.width - borderInset * 2,
|
||||||
plot.height + clipPad * 2
|
plot.height - borderInset * 2
|
||||||
);
|
);
|
||||||
ctx.clip();
|
ctx.clip();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: left;
|
align-items: left;
|
||||||
gap: 0.3cqh;
|
gap: 0.3cqh;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meter-bar__title {
|
.meter-bar__title {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
transition: width var(--dur-base) var(--ease-standard);
|
transition: width var(--dur-base) var(--ease-standard);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-rail--expanded {
|
.sidebar-rail--expanded {
|
||||||
@@ -32,13 +35,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
gap: var(--space-1);
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-rail--expanded .sidebar-header {
|
.sidebar-rail--expanded .sidebar-header {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-inline: var(--space-3);
|
padding-left: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-toggle {
|
.sidebar-toggle {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { InputChannel, OutputChannel } from '../../features/dsp408/types';
|
import { InputChannel, OutputChannel } from '../../features/dsp408/types';
|
||||||
import { DSP408 } from '../../types';
|
import { DSP408 } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +69,14 @@ const MARGIN = 36;
|
|||||||
const LEFT_CHAIN_W = 560;
|
const LEFT_CHAIN_W = 560;
|
||||||
const RIGHT_CHAIN_W = 620;
|
const RIGHT_CHAIN_W = 620;
|
||||||
|
|
||||||
|
// Width of the invisible "hitbox" stroke drawn under each visible line, in SVG
|
||||||
|
// user units. Much wider than the visible 1–2.5px line so it's easy to click
|
||||||
|
// or catch with a scroll wheel without needing pixel-perfect aim.
|
||||||
|
const LINE_HITBOX_WIDTH = 14;
|
||||||
|
|
||||||
|
// How long the gain tooltip stays visible after the last wheel tick, in ms.
|
||||||
|
const GAIN_TOOLTIP_HOLD_MS = 700;
|
||||||
|
|
||||||
function getRoutes(dsp: DSP408, output: OutputChannel) {
|
function getRoutes(dsp: DSP408, output: OutputChannel) {
|
||||||
return dsp.state.current_config.output_states[output].matrix_routes;
|
return dsp.state.current_config.output_states[output].matrix_routes;
|
||||||
}
|
}
|
||||||
@@ -79,6 +87,18 @@ function getGain(dsp: DSP408, output: OutputChannel, input: InputChannel) {
|
|||||||
return getRoutes(dsp, output).gains[input];
|
return getRoutes(dsp, output).gains[input];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatGainDb(value: number) {
|
||||||
|
const sign = value > 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GainTooltipState = {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
function SignalFlowMatrix({
|
function SignalFlowMatrix({
|
||||||
dsp,
|
dsp,
|
||||||
setMatrixRouting,
|
setMatrixRouting,
|
||||||
@@ -109,6 +129,20 @@ function SignalFlowMatrix({
|
|||||||
const inputNodeX = leftStart + LEFT_CHAIN_W;
|
const inputNodeX = leftStart + LEFT_CHAIN_W;
|
||||||
const outputNodeX = rightStart;
|
const outputNodeX = rightStart;
|
||||||
|
|
||||||
|
const [gainTooltip, setGainTooltip] = useState<GainTooltipState>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
label: '',
|
||||||
|
});
|
||||||
|
const hideTooltipTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToggle = (output: OutputChannel, input: InputChannel) => {
|
const handleToggle = (output: OutputChannel, input: InputChannel) => {
|
||||||
const routes = getRoutes(dsp, output);
|
const routes = getRoutes(dsp, output);
|
||||||
const next = routes.connected.includes(input)
|
const next = routes.connected.includes(input)
|
||||||
@@ -124,6 +158,19 @@ function SignalFlowMatrix({
|
|||||||
const next =
|
const next =
|
||||||
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
|
Math.round(Math.min(MATRIX_GAIN_MAX, Math.max(MATRIX_GAIN_MIN, current + delta)) * 10) / 10;
|
||||||
void setMatrixGain(input, output, next);
|
void setMatrixGain(input, output, next);
|
||||||
|
|
||||||
|
// Show a small tooltip with the new gain value right next to the cursor,
|
||||||
|
// then let it fade away shortly after wheeling stops.
|
||||||
|
if (hideTooltipTimer.current) clearTimeout(hideTooltipTimer.current);
|
||||||
|
setGainTooltip({
|
||||||
|
visible: true,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
label: formatGainDb(next),
|
||||||
|
});
|
||||||
|
hideTooltipTimer.current = setTimeout(() => {
|
||||||
|
setGainTooltip((t) => ({ ...t, visible: false }));
|
||||||
|
}, GAIN_TOOLTIP_HOLD_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
const routePairs = useMemo(() => {
|
const routePairs = useMemo(() => {
|
||||||
@@ -134,6 +181,18 @@ function SignalFlowMatrix({
|
|||||||
return pairs;
|
return pairs;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Draw dashed (unconnected) links first so connected (solid) links always
|
||||||
|
// paint on top of them, regardless of input/output index order. This has
|
||||||
|
// to be recomputed whenever routing changes, since isRouted reads live
|
||||||
|
// dsp state — unlike routePairs above, which is just static index pairs.
|
||||||
|
const orderedRoutePairs = useMemo(() => {
|
||||||
|
return [...routePairs].sort((a, b) => {
|
||||||
|
const aConnected = isRouted(dsp, a.output, a.input) ? 1 : 0;
|
||||||
|
const bConnected = isRouted(dsp, b.output, b.input) ? 1 : 0;
|
||||||
|
return aConnected - bConnected;
|
||||||
|
});
|
||||||
|
}, [routePairs, dsp]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signal-flow">
|
<div className="signal-flow">
|
||||||
<svg
|
<svg
|
||||||
@@ -141,7 +200,7 @@ function SignalFlowMatrix({
|
|||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
className="signal-flow__svg"
|
className="signal-flow__svg"
|
||||||
>
|
>
|
||||||
{routePairs.map(({ input, output, i, j }) => {
|
{orderedRoutePairs.map(({ input, output, i, j }) => {
|
||||||
const connected = isRouted(dsp, output, input);
|
const connected = isRouted(dsp, output, input);
|
||||||
const y1 = inputCenterY(i);
|
const y1 = inputCenterY(i);
|
||||||
const y2 = outputCenterY(j);
|
const y2 = outputCenterY(j);
|
||||||
@@ -152,6 +211,20 @@ function SignalFlowMatrix({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={`${input}-${output}`}>
|
<g key={`${input}-${output}`}>
|
||||||
|
{/* Invisible wide-stroke hitbox: carries the click/wheel handlers so
|
||||||
|
the line is easy to grab without needing to hit the thin visible
|
||||||
|
stroke exactly. Sits directly under the visible path. */}
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="transparent"
|
||||||
|
strokeWidth={LINE_HITBOX_WIDTH}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => handleToggle(output, input)}
|
||||||
|
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
|
||||||
|
/>
|
||||||
|
{/* Visible line, purely decorative — no pointer events, so it never
|
||||||
|
competes with the hitbox above for hover/click/wheel. */}
|
||||||
<path
|
<path
|
||||||
d={path}
|
d={path}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -159,9 +232,7 @@ function SignalFlowMatrix({
|
|||||||
strokeWidth={connected ? 2.5 : 1}
|
strokeWidth={connected ? 2.5 : 1}
|
||||||
strokeDasharray={connected ? undefined : '4 5'}
|
strokeDasharray={connected ? undefined : '4 5'}
|
||||||
opacity={connected ? 0.9 : 0.35}
|
opacity={connected ? 0.9 : 0.35}
|
||||||
style={{ cursor: 'pointer' }}
|
pointerEvents="none"
|
||||||
onClick={() => handleToggle(output, input)}
|
|
||||||
onWheel={connected ? (e) => handleWheelGain(e, output, input) : undefined}
|
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
@@ -252,6 +323,30 @@ function SignalFlowMatrix({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{gainTooltip.visible && (
|
||||||
|
<div
|
||||||
|
className="signal-flow__gain-tooltip"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: gainTooltip.x + 14,
|
||||||
|
top: gainTooltip.y - 28,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 50,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
background: 'var(--surface-elevated, rgba(20, 20, 24, 0.92))',
|
||||||
|
color: 'var(--text-primary, #fff)',
|
||||||
|
border: '1px solid var(--border-hairline, #333)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gainTooltip.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,9 @@
|
|||||||
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
|
(var(--space-3) * 2) + (var(--tab-height) * 0.5) + (var(--space-2) * 2) + 4ch +
|
||||||
var(--tab-close-size)
|
var(--tab-close-size)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gauge-track {
|
.gauge-track {
|
||||||
@@ -58,8 +61,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
touch-action: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.track {
|
.track {
|
||||||
|
|||||||
@@ -18,17 +18,23 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compressor-panels {
|
.compressor-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compressor-panel {
|
.compressor-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
aspect-ratio: 0.5;
|
aspect-ratio: 0.5;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -91,8 +97,9 @@
|
|||||||
|
|
||||||
.compressor-panel__cell--steppers {
|
.compressor-panel__cell--steppers {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: stretch;
|
||||||
gap: 1cqh;
|
gap: 1cqh;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compressor-panel__stepper-item {
|
.compressor-panel__stepper-item {
|
||||||
@@ -108,6 +115,19 @@
|
|||||||
gap: 1cqh;
|
gap: 1cqh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compressor-panel__stepper-item .choice-stepper {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compressor-panel__stepper {
|
||||||
|
display: flex;
|
||||||
|
font-size: 4cqh;
|
||||||
|
text-align: center;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.compressor-panel__cell {
|
.compressor-panel__cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -142,6 +162,9 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compressor-graph-panel {
|
.compressor-graph-panel {
|
||||||
@@ -206,3 +229,15 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compressor-panel__stepper--centered .choice-stepper__input {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compressor-panel__stepper--centered .choice-stepper__arrows {
|
||||||
|
flex: 0 0 20%;
|
||||||
|
width: 20%;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,67 +8,7 @@ import MeterBar from '../../../../components/MeterBar/MeterBar';
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
|
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
|
||||||
import './CompressorSection.css';
|
import './CompressorSection.css';
|
||||||
|
import { RATIO_ORDER, RATIO_LABEL, RATIO_NUMERIC, KNEE_VALUES } from '../../constants';
|
||||||
// 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 = [
|
const COMPRESSOR_SLIDER_PARAMS = [
|
||||||
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
|
{ key: 'threshold', label: 'Threshold', min: -90, max: 20, step: 0.5, unit: ' dB' },
|
||||||
@@ -76,19 +16,6 @@ const COMPRESSOR_SLIDER_PARAMS = [
|
|||||||
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
||||||
] as const;
|
] 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 CompressorParamKey = 'ratio' | 'attack' | 'release' | 'knee' | 'threshold';
|
||||||
|
|
||||||
type CompressorValues = {
|
type CompressorValues = {
|
||||||
@@ -154,6 +81,7 @@ function CompressorPanel({
|
|||||||
<div className="compressor-panel__cell compressor-panel__cell--steppers">
|
<div className="compressor-panel__cell compressor-panel__cell--steppers">
|
||||||
<div className="compressor-panel__stepper-item">
|
<div className="compressor-panel__stepper-item">
|
||||||
<ChoiceStepper
|
<ChoiceStepper
|
||||||
|
className="compressor-panel__stepper compressor-panel__stepper--centered"
|
||||||
value={comp.ratio}
|
value={comp.ratio}
|
||||||
values={RATIO_ORDER}
|
values={RATIO_ORDER}
|
||||||
formatLabel={(v) => RATIO_LABEL[v]}
|
formatLabel={(v) => RATIO_LABEL[v]}
|
||||||
@@ -167,6 +95,7 @@ function CompressorPanel({
|
|||||||
|
|
||||||
<div className="compressor-panel__stepper-item">
|
<div className="compressor-panel__stepper-item">
|
||||||
<ChoiceStepper
|
<ChoiceStepper
|
||||||
|
className="compressor-panel__stepper compressor-panel__stepper--centered"
|
||||||
value={comp.knee}
|
value={comp.knee}
|
||||||
values={KNEE_VALUES}
|
values={KNEE_VALUES}
|
||||||
formatLabel={(v) => `${v} dB`}
|
formatLabel={(v) => `${v} dB`}
|
||||||
@@ -349,10 +278,13 @@ function CompressorSection({ dsp, set_compressor }: { dsp: DSP; set_compressor:
|
|||||||
<div className="compressor-graph__meters">
|
<div className="compressor-graph__meters">
|
||||||
{outputs.map((output) => {
|
{outputs.map((output) => {
|
||||||
const outputState = dsp.state.current_config.output_states[output];
|
const outputState = dsp.state.current_config.output_states[output];
|
||||||
const idx = OUTPUT_METER_INDEX[output];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
|
<MeterBar
|
||||||
|
key={output}
|
||||||
|
label={outputState.name}
|
||||||
|
meter={dsp.output_meters.meters[output]}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-panel {
|
.connection-panel {
|
||||||
|
|||||||
@@ -19,13 +19,18 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delay-panels {
|
.delay-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delay-panel {
|
.delay-panel {
|
||||||
@@ -86,6 +91,9 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delay-graph-panel {
|
.delay-graph-panel {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-panels {
|
.gain-panels {
|
||||||
@@ -75,6 +77,9 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gain-graph-panel {
|
.gain-graph-panel {
|
||||||
@@ -168,6 +173,8 @@
|
|||||||
gap: 0.5cqh;
|
gap: 0.5cqh;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.magnitude-selector__chip {
|
.magnitude-selector__chip {
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ function GainSection({
|
|||||||
const { yMin: inputYMin, yMax: inputYMax } = computeYAxisBounds(
|
const { yMin: inputYMin, yMax: inputYMax } = computeYAxisBounds(
|
||||||
inputCurves,
|
inputCurves,
|
||||||
GRAPH_Y_MIN,
|
GRAPH_Y_MIN,
|
||||||
GRAPH_Y_MAX,
|
12,
|
||||||
Y_AXIS_STEP
|
Y_AXIS_STEP
|
||||||
);
|
);
|
||||||
const { yMin: outputYMin, yMax: outputYMax } = computeYAxisBounds(
|
const { yMin: outputYMin, yMax: outputYMax } = computeYAxisBounds(
|
||||||
@@ -457,7 +457,18 @@ function GainSection({
|
|||||||
yLabel="dB"
|
yLabel="dB"
|
||||||
showGrid={true}
|
showGrid={true}
|
||||||
formatX={(v) => Math.round(v).toString()}
|
formatX={(v) => Math.round(v).toString()}
|
||||||
formatY={(v) => Math.round(v).toString()}
|
formatY={(v) => {
|
||||||
|
// Always label 0.
|
||||||
|
if (v === 0) return '0';
|
||||||
|
|
||||||
|
// Positive side: label every 12 dB.
|
||||||
|
if (v > 0) {
|
||||||
|
return v % 12 === 0 ? `+${Math.round(v)}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative side: label every 15 dB.
|
||||||
|
return Math.abs(v) % 15 === 0 ? Math.round(v).toString() : '';
|
||||||
|
}}
|
||||||
draw={({ ctx, toPx }) => {
|
draw={({ ctx, toPx }) => {
|
||||||
drawCurves({
|
drawCurves({
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
||||||
height: 40%;
|
height: var(--dsp-control-heigth);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-panels {
|
.gate-panels {
|
||||||
@@ -106,6 +109,9 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-graph-panel {
|
.gate-graph-panel {
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ const GATE_PARAMS = [
|
|||||||
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
||||||
] as const;
|
] 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 GateParamKey = (typeof GATE_PARAMS)[number]['key'];
|
||||||
|
|
||||||
type GateValues = {
|
type GateValues = {
|
||||||
@@ -139,8 +132,8 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
|||||||
<LinearGraph
|
<LinearGraph
|
||||||
xMin={envelope.t0}
|
xMin={envelope.t0}
|
||||||
xMax={envelope.t5}
|
xMax={envelope.t5}
|
||||||
yMin={-90}
|
yMin={-95}
|
||||||
yMax={0}
|
yMax={5}
|
||||||
xLabel="ms"
|
xLabel="ms"
|
||||||
yLabel="dB"
|
yLabel="dB"
|
||||||
formatX={(v) => Math.round(v).toString()}
|
formatX={(v) => Math.round(v).toString()}
|
||||||
@@ -185,9 +178,14 @@ function GateSection({ dsp, set_gate }: { dsp: DSP; set_gate: SetGateFn }) {
|
|||||||
<div className="gate-graph__meters">
|
<div className="gate-graph__meters">
|
||||||
{inputs.map((input) => {
|
{inputs.map((input) => {
|
||||||
const inputState = dsp.state.current_config.input_states[input];
|
const inputState = dsp.state.current_config.input_states[input];
|
||||||
const idx = INPUT_METER_INDEX[input];
|
|
||||||
const meter = dsp.meters.channels[idx];
|
return (
|
||||||
return <MeterBar key={input} label={inputState.name} meter={meter} />;
|
<MeterBar
|
||||||
|
key={input}
|
||||||
|
label={inputState.name}
|
||||||
|
meter={dsp.input_meters.meters[input]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,13 +19,18 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.geq-panels {
|
.geq-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.geq-band-panel {
|
.geq-band-panel {
|
||||||
@@ -112,6 +117,9 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.geq-graph-panel {
|
.geq-graph-panel {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-graph-panel {
|
.input-graph-panel {
|
||||||
@@ -40,6 +43,9 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom control bar — same pattern as .compressor-section: absolute,
|
||||||
|
bottom-anchored, panels flow in a row and scroll horizontally instead
|
||||||
|
of squeezing into a fixed max-width. */
|
||||||
.input-control-section {
|
.input-control-section {
|
||||||
container-type: size;
|
container-type: size;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -50,7 +56,7 @@
|
|||||||
height: var(--dsp-control-heigth);
|
height: var(--dsp-control-heigth);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -60,104 +66,23 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-control-panel {
|
.input-control-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
gap: var(--space-2);
|
||||||
max-width: 60cqw;
|
overflow-x: auto;
|
||||||
|
justify-content: center;
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: center; /* ← add this */
|
|
||||||
gap: var(--space-4);
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 2cqh;
|
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 */
|
/* Peq Sliders */
|
||||||
.input-panel {
|
.input-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -260,16 +185,26 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Now the root of a ChoiceSelect rather than a native <select>. */
|
||||||
.crossover-panel__select {
|
.crossover-panel__select {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 85%;
|
width: 85%;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 3cqh;
|
}
|
||||||
padding: 1cqh;
|
|
||||||
background: var(--bg-panel);
|
.crossover-panel__select .choice-select__input,
|
||||||
color: inherit;
|
.crossover-panel__select .choice-select__input option {
|
||||||
border: var(--border-width) solid var(--border-hairline);
|
font-size: 4cqh;
|
||||||
border-radius: var(--radius-sm);
|
color: var(--accent-brand);
|
||||||
|
/* padding-left: 1cqh; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.crossover-panel__select {
|
||||||
|
font-size: 4cqh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crossover-panel__select .choice-select__input {
|
||||||
|
font-size: 4cqh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peq-band-list {
|
.peq-band-list {
|
||||||
@@ -298,7 +233,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1.2fr 1fr 1fr 1.4fr 1.2fr;
|
grid-template-columns: 1fr 1.2fr 1fr 1fr 1.6fr 1.2fr;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 1cqh;
|
gap: 1cqh;
|
||||||
|
|
||||||
@@ -340,8 +275,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peq-band-list__input,
|
.peq-band-list__input {
|
||||||
.peq-band-list__select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -362,6 +296,17 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.peq-band-list__stepper,
|
||||||
|
.peq-band-list__select {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 4cqh;
|
||||||
|
}
|
||||||
|
|
||||||
.input-graph-panel__magnitude-wrap {
|
.input-graph-panel__magnitude-wrap {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -405,3 +350,15 @@
|
|||||||
background: var(--accent, #4dd0e1);
|
background: var(--accent, #4dd0e1);
|
||||||
color: var(--bg-void);
|
color: var(--bg-void);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.peq-band-list__stepper .choice-stepper__input,
|
||||||
|
.peq-band-list__select .choice-select__input {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 4cqh;
|
||||||
|
color: var(--accent-brand);
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import VerticalSlider, {
|
|||||||
makeLogStep,
|
makeLogStep,
|
||||||
makeLogQuantize,
|
makeLogQuantize,
|
||||||
} from '../../../../components/VerticalSlider/VerticalSlider';
|
} from '../../../../components/VerticalSlider/VerticalSlider';
|
||||||
|
import ChoiceStepper from '../../../../components/ChoiceStepper/ChoiceStepper';
|
||||||
|
import ChoiceSelect from '../../../../components/ChoiceSelect/ChoiceSelect';
|
||||||
import {
|
import {
|
||||||
CROSSOVER_FILTER_LABEL,
|
CROSSOVER_FILTER_LABEL,
|
||||||
PEQ_BANDS_ORDER,
|
PEQ_BANDS_ORDER,
|
||||||
@@ -18,7 +20,6 @@ import { useRef, useState } from 'react';
|
|||||||
import './InputSection.css';
|
import './InputSection.css';
|
||||||
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
import ChannelGroup from '../../../../components/ChannelGroup/ChannelGroup';
|
||||||
import Button from '../../../../components/Button/Button';
|
import Button from '../../../../components/Button/Button';
|
||||||
// import { buildTotalCurveDraw, buildTotalPhaseCurveDraw } from '../../utils/totalCurve';
|
|
||||||
import {
|
import {
|
||||||
FREQUENCY_MAX,
|
FREQUENCY_MAX,
|
||||||
FREQUENCY_MIN,
|
FREQUENCY_MIN,
|
||||||
@@ -258,17 +259,15 @@ function CrossoverPanel({
|
|||||||
style={{ width: '100%', flex: '1 1 auto' }}
|
style={{ width: '100%', flex: '1 1 auto' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<select
|
<ChoiceSelect
|
||||||
className="crossover-panel__select"
|
className="crossover-panel__select"
|
||||||
value={bypass ? lastFilterType.current : filter_type}
|
value={bypass ? lastFilterType.current : filter_type}
|
||||||
onChange={(e) => commit({ filter_type: e.target.value as CrossoverFilter })}
|
options={CROSSOVER_FILTER_LABEL.map((opt) => opt.value)}
|
||||||
>
|
formatLabel={(v) =>
|
||||||
{CROSSOVER_FILTER_LABEL.map((opt) => (
|
CROSSOVER_FILTER_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
|
||||||
<option key={opt.value} value={opt.value}>
|
}
|
||||||
{opt.label}
|
onChange={(value) => commit({ filter_type: value })}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="toggle"
|
variant="toggle"
|
||||||
@@ -331,6 +330,9 @@ function PeqBandList({
|
|||||||
const peq = liveBands[band] ?? bands[band];
|
const peq = liveBands[band] ?? bands[band];
|
||||||
const isActive = band === activeBand;
|
const isActive = band === activeBand;
|
||||||
|
|
||||||
|
const [qMin, qMax] = PEQ_Q_RANGES[peq.filter_type];
|
||||||
|
const qValues = PEAK_Q_VALUES.filter((q) => q >= qMin && q <= qMax);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
|
className={`peq-band-list__row${isActive ? ' peq-band-list__row--active' : ''}`}
|
||||||
@@ -338,62 +340,56 @@ function PeqBandList({
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="toggle"
|
variant="toggle"
|
||||||
className={isActive ? 'active' : ''}
|
className={isActive ? 'active-info' : ''}
|
||||||
onClick={() => setActiveBand(band)}
|
onClick={() => setActiveBand(band)}
|
||||||
style={{ minHeight: 0 }}
|
style={{
|
||||||
|
minHeight: 0,
|
||||||
|
fontSize: '4cqh',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{`B${i + 1}`}
|
{`B${i + 1}`}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Frequency */}
|
{/* Frequency */}
|
||||||
<input
|
<ChoiceStepper
|
||||||
className="peq-band-list__input"
|
className="peq-band-list__stepper"
|
||||||
type="text"
|
value={peq.frequency}
|
||||||
value={
|
min={FREQUENCY_MIN}
|
||||||
peq.frequency >= 1000 ? (peq.frequency / 1000).toFixed(2) : peq.frequency.toFixed(1)
|
max={FREQUENCY_MAX}
|
||||||
|
step={FREQUENCY_QUANTIZATION}
|
||||||
|
formatLabel={(v) =>
|
||||||
|
v >= 1000 ? `${(v / 1000).toFixed(2)} kHz` : `${v.toFixed(1)} Hz`
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(value) => commit(band, { frequency: value })}
|
||||||
const value = Number(e.target.value);
|
|
||||||
|
|
||||||
if (!Number.isNaN(value)) {
|
|
||||||
commit(band, {
|
|
||||||
frequency: peq.frequency >= 1000 ? value * 1000 : value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<ChoiceStepper
|
||||||
className="peq-band-list__input"
|
className="peq-band-list__stepper"
|
||||||
type="number"
|
|
||||||
step={1}
|
|
||||||
min={1}
|
|
||||||
max={999}
|
|
||||||
value={peq.q}
|
value={peq.q}
|
||||||
onChange={(e) => commit(band, { q: Number(e.target.value) })}
|
values={qValues}
|
||||||
|
formatLabel={(v) => v.toFixed(2)}
|
||||||
|
onChange={(value) => commit(band, { q: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<ChoiceStepper
|
||||||
className="peq-band-list__input"
|
className="peq-band-list__stepper"
|
||||||
type="number"
|
value={peq.gain}
|
||||||
step={0.1}
|
|
||||||
min={-12}
|
min={-12}
|
||||||
max={12}
|
max={12}
|
||||||
value={peq.gain}
|
step={0.1}
|
||||||
onChange={(e) => commit(band, { gain: Number(e.target.value) })}
|
formatLabel={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
||||||
|
onChange={(value) => commit(band, { gain: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<select
|
<ChoiceSelect<PEQFilter>
|
||||||
className="peq-band-list__select"
|
className="peq-band-list__select"
|
||||||
value={peq.filter_type}
|
value={peq.filter_type}
|
||||||
onChange={(e) => commit(band, { filter_type: e.target.value as PEQFilter, q: 3.0 })}
|
options={PEQ_FILTERS_LABEL.map((opt) => opt.value)}
|
||||||
>
|
formatLabel={(v) =>
|
||||||
{PEQ_FILTERS_LABEL.map((opt) => (
|
PEQ_FILTERS_LABEL.find((opt) => opt.value === v)?.label ?? String(v)
|
||||||
<option key={opt.value} value={opt.value}>
|
}
|
||||||
{opt.label}
|
onChange={(value) => commit(band, { filter_type: value, q: 3.0 })}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="toggle"
|
variant="toggle"
|
||||||
@@ -416,75 +412,6 @@ type SetFilterFn = (
|
|||||||
filterType: CrossoverFilter
|
filterType: CrossoverFilter
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
|
||||||
/** Small DOM-overlay markers positioned on top of the magnitude graph via its exposed toPxX/toPxY. */
|
|
||||||
// function GraphMarkers({
|
|
||||||
// graphRef,
|
|
||||||
// bands,
|
|
||||||
// activeBand,
|
|
||||||
// setActiveBand,
|
|
||||||
// hpFreq,
|
|
||||||
// hpBypassed,
|
|
||||||
// lpFreq,
|
|
||||||
// lpBypassed,
|
|
||||||
// }: {
|
|
||||||
// graphRef: React.RefObject<LinearGraphHandle>;
|
|
||||||
// bands: Record<PEQBand, PEQ>;
|
|
||||||
// activeBand: PEQBand;
|
|
||||||
// setActiveBand: (band: PEQBand) => void;
|
|
||||||
// hpFreq: number;
|
|
||||||
// hpBypassed: boolean;
|
|
||||||
// lpFreq: number;
|
|
||||||
// lpBypassed: boolean;
|
|
||||||
// }) {
|
|
||||||
// const g = graphRef.current;
|
|
||||||
// if (!g) return null;
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <>
|
|
||||||
// {PEQ_BANDS_ORDER.map((band, i) => {
|
|
||||||
// const peq = bands[band];
|
|
||||||
// const left = g.toPxX(peq.frequency);
|
|
||||||
// const top = g.toPxY(peq.gain);
|
|
||||||
// const isActive = band === activeBand;
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <button
|
|
||||||
// key={band}
|
|
||||||
// type="button"
|
|
||||||
// className={`graph-marker graph-marker--peq${isActive ? ' graph-marker--active' : ''}${
|
|
||||||
// peq.bypass ? ' graph-marker--bypassed' : ''
|
|
||||||
// }`}
|
|
||||||
// style={{ left, top }}
|
|
||||||
// onClick={() => setActiveBand(band)}
|
|
||||||
// title={`B${i + 1} · ${Math.round(peq.frequency)} Hz · ${peq.gain.toFixed(1)} dB`}
|
|
||||||
// >
|
|
||||||
// {`B${i + 1}`}
|
|
||||||
// </button>
|
|
||||||
// );
|
|
||||||
// })}
|
|
||||||
|
|
||||||
// <div
|
|
||||||
// className={`graph-marker graph-marker--crossover graph-marker--hp${
|
|
||||||
// hpBypassed ? ' graph-marker--bypassed' : ''
|
|
||||||
// }`}
|
|
||||||
// style={{ left: g.toPxX(hpFreq), top: g.toPxY(0) }}
|
|
||||||
// title={`HPF · ${Math.round(hpFreq)} Hz`}
|
|
||||||
// >
|
|
||||||
// HP
|
|
||||||
// </div>
|
|
||||||
// <div
|
|
||||||
// className={`graph-marker graph-marker--crossover graph-marker--lp${
|
|
||||||
// lpBypassed ? ' graph-marker--bypassed' : ''
|
|
||||||
// }`}
|
|
||||||
// style={{ left: g.toPxX(lpFreq), top: g.toPxY(0) }}
|
|
||||||
// title={`LPF · ${Math.round(lpFreq)} Hz`}
|
|
||||||
// >
|
|
||||||
// LP
|
|
||||||
// </div>
|
|
||||||
// </>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
function InputSection({
|
function InputSection({
|
||||||
dsp,
|
dsp,
|
||||||
channel,
|
channel,
|
||||||
@@ -626,8 +553,8 @@ function InputSection({
|
|||||||
yMin={GRAPH_Y_MIN}
|
yMin={GRAPH_Y_MIN}
|
||||||
yMax={GRAPH_Y_MAX}
|
yMax={GRAPH_Y_MAX}
|
||||||
yStep={3}
|
yStep={3}
|
||||||
xLabel="Hz"
|
xLabel="Frequency - Hz"
|
||||||
yLabel="dB"
|
yLabel="Magnituded - dB"
|
||||||
showGrid={true}
|
showGrid={true}
|
||||||
formatX={(v) => Math.round(v).toString()}
|
formatX={(v) => Math.round(v).toString()}
|
||||||
formatY={(v) => Math.round(v).toString()}
|
formatY={(v) => Math.round(v).toString()}
|
||||||
@@ -635,7 +562,6 @@ function InputSection({
|
|||||||
drawCurves({
|
drawCurves({
|
||||||
ctx,
|
ctx,
|
||||||
toPx,
|
toPx,
|
||||||
|
|
||||||
xMin: GRAPH_X_MIN,
|
xMin: GRAPH_X_MIN,
|
||||||
xMax: GRAPH_X_MAX,
|
xMax: GRAPH_X_MAX,
|
||||||
yMin: GRAPH_Y_MIN,
|
yMin: GRAPH_Y_MIN,
|
||||||
@@ -667,8 +593,8 @@ function InputSection({
|
|||||||
yMin={-180}
|
yMin={-180}
|
||||||
yMax={180}
|
yMax={180}
|
||||||
yStep={45}
|
yStep={45}
|
||||||
xLabel="Hz"
|
xLabel="Frequency - Hz"
|
||||||
yLabel="°"
|
yLabel="Phase - Deg"
|
||||||
showGrid={true}
|
showGrid={true}
|
||||||
formatX={(v) => Math.round(v).toString()}
|
formatX={(v) => Math.round(v).toString()}
|
||||||
formatY={(v) => Math.round(v).toString()}
|
formatY={(v) => Math.round(v).toString()}
|
||||||
@@ -676,7 +602,6 @@ function InputSection({
|
|||||||
drawCurves({
|
drawCurves({
|
||||||
ctx,
|
ctx,
|
||||||
toPx,
|
toPx,
|
||||||
|
|
||||||
xMin: GRAPH_X_MIN,
|
xMin: GRAPH_X_MIN,
|
||||||
xMax: GRAPH_X_MAX,
|
xMax: GRAPH_X_MAX,
|
||||||
yMin: GRAPH_Y_MIN,
|
yMin: GRAPH_Y_MIN,
|
||||||
|
|||||||
@@ -18,18 +18,23 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.limiter-panels {
|
.limiter-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.limiter-panel {
|
.limiter-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 10;
|
height: 100%;
|
||||||
|
|
||||||
aspect-ratio: 0.5;
|
aspect-ratio: 0.5;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -116,6 +121,9 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.limiter-graph-panel {
|
.limiter-graph-panel {
|
||||||
|
|||||||
@@ -14,20 +14,6 @@ const LIMITER_SLIDER_PARAMS = [
|
|||||||
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
{ key: 'release', label: 'Release', min: 10, max: 3000, step: 1, unit: ' ms' },
|
||||||
] as const;
|
] 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 LimiterParamKey = 'threshold' | 'attack' | 'release';
|
||||||
|
|
||||||
type LimiterValues = {
|
type LimiterValues = {
|
||||||
@@ -206,10 +192,13 @@ function LimiterSection({ dsp, set_limiter }: { dsp: DSP; set_limiter: SetLimite
|
|||||||
<div className="limiter-graph__meters">
|
<div className="limiter-graph__meters">
|
||||||
{outputs.map((output) => {
|
{outputs.map((output) => {
|
||||||
const outputState = dsp.state.current_config.output_states[output];
|
const outputState = dsp.state.current_config.output_states[output];
|
||||||
const idx = OUTPUT_METER_INDEX[output];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
|
<MeterBar
|
||||||
|
key={output}
|
||||||
|
label={outputState.name}
|
||||||
|
meter={dsp.output_meters.meters[output]}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,19 +19,25 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|
||||||
border-top: var(--border-width) solid var(--border-hairline);
|
border-top: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-panels {
|
.matrix-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.matrix-panel {
|
.matrix-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
aspect-ratio: 0.6;
|
aspect-ratio: 0.5;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -84,10 +90,30 @@
|
|||||||
padding-inline: 3cqh;
|
padding-inline: 3cqh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.matrix-panel__row .choice-stepper {
|
||||||
|
align-self: center;
|
||||||
|
aspect-ratio: 3 / 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.matrix-flow {
|
.matrix-flow {
|
||||||
flex: 0 0 calc(100% - var(--dsp-control-heigth));
|
flex: 0 0 calc(100% - var(--dsp-control-heigth));
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
border-bottom: var(--border-width) solid var(--border-hairline);
|
border-bottom: var(--border-width) solid var(--border-hairline);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.matrix-gain-stepper .choice-stepper__input {
|
||||||
|
font-size: 2.8cqh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-gain-stepper .choice-stepper__arrows {
|
||||||
|
flex: 0 0 20%;
|
||||||
|
width: 20%;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
@@ -101,8 +101,8 @@ function MatrixSection({
|
|||||||
fontSize: '4cqh',
|
fontSize: '4cqh',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
marginTop: '2cqh',
|
marginTop: '3cqh',
|
||||||
marginBottom: '2cqh',
|
marginBottom: '3cqh',
|
||||||
}}
|
}}
|
||||||
onClick={() => handleToggle(output, input)}
|
onClick={() => handleToggle(output, input)}
|
||||||
>
|
>
|
||||||
@@ -110,6 +110,7 @@ function MatrixSection({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ChoiceStepper
|
<ChoiceStepper
|
||||||
|
className="matrix-gain-stepper"
|
||||||
value={gain}
|
value={gain}
|
||||||
values={GAIN_VALUES}
|
values={GAIN_VALUES}
|
||||||
formatLabel={(v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
formatLabel={(v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PEQBand,
|
PEQBand,
|
||||||
PEQFilter,
|
PEQFilter,
|
||||||
DelayUnit,
|
DelayUnit,
|
||||||
|
Ratio,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const INPUT_ORDER: InputChannel[] = [
|
export const INPUT_ORDER: InputChannel[] = [
|
||||||
@@ -101,6 +102,67 @@ export const GEQ_GAIN_MAX = 12;
|
|||||||
export const GEQ_GAIN_STEP = 0.1;
|
export const GEQ_GAIN_STEP = 0.1;
|
||||||
export const GEQ_CURVE_Q = 8.65 * 0.5;
|
export const GEQ_CURVE_Q = 8.65 * 0.5;
|
||||||
|
|
||||||
|
// ------ Compressor constants ------
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
];
|
||||||
|
|
||||||
|
export 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KNEE_VALUES = Array.from({ length: 13 }, (_, i) => i); // 0..12 dB, integer steps
|
||||||
|
|
||||||
// ------ Filter constants ------
|
// ------ Filter constants ------
|
||||||
|
|
||||||
export const CROSSOVER_FILTER_LABEL: { value: CrossoverFilter; label: string }[] = [
|
export const CROSSOVER_FILTER_LABEL: { value: CrossoverFilter; label: string }[] = [
|
||||||
@@ -127,6 +189,7 @@ export const PEQ_BANDS_ORDER: PEQBand[] = [
|
|||||||
PEQBand.B6,
|
PEQBand.B6,
|
||||||
PEQBand.B7,
|
PEQBand.B7,
|
||||||
PEQBand.B8,
|
PEQBand.B8,
|
||||||
|
PEQBand.B9,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PEQ_Q_RANGES: Record<PEQFilter, readonly [number, number]> = {
|
export const PEQ_Q_RANGES: Record<PEQFilter, readonly [number, number]> = {
|
||||||
@@ -179,8 +242,16 @@ export const DELAY_RANGES: Record<DelayUnit, [number, number]> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ------ Graph constants ------
|
// ------ Graph constants ------
|
||||||
export const GRAPH_X_MIN = 10;
|
const REAL_X_MIN = 20;
|
||||||
export const GRAPH_X_MAX = 30000;
|
const REAL_X_MAX = 20000;
|
||||||
|
const X_LOG_PADDING = 0.05;
|
||||||
|
|
||||||
|
const logMin = Math.log10(REAL_X_MIN);
|
||||||
|
const logMax = Math.log10(REAL_X_MAX);
|
||||||
|
const logRange = logMax - logMin;
|
||||||
|
|
||||||
|
export const GRAPH_X_MIN = Math.floor(Math.pow(10, logMin - logRange * X_LOG_PADDING));
|
||||||
|
export const GRAPH_X_MAX = Math.ceil(Math.pow(10, logMax + logRange * X_LOG_PADDING));
|
||||||
export const GRAPH_Y_MIN = -21;
|
export const GRAPH_Y_MIN = -21;
|
||||||
export const GRAPH_Y_MAX = 21;
|
export const GRAPH_Y_MAX = 21;
|
||||||
|
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
import {
|
|
||||||
Gate,
|
|
||||||
GraphicEQ,
|
|
||||||
DiscreteFrequency,
|
|
||||||
PEQ,
|
|
||||||
PEQFilter,
|
|
||||||
PEQBand,
|
|
||||||
PEQChain,
|
|
||||||
Crossover,
|
|
||||||
CrossoverFilter,
|
|
||||||
CrossoverFilters,
|
|
||||||
InputChannelState,
|
|
||||||
OutputChannelState,
|
|
||||||
InputChannel,
|
|
||||||
DSPState,
|
|
||||||
Meters,
|
|
||||||
Meter,
|
|
||||||
Ratio,
|
|
||||||
OutputChannel,
|
|
||||||
DelayUnit,
|
|
||||||
} from './types';
|
|
||||||
import { FREQUENCY_MIN, FREQUENCY_MAX } from './constants';
|
|
||||||
|
|
||||||
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).map((band) => [band, createDefaultPEQ()])
|
|
||||||
) as Record<PEQBand, PEQ>,
|
|
||||||
bypass: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultCrossover = (frequency: number): Crossover => ({
|
|
||||||
frequency: frequency,
|
|
||||||
slope: CrossoverFilter.Bypass,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDefaultCrossoverFilters = (): CrossoverFilters => ({
|
|
||||||
high_pass: createDefaultCrossover(FREQUENCY_MIN),
|
|
||||||
low_pass: createDefaultCrossover(FREQUENCY_MAX),
|
|
||||||
});
|
|
||||||
|
|
||||||
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: [InputChannel.InA],
|
|
||||||
gains: {
|
|
||||||
[InputChannel.InA]: 0,
|
|
||||||
[InputChannel.InB]: 0,
|
|
||||||
[InputChannel.InC]: 0,
|
|
||||||
[InputChannel.InD]: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
+13
-959
File diff suppressed because it is too large
Load Diff
@@ -238,12 +238,25 @@ export type DSPState = {
|
|||||||
current_config: DSPConfigState;
|
current_config: DSPConfigState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DSPMode = 'demo' | 'disconnected' | 'connected';
|
||||||
|
|
||||||
|
export type DSP408Snapshot = {
|
||||||
|
state: DSPState;
|
||||||
|
input_meters: DSP408InputMeters;
|
||||||
|
output_meters: DSP408OutputMeters;
|
||||||
|
mode: DSPMode;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Meter {
|
export interface Meter {
|
||||||
rms: number;
|
rms: number;
|
||||||
peak: number;
|
peak: number;
|
||||||
clip: boolean;
|
clip: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Meters {
|
export interface DSP408InputMeters {
|
||||||
channels: Meter[];
|
meters: Record<InputChannel, Meter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DSP408OutputMeters {
|
||||||
|
meters: Record<OutputChannel, Meter>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,14 +155,21 @@
|
|||||||
height: var(--card-control-height);
|
height: var(--card-control-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-dsp-form__stepper .choice-stepper__arrows {
|
|
||||||
padding: 4px 4px 4px 0;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-dsp-form__field .choice-stepper__input {
|
.add-dsp-form__field .choice-stepper__input {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: var(--font-weight-normal);
|
font-weight: var(--font-weight-normal);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dsp-form {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-dsp-form input,
|
||||||
|
.add-dsp-form select {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { DSP, DSP_DEFAULT_PARAMETERS, DSPTypes } from '../../types';
|
import { DSP_DEFAULT_PARAMETERS, DSPTypes } from '../../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 ChoiceStepper from '../../components/ChoiceStepper/ChoiceStepper';
|
import ChoiceStepper from '../../components/ChoiceStepper/ChoiceStepper';
|
||||||
@@ -18,12 +18,26 @@ type DSPForm = {
|
|||||||
|
|
||||||
const DSP_TYPES = Object.keys(DSP_DEFAULT_PARAMETERS) as DSPTypes[];
|
const DSP_TYPES = Object.keys(DSP_DEFAULT_PARAMETERS) as DSPTypes[];
|
||||||
|
|
||||||
|
function isValidIPv4(value: string): boolean {
|
||||||
|
const parts = value.trim().split('.');
|
||||||
|
|
||||||
|
if (parts.length !== 4) return false;
|
||||||
|
|
||||||
|
return parts.every((part) => {
|
||||||
|
if (!/^\d+$/.test(part)) return false;
|
||||||
|
|
||||||
|
const n = Number(part);
|
||||||
|
|
||||||
|
return n >= 0 && n <= 255;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function AddDSPForm({
|
function AddDSPForm({
|
||||||
onCancel,
|
onCancel,
|
||||||
onAdd,
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onAdd: (d: Omit<DSP, 'status' | 'id' | 'state' | 'meters' | 'pollingPaused'>) => void;
|
onAdd: (params: DSPForm) => void;
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<DSPForm>({
|
const [form, setForm] = useState<DSPForm>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -34,20 +48,46 @@ function AddDSPForm({
|
|||||||
demo: false,
|
demo: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lastValidIp = useRef(form.ip);
|
||||||
|
|
||||||
|
const validIp = form.demo || isValidIPv4(form.ip);
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
form.name.trim().length > 0 &&
|
form.name.trim().length > 0 &&
|
||||||
(form.demo || form.ip.trim().length > 0) &&
|
validIp &&
|
||||||
Number.isInteger(form.deviceId) &&
|
Number.isInteger(form.deviceId) &&
|
||||||
form.deviceId >= 1 &&
|
form.deviceId >= 1 &&
|
||||||
form.deviceId <= 255;
|
form.deviceId <= 255;
|
||||||
|
|
||||||
|
function validateIp() {
|
||||||
|
if (form.demo) return;
|
||||||
|
|
||||||
|
const ip = form.ip.trim();
|
||||||
|
|
||||||
|
if (isValidIPv4(ip)) {
|
||||||
|
lastValidIp.current = ip;
|
||||||
|
|
||||||
|
if (ip !== form.ip) {
|
||||||
|
setForm((prev) => ({ ...prev, ip }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ip: lastValidIp.current,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
onAdd({ ...form });
|
|
||||||
|
onAdd(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="add-dsp-form">
|
||||||
<div className="add-dsp-form__header">
|
<div className="add-dsp-form__header">
|
||||||
<h1 className="add-dsp-form__title">Add DSP</h1>
|
<h1 className="add-dsp-form__title">Add DSP</h1>
|
||||||
<p className="add-dsp-form__hint">Register a new device to control from this app.</p>
|
<p className="add-dsp-form__hint">Register a new device to control from this app.</p>
|
||||||
@@ -67,6 +107,7 @@ function AddDSPForm({
|
|||||||
<div className="add-dsp-form__field">
|
<div className="add-dsp-form__field">
|
||||||
<label htmlFor="f-type">Model</label>
|
<label htmlFor="f-type">Model</label>
|
||||||
<ChoiceSelect
|
<ChoiceSelect
|
||||||
|
className=""
|
||||||
id="f-type"
|
id="f-type"
|
||||||
value={form.type}
|
value={form.type}
|
||||||
options={DSP_TYPES}
|
options={DSP_TYPES}
|
||||||
@@ -91,12 +132,26 @@ function AddDSPForm({
|
|||||||
<div className="add-dsp-form__row">
|
<div className="add-dsp-form__row">
|
||||||
<div className="add-dsp-form__field">
|
<div className="add-dsp-form__field">
|
||||||
<label htmlFor="f-ip">IP address</label>
|
<label htmlFor="f-ip">IP address</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="f-ip"
|
id="f-ip"
|
||||||
className="mono"
|
className="mono"
|
||||||
value={form.ip}
|
value={form.ip}
|
||||||
disabled={form.demo}
|
disabled={form.demo}
|
||||||
onChange={(e) => setForm({ ...form, ip: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ip: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBlur={validateIp}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
validateIp();
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,7 +176,11 @@ function AddDSPForm({
|
|||||||
id="f-demo"
|
id="f-demo"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.demo}
|
checked={form.demo}
|
||||||
onChange={(e) => setForm({ ...form, demo: e.target.checked })}
|
onChange={(e) => {
|
||||||
|
const demo = e.target.checked;
|
||||||
|
|
||||||
|
setForm({ ...form, demo });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
Demo mode (simulate device, no network connection)
|
Demo mode (simulate device, no network connection)
|
||||||
</label>
|
</label>
|
||||||
@@ -131,6 +190,7 @@ function AddDSPForm({
|
|||||||
<Button variant="ghost" onClick={onCancel}>
|
<Button variant="ghost" onClick={onCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
|
<Button variant="primary" onClick={submit} disabled={!canSubmit}>
|
||||||
Add DSP
|
Add DSP
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,3 +24,15 @@
|
|||||||
|
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.credits-page {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-page input,
|
||||||
|
.credits-page select,
|
||||||
|
.credits-page textarea {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import './CreditsPage.css';
|
|||||||
|
|
||||||
function CreditsPage() {
|
function CreditsPage() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="credits-page">
|
||||||
<div className="credits-page__header">
|
<div className="credits-page__header">
|
||||||
<h1 className="credits-page__title">Credits</h1>
|
<h1 className="credits-page__title">Credits</h1>
|
||||||
<p className="credits-page__subtitle">
|
<p className="credits-page__subtitle">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.specs {
|
.specs {
|
||||||
|
|||||||
+9
-3
@@ -1,4 +1,9 @@
|
|||||||
import { DSPState as DSP408State, Meters as DSP408Meters } from './features/dsp408/types';
|
import {
|
||||||
|
DSPState as DSP408State,
|
||||||
|
DSPMode,
|
||||||
|
DSP408OutputMeters,
|
||||||
|
DSP408InputMeters,
|
||||||
|
} from './features/dsp408/types';
|
||||||
|
|
||||||
export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
|
export type DSPStatus = 'connected' | 'connecting' | 'disconnected';
|
||||||
|
|
||||||
@@ -31,7 +36,7 @@ interface DSPBase {
|
|||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
deviceId: number;
|
deviceId: number;
|
||||||
demo: boolean;
|
mode: DSPMode;
|
||||||
|
|
||||||
status: DSPStatus;
|
status: DSPStatus;
|
||||||
|
|
||||||
@@ -41,7 +46,8 @@ interface DSPBase {
|
|||||||
export type DSP408 = DSPBase & {
|
export type DSP408 = DSPBase & {
|
||||||
type: 'DSP408';
|
type: 'DSP408';
|
||||||
state: DSP408State;
|
state: DSP408State;
|
||||||
meters: DSP408Meters;
|
input_meters: DSP408InputMeters;
|
||||||
|
output_meters: DSP408OutputMeters;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DSP204 = DSPBase & {
|
export type DSP204 = DSPBase & {
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@
|
|||||||
|
|
||||||
/* ---------- Card ---------- */
|
/* ---------- Card ---------- */
|
||||||
--card-width: 500px;
|
--card-width: 500px;
|
||||||
--card-control-height: 36px;
|
--card-control-height: 40px;
|
||||||
|
|
||||||
/* ---------- Button ---------- */
|
/* ---------- Button ---------- */
|
||||||
--disabled-opacity: 0.45;
|
--disabled-opacity: 0.45;
|
||||||
|
|||||||
Reference in New Issue
Block a user