feat: implement medium effort features - markers, web workers, and bezier automation

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-20 08:25:33 +01:00
parent 8720c35f23
commit 119c8c2942
9 changed files with 1143 additions and 6 deletions

View File

@@ -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<Marker | undefined>(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<Marker>) => {
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() {
<main className="flex-1 flex flex-col overflow-hidden bg-background">
{/* Multi-Track View */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Marker Timeline */}
<React.Suspense fallback={null}>
<MarkerTimeline
markers={markers}
duration={duration}
currentTime={currentTime}
onMarkerEdit={handleEditMarker}
onMarkerDelete={handleDeleteMarker}
onSeek={seek}
/>
</React.Suspense>
<TrackList
tracks={tracks}
zoom={zoom}
@@ -2219,6 +2351,17 @@ export function AudioEditor() {
onClose={() => setShortcutsDialogOpen(false)}
/>
</React.Suspense>
{/* Marker Dialog */}
<React.Suspense fallback={null}>
<MarkerDialog
open={markerDialogOpen}
onClose={() => setMarkerDialogOpen(false)}
onSave={handleSaveMarker}
marker={editingMarker}
defaultTime={currentTime}
/>
</React.Suspense>
</>
);
}

View File

@@ -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<number | null>(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<HTMLCanvasElement>) => {
if (!onSeek || !duration || isDragging) return;

View File

@@ -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<Marker>) => 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<MarkerType>(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> = {
...(marker?.id && { id: marker.id }),
name: name || 'Untitled Marker',
type,
time,
...(type === 'region' && { endTime }),
color,
description,
};
onSave(markerData);
onClose();
};
return (
<Modal
open={open}
onClose={onClose}
title={marker ? 'Edit Marker' : 'Add Marker'}
description={marker ? 'Edit marker properties' : 'Add a new marker or region to the timeline'}
size="md"
footer={
<>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>{marker ? 'Save' : 'Add'}</Button>
</>
}
>
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-foreground">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
{/* Type */}
<div className="space-y-2">
<label htmlFor="type" className="text-sm font-medium text-foreground">
Type
</label>
<select
id="type"
value={type}
onChange={(e) => setType(e.target.value as MarkerType)}
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"
>
<option value="point">Point Marker</option>
<option value="region">Region</option>
</select>
</div>
{/* Time */}
<div className="space-y-2">
<label htmlFor="time" className="text-sm font-medium text-foreground">
{type === 'region' ? 'Start Time' : 'Time'} (seconds)
</label>
<input
id="time"
type="number"
step="0.1"
min="0"
value={time}
onChange={(e) => 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"
/>
</div>
{/* End Time (for regions) */}
{type === 'region' && (
<div className="space-y-2">
<label htmlFor="endTime" className="text-sm font-medium text-foreground">
End Time (seconds)
</label>
<input
id="endTime"
type="number"
step="0.1"
min={time}
value={endTime}
onChange={(e) => 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"
/>
</div>
)}
{/* Color */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Color
</label>
<div className="flex gap-2">
{MARKER_COLORS.map((c) => (
<button
key={c}
type="button"
className="w-8 h-8 rounded border-2 transition-all hover:scale-110"
style={{
backgroundColor: c,
borderColor: color === c ? 'white' : 'transparent',
}}
onClick={() => setColor(c)}
/>
))}
</div>
</div>
{/* Description */}
<div className="space-y-2">
<label htmlFor="description" className="text-sm font-medium text-foreground">
Description (optional)
</label>
<input
id="description"
type="text"
value={description}
onChange={(e) => 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"
/>
</div>
</div>
</Modal>
);
}

View File

@@ -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<HTMLDivElement>(null);
const [hoveredMarkerId, setHoveredMarkerId] = React.useState<string | null>(null);
const timeToX = React.useCallback(
(time: number): number => {
if (!containerRef.current) return 0;
const width = containerRef.current.clientWidth;
return (time / duration) * width;
},
[duration]
);
return (
<div
ref={containerRef}
className={cn(
'relative w-full h-8 bg-muted/30 border-b border-border',
className
)}
>
{/* Markers */}
{markers.map((marker) => {
const x = timeToX(marker.time);
const isHovered = hoveredMarkerId === marker.id;
if (marker.type === 'point') {
return (
<div
key={marker.id}
className="absolute top-0 bottom-0 group cursor-pointer"
style={{ left: `${x}px` }}
onMouseEnter={() => setHoveredMarkerId(marker.id)}
onMouseLeave={() => setHoveredMarkerId(null)}
onClick={() => {
onMarkerClick?.(marker);
onSeek?.(marker.time);
}}
>
{/* Marker line */}
<div
className={cn(
'absolute top-0 bottom-0 w-0.5 transition-colors',
isHovered ? 'bg-primary' : 'bg-primary/60'
)}
style={{ backgroundColor: marker.color }}
/>
{/* Marker flag */}
<Flag
className={cn(
'absolute top-0.5 -left-2 h-4 w-4 transition-colors',
isHovered ? 'text-primary' : 'text-primary/60'
)}
style={{ color: marker.color }}
/>
{/* Hover tooltip with actions */}
{isHovered && (
<div className="absolute top-full left-0 mt-1 z-10 bg-popover border border-border rounded shadow-lg p-2 min-w-[200px]">
<div className="text-xs font-medium mb-1">{marker.name}</div>
{marker.description && (
<div className="text-xs text-muted-foreground mb-2">{marker.description}</div>
)}
<div className="flex gap-1">
{onMarkerEdit && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onMarkerEdit(marker);
}}
title="Edit marker"
className="h-6 w-6"
>
<Edit2 className="h-3 w-3" />
</Button>
)}
{onMarkerDelete && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onMarkerDelete(marker.id);
}}
title="Delete marker"
className="h-6 w-6 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
)}
</div>
);
} else {
// Region marker
const endX = timeToX(marker.endTime || marker.time);
const width = endX - x;
return (
<div
key={marker.id}
className="absolute top-0 bottom-0 group cursor-pointer"
style={{ left: `${x}px`, width: `${width}px` }}
onMouseEnter={() => setHoveredMarkerId(marker.id)}
onMouseLeave={() => setHoveredMarkerId(null)}
onClick={() => {
onMarkerClick?.(marker);
onSeek?.(marker.time);
}}
>
{/* Region background */}
<div
className={cn(
'absolute inset-0 transition-opacity',
isHovered ? 'opacity-30' : 'opacity-20'
)}
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
/>
{/* Region borders */}
<div
className="absolute top-0 bottom-0 left-0 w-0.5"
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
/>
<div
className="absolute top-0 bottom-0 right-0 w-0.5"
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
/>
{/* Region label */}
<div
className="absolute top-0.5 left-1 text-[10px] font-medium truncate pr-1"
style={{ color: marker.color || 'var(--color-primary)', maxWidth: `${width - 8}px` }}
>
{marker.name}
</div>
{/* Hover tooltip with actions */}
{isHovered && (
<div className="absolute top-full left-0 mt-1 z-10 bg-popover border border-border rounded shadow-lg p-2 min-w-[200px]">
<div className="text-xs font-medium mb-1">{marker.name}</div>
{marker.description && (
<div className="text-xs text-muted-foreground mb-2">{marker.description}</div>
)}
<div className="flex gap-1">
{onMarkerEdit && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onMarkerEdit(marker);
}}
title="Edit marker"
className="h-6 w-6"
>
<Edit2 className="h-3 w-3" />
</Button>
)}
{onMarkerDelete && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onMarkerDelete(marker.id);
}}
title="Delete marker"
className="h-6 w-6 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
)}
</div>
);
}
})}
</div>
);
}

View File

@@ -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
*/

138
lib/hooks/useAudioWorker.ts Normal file
View File

@@ -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<Worker | null>(null);
const callbacksRef = useRef<Map<string, (result: any, error?: string) => 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<WorkerResponse>) => {
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(
<T = any>(type: WorkerMessage['type'], payload: any): Promise<T> => {
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<Float32Array> => {
const result = await sendMessage<Float32Array>('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<Float32Array> => {
const result = await sendMessage<Float32Array>('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<number> => {
return sendMessage<number>('findPeak', { channelData });
},
[sendMessage]
);
return {
generatePeaks,
generateMinMaxPeaks,
normalizePeaks,
analyzeAudio,
findPeak,
};
}

70
lib/hooks/useMarkers.ts Normal file
View File

@@ -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<Marker[]>([]);
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<Marker>) => {
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,
};
}

200
lib/workers/audio.worker.ts Normal file
View File

@@ -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<WorkerMessage>) => {
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;
}

29
types/marker.ts Normal file
View File

@@ -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<Marker, 'id'>;