Fixed canvas visualization not painting sometimes by: - Adding `open` prop check before rendering - Adding `open` to useEffect dependencies - Adding dimension validation for dynamic canvas sizes - Ensures canvas properly renders when dialog becomes visible Affected dialogs: DynamicsParameterDialog, TimeBasedParameterDialog, AdvancedParameterDialog 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
526 lines
17 KiB
TypeScript
526 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 (!open || !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;
|
|
|
|
// Ensure canvas has dimensions before drawing
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
|
|
// 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, open]);
|
|
|
|
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>
|
|
);
|
|
}
|