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>
523 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|