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:
2025-11-17 20:03:40 +01:00
parent f414573655
commit ee48f9475f
26 changed files with 6027 additions and 273 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -207,15 +207,11 @@ export function Waveform({
const actualX = x + scrollOffset;
const clickedTime = (actualX / visibleWidth) * duration;
// Shift key for selection
if (e.shiftKey && onSelectionChange) {
setIsSelecting(true);
setSelectionStart(clickedTime);
// Start selection on drag
setIsSelecting(true);
setSelectionStart(clickedTime);
if (onSelectionChange) {
onSelectionChange({ start: clickedTime, end: clickedTime });
} else if (onSeek) {
// Regular dragging for scrubbing (without auto-play)
setIsDragging(true);
onSeek(clickedTime, false);
}
};
@@ -234,31 +230,33 @@ export function Waveform({
// Handle selection dragging
if (isSelecting && onSelectionChange && selectionStart !== null) {
setIsDragging(true); // Mark that we're dragging
const start = Math.min(selectionStart, clampedTime);
const end = Math.max(selectionStart, clampedTime);
onSelectionChange({ start, end });
}
// Handle scrubbing (without auto-play during drag)
else if (isDragging && onSeek) {
onSeek(clampedTime, false);
}
};
const handleMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
// If we were dragging (scrubbing), trigger auto-play on mouse up
if (isDragging && onSeek && !isSelecting) {
// If we didn't drag (just clicked), seek to that position and clear selection
if (!isDragging && onSeek) {
const canvas = canvasRef.current;
if (canvas) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const visibleWidth = width * zoom;
const actualX = x + scrollOffset;
const releaseTime = (actualX / visibleWidth) * duration;
const clampedTime = Math.max(0, Math.min(duration, releaseTime));
// Auto-play on mouse up after dragging
const clickTime = (actualX / visibleWidth) * duration;
const clampedTime = Math.max(0, Math.min(duration, clickTime));
// Seek and auto-play
onSeek(clampedTime, true);
// Clear selection on click
if (onSelectionChange) {
onSelectionChange(null);
}
}
}
// If we dragged, the selection is already set via handleMouseMove
setIsDragging(false);
setIsSelecting(false);

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View File

@@ -0,0 +1,515 @@
'use client';
import * as React from 'react';
import {
FileAudio,
History,
Info,
ChevronLeft,
ChevronRight,
Upload,
Download,
X,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn';
import { formatDuration } from '@/lib/audio/decoder';
import type { Selection } from '@/types/selection';
import type { HistoryState } from '@/lib/history/history-manager';
export interface SidePanelProps {
// File info
fileName: string | null;
audioBuffer: AudioBuffer | null;
onFileSelect: (file: File) => void;
onClear: () => void;
// Selection info
selection: Selection | null;
// History info
historyState: HistoryState;
// Effects handlers
onNormalize: () => void;
onFadeIn: () => void;
onFadeOut: () => void;
onReverse: () => void;
onLowPassFilter: () => void;
onHighPassFilter: () => void;
onBandPassFilter: () => void;
onCompressor: () => void;
onLimiter: () => void;
onGate: () => void;
onDelay: () => void;
onReverb: () => void;
onChorus: () => void;
onFlanger: () => void;
onPhaser: () => void;
onPitchShift: () => void;
onTimeStretch: () => void;
onDistortion: () => void;
onBitcrusher: () => void;
className?: string;
}
export function SidePanel({
fileName,
audioBuffer,
onFileSelect,
onClear,
selection,
historyState,
onNormalize,
onFadeIn,
onFadeOut,
onReverse,
onLowPassFilter,
onHighPassFilter,
onBandPassFilter,
onCompressor,
onLimiter,
onGate,
onDelay,
onReverb,
onChorus,
onFlanger,
onPhaser,
onPitchShift,
onTimeStretch,
onDistortion,
onBitcrusher,
className,
}: SidePanelProps) {
const [isCollapsed, setIsCollapsed] = React.useState(false);
const [activeTab, setActiveTab] = React.useState<'file' | 'history' | 'info' | 'effects'>('file');
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleFileClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onFileSelect(file);
}
};
if (isCollapsed) {
return (
<div
className={cn(
'w-12 bg-card border-r border-border flex flex-col items-center py-2',
className
)}
>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setIsCollapsed(false)}
title="Expand Side Panel"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}
return (
<div className={cn('w-64 bg-card border-r border-border flex flex-col', className)}>
{/* Header */}
<div className="flex items-center justify-between p-2 border-b border-border">
<div className="flex items-center gap-1">
<Button
variant={activeTab === 'file' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('file')}
title="File"
>
<FileAudio className="h-4 w-4" />
</Button>
<Button
variant={activeTab === 'effects' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('effects')}
title="Effects"
>
<Sparkles className="h-4 w-4" />
</Button>
<Button
variant={activeTab === 'history' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('history')}
title="History"
>
<History className="h-4 w-4" />
</Button>
<Button
variant={activeTab === 'info' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('info')}
title="Info"
>
<Info className="h-4 w-4" />
</Button>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setIsCollapsed(true)}
title="Collapse Side Panel"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar">
{activeTab === 'file' && (
<>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Audio File
</h3>
{audioBuffer ? (
<div className="space-y-2">
<div className="p-2 bg-secondary/30 rounded text-xs">
<div className="font-medium text-foreground truncate" title={fileName || 'Unknown'}>
{fileName || 'Unknown'}
</div>
<div className="text-muted-foreground mt-1">
Duration: {formatDuration(audioBuffer.duration)}
</div>
<div className="text-muted-foreground">
Channels: {audioBuffer.numberOfChannels}
</div>
<div className="text-muted-foreground">
Sample Rate: {audioBuffer.sampleRate} Hz
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={onClear}
className="w-full"
>
<X className="h-3.5 w-3.5 mr-1.5" />
Clear File
</Button>
</div>
) : (
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="outline"
size="sm"
onClick={handleFileClick}
className="w-full"
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Load Audio File
</Button>
<div className="text-xs text-muted-foreground">
Or drag and drop an audio file onto the waveform area.
</div>
</div>
)}
</div>
</>
)}
{activeTab === 'history' && (
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Edit History
</h3>
{historyState.historySize > 0 ? (
<div className="space-y-1 text-xs">
<div className="p-2 bg-secondary/30 rounded">
<div className="text-foreground">
{historyState.historySize} action{historyState.historySize !== 1 ? 's' : ''}
</div>
{historyState.undoDescription && (
<div className="text-muted-foreground mt-1">
Next undo: {historyState.undoDescription}
</div>
)}
{historyState.redoDescription && (
<div className="text-muted-foreground mt-1">
Next redo: {historyState.redoDescription}
</div>
)}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
No history available. Edit operations will appear here.
</div>
)}
</div>
)}
{activeTab === 'info' && (
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Selection Info
</h3>
{selection ? (
<div className="p-2 bg-secondary/30 rounded text-xs">
<div className="text-foreground font-medium">Selection Active</div>
<div className="text-muted-foreground mt-1">
Duration: {formatDuration(selection.end - selection.start)}
</div>
<div className="text-muted-foreground">
Start: {formatDuration(selection.start)}
</div>
<div className="text-muted-foreground">
End: {formatDuration(selection.end)}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
No selection. Drag on the waveform to select a region.
</div>
)}
</div>
)}
{activeTab === 'effects' && (
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Basic Effects
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onNormalize}
className="w-full justify-start text-xs"
>
Normalize
</Button>
<Button
variant="outline"
size="sm"
onClick={onFadeIn}
className="w-full justify-start text-xs"
>
Fade In
</Button>
<Button
variant="outline"
size="sm"
onClick={onFadeOut}
className="w-full justify-start text-xs"
>
Fade Out
</Button>
<Button
variant="outline"
size="sm"
onClick={onReverse}
className="w-full justify-start text-xs"
>
Reverse
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply effects.
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Filters
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onLowPassFilter}
className="w-full justify-start text-xs"
>
Low-Pass Filter
</Button>
<Button
variant="outline"
size="sm"
onClick={onHighPassFilter}
className="w-full justify-start text-xs"
>
High-Pass Filter
</Button>
<Button
variant="outline"
size="sm"
onClick={onBandPassFilter}
className="w-full justify-start text-xs"
>
Band-Pass Filter
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply filters.
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Dynamics Processing
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onCompressor}
className="w-full justify-start text-xs"
>
Compressor
</Button>
<Button
variant="outline"
size="sm"
onClick={onLimiter}
className="w-full justify-start text-xs"
>
Limiter
</Button>
<Button
variant="outline"
size="sm"
onClick={onGate}
className="w-full justify-start text-xs"
>
Gate/Expander
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply dynamics processing.
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Time-Based Effects
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onDelay}
className="w-full justify-start text-xs"
>
Delay/Echo
</Button>
<Button
variant="outline"
size="sm"
onClick={onReverb}
className="w-full justify-start text-xs"
>
Reverb
</Button>
<Button
variant="outline"
size="sm"
onClick={onChorus}
className="w-full justify-start text-xs"
>
Chorus
</Button>
<Button
variant="outline"
size="sm"
onClick={onFlanger}
className="w-full justify-start text-xs"
>
Flanger
</Button>
<Button
variant="outline"
size="sm"
onClick={onPhaser}
className="w-full justify-start text-xs"
>
Phaser
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply time-based effects.
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Advanced Effects
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onPitchShift}
className="w-full justify-start text-xs"
>
Pitch Shifter
</Button>
<Button
variant="outline"
size="sm"
onClick={onTimeStretch}
className="w-full justify-start text-xs"
>
Time Stretch
</Button>
<Button
variant="outline"
size="sm"
onClick={onDistortion}
className="w-full justify-start text-xs"
>
Distortion
</Button>
<Button
variant="outline"
size="sm"
onClick={onBitcrusher}
className="w-full justify-start text-xs"
>
Bitcrusher
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply advanced effects.
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,237 @@
'use client';
import * as React from 'react';
import {
Play,
Pause,
Square,
SkipBack,
Scissors,
Copy,
Clipboard,
Trash2,
CropIcon,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
Maximize2,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn';
export interface ToolbarProps {
// Playback
isPlaying: boolean;
isPaused: boolean;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
// Edit
hasSelection: boolean;
hasClipboard: boolean;
onCut: () => void;
onCopy: () => void;
onPaste: () => void;
onDelete: () => void;
onTrim: () => void;
// History
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
// Zoom
onZoomIn: () => void;
onZoomOut: () => void;
onFitToView: () => void;
disabled?: boolean;
className?: string;
}
export function Toolbar({
isPlaying,
isPaused,
onPlay,
onPause,
onStop,
hasSelection,
hasClipboard,
onCut,
onCopy,
onPaste,
onDelete,
onTrim,
canUndo,
canRedo,
onUndo,
onRedo,
onZoomIn,
onZoomOut,
onFitToView,
disabled = false,
className,
}: ToolbarProps) {
const handlePlayPause = () => {
if (isPlaying) {
onPause();
} else {
onPlay();
}
};
return (
<div
className={cn(
'flex items-center gap-1 px-2 py-1.5 bg-card border-b border-border',
className
)}
>
{/* Transport Controls */}
<div className="flex items-center gap-0.5 pr-2 border-r border-border">
<Button
variant="ghost"
size="icon-sm"
onClick={onStop}
disabled={disabled || (!isPlaying && !isPaused)}
title="Stop"
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
variant={isPlaying ? 'default' : 'ghost'}
size="icon-sm"
onClick={handlePlayPause}
disabled={disabled}
title={isPlaying ? 'Pause (Space)' : 'Play (Space)'}
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4 ml-0.5" />
)}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onStop}
disabled={disabled || (!isPlaying && !isPaused)}
title="Stop"
>
<Square className="h-4 w-4" />
</Button>
</div>
{/* Edit Tools */}
<div className="flex items-center gap-0.5 px-2 border-r border-border">
<Button
variant="ghost"
size="icon-sm"
onClick={onCut}
disabled={!hasSelection}
title="Cut (Ctrl+X)"
>
<Scissors className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onCopy}
disabled={!hasSelection}
title="Copy (Ctrl+C)"
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onPaste}
disabled={!hasClipboard}
title="Paste (Ctrl+V)"
>
<Clipboard className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onDelete}
disabled={!hasSelection}
title="Delete (Del)"
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onTrim}
disabled={!hasSelection}
title="Trim to Selection"
>
<CropIcon className="h-4 w-4" />
</Button>
</div>
{/* History */}
<div className="flex items-center gap-0.5 px-2 border-r border-border">
<Button
variant="ghost"
size="icon-sm"
onClick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Y)"
>
<Redo2 className="h-4 w-4" />
</Button>
</div>
{/* Zoom Controls */}
<div className="flex items-center gap-0.5 px-2">
<Button
variant="ghost"
size="icon-sm"
onClick={onZoomOut}
title="Zoom Out"
>
<ZoomOut className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onZoomIn}
title="Zoom In"
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onFitToView}
title="Fit to View"
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils/cn';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -32,6 +32,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
'h-9 rounded-md px-3': size === 'sm',
'h-11 rounded-md px-8': size === 'lg',
'h-10 w-10': size === 'icon',
'h-8 w-8': size === 'icon-sm',
},
className
)}

