feat: implement Phase 2 - Web Audio API engine and waveform visualization

Phase 2 Complete Features:
- Web Audio API context management with browser compatibility
- Audio file upload with drag-and-drop support
- Audio decoding for multiple formats (WAV, MP3, OGG, FLAC, AAC, M4A)
- AudioPlayer class with full playback control
- Waveform visualization using Canvas API
- Real-time waveform rendering with progress indicator
- Playback controls (play, pause, stop, seek)
- Volume control with mute/unmute
- Timeline scrubbing
- Audio file information display

Components:
- AudioEditor: Main editor container
- FileUpload: Drag-and-drop file upload component
- AudioInfo: Display audio file metadata
- Waveform: Canvas-based waveform visualization
- PlaybackControls: Transport controls with volume slider

Audio Engine:
- lib/audio/context.ts: AudioContext management
- lib/audio/decoder.ts: Audio file decoding utilities
- lib/audio/player.ts: AudioPlayer class for playback
- lib/waveform/peaks.ts: Waveform peak generation

Hooks:
- useAudioPlayer: Complete audio player state management

Types:
- types/audio.ts: TypeScript definitions for audio types

Features Working:
✓ Load audio files via drag-and-drop or file picker
✓ Display waveform with real-time progress
✓ Play/pause/stop controls
✓ Seek by clicking on waveform or using timeline slider
✓ Volume control with visual feedback
✓ Audio file metadata display (duration, sample rate, channels)
✓ Toast notifications for user feedback
✓ SSR-safe audio context initialization
✓ Dark/light theme support

Tech Stack:
- Web Audio API for playback
- Canvas API for waveform rendering
- React 19 hooks for state management
- TypeScript for type safety

Build verified and working ✓

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-17 15:32:00 +01:00
parent 45b73e148b
commit ebfb4d3fff
12 changed files with 1313 additions and 103 deletions

View File

