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>
392 lines
12 KiB
TypeScript
392 lines
12 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 { 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;
|
|
}
|
|
}
|