'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; } export interface DynamicsParameterDialogProps { open: boolean; onClose: () => void; effectType: DynamicsType; onApply: (params: DynamicsParameters) => void; } const EFFECT_LABELS: Record = { compressor: 'Compressor', limiter: 'Limiter', gate: 'Gate/Expander', }; const EFFECT_DESCRIPTIONS: Record = { 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 = { 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(() => { 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(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 ( } >
{/* Transfer Curve Visualization */}

Shows input vs output levels. Threshold (orange line), ratio, knee, and makeup gain affect this curve. Attack and release control timing (not shown here).

{/* Presets */} {presets.length > 0 && (
{presets.map((preset) => ( ))}
)} {/* Threshold Parameter */}
setParameters((prev) => ({ ...prev, threshold: value })) } min={-60} max={0} step={0.5} className="w-full" />
-60 dB 0 dB
{/* Ratio Parameter (Compressor and Gate only) */} {(effectType === 'compressor' || effectType === 'gate') && (
setParameters((prev) => ({ ...prev, ratio: value })) } min={1} max={20} step={0.5} className="w-full" />
1:1 (None) 20:1 (Hard)
)} {/* Attack Parameter */}
setParameters((prev) => ({ ...prev, attack: Math.pow(10, value) })) } min={-1} max={2} step={0.01} className="w-full" />
0.1 ms (Fast) 100 ms (Slow)
{/* Release Parameter */}
setParameters((prev) => ({ ...prev, release: Math.pow(10, value) })) } min={1} max={3} step={0.01} className="w-full" />
10 ms (Fast) 1000 ms (Slow)
{/* Knee Parameter (Compressor and Gate only) */} {(effectType === 'compressor' || effectType === 'gate') && (
setParameters((prev) => ({ ...prev, knee: value })) } min={0} max={12} step={0.5} className="w-full" />
0 dB (Hard) 12 dB (Soft)
)} {/* Makeup Gain Parameter (Compressor and Limiter only) */} {(effectType === 'compressor' || effectType === 'limiter') && (
setParameters((prev) => ({ ...prev, makeupGain: value })) } min={0} max={24} step={0.5} className="w-full" />
0 dB +24 dB
)}
); }