feat: complete Phase 8.2 - integrate recording into multi-track UI

Completed the integration of recording functionality into the AudioEditor:

Recording Controls:
- Added global record button to PlaybackControls component
- Implemented track arming system (arm track before recording)
- Added visual feedback with pulsing red animations during recording
- Connected recording state from useRecording hook to UI

AudioEditor Integration:
- Added handleToggleRecordEnable for per-track record arming
- Added handleStartRecording with permission checks and toast notifications
- Added handleStopRecording to save recorded audio to track buffer
- Connected recording input level monitoring to track meters

TrackList Integration:
- Pass recording props (onToggleRecordEnable, recordingTrackId, recordingLevel)
- Individual tracks show input level meters when armed or recording
- Visual indicators for armed and actively recording tracks

This completes Phase 8 (Recording) implementation with:
 MediaRecorder API integration
 Input level monitoring with RMS calculation
 Per-track record arming
 Global record button
 Real-time visual feedback
 Permission handling
 Error handling with user notifications

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 14:44:15 +01:00
parent 5e6c61d951
commit f65dd0be7f
3 changed files with 138 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ import { TrackList } from '@/components/tracks/TrackList';
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
import { formatDuration } from '@/lib/audio/decoder';
import { useHistory } from '@/lib/hooks/useHistory';
import { useRecording } from '@/lib/hooks/useRecording';
import {
createMultiTrackCutCommand,
createMultiTrackCopyCommand,
@@ -30,6 +31,7 @@ export function AudioEditor() {
const [zoom, setZoom] = React.useState(1);
const [masterVolume, setMasterVolume] = React.useState(0.8);
const [clipboard, setClipboard] = React.useState<AudioBuffer | null>(null);
const [recordingTrackId, setRecordingTrackId] = React.useState<string | null>(null);
const { addToast } = useToast();
@@ -38,6 +40,14 @@ export function AudioEditor() {
const canUndo = historyState.canUndo;
const canRedo = historyState.canRedo;
// Recording hook
const {
state: recordingState,
startRecording,
stopRecording,
requestPermission,
} = useRecording();
// Multi-track hooks
const {
tracks,
@@ -155,6 +165,91 @@ export function AudioEditor() {
updateTrack(trackId, { selection });
};
// Recording handlers
const handleToggleRecordEnable = React.useCallback((trackId: string) => {
const track = tracks.find((t) => t.id === trackId);
if (!track) return;
// Toggle record enable
updateTrack(trackId, { recordEnabled: !track.recordEnabled });
}, [tracks, updateTrack]);
const handleStartRecording = React.useCallback(async () => {
// Find first armed track
const armedTrack = tracks.find((t) => t.recordEnabled);
if (!armedTrack) {
addToast({
title: 'No Track Armed',
description: 'Please arm a track for recording first',
variant: 'warning',
duration: 3000,
});
return;
}
// Request permission if needed
const hasPermission = await requestPermission();
if (!hasPermission) {
addToast({
title: 'Microphone Access Denied',
description: 'Please allow microphone access to record',
variant: 'error',
duration: 3000,
});
return;
}
try {
await startRecording();
setRecordingTrackId(armedTrack.id);
addToast({
title: 'Recording Started',
description: `Recording to ${armedTrack.name}`,
variant: 'success',
duration: 2000,
});
} catch (error) {
console.error('Failed to start recording:', error);
addToast({
title: 'Recording Failed',
description: 'Failed to start recording',
variant: 'error',
duration: 3000,
});
}
}, [tracks, startRecording, requestPermission, addToast]);
const handleStopRecording = React.useCallback(async () => {
if (!recordingTrackId) return;
try {
const audioBuffer = await stopRecording();
if (audioBuffer) {
// Update the track with recorded audio
updateTrack(recordingTrackId, { audioBuffer });
addToast({
title: 'Recording Complete',
description: `Recorded ${audioBuffer.duration.toFixed(2)}s of audio`,
variant: 'success',
duration: 3000,
});
}
setRecordingTrackId(null);
} catch (error) {
console.error('Failed to stop recording:', error);
addToast({
title: 'Recording Error',
description: 'Failed to save recording',
variant: 'error',
duration: 3000,
});
setRecordingTrackId(null);
}
}, [recordingTrackId, stopRecording, updateTrack, addToast]);
// Edit handlers
const handleCut = React.useCallback(() => {
const track = tracks.find((t) => t.selection);
@@ -540,6 +635,9 @@ export function AudioEditor() {
onUpdateTrack={updateTrack}
onSeek={seek}
onSelectionChange={handleSelectionChange}
onToggleRecordEnable={handleToggleRecordEnable}
recordingTrackId={recordingTrackId}
recordingLevel={recordingState.inputLevel}
/>
</div>
@@ -561,6 +659,9 @@ export function AudioEditor() {
onVolumeChange={setMasterVolume}
currentTimeFormatted={formatDuration(currentTime)}
durationFormatted={formatDuration(duration)}
isRecording={recordingState.isRecording}
onStartRecording={handleStartRecording}
onStopRecording={handleStopRecording}
/>
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Play, Pause, Square, SkipBack, Volume2, VolumeX } from 'lucide-react';
import { Play, Pause, Square, SkipBack, Volume2, VolumeX, Circle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
import { cn } from '@/lib/utils/cn';
@@ -21,6 +21,9 @@ export interface PlaybackControlsProps {
className?: string;
currentTimeFormatted?: string;
durationFormatted?: string;
isRecording?: boolean;
onStartRecording?: () => void;
onStopRecording?: () => void;
}
export function PlaybackControls({
@@ -38,6 +41,9 @@ export function PlaybackControls({
className,
currentTimeFormatted,
durationFormatted,
isRecording = false,
onStartRecording,
onStopRecording,
}: PlaybackControlsProps) {
const [isMuted, setIsMuted] = React.useState(false);
const [previousVolume, setPreviousVolume] = React.useState(volume);
@@ -144,6 +150,23 @@ export function PlaybackControls({
>
<Square className="h-4 w-4" />
</Button>
{/* Record Button */}
{(onStartRecording || onStopRecording) && (
<Button
variant="outline"
size="icon"
onClick={isRecording ? onStopRecording : onStartRecording}
disabled={disabled}
title={isRecording ? 'Stop Recording' : 'Start Recording'}
className={cn(
isRecording && 'bg-red-500/20 hover:bg-red-500/30 border-red-500/50',
isRecording && 'animate-pulse'
)}
>
<Circle className={cn('h-4 w-4', isRecording && 'text-red-500 fill-red-500')} />
</Button>
)}
</div>
{/* Volume Control */}

View File

@@ -21,6 +21,9 @@ export interface TrackListProps {
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
onSeek?: (time: number) => void;
onSelectionChange?: (trackId: string, selection: { start: number; end: number } | null) => void;
onToggleRecordEnable?: (trackId: string) => void;
recordingTrackId?: string | null;
recordingLevel?: number;
}
export function TrackList({
@@ -36,6 +39,9 @@ export function TrackList({
onUpdateTrack,
onSeek,
onSelectionChange,
onToggleRecordEnable,
recordingTrackId,
recordingLevel = 0,
}: TrackListProps) {
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
@@ -151,6 +157,13 @@ export function TrackList({
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
/>
))}
</div>