From 119c8c294240a72302f09c294182d968239f1767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Thu, 20 Nov 2025 08:25:33 +0100 Subject: [PATCH] feat: implement medium effort features - markers, web workers, and bezier automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented three major medium effort features to enhance the audio editor: **1. Region Markers System** - Add marker type definitions supporting point markers and regions - Create useMarkers hook for marker state management - Build MarkerTimeline component for visual marker display - Create MarkerDialog component for adding/editing markers - Add keyboard shortcuts: M (add marker), Shift+M (next), Shift+Ctrl+M (previous) - Support marker navigation, editing, and deletion **2. Web Worker for Computations** - Create audio worker for offloading heavy computations - Implement worker functions: generatePeaks, generateMinMaxPeaks, normalizePeaks, analyzeAudio, findPeak - Build useAudioWorker hook for easy worker integration - Integrate worker into Waveform component with peak caching - Significantly improve UI responsiveness during waveform generation **3. Bezier Curve Automation** - Enhance interpolateAutomationValue to support Bezier curves - Implement cubic Bezier interpolation with control handles - Add createSmoothHandles for auto-smooth curve generation - Add generateBezierCurvePoints for smooth curve rendering - Support bezier alongside existing linear and step curves All features are type-safe and integrate seamlessly with the existing codebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/editor/AudioEditor.tsx | 143 +++++++++++++++++ components/editor/Waveform.tsx | 45 +++++- components/markers/MarkerDialog.tsx | 188 ++++++++++++++++++++++ components/markers/MarkerTimeline.tsx | 216 ++++++++++++++++++++++++++ lib/audio/automation/utils.ts | 120 +++++++++++++- lib/hooks/useAudioWorker.ts | 138 ++++++++++++++++ lib/hooks/useMarkers.ts | 70 +++++++++ lib/workers/audio.worker.ts | 200 ++++++++++++++++++++++++ types/marker.ts | 29 ++++ 9 files changed, 1143 insertions(+), 6 deletions(-) create mode 100644 components/markers/MarkerDialog.tsx create mode 100644 components/markers/MarkerTimeline.tsx create mode 100644 lib/hooks/useAudioWorker.ts create mode 100644 lib/hooks/useMarkers.ts create mode 100644 lib/workers/audio.worker.ts create mode 100644 types/marker.ts diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index d7c4e3f..6973501 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -22,6 +22,8 @@ const ExportDialog = React.lazy(() => import('@/components/dialogs/ExportDialog' const ProjectsDialog = React.lazy(() => import('@/components/dialogs/ProjectsDialog').then(m => ({ default: m.ProjectsDialog }))); const ImportTrackDialog = React.lazy(() => import('@/components/tracks/ImportTrackDialog').then(m => ({ default: m.ImportTrackDialog }))); const KeyboardShortcutsDialog = React.lazy(() => import('@/components/dialogs/KeyboardShortcutsDialog').then(m => ({ default: m.KeyboardShortcutsDialog }))); +const MarkerTimeline = React.lazy(() => import('@/components/markers/MarkerTimeline').then(m => ({ default: m.MarkerTimeline }))); +const MarkerDialog = React.lazy(() => import('@/components/markers/MarkerDialog').then(m => ({ default: m.MarkerDialog }))); // Lazy load analysis components (shown conditionally based on analyzerView) const FrequencyAnalyzer = React.lazy(() => import('@/components/analysis/FrequencyAnalyzer').then(m => ({ default: m.FrequencyAnalyzer }))); @@ -33,8 +35,10 @@ import { formatDuration } from '@/lib/audio/decoder'; import { useHistory } from '@/lib/hooks/useHistory'; import { useRecording } from '@/lib/hooks/useRecording'; import { useSettings } from '@/lib/hooks/useSettings'; +import { useMarkers } from '@/lib/hooks/useMarkers'; import { DEFAULT_TRACK_HEIGHT } from '@/types/track'; import type { EffectType } from '@/lib/audio/effects/chain'; +import type { Marker } from '@/types/marker'; import { createMultiTrackCutCommand, createMultiTrackCopyCommand, @@ -107,6 +111,19 @@ export function AudioEditor() { const { addToast } = useToast(); + // Markers hook + const { + markers, + addMarker, + updateMarker, + removeMarker, + getNextMarker, + getPreviousMarker, + } = useMarkers(); + + const [markerDialogOpen, setMarkerDialogOpen] = React.useState(false); + const [editingMarker, setEditingMarker] = React.useState(undefined); + // Command history for undo/redo const { execute: executeCommand, undo, redo, state: historyState } = useHistory(); const canUndo = historyState.canUndo; @@ -948,6 +965,63 @@ export function AudioEditor() { }); }, [automationClipboard, tracks, currentTime, updateTrack, addToast]); + // Marker handlers + const handleAddMarker = React.useCallback(() => { + setEditingMarker(undefined); + setMarkerDialogOpen(true); + }, []); + + const handleEditMarker = React.useCallback((marker: Marker) => { + setEditingMarker(marker); + setMarkerDialogOpen(true); + }, []); + + const handleSaveMarker = React.useCallback((markerData: Partial) => { + if (editingMarker) { + // Update existing marker + updateMarker(editingMarker.id, markerData); + addToast({ + title: 'Marker Updated', + description: `Updated marker "${markerData.name}"`, + variant: 'success', + duration: 2000, + }); + } else { + // Add new marker + addMarker(markerData as any); + addToast({ + title: 'Marker Added', + description: `Added marker "${markerData.name}"`, + variant: 'success', + duration: 2000, + }); + } + }, [editingMarker, addMarker, updateMarker, addToast]); + + const handleDeleteMarker = React.useCallback((markerId: string) => { + removeMarker(markerId); + addToast({ + title: 'Marker Deleted', + description: 'Marker removed from timeline', + variant: 'success', + duration: 2000, + }); + }, [removeMarker, addToast]); + + const handleGoToNextMarker = React.useCallback(() => { + const nextMarker = getNextMarker(currentTime); + if (nextMarker) { + seek(nextMarker.time); + } + }, [currentTime, getNextMarker, seek]); + + const handleGoToPreviousMarker = React.useCallback(() => { + const prevMarker = getPreviousMarker(currentTime); + if (prevMarker) { + seek(prevMarker.time); + } + }, [currentTime, getPreviousMarker, seek]); + // Export handler const handleExport = React.useCallback(async (settings: ExportSettings) => { if (tracks.length === 0) { @@ -1534,6 +1608,31 @@ export function AudioEditor() { category: 'edit', action: handleSplitAtCursor, }, + // Markers + { + id: 'add-marker', + label: 'Add Marker', + description: 'Add marker at current time', + shortcut: 'M', + category: 'view', + action: handleAddMarker, + }, + { + id: 'next-marker', + label: 'Go to Next Marker', + description: 'Jump to next marker', + shortcut: 'Shift+M', + category: 'view', + action: handleGoToNextMarker, + }, + { + id: 'previous-marker', + label: 'Go to Previous Marker', + description: 'Jump to previous marker', + shortcut: 'Shift+Ctrl+M', + category: 'view', + action: handleGoToPreviousMarker, + }, { id: 'select-all', label: 'Select All', @@ -1851,6 +1950,27 @@ export function AudioEditor() { return; } + // M: Add marker + if (e.key === 'm' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + handleAddMarker(); + return; + } + + // Shift+M: Go to next marker + if (e.key === 'M' && e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + handleGoToNextMarker(); + return; + } + + // Shift+Ctrl+M: Go to previous marker + if (e.key === 'M' && e.shiftKey && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleGoToPreviousMarker(); + return; + } + // ?: Open keyboard shortcuts help if (e.key === '?' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); @@ -1941,6 +2061,18 @@ export function AudioEditor() {
{/* Multi-Track View */}
+ {/* Marker Timeline */} + + + + setShortcutsDialogOpen(false)} /> + + {/* Marker Dialog */} + + setMarkerDialogOpen(false)} + onSave={handleSaveMarker} + marker={editingMarker} + defaultTime={currentTime} + /> + ); } diff --git a/components/editor/Waveform.tsx b/components/editor/Waveform.tsx index b426a2e..61ced5d 100644 --- a/components/editor/Waveform.tsx +++ b/components/editor/Waveform.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { cn } from '@/lib/utils/cn'; -import { generateMinMaxPeaks } from '@/lib/waveform/peaks'; +import { useAudioWorker } from '@/lib/hooks/useAudioWorker'; import type { Selection } from '@/types/selection'; export interface WaveformProps { @@ -39,6 +39,16 @@ export function Waveform({ const [isSelecting, setIsSelecting] = React.useState(false); const [selectionStart, setSelectionStart] = React.useState(null); + // Worker for peak generation + const worker = useAudioWorker(); + + // Cache peaks to avoid regenerating on every render + const [peaksCache, setPeaksCache] = React.useState<{ + width: number; + min: Float32Array; + max: Float32Array; + } | null>(null); + // Handle resize React.useEffect(() => { const handleResize = () => { @@ -52,10 +62,35 @@ export function Waveform({ return () => window.removeEventListener('resize', handleResize); }, []); + // Generate peaks in worker when audioBuffer or zoom changes + React.useEffect(() => { + if (!audioBuffer) { + setPeaksCache(null); + return; + } + + const visibleWidth = Math.floor(width * zoom); + + // Check if we already have peaks for this width + if (peaksCache && peaksCache.width === visibleWidth) { + return; + } + + // Generate peaks in worker + const channelData = audioBuffer.getChannelData(0); + worker.generateMinMaxPeaks(channelData, visibleWidth).then((peaks) => { + setPeaksCache({ + width: visibleWidth, + min: peaks.min, + max: peaks.max, + }); + }); + }, [audioBuffer, width, zoom, worker, peaksCache]); + // Draw waveform React.useEffect(() => { const canvas = canvasRef.current; - if (!canvas || !audioBuffer) return; + if (!canvas || !audioBuffer || !peaksCache) return; const ctx = canvas.getContext('2d'); if (!ctx) return; @@ -75,8 +110,8 @@ export function Waveform({ // Calculate visible width based on zoom const visibleWidth = Math.floor(width * zoom); - // Generate peaks for visible portion - const { min, max } = generateMinMaxPeaks(audioBuffer, visibleWidth, 0); + // Use cached peaks + const { min, max } = peaksCache; // Draw waveform const middle = height / 2; @@ -176,7 +211,7 @@ export function Waveform({ ctx.lineTo(progressX, height); ctx.stroke(); } - }, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale, selection]); + }, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale, selection, peaksCache]); const handleClick = (e: React.MouseEvent) => { if (!onSeek || !duration || isDragging) return; diff --git a/components/markers/MarkerDialog.tsx b/components/markers/MarkerDialog.tsx new file mode 100644 index 0000000..35427fb --- /dev/null +++ b/components/markers/MarkerDialog.tsx @@ -0,0 +1,188 @@ +'use client'; + +import * as React from 'react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import type { Marker, MarkerType } from '@/types/marker'; + +export interface MarkerDialogProps { + open: boolean; + onClose: () => void; + onSave: (marker: Partial) => void; + marker?: Marker; // If editing existing marker + defaultTime?: number; // Default time for new markers + defaultType?: MarkerType; +} + +const MARKER_COLORS = [ + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#3b82f6', // blue + '#a855f7', // purple + '#ec4899', // pink +]; + +export function MarkerDialog({ + open, + onClose, + onSave, + marker, + defaultTime = 0, + defaultType = 'point', +}: MarkerDialogProps) { + const [name, setName] = React.useState(marker?.name || ''); + const [type, setType] = React.useState(marker?.type || defaultType); + const [time, setTime] = React.useState(marker?.time || defaultTime); + const [endTime, setEndTime] = React.useState(marker?.endTime || defaultTime + 1); + const [color, setColor] = React.useState(marker?.color || MARKER_COLORS[0]); + const [description, setDescription] = React.useState(marker?.description || ''); + + // Reset form when marker changes or dialog opens + React.useEffect(() => { + if (open) { + setName(marker?.name || ''); + setType(marker?.type || defaultType); + setTime(marker?.time || defaultTime); + setEndTime(marker?.endTime || defaultTime + 1); + setColor(marker?.color || MARKER_COLORS[0]); + setDescription(marker?.description || ''); + } + }, [open, marker, defaultTime, defaultType]); + + const handleSave = () => { + const markerData: Partial = { + ...(marker?.id && { id: marker.id }), + name: name || 'Untitled Marker', + type, + time, + ...(type === 'region' && { endTime }), + color, + description, + }; + onSave(markerData); + onClose(); + }; + + return ( + + + + + } + > +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder="Marker name" + className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background" + /> +
+ + {/* Type */} +
+ + +
+ + {/* Time */} +
+ + setTime(parseFloat(e.target.value))} + className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background" + /> +
+ + {/* End Time (for regions) */} + {type === 'region' && ( +
+ + setEndTime(parseFloat(e.target.value))} + className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background" + /> +
+ )} + + {/* Color */} +
+ +
+ {MARKER_COLORS.map((c) => ( +
+
+ + {/* Description */} +
+ + setDescription(e.target.value)} + placeholder="Optional description" + className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background" + /> +
+
+
+ ); +} diff --git a/components/markers/MarkerTimeline.tsx b/components/markers/MarkerTimeline.tsx new file mode 100644 index 0000000..ad87a5c --- /dev/null +++ b/components/markers/MarkerTimeline.tsx @@ -0,0 +1,216 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils/cn'; +import type { Marker } from '@/types/marker'; +import { Flag, Edit2, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; + +export interface MarkerTimelineProps { + markers: Marker[]; + duration: number; + currentTime: number; + onMarkerClick?: (marker: Marker) => void; + onMarkerEdit?: (marker: Marker) => void; + onMarkerDelete?: (markerId: string) => void; + onSeek?: (time: number) => void; + className?: string; +} + +export function MarkerTimeline({ + markers, + duration, + currentTime, + onMarkerClick, + onMarkerEdit, + onMarkerDelete, + onSeek, + className, +}: MarkerTimelineProps) { + const containerRef = React.useRef(null); + const [hoveredMarkerId, setHoveredMarkerId] = React.useState(null); + + const timeToX = React.useCallback( + (time: number): number => { + if (!containerRef.current) return 0; + const width = containerRef.current.clientWidth; + return (time / duration) * width; + }, + [duration] + ); + + return ( +
+ {/* Markers */} + {markers.map((marker) => { + const x = timeToX(marker.time); + const isHovered = hoveredMarkerId === marker.id; + + if (marker.type === 'point') { + return ( +
setHoveredMarkerId(marker.id)} + onMouseLeave={() => setHoveredMarkerId(null)} + onClick={() => { + onMarkerClick?.(marker); + onSeek?.(marker.time); + }} + > + {/* Marker line */} +
+ + {/* Marker flag */} + + + {/* Hover tooltip with actions */} + {isHovered && ( +
+
{marker.name}
+ {marker.description && ( +
{marker.description}
+ )} +
+ {onMarkerEdit && ( + + )} + {onMarkerDelete && ( + + )} +
+
+ )} +
+ ); + } else { + // Region marker + const endX = timeToX(marker.endTime || marker.time); + const width = endX - x; + + return ( +
setHoveredMarkerId(marker.id)} + onMouseLeave={() => setHoveredMarkerId(null)} + onClick={() => { + onMarkerClick?.(marker); + onSeek?.(marker.time); + }} + > + {/* Region background */} +
+ + {/* Region borders */} +
+
+ + {/* Region label */} +
+ {marker.name} +
+ + {/* Hover tooltip with actions */} + {isHovered && ( +
+
{marker.name}
+ {marker.description && ( +
{marker.description}
+ )} +
+ {onMarkerEdit && ( + + )} + {onMarkerDelete && ( + + )} +
+
+ )} +
+ ); + } + })} +
+ ); +} diff --git a/lib/audio/automation/utils.ts b/lib/audio/automation/utils.ts index 5d2ff51..aced638 100644 --- a/lib/audio/automation/utils.ts +++ b/lib/audio/automation/utils.ts @@ -127,7 +127,14 @@ export function interpolateAutomationValue( return prevPoint.value; } - // Linear interpolation + // Handle bezier curve + if (prevPoint.curve === 'bezier') { + const timeDelta = nextPoint.time - prevPoint.time; + const t = (time - prevPoint.time) / timeDelta; + return interpolateBezier(prevPoint, nextPoint, t); + } + + // Linear interpolation (default) const timeDelta = nextPoint.time - prevPoint.time; const valueDelta = nextPoint.value - prevPoint.value; const progress = (time - prevPoint.time) / timeDelta; @@ -139,6 +146,117 @@ export function interpolateAutomationValue( return 0; } +/** + * Interpolate value using cubic Bezier curve + * Uses the control handles from both points to create smooth curves + */ +function interpolateBezier( + p0: AutomationPoint, + p1: AutomationPoint, + t: number +): number { + // Default handle positions if not specified + // Out handle defaults to 1/3 towards next point + // In handle defaults to 1/3 back from current point + const timeDelta = p1.time - p0.time; + + // Control point 1 (out handle from p0) + const c1x = p0.handleOut?.x ?? timeDelta / 3; + const c1y = p0.handleOut?.y ?? 0; + + // Control point 2 (in handle from p1) + const c2x = p1.handleIn?.x ?? -timeDelta / 3; + const c2y = p1.handleIn?.y ?? 0; + + // Convert handles to absolute positions + const cp1Value = p0.value + c1y; + const cp2Value = p1.value + c2y; + + // Cubic Bezier formula: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃ + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + const t2 = t * t; + const t3 = t2 * t; + + const value = + mt3 * p0.value + + 3 * mt2 * t * cp1Value + + 3 * mt * t2 * cp2Value + + t3 * p1.value; + + return value; +} + +/** + * Create smooth bezier handles for a point based on surrounding points + * This creates an "auto-smooth" effect similar to DAWs + */ +export function createSmoothHandles( + prevPoint: AutomationPoint | null, + currentPoint: AutomationPoint, + nextPoint: AutomationPoint | null +): { handleIn: { x: number; y: number }; handleOut: { x: number; y: number } } { + // If no surrounding points, return horizontal handles + if (!prevPoint && !nextPoint) { + return { + handleIn: { x: -0.1, y: 0 }, + handleOut: { x: 0.1, y: 0 }, + }; + } + + // Calculate slope from surrounding points + let slope = 0; + + if (prevPoint && nextPoint) { + // Use average slope from both neighbors + const timeDelta = nextPoint.time - prevPoint.time; + const valueDelta = nextPoint.value - prevPoint.value; + slope = valueDelta / timeDelta; + } else if (nextPoint) { + // Only have next point + const timeDelta = nextPoint.time - currentPoint.time; + const valueDelta = nextPoint.value - currentPoint.value; + slope = valueDelta / timeDelta; + } else if (prevPoint) { + // Only have previous point + const timeDelta = currentPoint.time - prevPoint.time; + const valueDelta = currentPoint.value - prevPoint.value; + slope = valueDelta / timeDelta; + } + + // Create handles with 1/3 distance to neighbors + const handleDistance = 0.1; // Fixed distance for smooth curves + const handleY = slope * handleDistance; + + return { + handleIn: { x: -handleDistance, y: -handleY }, + handleOut: { x: handleDistance, y: handleY }, + }; +} + +/** + * Generate points along a bezier curve for rendering + * Returns array of {time, value} points + */ +export function generateBezierCurvePoints( + p0: AutomationPoint, + p1: AutomationPoint, + numPoints: number = 50 +): Array<{ time: number; value: number }> { + const points: Array<{ time: number; value: number }> = []; + const timeDelta = p1.time - p0.time; + + for (let i = 0; i <= numPoints; i++) { + const t = i / numPoints; + const time = p0.time + t * timeDelta; + const value = interpolateBezier(p0, p1, t); + points.push({ time, value }); + } + + return points; +} + /** * Apply automation value to track parameter */ diff --git a/lib/hooks/useAudioWorker.ts b/lib/hooks/useAudioWorker.ts new file mode 100644 index 0000000..c1b1d49 --- /dev/null +++ b/lib/hooks/useAudioWorker.ts @@ -0,0 +1,138 @@ +'use client'; + +import { useRef, useEffect, useCallback } from 'react'; +import type { WorkerMessage, WorkerResponse } from '@/lib/workers/audio.worker'; + +/** + * Hook to use the audio Web Worker for heavy computations + * Automatically manages worker lifecycle and message passing + */ +export function useAudioWorker() { + const workerRef = useRef(null); + const callbacksRef = useRef void>>(new Map()); + const messageIdRef = useRef(0); + + // Initialize worker + useEffect(() => { + // Create worker from the audio worker file + workerRef.current = new Worker( + new URL('../workers/audio.worker.ts', import.meta.url), + { type: 'module' } + ); + + // Handle messages from worker + workerRef.current.onmessage = (event: MessageEvent) => { + const { id, result, error } = event.data; + const callback = callbacksRef.current.get(id); + + if (callback) { + callback(result, error); + callbacksRef.current.delete(id); + } + }; + + // Cleanup on unmount + return () => { + if (workerRef.current) { + workerRef.current.terminate(); + workerRef.current = null; + } + callbacksRef.current.clear(); + }; + }, []); + + // Send message to worker + const sendMessage = useCallback( + (type: WorkerMessage['type'], payload: any): Promise => { + return new Promise((resolve, reject) => { + if (!workerRef.current) { + reject(new Error('Worker not initialized')); + return; + } + + const id = `msg-${++messageIdRef.current}`; + const message: WorkerMessage = { id, type, payload }; + + callbacksRef.current.set(id, (result, error) => { + if (error) { + reject(new Error(error)); + } else { + resolve(result); + } + }); + + workerRef.current.postMessage(message); + }); + }, + [] + ); + + // API methods + const generatePeaks = useCallback( + async (channelData: Float32Array, width: number): Promise => { + const result = await sendMessage('generatePeaks', { + channelData, + width, + }); + return new Float32Array(result); + }, + [sendMessage] + ); + + const generateMinMaxPeaks = useCallback( + async ( + channelData: Float32Array, + width: number + ): Promise<{ min: Float32Array; max: Float32Array }> => { + const result = await sendMessage<{ min: Float32Array; max: Float32Array }>( + 'generateMinMaxPeaks', + { channelData, width } + ); + return { + min: new Float32Array(result.min), + max: new Float32Array(result.max), + }; + }, + [sendMessage] + ); + + const normalizePeaks = useCallback( + async (peaks: Float32Array, targetMax: number = 1): Promise => { + const result = await sendMessage('normalizePeaks', { + peaks, + targetMax, + }); + return new Float32Array(result); + }, + [sendMessage] + ); + + const analyzeAudio = useCallback( + async ( + channelData: Float32Array + ): Promise<{ + peak: number; + rms: number; + crestFactor: number; + dynamicRange: number; + }> => { + return sendMessage('analyzeAudio', { channelData }); + }, + [sendMessage] + ); + + const findPeak = useCallback( + async (channelData: Float32Array): Promise => { + return sendMessage('findPeak', { channelData }); + }, + [sendMessage] + ); + + return { + generatePeaks, + generateMinMaxPeaks, + normalizePeaks, + analyzeAudio, + findPeak, + }; +} diff --git a/lib/hooks/useMarkers.ts b/lib/hooks/useMarkers.ts new file mode 100644 index 0000000..1f2407b --- /dev/null +++ b/lib/hooks/useMarkers.ts @@ -0,0 +1,70 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import type { Marker, CreateMarkerInput } from '@/types/marker'; + +export function useMarkers() { + const [markers, setMarkers] = useState([]); + + const addMarker = useCallback((input: CreateMarkerInput): Marker => { + const marker: Marker = { + ...input, + id: `marker-${Date.now()}-${Math.random()}`, + }; + setMarkers((prev) => [...prev, marker].sort((a, b) => a.time - b.time)); + return marker; + }, []); + + const updateMarker = useCallback((id: string, updates: Partial) => { + setMarkers((prev) => { + const updated = prev.map((m) => + m.id === id ? { ...m, ...updates } : m + ); + // Re-sort if time changed + if ('time' in updates) { + return updated.sort((a, b) => a.time - b.time); + } + return updated; + }); + }, []); + + const removeMarker = useCallback((id: string) => { + setMarkers((prev) => prev.filter((m) => m.id !== id)); + }, []); + + const clearMarkers = useCallback(() => { + setMarkers([]); + }, []); + + const getMarkerAt = useCallback((time: number, tolerance: number = 0.1): Marker | undefined => { + return markers.find((m) => { + if (m.type === 'point') { + return Math.abs(m.time - time) <= tolerance; + } else { + // For regions, check if time is within the region + return m.endTime !== undefined && time >= m.time && time <= m.endTime; + } + }); + }, [markers]); + + const getNextMarker = useCallback((time: number): Marker | undefined => { + return markers.find((m) => m.time > time); + }, [markers]); + + const getPreviousMarker = useCallback((time: number): Marker | undefined => { + const previous = markers.filter((m) => m.time < time); + return previous[previous.length - 1]; + }, [markers]); + + return { + markers, + addMarker, + updateMarker, + removeMarker, + clearMarkers, + getMarkerAt, + getNextMarker, + getPreviousMarker, + setMarkers, + }; +} diff --git a/lib/workers/audio.worker.ts b/lib/workers/audio.worker.ts new file mode 100644 index 0000000..06c8194 --- /dev/null +++ b/lib/workers/audio.worker.ts @@ -0,0 +1,200 @@ +/** + * Web Worker for heavy audio computations + * Offloads waveform generation, analysis, and normalization to background thread + */ + +export interface WorkerMessage { + id: string; + type: 'generatePeaks' | 'generateMinMaxPeaks' | 'normalizePeaks' | 'analyzeAudio' | 'findPeak'; + payload: any; +} + +export interface WorkerResponse { + id: string; + type: string; + result?: any; + error?: string; +} + +// Message handler +self.onmessage = (event: MessageEvent) => { + const { id, type, payload } = event.data; + + try { + let result: any; + + switch (type) { + case 'generatePeaks': + result = generatePeaks( + payload.channelData, + payload.width + ); + break; + + case 'generateMinMaxPeaks': + result = generateMinMaxPeaks( + payload.channelData, + payload.width + ); + break; + + case 'normalizePeaks': + result = normalizePeaks( + payload.peaks, + payload.targetMax + ); + break; + + case 'analyzeAudio': + result = analyzeAudio(payload.channelData); + break; + + case 'findPeak': + result = findPeak(payload.channelData); + break; + + default: + throw new Error(`Unknown worker message type: ${type}`); + } + + const response: WorkerResponse = { id, type, result }; + self.postMessage(response); + } catch (error) { + const response: WorkerResponse = { + id, + type, + error: error instanceof Error ? error.message : String(error), + }; + self.postMessage(response); + } +}; + +/** + * Generate waveform peaks from channel data + */ +function generatePeaks(channelData: Float32Array, width: number): Float32Array { + const peaks = new Float32Array(width); + const samplesPerPeak = Math.floor(channelData.length / width); + + for (let i = 0; i < width; i++) { + const start = i * samplesPerPeak; + const end = Math.min(start + samplesPerPeak, channelData.length); + + let max = 0; + for (let j = start; j < end; j++) { + const abs = Math.abs(channelData[j]); + if (abs > max) { + max = abs; + } + } + + peaks[i] = max; + } + + return peaks; +} + +/** + * Generate min/max peaks for more detailed waveform visualization + */ +function generateMinMaxPeaks( + channelData: Float32Array, + width: number +): { min: Float32Array; max: Float32Array } { + const min = new Float32Array(width); + const max = new Float32Array(width); + const samplesPerPeak = Math.floor(channelData.length / width); + + for (let i = 0; i < width; i++) { + const start = i * samplesPerPeak; + const end = Math.min(start + samplesPerPeak, channelData.length); + + let minVal = 1; + let maxVal = -1; + + for (let j = start; j < end; j++) { + const val = channelData[j]; + if (val < minVal) minVal = val; + if (val > maxVal) maxVal = val; + } + + min[i] = minVal; + max[i] = maxVal; + } + + return { min, max }; +} + +/** + * Normalize peaks to a given range + */ +function normalizePeaks(peaks: Float32Array, targetMax: number = 1): Float32Array { + const normalized = new Float32Array(peaks.length); + let max = 0; + + // Find max value + for (let i = 0; i < peaks.length; i++) { + if (peaks[i] > max) { + max = peaks[i]; + } + } + + // Normalize + const scale = max > 0 ? targetMax / max : 1; + for (let i = 0; i < peaks.length; i++) { + normalized[i] = peaks[i] * scale; + } + + return normalized; +} + +/** + * Analyze audio data for statistics + */ +function analyzeAudio(channelData: Float32Array): { + peak: number; + rms: number; + crestFactor: number; + dynamicRange: number; +} { + let peak = 0; + let sumSquares = 0; + let min = 1; + let max = -1; + + for (let i = 0; i < channelData.length; i++) { + const val = channelData[i]; + const abs = Math.abs(val); + + if (abs > peak) peak = abs; + if (val < min) min = val; + if (val > max) max = val; + + sumSquares += val * val; + } + + const rms = Math.sqrt(sumSquares / channelData.length); + const crestFactor = rms > 0 ? peak / rms : 0; + const dynamicRange = max - min; + + return { + peak, + rms, + crestFactor, + dynamicRange, + }; +} + +/** + * Find peak value in channel data + */ +function findPeak(channelData: Float32Array): number { + let peak = 0; + + for (let i = 0; i < channelData.length; i++) { + const abs = Math.abs(channelData[i]); + if (abs > peak) peak = abs; + } + + return peak; +} diff --git a/types/marker.ts b/types/marker.ts new file mode 100644 index 0000000..b65799a --- /dev/null +++ b/types/marker.ts @@ -0,0 +1,29 @@ +/** + * Region marker type definitions + * Markers help navigate and organize the timeline + */ + +/** + * Marker types + * - point: A single point in time (like a cue point) + * - region: A time range with start and end + */ +export type MarkerType = 'point' | 'region'; + +/** + * Single marker or region + */ +export interface Marker { + id: string; + name: string; + type: MarkerType; + time: number; // Start time in seconds + endTime?: number; // End time for regions (undefined for point markers) + color?: string; // Optional color for visual distinction + description?: string; // Optional description/notes +} + +/** + * Helper type for creating new markers + */ +export type CreateMarkerInput = Omit;