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

448 lines
15 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 type {
PitchShifterParameters,
TimeStretchParameters,
DistortionParameters,
BitcrusherParameters,
} from '@/lib/audio/effects/advanced';
export type AdvancedType = 'pitch' | 'timestretch' | 'distortion' | 'bitcrusher';
export type AdvancedParameters =
| (PitchShifterParameters & { type: 'pitch' })
| (BitcrusherParameters & { type: 'bitcrusher' })
| (TimeStretchParameters & { type: 'timestretch' })
| (DistortionParameters & { type: 'distortion' });
interface EffectPreset<T = any> {
name: string;
parameters: T;
}
const PRESETS: Record<AdvancedType, EffectPreset[]> = {
pitch: [
{ name: 'Octave Up', parameters: { semitones: 12, cents: 0, mix: 1.0 } },
{ name: 'Fifth Up', parameters: { semitones: 7, cents: 0, mix: 1.0 } },
{ name: 'Octave Down', parameters: { semitones: -12, cents: 0, mix: 1.0 } },
{ name: 'Subtle Shift', parameters: { semitones: 2, cents: 0, mix: 0.5 } },
],
timestretch: [
{ name: 'Half Speed', parameters: { rate: 0.5, preservePitch: true, mix: 1.0 } },
{ name: 'Double Speed', parameters: { rate: 2.0, preservePitch: true, mix: 1.0 } },
{ name: 'Slow Motion', parameters: { rate: 0.75, preservePitch: true, mix: 1.0 } },
{ name: 'Fast Forward', parameters: { rate: 1.5, preservePitch: true, mix: 1.0 } },
],
distortion: [
{ name: 'Light Overdrive', parameters: { drive: 0.3, tone: 0.7, output: 0.8, type: 'soft' as const, mix: 1.0 } },
{ name: 'Heavy Distortion', parameters: { drive: 0.8, tone: 0.5, output: 0.6, type: 'hard' as const, mix: 1.0 } },
{ name: 'Tube Warmth', parameters: { drive: 0.4, tone: 0.6, output: 0.75, type: 'tube' as const, mix: 0.8 } },
{ name: 'Extreme Fuzz', parameters: { drive: 1.0, tone: 0.3, output: 0.5, type: 'hard' as const, mix: 1.0 } },
],
bitcrusher: [
{ name: 'Lo-Fi', parameters: { bitDepth: 8, sampleRate: 8000, mix: 1.0 } },
{ name: 'Telephone', parameters: { bitDepth: 4, sampleRate: 4000, mix: 1.0 } },
{ name: 'Subtle Crunch', parameters: { bitDepth: 12, sampleRate: 22050, mix: 0.6 } },
{ name: 'Extreme Crush', parameters: { bitDepth: 2, sampleRate: 2000, mix: 1.0 } },
],
};
const DEFAULT_PARAMS: Record<AdvancedType, any> = {
pitch: { semitones: 0, cents: 0, mix: 1.0 },
timestretch: { rate: 1.0, preservePitch: true, mix: 1.0 },
distortion: { drive: 0.5, tone: 0.5, output: 0.7, type: 'soft', mix: 1.0 },
bitcrusher: { bitDepth: 8, sampleRate: 8000, mix: 1.0 },
};
const EFFECT_LABELS: Record<AdvancedType, string> = {
pitch: 'Pitch Shifter',
timestretch: 'Time Stretch',
distortion: 'Distortion',
bitcrusher: 'Bitcrusher',
};
export interface AdvancedParameterDialogProps {
open: boolean;
onClose: () => void;
effectType: AdvancedType;
onApply: (params: AdvancedParameters) => void;
}
export function AdvancedParameterDialog({
open,
onClose,
effectType,
onApply,
}: AdvancedParameterDialogProps) {
const [parameters, setParameters] = React.useState<any>(
DEFAULT_PARAMS[effectType]
);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
// Reset parameters when effect type changes
React.useEffect(() => {
setParameters(DEFAULT_PARAMS[effectType]);
}, [effectType]);
// Draw visual feedback
React.useEffect(() => {
if (!open) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Draw background
ctx.fillStyle = 'rgb(15, 23, 42)';
ctx.fillRect(0, 0, width, height);
// Draw visualization based on effect type
ctx.strokeStyle = 'rgb(59, 130, 246)';
ctx.lineWidth = 2;
if (effectType === 'pitch') {
const pitchParams = parameters as PitchShifterParameters;
const totalCents = (pitchParams.semitones ?? 0) * 100 + (pitchParams.cents ?? 0);
const pitchRatio = Math.pow(2, totalCents / 1200);
// Draw waveform with pitch shift
ctx.beginPath();
for (let x = 0; x < width; x++) {
const t = (x / width) * 4 * Math.PI * pitchRatio;
const y = height / 2 + Math.sin(t) * (height / 3);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw reference waveform
ctx.strokeStyle = 'rgba(148, 163, 184, 0.3)';
ctx.beginPath();
for (let x = 0; x < width; x++) {
const t = (x / width) * 4 * Math.PI;
const y = height / 2 + Math.sin(t) * (height / 3);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else if (effectType === 'timestretch') {
const stretchParams = parameters as TimeStretchParameters;
// Draw time-stretched waveform
ctx.beginPath();
for (let x = 0; x < width; x++) {
const t = (x / width) * 4 * Math.PI / (stretchParams.rate ?? 1.0);
const y = height / 2 + Math.sin(t) * (height / 3);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else if (effectType === 'distortion') {
const distParams = parameters as DistortionParameters;
// Draw distorted waveform
ctx.beginPath();
for (let x = 0; x < width; x++) {
const t = (x / width) * 4 * Math.PI;
let sample = Math.sin(t);
// Apply distortion
const drive = 1 + (distParams.drive ?? 0.5) * 10;
sample *= drive;
const distType = distParams.type ?? 'soft';
if (distType === 'soft') {
sample = Math.tanh(sample);
} else if (distType === 'hard') {
sample = Math.max(-1, Math.min(1, sample));
} else {
sample = sample > 0 ? 1 - Math.exp(-sample) : -1 + Math.exp(sample);
}
const y = height / 2 - sample * (height / 3);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else if (effectType === 'bitcrusher') {
const crushParams = parameters as BitcrusherParameters;
const bitLevels = Math.pow(2, crushParams.bitDepth ?? 8);
const step = 2 / bitLevels;
// Draw bitcrushed waveform
ctx.beginPath();
let lastY = height / 2;
for (let x = 0; x < width; x++) {
const t = (x / width) * 4 * Math.PI;
let sample = Math.sin(t);
// Quantize
sample = Math.floor(sample / step) * step;
const y = height / 2 - sample * (height / 3);
// Sample and hold effect
if (x % Math.max(1, Math.floor(width / ((crushParams.sampleRate ?? 8000) / 1000))) === 0) {
lastY = y;
}
if (x === 0) ctx.moveTo(x, lastY);
else ctx.lineTo(x, lastY);
}
ctx.stroke();
}
// Draw center line
ctx.strokeStyle = 'rgba(148, 163, 184, 0.2)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
}, [parameters, effectType, open]);
const handleApply = () => {
onApply({ ...parameters, type: effectType } as AdvancedParameters);
onClose();
};
const handlePreset = (preset: EffectPreset) => {
setParameters(preset.parameters);
};
return (
<Modal open={open} onClose={onClose} title={EFFECT_LABELS[effectType]}>
<div className="space-y-4">
{/* Visual Feedback */}
<div className="rounded-lg border border-border bg-card p-4">
<canvas
ref={canvasRef}
width={400}
height={120}
className="w-full rounded"
/>
</div>
{/* Presets */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Presets</label>
<div className="grid grid-cols-2 gap-2">
{PRESETS[effectType].map((preset) => (
<Button
key={preset.name}
variant="outline"
size="sm"
onClick={() => handlePreset(preset)}
className="justify-start"
>
{preset.name}
</Button>
))}
</div>
</div>
{/* Effect-specific parameters */}
{effectType === 'pitch' && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Semitones: {(parameters as PitchShifterParameters).semitones}
</label>
<Slider
value={[(parameters as PitchShifterParameters).semitones]}
onValueChange={([value]) =>
setParameters({ ...parameters, semitones: value })
}
min={-12}
max={12}
step={1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Cents: {(parameters as PitchShifterParameters).cents}
</label>
<Slider
value={[(parameters as PitchShifterParameters).cents]}
onValueChange={([value]) =>
setParameters({ ...parameters, cents: value })
}
min={-100}
max={100}
step={1}
/>
</div>
</>
)}
{effectType === 'timestretch' && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Rate: {((parameters as TimeStretchParameters).rate ?? 1.0).toFixed(2)}x
</label>
<Slider
value={[(parameters as TimeStretchParameters).rate ?? 1.0]}
onValueChange={([value]) =>
setParameters({ ...parameters, rate: value })
}
min={0.5}
max={2.0}
step={0.1}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="preservePitch"
checked={(parameters as TimeStretchParameters).preservePitch ?? true}
onChange={(e) =>
setParameters({ ...parameters, preservePitch: e.target.checked })
}
className="h-4 w-4 rounded border-border"
/>
<label htmlFor="preservePitch" className="text-sm text-foreground">
Preserve Pitch
</label>
</div>
</>
)}
{effectType === 'distortion' && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Type
</label>
<div className="grid grid-cols-3 gap-2">
{(['soft', 'hard', 'tube'] as const).map((type) => (
<Button
key={type}
variant={((parameters as DistortionParameters).type ?? 'soft') === type ? 'secondary' : 'outline'}
size="sm"
onClick={() => setParameters({ ...parameters, type })}
>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Drive: {(((parameters as DistortionParameters).drive ?? 0.5) * 100).toFixed(0)}%
</label>
<Slider
value={[(parameters as DistortionParameters).drive ?? 0.5]}
onValueChange={([value]) =>
setParameters({ ...parameters, drive: value })
}
min={0}
max={1}
step={0.01}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Tone: {(((parameters as DistortionParameters).tone ?? 0.5) * 100).toFixed(0)}%
</label>
<Slider
value={[(parameters as DistortionParameters).tone ?? 0.5]}
onValueChange={([value]) =>
setParameters({ ...parameters, tone: value })
}
min={0}
max={1}
step={0.01}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Output: {(((parameters as DistortionParameters).output ?? 0.7) * 100).toFixed(0)}%
</label>
<Slider
value={[(parameters as DistortionParameters).output ?? 0.7]}
onValueChange={([value]) =>
setParameters({ ...parameters, output: value })
}
min={0}
max={1}
step={0.01}
/>
</div>
</>
)}
{effectType === 'bitcrusher' && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Bit Depth: {(parameters as BitcrusherParameters).bitDepth ?? 8} bits
</label>
<Slider
value={[(parameters as BitcrusherParameters).bitDepth ?? 8]}
onValueChange={([value]) =>
setParameters({ ...parameters, bitDepth: Math.round(value) })
}
min={1}
max={16}
step={1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Sample Rate: {(parameters as BitcrusherParameters).sampleRate ?? 8000} Hz
</label>
<Slider
value={[(parameters as BitcrusherParameters).sampleRate ?? 8000]}
onValueChange={([value]) =>
setParameters({ ...parameters, sampleRate: Math.round(value) })
}
min={100}
max={48000}
step={100}
/>
</div>
</>
)}
{/* Mix control */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Mix: {(parameters.mix * 100).toFixed(0)}%
</label>
<Slider
value={[parameters.mix]}
onValueChange={([value]) =>
setParameters({ ...parameters, mix: value })
}
min={0}
max={1}
step={0.01}
/>
</div>
{/* Actions */}
<div className="flex justify-end space-x-2 pt-4">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleApply}>Apply</Button>
</div>
</div>
</Modal>
);
}