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>
This commit is contained in:
445
components/effects/AdvancedParameterDialog.tsx
Normal file
445
components/effects/AdvancedParameterDialog.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
'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' })
|
||||
| (TimeStretchParameters & { type: 'timestretch' })
|
||||
| (DistortionParameters & { type: 'distortion' })
|
||||
| (BitcrusherParameters & { type: 'bitcrusher' });
|
||||
|
||||
interface EffectPreset {
|
||||
name: string;
|
||||
parameters: Omit<AdvancedParameters, 'type'>;
|
||||
}
|
||||
|
||||
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, Omit<AdvancedParameters, 'type'>> = {
|
||||
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<Omit<AdvancedParameters, 'type'>>(
|
||||
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(() => {
|
||||
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]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
522
components/effects/DynamicsParameterDialog.tsx
Normal file
522
components/effects/DynamicsParameterDialog.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
'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 (!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 (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]);
|
||||
|
||||
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.toFixed(1)} dB
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[parameters.threshold]}
|
||||
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.toFixed(1)}:1
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[(parameters as CompressorParameters | GateParameters).ratio]}
|
||||
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.toFixed(2)} ms
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[Math.log10(parameters.attack)]}
|
||||
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.toFixed(1)} ms
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[Math.log10(parameters.release)]}
|
||||
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.toFixed(1)} dB
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[(parameters as CompressorParameters | GateParameters).knee]}
|
||||
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 ? '+' : ''}
|
||||
{(parameters as CompressorParameters | LimiterParameters).makeupGain.toFixed(1)} dB
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[(parameters as CompressorParameters | LimiterParameters).makeupGain]}
|
||||
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>
|
||||
);
|
||||
}
|
||||
391
components/effects/EffectParameterDialog.tsx
Normal file
391
components/effects/EffectParameterDialog.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
'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 { FilterType } from '@/lib/audio/effects/filters';
|
||||
|
||||
export interface FilterParameters {
|
||||
type: FilterType;
|
||||
frequency: number;
|
||||
Q?: number;
|
||||
gain?: number;
|
||||
}
|
||||
|
||||
export interface EffectPreset {
|
||||
name: string;
|
||||
parameters: Partial<FilterParameters>;
|
||||
}
|
||||
|
||||
export interface EffectParameterDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
effectType: 'lowpass' | 'highpass' | 'bandpass' | 'notch' | 'lowshelf' | 'highshelf' | 'peaking';
|
||||
onApply: (params: FilterParameters) => void;
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
const EFFECT_LABELS: Record<string, string> = {
|
||||
lowpass: 'Low-Pass Filter',
|
||||
highpass: 'High-Pass Filter',
|
||||
bandpass: 'Band-Pass Filter',
|
||||
notch: 'Notch Filter',
|
||||
lowshelf: 'Low Shelf Filter',
|
||||
highshelf: 'High Shelf Filter',
|
||||
peaking: 'Peaking EQ',
|
||||
};
|
||||
|
||||
const EFFECT_DESCRIPTIONS: Record<string, string> = {
|
||||
lowpass: 'Removes high frequencies above the cutoff',
|
||||
highpass: 'Removes low frequencies below the cutoff',
|
||||
bandpass: 'Isolates frequencies around the center frequency',
|
||||
notch: 'Removes frequencies around the center frequency',
|
||||
lowshelf: 'Boosts or cuts low frequencies',
|
||||
highshelf: 'Boosts or cuts high frequencies',
|
||||
peaking: 'Boosts or cuts a specific frequency band',
|
||||
};
|
||||
|
||||
const PRESETS: Record<string, EffectPreset[]> = {
|
||||
lowpass: [
|
||||
{ name: 'Telephone', parameters: { frequency: 3000, Q: 0.7 } },
|
||||
{ name: 'Radio', parameters: { frequency: 5000, Q: 1.0 } },
|
||||
{ name: 'Warm', parameters: { frequency: 8000, Q: 0.5 } },
|
||||
{ name: 'Muffled', parameters: { frequency: 1000, Q: 1.5 } },
|
||||
],
|
||||
highpass: [
|
||||
{ name: 'Rumble Removal', parameters: { frequency: 80, Q: 0.7 } },
|
||||
{ name: 'Voice Clarity', parameters: { frequency: 150, Q: 1.0 } },
|
||||
{ name: 'Thin', parameters: { frequency: 300, Q: 0.5 } },
|
||||
],
|
||||
bandpass: [
|
||||
{ name: 'Telephone', parameters: { frequency: 1000, Q: 2.0 } },
|
||||
{ name: 'Vocal Range', parameters: { frequency: 2000, Q: 1.0 } },
|
||||
{ name: 'Narrow', parameters: { frequency: 1000, Q: 10.0 } },
|
||||
],
|
||||
notch: [
|
||||
{ name: '60Hz Hum', parameters: { frequency: 60, Q: 10.0 } },
|
||||
{ name: '50Hz Hum', parameters: { frequency: 50, Q: 10.0 } },
|
||||
{ name: 'Narrow Notch', parameters: { frequency: 1000, Q: 20.0 } },
|
||||
],
|
||||
lowshelf: [
|
||||
{ name: 'Bass Boost', parameters: { frequency: 200, gain: 6 } },
|
||||
{ name: 'Bass Cut', parameters: { frequency: 200, gain: -6 } },
|
||||
{ name: 'Warmth', parameters: { frequency: 150, gain: 3 } },
|
||||
],
|
||||
highshelf: [
|
||||
{ name: 'Treble Boost', parameters: { frequency: 3000, gain: 6 } },
|
||||
{ name: 'Treble Cut', parameters: { frequency: 3000, gain: -6 } },
|
||||
{ name: 'Brightness', parameters: { frequency: 5000, gain: 3 } },
|
||||
],
|
||||
peaking: [
|
||||
{ name: 'Presence Boost', parameters: { frequency: 3000, Q: 1.0, gain: 4 } },
|
||||
{ name: 'Vocal Cut', parameters: { frequency: 2000, Q: 2.0, gain: -3 } },
|
||||
{ name: 'Narrow Boost', parameters: { frequency: 1000, Q: 5.0, gain: 6 } },
|
||||
],
|
||||
};
|
||||
|
||||
export function EffectParameterDialog({
|
||||
open,
|
||||
onClose,
|
||||
effectType,
|
||||
onApply,
|
||||
sampleRate = 48000,
|
||||
}: EffectParameterDialogProps) {
|
||||
const [parameters, setParameters] = React.useState<FilterParameters>(() => ({
|
||||
type: effectType,
|
||||
frequency: effectType === 'lowpass' ? 1000 : effectType === 'highpass' ? 100 : 1000,
|
||||
Q: 1.0,
|
||||
gain: 0,
|
||||
}));
|
||||
|
||||
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(() => {
|
||||
setParameters((prev) => ({ ...prev, type: effectType }));
|
||||
}, [effectType]);
|
||||
|
||||
// Draw frequency response curve
|
||||
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 (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);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const nyquist = sampleRate / 2;
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('background-color') || '#1a1a1a';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = 'rgba(128, 128, 128, 0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Horizontal grid lines (dB)
|
||||
for (let db = -24; db <= 24; db += 6) {
|
||||
const y = height / 2 - (db / 24) * (height / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Vertical grid lines (frequency)
|
||||
const frequencies = [100, 1000, 10000];
|
||||
frequencies.forEach((freq) => {
|
||||
const x = (Math.log10(freq) - 1) / (Math.log10(nyquist) - 1) * width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// Draw frequency response curve
|
||||
ctx.strokeStyle = '#3b82f6'; // Primary blue
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const freq = Math.pow(10, 1 + (x / width) * (Math.log10(nyquist) - 1));
|
||||
const magnitude = getFilterMagnitude(freq, parameters, sampleRate);
|
||||
const db = 20 * Math.log10(Math.max(magnitude, 0.0001)); // Prevent log(0)
|
||||
const y = height / 2 - (db / 24) * (height / 2);
|
||||
|
||||
if (x === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// Draw 0dB line
|
||||
ctx.strokeStyle = 'rgba(156, 163, 175, 0.5)'; // Muted foreground
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height / 2);
|
||||
ctx.lineTo(width, height / 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
}, [parameters, sampleRate]);
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(parameters);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePresetClick = (preset: EffectPreset) => {
|
||||
setParameters((prev) => ({
|
||||
...prev,
|
||||
...preset.parameters,
|
||||
}));
|
||||
};
|
||||
|
||||
const needsQ = ['lowpass', 'highpass', 'bandpass', 'notch', 'peaking'].includes(effectType);
|
||||
const needsGain = ['lowshelf', 'highshelf', 'peaking'].includes(effectType);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={EFFECT_LABELS[effectType] || 'Effect Parameters'}
|
||||
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">
|
||||
{/* Frequency Response Visualization */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Frequency Response
|
||||
</label>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-48 border border-border rounded bg-background"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground px-2">
|
||||
<span>100 Hz</span>
|
||||
<span>1 kHz</span>
|
||||
<span>10 kHz</span>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Frequency Parameter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground flex justify-between">
|
||||
<span>Frequency</span>
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{parameters.frequency.toFixed(0)} Hz
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[Math.log10(parameters.frequency)]}
|
||||
onValueChange={([value]) =>
|
||||
setParameters((prev) => ({ ...prev, frequency: Math.pow(10, value) }))
|
||||
}
|
||||
min={1}
|
||||
max={Math.log10(sampleRate / 2)}
|
||||
step={0.01}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>10 Hz</span>
|
||||
<span>{(sampleRate / 2).toFixed(0)} Hz</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Q Parameter */}
|
||||
{needsQ && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground flex justify-between">
|
||||
<span>Q (Resonance)</span>
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{parameters.Q?.toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[parameters.Q || 1.0]}
|
||||
onValueChange={([value]) =>
|
||||
setParameters((prev) => ({ ...prev, Q: value }))
|
||||
}
|
||||
min={0.1}
|
||||
max={20}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>0.1 (Gentle)</span>
|
||||
<span>20 (Sharp)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gain Parameter */}
|
||||
{needsGain && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground flex justify-between">
|
||||
<span>Gain</span>
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{parameters.gain && parameters.gain > 0 ? '+' : ''}
|
||||
{parameters.gain?.toFixed(1)} dB
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
value={[parameters.gain || 0]}
|
||||
onValueChange={([value]) =>
|
||||
setParameters((prev) => ({ ...prev, gain: value }))
|
||||
}
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>-24 dB</span>
|
||||
<span>+24 dB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate filter magnitude at a given frequency
|
||||
*/
|
||||
function getFilterMagnitude(
|
||||
freq: number,
|
||||
params: FilterParameters,
|
||||
sampleRate: number
|
||||
): number {
|
||||
const w = (2 * Math.PI * freq) / sampleRate;
|
||||
const w0 = (2 * Math.PI * params.frequency) / sampleRate;
|
||||
const Q = params.Q || 1.0;
|
||||
const gain = params.gain || 0;
|
||||
const A = Math.pow(10, gain / 40);
|
||||
|
||||
// Simplified magnitude calculation for different filter types
|
||||
switch (params.type) {
|
||||
case 'lowpass': {
|
||||
const ratio = freq / params.frequency;
|
||||
return 1 / Math.sqrt(1 + Math.pow(ratio * Q, 2 * 2));
|
||||
}
|
||||
case 'highpass': {
|
||||
const ratio = params.frequency / freq;
|
||||
return 1 / Math.sqrt(1 + Math.pow(ratio * Q, 2 * 2));
|
||||
}
|
||||
case 'bandpass': {
|
||||
const ratio = Math.abs(freq - params.frequency) / (params.frequency / Q);
|
||||
return 1 / Math.sqrt(1 + Math.pow(ratio, 2));
|
||||
}
|
||||
case 'notch': {
|
||||
const ratio = Math.abs(freq - params.frequency) / (params.frequency / Q);
|
||||
return Math.abs(ratio) / Math.sqrt(1 + Math.pow(ratio, 2));
|
||||
}
|
||||
case 'lowshelf':
|
||||
case 'highshelf':
|
||||
case 'peaking': {
|
||||
// Simplified for visualization
|
||||
const dist = Math.abs(Math.log(freq / params.frequency));
|
||||
const influence = Math.exp(-dist * Q);
|
||||
return 1 + (A - 1) * influence;
|
||||
}
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
673
components/effects/TimeBasedParameterDialog.tsx
Normal file
673
components/effects/TimeBasedParameterDialog.tsx
Normal file
@@ -0,0 +1,673 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user