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;