Files
audio-ui/components/effects/DynamicsParameterDialog.tsx
Sebastian Krüger 2fc1620495 fix: add nullish coalescing to parameter dialogs
Fixed "Cannot read properties of undefined (reading 'toFixed')" errors
in TimeBasedParameterDialog and DynamicsParameterDialog by adding
nullish coalescing operators with default values to all parameter
accesses. This prevents errors when loading presets that have partial
parameter sets.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 20:50:48 +01:00

523 lines
17 KiB
TypeScript

'use client';
import * as React from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
import { cn } from '@/lib/utils/cn';
import type {
CompressorParameters,
LimiterParameters,
GateParameters,
} from '@/lib/audio/effects/dynamics';
export type DynamicsType = 'compressor' | 'limiter' | 'gate';
export type DynamicsParameters =
| (CompressorParameters & { type: 'compressor' })
| (LimiterParameters & { type: 'limiter' })
| (GateParameters & { type: 'gate' });
export interface EffectPreset {
name: string;
parameters: Partial<CompressorParameters | LimiterParameters | GateParameters>;
}
export interface DynamicsParameterDialogProps {
open: boolean;
onClose: () => void;
effectType: DynamicsType;
onApply: (params: DynamicsParameters) => void;
}
const EFFECT_LABELS: Record<DynamicsType, string> = {
compressor: 'Compressor',
limiter: 'Limiter',
gate: 'Gate/Expander',
};
const EFFECT_DESCRIPTIONS: Record<DynamicsType, string> = {
compressor: 'Reduces dynamic range by lowering loud sounds',
limiter: 'Prevents audio from exceeding threshold',
gate: 'Reduces volume of quiet sounds below threshold',
};
const PRESETS: Record<DynamicsType, EffectPreset[]> = {
compressor: [
{ name: 'Gentle', parameters: { threshold: -20, ratio: 2, attack: 10, release: 100, knee: 6, makeupGain: 3 } },
{ name: 'Medium', parameters: { threshold: -18, ratio: 4, attack: 5, release: 50, knee: 3, makeupGain: 6 } },
{ name: 'Heavy', parameters: { threshold: -15, ratio: 8, attack: 1, release: 30, knee: 0, makeupGain: 10 } },
{ name: 'Vocal', parameters: { threshold: -16, ratio: 3, attack: 5, release: 80, knee: 4, makeupGain: 5 } },
],
limiter: [
{ name: 'Transparent', parameters: { threshold: -3, attack: 0.5, release: 50, makeupGain: 0 } },
{ name: 'Loud', parameters: { threshold: -1, attack: 0.1, release: 20, makeupGain: 2 } },
{ name: 'Broadcast', parameters: { threshold: -0.5, attack: 0.1, release: 10, makeupGain: 0 } },
{ name: 'Mastering', parameters: { threshold: -2, attack: 0.3, release: 30, makeupGain: 1 } },
],
gate: [
{ name: 'Gentle', parameters: { threshold: -40, ratio: 2, attack: 5, release: 100, knee: 6 } },
{ name: 'Medium', parameters: { threshold: -50, ratio: 4, attack: 1, release: 50, knee: 3 } },
{ name: 'Hard', parameters: { threshold: -60, ratio: 10, attack: 0.5, release: 20, knee: 0 } },
{ name: 'Noise Reduction', parameters: { threshold: -45, ratio: 6, attack: 1, release: 80, knee: 4 } },
],
};
export function DynamicsParameterDialog({
open,
onClose,
effectType,
onApply,
}: DynamicsParameterDialogProps) {
const [parameters, setParameters] = React.useState<DynamicsParameters>(() => {
if (effectType === 'compressor') {
return {
type: 'compressor',
threshold: -20,
ratio: 4,
attack: 5,
release: 50,
knee: 3,
makeupGain: 6,
};
} else if (effectType === 'limiter') {
return {
type: 'limiter',
threshold: -3,
attack: 0.5,
release: 50,
makeupGain: 0,
};
} else {
return {
type: 'gate',
threshold: -40,
ratio: 4,
attack: 5,
release: 50,
knee: 3,
};
}
});
const canvasRef = React.useRef<HTMLCanvasElement>(null);
// Get appropriate presets for this effect type
const presets = PRESETS[effectType] || [];
// Update parameters when effect type changes
React.useEffect(() => {
if (effectType === 'compressor') {
setParameters({
type: 'compressor',
threshold: -20,
ratio: 4,
attack: 5,
release: 50,
knee: 3,
makeupGain: 6,
});
} else if (effectType === 'limiter') {
setParameters({
type: 'limiter',
threshold: -3,
attack: 0.5,
release: 50,
makeupGain: 0,
});
} else {
setParameters({
type: 'gate',
threshold: -40,
ratio: 4,
attack: 5,
release: 50,
knee: 3,
});
}
}, [effectType]);
// Draw transfer curve (input level vs output level)
React.useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Get actual dimensions
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// Set actual size in memory (scaled to account for extra pixel density)
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
// Normalize coordinate system to use CSS pixels
ctx.scale(dpr, dpr);
// Clear any previous drawings first
ctx.clearRect(0, 0, canvas.width, canvas.height);
const width = rect.width;
const height = rect.height;
const padding = 40;
const graphWidth = width - padding * 2;
const graphHeight = height - padding * 2;
// Clear canvas
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('background-color') || '#1a1a1a';
ctx.fillRect(0, 0, width, height);
// Draw axes
ctx.strokeStyle = 'rgba(128, 128, 128, 0.5)';
ctx.lineWidth = 1;
// Horizontal and vertical grid lines
ctx.beginPath();
for (let db = -60; db <= 0; db += 10) {
const x = padding + ((db + 60) / 60) * graphWidth;
const y = padding + graphHeight - ((db + 60) / 60) * graphHeight;
// Vertical grid line
ctx.moveTo(x, padding);
ctx.lineTo(x, padding + graphHeight);
// Horizontal grid line
ctx.moveTo(padding, y);
ctx.lineTo(padding + graphWidth, y);
}
ctx.stroke();
// Draw unity line (input = output)
ctx.strokeStyle = 'rgba(128, 128, 128, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(padding, padding + graphHeight);
ctx.lineTo(padding + graphWidth, padding);
ctx.stroke();
ctx.setLineDash([]);
// Draw threshold line
const threshold = parameters.threshold;
const thresholdX = padding + ((threshold + 60) / 60) * graphWidth;
ctx.strokeStyle = 'rgba(255, 165, 0, 0.5)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(thresholdX, padding);
ctx.lineTo(thresholdX, padding + graphHeight);
ctx.stroke();
ctx.setLineDash([]);
// Draw transfer curve
ctx.strokeStyle = '#3b82f6'; // Primary blue
ctx.lineWidth = 2;
ctx.beginPath();
for (let inputDb = -60; inputDb <= 0; inputDb += 0.5) {
let outputDb = inputDb;
if (effectType === 'compressor' || effectType === 'limiter') {
const ratio = parameters.type === 'limiter' ? 100 : (parameters as CompressorParameters).ratio;
const knee = parameters.type === 'limiter' ? 0 : (parameters as CompressorParameters).knee;
const makeupGain = (parameters as CompressorParameters | LimiterParameters).makeupGain;
if (inputDb > threshold) {
const overThreshold = inputDb - threshold;
// Soft knee calculation
if (knee > 0 && overThreshold < knee / 2) {
const kneeRatio = overThreshold / (knee / 2);
const compressionAmount = (1 - 1 / ratio) * kneeRatio;
outputDb = inputDb - overThreshold * compressionAmount;
} else {
// Above knee - full compression
outputDb = threshold + overThreshold / ratio;
}
outputDb += makeupGain;
} else {
outputDb += makeupGain;
}
} else if (effectType === 'gate') {
const { ratio, knee } = parameters as GateParameters;
if (inputDb < threshold) {
const belowThreshold = threshold - inputDb;
// Soft knee calculation
if (knee > 0 && belowThreshold < knee / 2) {
const kneeRatio = belowThreshold / (knee / 2);
const expansionAmount = (ratio - 1) * kneeRatio;
outputDb = inputDb - belowThreshold * expansionAmount;
} else {
// Below knee - full expansion
outputDb = threshold - belowThreshold * ratio;
}
}
}
// Clamp output
outputDb = Math.max(-60, Math.min(0, outputDb));
const x = padding + ((inputDb + 60) / 60) * graphWidth;
const y = padding + graphHeight - ((outputDb + 60) / 60) * graphHeight;
if (inputDb === -60) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
// Draw axis labels
ctx.fillStyle = 'rgba(156, 163, 175, 0.8)';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
// X-axis label
ctx.fillText('Input Level (dB)', width / 2, height - 5);
// Y-axis label (rotated)
ctx.save();
ctx.translate(10, height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Output Level (dB)', 0, 0);
ctx.restore();
// Tick labels
ctx.textAlign = 'center';
for (let db = -60; db <= 0; db += 20) {
const x = padding + ((db + 60) / 60) * graphWidth;
ctx.fillText(db.toString(), x, height - 20);
}
}, [parameters, effectType]);
const handleApply = () => {
onApply(parameters);
onClose();
};
const handlePresetClick = (preset: EffectPreset) => {
setParameters((prev) => ({
...prev,
...preset.parameters,
}));
};
return (
<Modal
open={open}
onClose={onClose}
title={EFFECT_LABELS[effectType] || 'Dynamics Processing'}
description={EFFECT_DESCRIPTIONS[effectType]}
size="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleApply}>
Apply Effect
</Button>
</>
}
>
<div className="space-y-4">
{/* Transfer Curve Visualization */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Transfer Curve
</label>
<canvas
ref={canvasRef}
className="w-full h-64 border border-border rounded bg-background"
/>
<p className="text-xs text-muted-foreground">
Shows input vs output levels. Threshold (orange line), ratio, knee, and makeup gain affect this curve.
Attack and release control timing (not shown here).
</p>
</div>
{/* Presets */}
{presets.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Presets
</label>
<div className="grid grid-cols-2 gap-2">
{presets.map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
onClick={() => handlePresetClick(preset)}
className="justify-start"
>
{preset.name}
</Button>
))}
</div>
</div>
)}
{/* Threshold Parameter */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Threshold</span>
<span className="text-muted-foreground font-mono">
{(parameters.threshold ?? -20).toFixed(1)} dB
</span>
</label>
<Slider
value={[(parameters.threshold ?? -20)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, threshold: value }))
}
min={-60}
max={0}
step={0.5}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>-60 dB</span>
<span>0 dB</span>
</div>
</div>
{/* Ratio Parameter (Compressor and Gate only) */}
{(effectType === 'compressor' || effectType === 'gate') && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Ratio</span>
<span className="text-muted-foreground font-mono">
{((parameters as CompressorParameters | GateParameters).ratio ?? 4).toFixed(1)}:1
</span>
</label>
<Slider
value={[((parameters as CompressorParameters | GateParameters).ratio ?? 4)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, ratio: value }))
}
min={1}
max={20}
step={0.5}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1:1 (None)</span>
<span>20:1 (Hard)</span>
</div>
</div>
)}
{/* Attack Parameter */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Attack</span>
<span className="text-muted-foreground font-mono">
{(parameters.attack ?? 5).toFixed(2)} ms
</span>
</label>
<Slider
value={[Math.log10(parameters.attack ?? 5)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, attack: Math.pow(10, value) }))
}
min={-1}
max={2}
step={0.01}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0.1 ms (Fast)</span>
<span>100 ms (Slow)</span>
</div>
</div>
{/* Release Parameter */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Release</span>
<span className="text-muted-foreground font-mono">
{(parameters.release ?? 50).toFixed(1)} ms
</span>
</label>
<Slider
value={[Math.log10(parameters.release ?? 50)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, release: Math.pow(10, value) }))
}
min={1}
max={3}
step={0.01}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>10 ms (Fast)</span>
<span>1000 ms (Slow)</span>
</div>
</div>
{/* Knee Parameter (Compressor and Gate only) */}
{(effectType === 'compressor' || effectType === 'gate') && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Knee</span>
<span className="text-muted-foreground font-mono">
{((parameters as CompressorParameters | GateParameters).knee ?? 3).toFixed(1)} dB
</span>
</label>
<Slider
value={[((parameters as CompressorParameters | GateParameters).knee ?? 3)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, knee: value }))
}
min={0}
max={12}
step={0.5}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0 dB (Hard)</span>
<span>12 dB (Soft)</span>
</div>
</div>
)}
{/* Makeup Gain Parameter (Compressor and Limiter only) */}
{(effectType === 'compressor' || effectType === 'limiter') && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Makeup Gain</span>
<span className="text-muted-foreground font-mono">
{((parameters as CompressorParameters | LimiterParameters).makeupGain ?? 0) > 0 ? '+' : ''}
{((parameters as CompressorParameters | LimiterParameters).makeupGain ?? 0).toFixed(1)} dB
</span>
</label>
<Slider
value={[((parameters as CompressorParameters | LimiterParameters).makeupGain ?? 0)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, makeupGain: value }))
}
min={0}
max={24}
step={0.5}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0 dB</span>
<span>+24 dB</span>
</div>
</div>
)}
</div>
</Modal>
);
}