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:
@@ -15,6 +15,7 @@ import { TrackList } from '@/components/tracks/TrackList';
|
|||||||
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
|
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
|
||||||
import { formatDuration } from '@/lib/audio/decoder';
|
import { formatDuration } from '@/lib/audio/decoder';
|
||||||
import { useHistory } from '@/lib/hooks/useHistory';
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
|
import { useRecording } from '@/lib/hooks/useRecording';
|
||||||
import {
|
import {
|
||||||
createMultiTrackCutCommand,
|
createMultiTrackCutCommand,
|
||||||
createMultiTrackCopyCommand,
|
createMultiTrackCopyCommand,
|
||||||
@@ -30,6 +31,7 @@ export function AudioEditor() {
|
|||||||
const [zoom, setZoom] = React.useState(1);
|
const [zoom, setZoom] = React.useState(1);
|
||||||
const [masterVolume, setMasterVolume] = React.useState(0.8);
|
const [masterVolume, setMasterVolume] = React.useState(0.8);
|
||||||
const [clipboard, setClipboard] = React.useState<AudioBuffer | null>(null);
|
const [clipboard, setClipboard] = React.useState<AudioBuffer | null>(null);
|
||||||
|
const [recordingTrackId, setRecordingTrackId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
@@ -38,6 +40,14 @@ export function AudioEditor() {
|
|||||||
const canUndo = historyState.canUndo;
|
const canUndo = historyState.canUndo;
|
||||||
const canRedo = historyState.canRedo;
|
const canRedo = historyState.canRedo;
|
||||||
|
|
||||||
|
// Recording hook
|
||||||
|
const {
|
||||||
|
state: recordingState,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
requestPermission,
|
||||||
|
} = useRecording();
|
||||||
|
|
||||||
// Multi-track hooks
|
// Multi-track hooks
|
||||||
const {
|
const {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -155,6 +165,91 @@ export function AudioEditor() {
|
|||||||
updateTrack(trackId, { selection });
|
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
|
// Edit handlers
|
||||||
const handleCut = React.useCallback(() => {
|
const handleCut = React.useCallback(() => {
|
||||||
const track = tracks.find((t) => t.selection);
|
const track = tracks.find((t) => t.selection);
|
||||||
@@ -540,6 +635,9 @@ export function AudioEditor() {
|
|||||||
onUpdateTrack={updateTrack}
|
onUpdateTrack={updateTrack}
|
||||||
onSeek={seek}
|
onSeek={seek}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
|
onToggleRecordEnable={handleToggleRecordEnable}
|
||||||
|
recordingTrackId={recordingTrackId}
|
||||||
|
recordingLevel={recordingState.inputLevel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -561,6 +659,9 @@ export function AudioEditor() {
|
|||||||
onVolumeChange={setMasterVolume}
|
onVolumeChange={setMasterVolume}
|
||||||
currentTimeFormatted={formatDuration(currentTime)}
|
currentTimeFormatted={formatDuration(currentTime)}
|
||||||
durationFormatted={formatDuration(duration)}
|
durationFormatted={formatDuration(duration)}
|
||||||
|
isRecording={recordingState.isRecording}
|
||||||
|
onStartRecording={handleStartRecording}
|
||||||
|
onStopRecording={handleStopRecording}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
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 { Button } from '@/components/ui/Button';
|
||||||
import { Slider } from '@/components/ui/Slider';
|
import { Slider } from '@/components/ui/Slider';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
@@ -21,6 +21,9 @@ export interface PlaybackControlsProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
currentTimeFormatted?: string;
|
currentTimeFormatted?: string;
|
||||||
durationFormatted?: string;
|
durationFormatted?: string;
|
||||||
|
isRecording?: boolean;
|
||||||
|
onStartRecording?: () => void;
|
||||||
|
onStopRecording?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaybackControls({
|
export function PlaybackControls({
|
||||||
@@ -38,6 +41,9 @@ export function PlaybackControls({
|
|||||||
className,
|
className,
|
||||||
currentTimeFormatted,
|
currentTimeFormatted,
|
||||||
durationFormatted,
|
durationFormatted,
|
||||||
|
isRecording = false,
|
||||||
|
onStartRecording,
|
||||||
|
onStopRecording,
|
||||||
}: PlaybackControlsProps) {
|
}: PlaybackControlsProps) {
|
||||||
const [isMuted, setIsMuted] = React.useState(false);
|
const [isMuted, setIsMuted] = React.useState(false);
|
||||||
const [previousVolume, setPreviousVolume] = React.useState(volume);
|
const [previousVolume, setPreviousVolume] = React.useState(volume);
|
||||||
@@ -144,6 +150,23 @@ export function PlaybackControls({
|
|||||||
>
|
>
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Volume Control */}
|
{/* Volume Control */}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export interface TrackListProps {
|
|||||||
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
|
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
onSelectionChange?: (trackId: string, selection: { start: number; end: number } | null) => void;
|
onSelectionChange?: (trackId: string, selection: { start: number; end: number } | null) => void;
|
||||||
|
onToggleRecordEnable?: (trackId: string) => void;
|
||||||
|
recordingTrackId?: string | null;
|
||||||
|
recordingLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrackList({
|
export function TrackList({
|
||||||
@@ -36,6 +39,9 @@ export function TrackList({
|
|||||||
onUpdateTrack,
|
onUpdateTrack,
|
||||||
onSeek,
|
onSeek,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
|
onToggleRecordEnable,
|
||||||
|
recordingTrackId,
|
||||||
|
recordingLevel = 0,
|
||||||
}: TrackListProps) {
|
}: TrackListProps) {
|
||||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -151,6 +157,13 @@ export function TrackList({
|
|||||||
? (selection) => onSelectionChange(track.id, selection)
|
? (selection) => onSelectionChange(track.id, selection)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onToggleRecordEnable={
|
||||||
|
onToggleRecordEnable
|
||||||
|
? () => onToggleRecordEnable(track.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isRecording={recordingTrackId === track.id}
|
||||||
|
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user