@@ -0,0 +1,148 @@
'use client';
import * as React from 'react';
import { FileUpload } from './FileUpload';
import { AudioInfo } from './AudioInfo';
import { Waveform } from './Waveform';
import { PlaybackControls } from './PlaybackControls';
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useToast } from '@/components/ui/Toast';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Loader2 } from 'lucide-react';
export function AudioEditor() {
const {
loadFile,
clearFile,
play,
pause,
stop,
seek,
setVolume,
isPlaying,
isPaused,
currentTime,
duration,
volume,
audioBuffer,
fileName,
isLoading,
error,
currentTimeFormatted,
durationFormatted,
} = useAudioPlayer();
const { addToast } = useToast();
const handleFileSelect = async (file: File) => {
try {
await loadFile(file);
addToast({
title: 'File loaded',
description: `Successfully loaded ${file.name}`,
variant: 'success',
duration: 3000,
});
} catch (err) {
addToast({
title: 'Error loading file',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'error',
duration: 5000,
});
}
};
const handleClear = () => {
clearFile();
addToast({
title: 'Audio cleared',
description: 'Audio file has been removed',
variant: 'info',
duration: 2000,
});
};
// Show error toast
React.useEffect(() => {
if (error) {
addToast({
title: 'Error',
description: error,
variant: 'error',
duration: 5000,
});
}
}, [error, addToast]);
return (
<div className="space-y-6">
{/* File Upload or Audio Info */}
{!audioBuffer ? (
<FileUpload onFileSelect={handleFileSelect} />
) : (
<AudioInfo
fileName={fileName || 'Unknown'}
audioBuffer={audioBuffer}
onClear={handleClear}
/>
)}
{/* Loading State */}
{isLoading && (
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center justify-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading audio file...</p>
</div>
</CardContent>
</Card>
)}
{/* Waveform and Controls */}
{audioBuffer && !isLoading && (
<>
{/* Waveform */}
<Card>
<CardHeader>
<CardTitle>Waveform</CardTitle>
</CardHeader>
<CardContent>
<Waveform
audioBuffer={audioBuffer}
currentTime={currentTime}
duration={duration}
onSeek={seek}
height={150}
/>
</CardContent>
</Card>
{/* Playback Controls */}
<Card>
<CardHeader>
<CardTitle>Playback</CardTitle>
</CardHeader>
<CardContent>
<PlaybackControls
isPlaying={isPlaying}
isPaused={isPaused}
currentTime={currentTime}
duration={duration}
volume={volume}
onPlay={play}
onPause={pause}
onStop={stop}
onSeek={seek}
onVolumeChange={setVolume}
currentTimeFormatted={currentTimeFormatted}
durationFormatted={durationFormatted}
/>
</CardContent>
</Card>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import * as React from 'react';
import { FileAudio, X } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { formatFileSize, formatDuration } from '@/lib/audio/decoder';
import { cn } from '@/lib/utils/cn';
export interface AudioInfoProps {
fileName: string;
audioBuffer: AudioBuffer;
onClear?: () => void;
className?: string;
}
export function AudioInfo({ fileName, audioBuffer, onClear, className }: AudioInfoProps) {
const fileSize = audioBuffer.length * audioBuffer.numberOfChannels * 4; // Approximate size in bytes
return (
<Card className={cn('', className)}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<FileAudio className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="font-medium text-foreground truncate" title={fileName}>
{fileName}
</h3>
</div>
{onClear && (
<Button
variant="ghost"
size="icon"
onClick={onClear}
className="h-8 w-8 flex-shrink-0"
title="Clear audio"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-muted-foreground">
<div>
<span className="font-medium">Duration:</span>{' '}
{formatDuration(audioBuffer.duration)}
</div>
<div>
<span className="font-medium">Sample Rate:</span>{' '}
{audioBuffer.sampleRate.toLocaleString()} Hz
</div>
<div>
<span className="font-medium">Channels:</span>{' '}
{audioBuffer.numberOfChannels === 1 ? 'Mono' : audioBuffer.numberOfChannels === 2 ? 'Stereo' : `${audioBuffer.numberOfChannels} channels`}
</div>
<div>
<span className="font-medium">Size:</span>{' '}
{formatFileSize(fileSize)}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import * as React from 'react';
import { Upload, Music } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { isSupportedAudioFormat } from '@/lib/audio/decoder';
export interface FileUploadProps {
onFileSelect: (file: File) => void;
className?: string;
}
export function FileUpload({ onFileSelect, className }: FileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const audioFile = files.find(isSupportedAudioFormat);
if (audioFile) {
onFileSelect(audioFile);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && isSupportedAudioFormat(file)) {
onFileSelect(file);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<div
className={cn(
'relative flex flex-col items-center justify-center w-full p-8 border-2 border-dashed rounded-lg transition-colors cursor-pointer',
isDragging
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50 hover:bg-accent/50',
className
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileChange}
className="hidden"
/>
<div className="flex flex-col items-center gap-4">
{isDragging ? (
<Upload className="h-12 w-12 text-primary animate-pulseSubtle" />
) : (
<Music className="h-12 w-12 text-muted-foreground" />
)}
<div className="text-center">
<p className="text-lg font-medium text-foreground">
{isDragging ? 'Drop your audio file here' : 'Upload Audio File'}
</p>
<p className="text-sm text-muted-foreground mt-1">
Click to browse or drag and drop
</p>
<p className="text-xs text-muted-foreground mt-2">
Supported formats: WAV, MP3, OGG, FLAC, AAC, M4A
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import * as React from 'react';
import { Play, Pause, Square, SkipBack, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
import { cn } from '@/lib/utils/cn';
export interface PlaybackControlsProps {
isPlaying: boolean;
isPaused: boolean;
currentTime: number;
duration: number;
volume: number;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
onSeek: (time: number) => void;
onVolumeChange: (volume: number) => void;
disabled?: boolean;
className?: string;
currentTimeFormatted?: string;
durationFormatted?: string;
}
export function PlaybackControls({
isPlaying,
isPaused,
currentTime,
duration,
volume,
onPlay,
onPause,
onStop,
onSeek,
onVolumeChange,
disabled = false,
className,
currentTimeFormatted,
durationFormatted,
}: PlaybackControlsProps) {
const [isMuted, setIsMuted] = React.useState(false);
const [previousVolume, setPreviousVolume] = React.useState(volume);
const handlePlayPause = () => {
if (isPlaying) {
onPause();
} else {
onPlay();
}
};
const handleMuteToggle = () => {
if (isMuted) {
onVolumeChange(previousVolume);
setIsMuted(false);
} else {
setPreviousVolume(volume);
onVolumeChange(0);
setIsMuted(true);
}
};
const handleVolumeChange = (newVolume: number) => {
onVolumeChange(newVolume);
if (newVolume === 0) {
setIsMuted(true);
} else {
setIsMuted(false);
}
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className={cn('space-y-4', className)}>
{/* Timeline Slider */}
<div className="space-y-2">
<input
type="range"
min={0}
max={duration || 100}
step={0.01}
value={currentTime}
onChange={(e) => onSeek(parseFloat(e.target.value))}
disabled={disabled || duration === 0}
className={cn(
'w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
'[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-colors',
'[&::-webkit-slider-thumb]:hover:bg-primary/90',
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer'
)}
style={{
background: `linear-gradient(to right, var(--color-primary) ${progress}%, var(--color-secondary) ${progress}%)`,
}}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{currentTimeFormatted || '00:00'}</span>
<span>{durationFormatted || '00:00'}</span>
</div>
</div>
{/* Transport Controls */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onStop}
disabled={disabled || (!isPlaying && !isPaused)}
title="Stop"
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
variant="default"
size="icon"
onClick={handlePlayPause}
disabled={disabled}
title={isPlaying ? 'Pause' : 'Play'}
className="h-12 w-12"
>
{isPlaying ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6 ml-0.5" />
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={onStop}
disabled={disabled || (!isPlaying && !isPaused)}
title="Stop"
>
<Square className="h-4 w-4" />
</Button>
</div>
{/* Volume Control */}
<div className="flex items-center gap-3 min-w-[200px]">
<Button
variant="ghost"
size="icon"
onClick={handleMuteToggle}
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? (
<VolumeX className="h-5 w-5" />
) : (
<Volume2 className="h-5 w-5" />
)}
</Button>
<Slider
value={volume}
onChange={handleVolumeChange}
min={0}
max={1}
step={0.01}
className="flex-1"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import { generateMinMaxPeaks } from '@/lib/waveform/peaks';
export interface WaveformProps {
audioBuffer: AudioBuffer | null;
currentTime: number;
duration: number;
onSeek?: (time: number) => void;
className?: string;
height?: number;
}
export function Waveform({
audioBuffer,
currentTime,
duration,
onSeek,
className,
height = 128,
}: WaveformProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(800);
// Handle resize
React.useEffect(() => {
const handleResize = () => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Draw waveform
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !audioBuffer) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
// Clear canvas
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-waveform-bg') || '#f5f5f5';
ctx.fillRect(0, 0, width, height);
// Generate peaks
const { min, max } = generateMinMaxPeaks(audioBuffer, width, 0);
// Draw waveform
const middle = height / 2;
const scale = height / 2;
// Waveform color
const waveformColor = getComputedStyle(canvas).getPropertyValue('--color-waveform') || '#3b82f6';
const progressColor = getComputedStyle(canvas).getPropertyValue('--color-waveform-progress') || '#10b981';
// Calculate progress position
const progressX = duration > 0 ? (currentTime / duration) * width : 0;
// Draw waveform
for (let i = 0; i < width; i++) {
const minVal = min[i] * scale;
const maxVal = max[i] * scale;
// Use different color for played portion
ctx.fillStyle = i < progressX ? progressColor : waveformColor;
ctx.fillRect(
i,
middle + minVal,
1,
Math.max(1, maxVal - minVal)
);
}
// Draw center line
ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, middle);
ctx.lineTo(width, middle);
ctx.stroke();
// Draw playhead
if (progressX > 0) {
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(progressX, 0);
ctx.lineTo(progressX, height);
ctx.stroke();
}
}, [audioBuffer, width, height, currentTime, duration]);
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!onSeek || !duration) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const clickedTime = (x / width) * duration;
onSeek(clickedTime);
};
return (
<div ref={containerRef} className={cn('w-full', className)}>
{audioBuffer ? (
<canvas
ref={canvasRef}
onClick={handleClick}
className="w-full cursor-pointer rounded-lg border border-border"
style={{ height: `${height}px` }}
/>
) : (
<div
className="flex items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30"
style={{ height: `${height}px` }}
>
<p className="text-sm text-muted-foreground">
Load an audio file to see waveform
</p>
</div>
)}
</div>
);
}