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