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:
138
lib/hooks/useAudioWorker.ts
Normal file
138
lib/hooks/useAudioWorker.ts
Normal 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
70
lib/hooks/useMarkers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user