Files
audio-ui/components/effects/TimeBasedParameterDialog.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

674 lines
24 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 {
DelayParameters,
ReverbParameters,
ChorusParameters,
FlangerParameters,
PhaserParameters,
} from '@/lib/audio/effects/time-based';
export type TimeBasedType = 'delay' | 'reverb' | 'chorus' | 'flanger' | 'phaser';
export type TimeBasedParameters =
| (DelayParameters & { type: 'delay' })
| (ReverbParameters & { type: 'reverb' })
| (ChorusParameters & { type: 'chorus' })
| (FlangerParameters & { type: 'flanger' })
| (PhaserParameters & { type: 'phaser' });
export interface EffectPreset {
name: string;
parameters: Partial<DelayParameters | ReverbParameters | ChorusParameters | FlangerParameters | PhaserParameters>;
}
export interface TimeBasedParameterDialogProps {
open: boolean;
onClose: () => void;
effectType: TimeBasedType;
onApply: (params: TimeBasedParameters) => void;
}
const EFFECT_LABELS: Record<TimeBasedType, string> = {
delay: 'Delay/Echo',
reverb: 'Reverb',
chorus: 'Chorus',
flanger: 'Flanger',
phaser: 'Phaser',
};
const EFFECT_DESCRIPTIONS: Record<TimeBasedType, string> = {
delay: 'Creates echo effects by repeating the audio signal',
reverb: 'Simulates acoustic space and ambience',
chorus: 'Thickens sound by adding modulated copies',
flanger: 'Creates sweeping comb-filter effect',
phaser: 'Creates a phase-shifting swoosh effect',
};
const PRESETS: Record<TimeBasedType, EffectPreset[]> = {
delay: [
{ name: 'Short Slap', parameters: { time: 80, feedback: 0.2, mix: 0.3 } },
{ name: 'Medium Echo', parameters: { time: 250, feedback: 0.4, mix: 0.4 } },
{ name: 'Long Echo', parameters: { time: 500, feedback: 0.5, mix: 0.5 } },
{ name: 'Ping Pong', parameters: { time: 375, feedback: 0.6, mix: 0.4 } },
],
reverb: [
{ name: 'Small Room', parameters: { roomSize: 0.3, damping: 0.5, mix: 0.2 } },
{ name: 'Medium Hall', parameters: { roomSize: 0.6, damping: 0.3, mix: 0.3 } },
{ name: 'Large Hall', parameters: { roomSize: 0.8, damping: 0.2, mix: 0.4 } },
{ name: 'Cathedral', parameters: { roomSize: 1.0, damping: 0.1, mix: 0.5 } },
],
chorus: [
{ name: 'Subtle', parameters: { rate: 0.5, depth: 0.2, delay: 20, mix: 0.3 } },
{ name: 'Classic', parameters: { rate: 1.0, depth: 0.5, delay: 25, mix: 0.5 } },
{ name: 'Deep', parameters: { rate: 1.5, depth: 0.7, delay: 30, mix: 0.6 } },
{ name: 'Lush', parameters: { rate: 0.8, depth: 0.6, delay: 35, mix: 0.7 } },
],
flanger: [
{ name: 'Subtle', parameters: { rate: 0.3, depth: 0.3, feedback: 0.2, delay: 2, mix: 0.4 } },
{ name: 'Classic', parameters: { rate: 0.5, depth: 0.5, feedback: 0.4, delay: 3, mix: 0.5 } },
{ name: 'Jet', parameters: { rate: 0.2, depth: 0.7, feedback: 0.6, delay: 1.5, mix: 0.6 } },
{ name: 'Extreme', parameters: { rate: 1.0, depth: 0.8, feedback: 0.7, delay: 2.5, mix: 0.7 } },
],
phaser: [
{ name: 'Gentle', parameters: { rate: 0.4, depth: 0.3, feedback: 0.2, stages: 4, mix: 0.4 } },
{ name: 'Classic', parameters: { rate: 0.6, depth: 0.5, feedback: 0.4, stages: 6, mix: 0.5 } },
{ name: 'Deep', parameters: { rate: 0.3, depth: 0.7, feedback: 0.5, stages: 8, mix: 0.6 } },
{ name: 'Vintage', parameters: { rate: 0.5, depth: 0.6, feedback: 0.6, stages: 4, mix: 0.7 } },
],
};
export function TimeBasedParameterDialog({
open,
onClose,
effectType,
onApply,
}: TimeBasedParameterDialogProps) {
const [parameters, setParameters] = React.useState<TimeBasedParameters>(() => {
if (effectType === 'delay') {
return { type: 'delay', time: 250, feedback: 0.4, mix: 0.4 };
} else if (effectType === 'reverb') {
return { type: 'reverb', roomSize: 0.6, damping: 0.3, mix: 0.3 };
} else if (effectType === 'chorus') {
return { type: 'chorus', rate: 1.0, depth: 0.5, delay: 25, mix: 0.5 };
} else if (effectType === 'flanger') {
return { type: 'flanger', rate: 0.5, depth: 0.5, feedback: 0.4, delay: 3, mix: 0.5 };
} else {
return { type: 'phaser', rate: 0.6, depth: 0.5, feedback: 0.4, stages: 6, mix: 0.5 };
}
});
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 === 'delay') {
setParameters({ type: 'delay', time: 250, feedback: 0.4, mix: 0.4 });
} else if (effectType === 'reverb') {
setParameters({ type: 'reverb', roomSize: 0.6, damping: 0.3, mix: 0.3 });
} else if (effectType === 'chorus') {
setParameters({ type: 'chorus', rate: 1.0, depth: 0.5, delay: 25, mix: 0.5 });
} else if (effectType === 'flanger') {
setParameters({ type: 'flanger', rate: 0.5, depth: 0.5, feedback: 0.4, delay: 3, mix: 0.5 });
} else {
setParameters({ type: 'phaser', rate: 0.6, depth: 0.5, feedback: 0.4, stages: 6, mix: 0.5 });
}
}, [effectType]);
// Draw visualization
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
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
// Normalize coordinate system
ctx.scale(dpr, dpr);
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
const width = rect.width;
const height = rect.height;
// Clear with background
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('background-color') || '#1a1a1a';
ctx.fillRect(0, 0, width, height);
if (effectType === 'delay') {
// Draw delay echoes
const delayParams = parameters as DelayParameters & { type: 'delay' };
const maxTime = 2000; // ms
const echoCount = 5;
ctx.strokeStyle = 'rgba(128, 128, 128, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
for (let i = 0; i <= 4; i++) {
const x = (i / 4) * width;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
ctx.setLineDash([]);
let gain = 1.0;
for (let i = 0; i < echoCount; i++) {
const x = (i * delayParams.time / maxTime) * width;
const barHeight = height * gain * 0.8;
const y = (height - barHeight) / 2;
ctx.fillStyle = `rgba(59, 130, 246, ${gain})`;
ctx.fillRect(x - 3, y, 6, barHeight);
gain *= delayParams.feedback;
if (gain < 0.01) break;
}
} else if (effectType === 'reverb') {
// Draw reverb decay
const reverbParams = parameters as ReverbParameters & { type: 'reverb' };
const decayTime = reverbParams.roomSize * 3000; // ms
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < width; x++) {
const time = (x / width) * 3000;
const decay = Math.exp(-time / (decayTime * (1 - reverbParams.damping * 0.5)));
const y = height / 2 + (height / 2 - 20) * (1 - decay);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw reference line
ctx.strokeStyle = 'rgba(128, 128, 128, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
ctx.setLineDash([]);
} else {
// Draw LFO waveform for chorus, flanger, phaser
let rate = 1.0;
let depth = 0.5;
if (effectType === 'chorus') {
const chorusParams = parameters as ChorusParameters & { type: 'chorus' };
rate = chorusParams.rate;
depth = chorusParams.depth;
} else if (effectType === 'flanger') {
const flangerParams = parameters as FlangerParameters & { type: 'flanger' };
rate = flangerParams.rate;
depth = flangerParams.depth;
} else if (effectType === 'phaser') {
const phaserParams = parameters as PhaserParameters & { type: 'phaser' };
rate = phaserParams.rate;
depth = phaserParams.depth;
}
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
const cycles = rate * 2; // Show 2 seconds worth
for (let x = 0; x < width; x++) {
const phase = (x / width) * cycles * 2 * Math.PI;
const lfo = Math.sin(phase);
const y = height / 2 - (lfo * depth * height * 0.4);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw center line
ctx.strokeStyle = 'rgba(128, 128, 128, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
ctx.setLineDash([]);
}
}, [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] || 'Time-Based Effect'}
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">
{/* Visualization */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{effectType === 'delay' && 'Echo Pattern'}
{effectType === 'reverb' && 'Reverb Decay'}
{(effectType === 'chorus' || effectType === 'flanger' || effectType === 'phaser') && 'Modulation (LFO)'}
</label>
<canvas
ref={canvasRef}
className="w-full h-32 border border-border rounded bg-background"
/>
</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>
)}
{/* Effect-specific parameters */}
{effectType === 'delay' && (
<>
{/* Delay Time */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Delay Time</span>
<span className="text-muted-foreground font-mono">
{((parameters as DelayParameters).time ?? 250).toFixed(0)} ms
</span>
</label>
<Slider
value={[((parameters as DelayParameters).time ?? 250)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, time: value }))
}
min={10}
max={2000}
step={10}
className="w-full"
/>
</div>
{/* Feedback */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Feedback</span>
<span className="text-muted-foreground font-mono">
{(((parameters as DelayParameters).feedback ?? 0.4) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[((parameters as DelayParameters).feedback ?? 0.4)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, feedback: value }))
}
min={0}
max={0.95}
step={0.01}
className="w-full"
/>
</div>
</>
)}
{effectType === 'reverb' && (
<>
{/* Room Size */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Room Size</span>
<span className="text-muted-foreground font-mono">
{(((parameters as ReverbParameters).roomSize ?? 0.6) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[((parameters as ReverbParameters).roomSize ?? 0.6)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, roomSize: value }))
}
min={0.1}
max={1}
step={0.01}
className="w-full"
/>
</div>
{/* Damping */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Damping</span>
<span className="text-muted-foreground font-mono">
{(((parameters as ReverbParameters).damping ?? 0.3) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[((parameters as ReverbParameters).damping ?? 0.3)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, damping: value }))
}
min={0}
max={1}
step={0.01}
className="w-full"
/>
</div>
</>
)}
{effectType === 'chorus' && (
<>
{/* Rate */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Rate</span>
<span className="text-muted-foreground font-mono">
{((parameters as ChorusParameters).rate ?? 1.0).toFixed(2)} Hz
</span>
</label>
<Slider
value={[((parameters as ChorusParameters).rate ?? 1.0)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, rate: value }))
}
min={0.1}
max={5}
step={0.1}
className="w-full"
/>
</div>
{/* Depth */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Depth</span>
<span className="text-muted-foreground font-mono">
{(((parameters as ChorusParameters).depth ?? 0.5) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[((parameters as ChorusParameters).depth ?? 0.5)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, depth: value }))
}
min={0}
max={1}
step={0.01}
className="w-full"
/>
</div>
{/* Base Delay */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Base Delay</span>
<span className="text-muted-foreground font-mono">
{((parameters as ChorusParameters).delay ?? 25).toFixed(1)} ms
</span>
</label>
<Slider
value={[((parameters as ChorusParameters).delay ?? 25)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, delay: value }))
}
min={5}
max={50}
step={0.5}
className="w-full"
/>
</div>
</>
)}
{effectType === 'flanger' && (
<>
{/* Rate */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Rate</span>
<span className="text-muted-foreground font-mono">
{((parameters as FlangerParameters).rate ?? 0.5).toFixed(2)} Hz
</span>
</label>
<Slider
value={[((parameters as FlangerParameters).rate ?? 0.5)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, rate: value }))
}
min={0.1}
max={5}
step={0.1}
className="w-full"
/>
</div>
{/* Depth */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Depth</span>
<span className="text-muted-foreground font-mono">
{(((parameters as FlangerParameters).depth ?? 0.5) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[((parameters as FlangerParameters).depth ?? 0.5)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, depth: value }))
}
min={0}
max={1}
step={0.01}
className="w-full"
/>
</div>
{/* Feedback */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Feedback</span>
<span className="text-muted-foreground font-mono">
{(((parameters as FlangerParameters).feedback ?? 0.4) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[((parameters as FlangerParameters).feedback ?? 0.4)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, feedback: value }))
}
min={0}
max={0.95}
step={0.01}
className="w-full"
/>
</div>
{/* Base Delay */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Base Delay</span>
<span className="text-muted-foreground font-mono">
{((parameters as FlangerParameters).delay ?? 3).toFixed(1)} ms
</span>
</label>
<Slider
value={[((parameters as FlangerParameters).delay ?? 3)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, delay: value }))
}
min={0.5}
max={10}
step={0.1}
className="w-full"
/>
</div>
</>
)}
{effectType === 'phaser' && (
<>
{/* Rate */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Rate</span>
<span className="text-muted-foreground font-mono">
{((parameters as PhaserParameters).rate ?? 0.6).toFixed(2)} Hz
</span>
</label>
<Slider
value={[((parameters as PhaserParameters).rate ?? 0.6)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, rate: value }))
}
min={0.1}
max={5}
step={0.1}
className="w-full"
/>
</div>
{/* Depth */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Depth</span>
<span className="text-muted-foreground font-mono">
{(((parameters as PhaserParameters).depth ?? 0.5) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[((parameters as PhaserParameters).depth ?? 0.5)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, depth: value }))
}
min={0}
max={1}
step={0.01}
className="w-full"
/>
</div>
{/* Feedback */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Feedback</span>
<span className="text-muted-foreground font-mono">
{(((parameters as PhaserParameters).feedback ?? 0.4) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[((parameters as PhaserParameters).feedback ?? 0.4)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, feedback: value }))
}
min={0}
max={0.95}
step={0.01}
className="w-full"
/>
</div>
{/* Stages */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Stages</span>
<span className="text-muted-foreground font-mono">
{((parameters as PhaserParameters).stages ?? 6).toFixed(0)}
</span>
</label>
<Slider
value={[((parameters as PhaserParameters).stages ?? 6)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, stages: Math.floor(value) }))
}
min={2}
max={12}
step={1}
className="w-full"
/>
</div>
</>
)}
{/* Mix (common to all) */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground flex justify-between">
<span>Mix (Dry/Wet)</span>
<span className="text-muted-foreground font-mono">
{((parameters.mix ?? 0.5) * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters.mix ?? 0.5)]}
onValueChange={([value]) =>
setParameters((prev) => ({ ...prev, mix: value }))
}
min={0}
max={1}
step={0.01}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0% (Dry)</span>
<span>100% (Wet)</span>
</div>
</div>
</div>
</Modal>
);
}