View File

@@ -0,0 +1,195 @@
'use client';
import * as React from 'react';
import { Command } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
export interface CommandAction {
id: string;
label: string;
description?: string;
shortcut?: string;
category: 'edit' | 'playback' | 'file' | 'view' | 'effects';
action: () => void;
}
export interface CommandPaletteProps {
actions: CommandAction[];
className?: string;
}
export function CommandPalette({ actions, className }: CommandPaletteProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [selectedIndex, setSelectedIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const filteredActions = React.useMemo(() => {
if (!search) return actions;
const query = search.toLowerCase();
return actions.filter(
(action) =>
action.label.toLowerCase().includes(query) ||
action.description?.toLowerCase().includes(query) ||
action.category.toLowerCase().includes(query)
);
}, [actions, search]);
const groupedActions = React.useMemo(() => {
const groups: Record<string, CommandAction[]> = {};
filteredActions.forEach((action) => {
if (!groups[action.category]) {
groups[action.category] = [];
}
groups[action.category].push(action);
});
return groups;
}, [filteredActions]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+K or Cmd+K to open
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setIsOpen(true);
}
// Escape to close
if (e.key === 'Escape') {
setIsOpen(false);
setSearch('');
setSelectedIndex(0);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
React.useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filteredActions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (filteredActions[selectedIndex]) {
filteredActions[selectedIndex].action();
setIsOpen(false);
setSearch('');
setSelectedIndex(0);
}
}
};
const executeAction = (action: CommandAction) => {
action.action();
setIsOpen(false);
setSearch('');
setSelectedIndex(0);
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className={cn(
'h-9 w-9 rounded-md',
'inline-flex items-center justify-center',
'hover:bg-accent hover:text-accent-foreground',
'transition-colors',
className
)}
title="Command Palette (Ctrl+K)"
>
<Command className="h-5 w-5" />
</button>
);
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 sm:p-8 bg-black/50 backdrop-blur-sm">
<div
className={cn(
'w-full max-w-2xl mt-20 bg-card rounded-lg border-2 border-border shadow-2xl',
'animate-slideInFromTop',
className
)}
>
{/* Search Input */}
<div className="flex items-center gap-3 p-4 border-b border-border">
<Command className="h-5 w-5 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder="Type a command or search..."
className="flex-1 bg-transparent border-none outline-none text-foreground placeholder:text-muted-foreground"
/>
<kbd className="px-2 py-1 text-xs bg-muted rounded border border-border">
ESC
</kbd>
</div>
{/* Results */}
<div className="max-h-96 overflow-y-auto p-2">
{Object.keys(groupedActions).length === 0 ? (
<div className="p-8 text-center text-muted-foreground text-sm">
No commands found
</div>
) : (
Object.entries(groupedActions).map(([category, categoryActions]) => (
<div key={category} className="mb-4 last:mb-0">
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase">
{category}
</div>
{categoryActions.map((action, index) => {
const globalIndex = filteredActions.indexOf(action);
return (
<button
key={action.id}
onClick={() => executeAction(action)}
className={cn(
'w-full flex items-center justify-between gap-4 px-3 py-2.5 rounded-md',
'hover:bg-secondary/50 transition-colors text-left',
globalIndex === selectedIndex && 'bg-secondary'
)}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">
{action.label}
</div>
{action.description && (
<div className="text-xs text-muted-foreground truncate">
{action.description}
</div>
)}
</div>
{action.shortcut && (
<kbd className="px-2 py-1 text-xs bg-muted rounded border border-border whitespace-nowrap">
{action.shortcut}
</kbd>
)}
</button>
);
})}
</div>
))
)}
</div>
</div>
</div>
);
}

