Files
audio-ui/components/effects/TimeBasedParameterDialog.tsx
Sebastian Krüger ee48f9475f feat: add advanced audio effects and improve UI
Phase 6.5 Advanced Effects:
- Add Pitch Shifter with semitones and cents adjustment
- Add Time Stretch with pitch preservation using overlap-add
- Add Distortion with soft/hard/tube types and tone control
- Add Bitcrusher with bit depth and sample rate reduction
- Add AdvancedParameterDialog with real-time waveform visualization
- Add 4 professional presets per effect type

Improvements:
- Fix undefined parameter errors by adding nullish coalescing operators
- Add global custom scrollbar styling with color-mix transparency
- Add custom-scrollbar utility class for side panel
- Improve theme-aware scrollbar appearance in light/dark modes
- Fix parameter initialization when switching effect types

Integration:
- All advanced effects support undo/redo via EffectCommand
- Effects accessible via command palette and side panel
- Selection-based processing support
- Toast notifications for all effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 20:03:40 +01:00

674 lines
23 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.toFixed(0)} ms
</span>
</label>
<Slider
value={[(parameters as DelayParameters).time]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters as DelayParameters).feedback]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters as ReverbParameters).roomSize]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters as ReverbParameters).damping]}
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.toFixed(2)} Hz
</span>
</label>
<Slider
value={[(parameters as ChorusParameters).rate]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters as ChorusParameters).depth]}
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.toFixed(1)} ms
</span>
</label>
<Slider
value={[(parameters as ChorusParameters).delay]}
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.toFixed(2)} Hz
</span>
</label>
<Slider
value={[(parameters as FlangerParameters).rate]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters as FlangerParameters).depth]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters as FlangerParameters).feedback]}
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.toFixed(1)} ms
</span>
</label>
<Slider
value={[(parameters as FlangerParameters).delay]}
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.toFixed(2)} Hz
</span>
</label>
<Slider
value={[(parameters as PhaserParameters).rate]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters as PhaserParameters).depth]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[(parameters as PhaserParameters).feedback]}
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.toFixed(0)}
</span>
</label>
<Slider
value={[(parameters as PhaserParameters).stages]}
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 * 100).toFixed(0)}%
</span>
</label>
<Slider
value={[parameters.mix]}
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>
);
}