Files
audio-ui/components/effects/DynamicsParameterDialog.tsx
Sebastian Krüger 39624ca9cf fix: ensure canvas renders when dialog opens
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>
2025-11-17 20:53:59 +01:00

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>
);
}