118
components/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,118 @@
'use client';
import * as React from 'react';
import { X } from 'lucide-react';
import { Button } from './Button';
import { cn } from '@/lib/utils/cn';
export interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
footer?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
export function Modal({
open,
onClose,
title,
description,
children,
footer,
size = 'md',
className,
}: ModalProps) {
// Close on Escape key
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
onClose();
}
};
if (open) {
document.addEventListener('keydown', handleEscape);
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [open, onClose]);
if (!open) return null;
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
className={cn(
'relative w-full bg-card border border-border rounded-lg shadow-lg',
'flex flex-col max-h-[90vh]',
sizeClasses[size],
className
)}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-border">
<div className="flex-1">
<h2
id="modal-title"
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
className="ml-2"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{children}
</div>
{/* Footer */}
{footer && (
<div className="flex items-center justify-end gap-2 p-4 border-t border-border">
{footer}
</div>
)}
</div>
</div>
);
}

View File

@@ -4,9 +4,10 @@ import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface SliderProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
value?: number;
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'onValueChange'> {
value?: number | number[];
onChange?: (value: number) => void;
onValueChange?: (value: number[]) => void;
min?: number;
max?: number;
step?: number;
@@ -20,6 +21,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
className,
value = 0,
onChange,
onValueChange,
min = 0,
max = 100,
step = 1,
@@ -30,8 +32,13 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
},
ref
) => {
// Support both value formats (number or number[])
const currentValue = Array.isArray(value) ? value[0] : value;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(parseFloat(e.target.value));
const numValue = parseFloat(e.target.value);
onChange?.(numValue);
onValueChange?.([numValue]);
};
return (
@@ -44,7 +51,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
</label>
)}
{showValue && (
<span className="text-sm text-muted-foreground">{value}</span>
<span className="text-sm text-muted-foreground">{currentValue}</span>
)}
</div>
)}
@@ -54,7 +61,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
min={min}
max={max}
step={step}
value={value}
value={currentValue}
onChange={handleChange}
disabled={disabled}
className={cn(