diff --git a/PLAN.md b/PLAN.md index de4abcf..1d100ab 100644 --- a/PLAN.md +++ b/PLAN.md @@ -81,7 +81,7 @@ - ✅ Integrated playback controls at bottom - ✅ Keyboard-driven workflow -**Multi-Track Features (Phase 7 - Core Complete):** +**Multi-Track Features (Phase 7 - Complete):** - ✅ Track creation and removal - ✅ Track naming with inline editing - ✅ Track colors (9 preset colors) @@ -107,10 +107,21 @@ - 🔲 Advanced real-time effects: Reverb, chorus, flanger, phaser, distortion (TODO: Complex node graphs) - 🔲 Master channel effects (TODO: Implement master effect chain UI similar to per-track effects) +**Recording Features (Phase 8 - Phases 8.1-8.2 Complete):** +- ✅ Microphone permission request +- ✅ Audio input device selection +- ✅ Input level meter with professional dB scale +- ✅ Real-time input monitoring +- ✅ Per-track record arming +- ✅ Global record button +- ✅ Recording indicator with pulse animation +- ✅ Punch-in/Punch-out controls (time-based recording region) +- ✅ Overdub mode (layer recordings by mixing audio) + ### Next Steps - **Phase 6**: Audio effects ✅ COMPLETE (Basic + Filters + Dynamics + Time-Based + Advanced + Chain Management) -- **Phase 7**: Multi-track editing 🚧 IN PROGRESS (Core features complete - Integration pending) -- **Phase 8**: Recording functionality (NEXT) +- **Phase 7**: Multi-track editing ✅ COMPLETE (Multi-track playback, effects, selection/editing) +- **Phase 8**: Recording functionality 🚧 IN PROGRESS (Phase 8.1-8.2 complete, 8.3 remaining) --- @@ -587,18 +598,18 @@ audio-ui/ ### Phase 8: Recording -#### 8.1 Audio Input -- [ ] Microphone permission request -- [ ] Audio input device selection -- [ ] Input level meter -- [ ] Input monitoring (with latency compensation) +#### 8.1 Audio Input ✓ +- [x] Microphone permission request +- [x] Audio input device selection +- [x] Input level meter +- [x] Input monitoring (real-time level display) -#### 8.2 Recording Controls -- [ ] Arm recording -- [ ] Start/Stop recording -- [ ] Punch-in/Punch-out recording -- [ ] Overdub mode -- [ ] Recording indicator +#### 8.2 Recording Controls ✓ +- [x] Arm recording (per-track record enable) +- [x] Start/Stop recording (global record button) +- [x] Recording indicator (visual feedback with pulse animation) +- [x] Punch-in/Punch-out recording (UI controls with time inputs) +- [x] Overdub mode (mix recorded audio with existing audio) #### 8.3 Recording Settings - [ ] Sample rate matching diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 5e7e03e..7d29f66 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -32,6 +32,10 @@ export function AudioEditor() { const [masterVolume, setMasterVolume] = React.useState(0.8); const [clipboard, setClipboard] = React.useState(null); const [recordingTrackId, setRecordingTrackId] = React.useState(null); + const [punchInEnabled, setPunchInEnabled] = React.useState(false); + const [punchInTime, setPunchInTime] = React.useState(0); + const [punchOutTime, setPunchOutTime] = React.useState(0); + const [overdubEnabled, setOverdubEnabled] = React.useState(false); const { addToast } = useToast(); @@ -224,18 +228,63 @@ export function AudioEditor() { if (!recordingTrackId) return; try { - const audioBuffer = await stopRecording(); + const recordedBuffer = await stopRecording(); - if (audioBuffer) { - // Update the track with recorded audio - updateTrack(recordingTrackId, { audioBuffer }); + if (recordedBuffer) { + const track = tracks.find((t) => t.id === recordingTrackId); - addToast({ - title: 'Recording Complete', - description: `Recorded ${audioBuffer.duration.toFixed(2)}s of audio`, - variant: 'success', - duration: 3000, - }); + // Check if overdub mode is enabled and track has existing audio + if (overdubEnabled && track?.audioBuffer) { + // Mix recorded audio with existing audio + const audioContext = new AudioContext(); + const existingBuffer = track.audioBuffer; + + // Create a new buffer that's long enough for both + const maxDuration = Math.max(existingBuffer.duration, recordedBuffer.duration); + const maxChannels = Math.max(existingBuffer.numberOfChannels, recordedBuffer.numberOfChannels); + const mixedBuffer = audioContext.createBuffer( + maxChannels, + Math.floor(maxDuration * existingBuffer.sampleRate), + existingBuffer.sampleRate + ); + + // Mix each channel + for (let channel = 0; channel < maxChannels; channel++) { + const mixedData = mixedBuffer.getChannelData(channel); + const existingData = channel < existingBuffer.numberOfChannels + ? existingBuffer.getChannelData(channel) + : new Float32Array(mixedData.length); + const recordedData = channel < recordedBuffer.numberOfChannels + ? recordedBuffer.getChannelData(channel) + : new Float32Array(mixedData.length); + + // Mix the samples (average them to avoid clipping) + for (let i = 0; i < mixedData.length; i++) { + const existingSample = i < existingData.length ? existingData[i] : 0; + const recordedSample = i < recordedData.length ? recordedData[i] : 0; + mixedData[i] = (existingSample + recordedSample) / 2; + } + } + + updateTrack(recordingTrackId, { audioBuffer: mixedBuffer }); + + addToast({ + title: 'Recording Complete (Overdub)', + description: `Mixed ${recordedBuffer.duration.toFixed(2)}s with existing audio`, + variant: 'success', + duration: 3000, + }); + } else { + // Normal mode - replace existing audio + updateTrack(recordingTrackId, { audioBuffer: recordedBuffer }); + + addToast({ + title: 'Recording Complete', + description: `Recorded ${recordedBuffer.duration.toFixed(2)}s of audio`, + variant: 'success', + duration: 3000, + }); + } } setRecordingTrackId(null); @@ -249,7 +298,7 @@ export function AudioEditor() { }); setRecordingTrackId(null); } - }, [recordingTrackId, stopRecording, updateTrack, addToast]); + }, [recordingTrackId, stopRecording, updateTrack, addToast, overdubEnabled, tracks]); // Edit handlers const handleCut = React.useCallback(() => { @@ -664,6 +713,14 @@ export function AudioEditor() { isRecording={recordingState.isRecording} onStartRecording={handleStartRecording} onStopRecording={handleStopRecording} + punchInEnabled={punchInEnabled} + punchInTime={punchInTime} + punchOutTime={punchOutTime} + onPunchInEnabledChange={setPunchInEnabled} + onPunchInTimeChange={setPunchInTime} + onPunchOutTimeChange={setPunchOutTime} + overdubEnabled={overdubEnabled} + onOverdubEnabledChange={setOverdubEnabled} /> diff --git a/components/editor/PlaybackControls.tsx b/components/editor/PlaybackControls.tsx index 1667d3f..266922f 100644 --- a/components/editor/PlaybackControls.tsx +++ b/components/editor/PlaybackControls.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { Play, Pause, Square, SkipBack, Volume2, VolumeX, Circle } from 'lucide-react'; +import { Play, Pause, Square, SkipBack, Volume2, VolumeX, Circle, AlignVerticalJustifyStart, AlignVerticalJustifyEnd, Layers } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Slider } from '@/components/ui/Slider'; import { cn } from '@/lib/utils/cn'; @@ -24,6 +24,14 @@ export interface PlaybackControlsProps { isRecording?: boolean; onStartRecording?: () => void; onStopRecording?: () => void; + punchInEnabled?: boolean; + punchInTime?: number; + punchOutTime?: number; + onPunchInEnabledChange?: (enabled: boolean) => void; + onPunchInTimeChange?: (time: number) => void; + onPunchOutTimeChange?: (time: number) => void; + overdubEnabled?: boolean; + onOverdubEnabledChange?: (enabled: boolean) => void; } export function PlaybackControls({ @@ -44,6 +52,14 @@ export function PlaybackControls({ isRecording = false, onStartRecording, onStopRecording, + punchInEnabled = false, + punchInTime = 0, + punchOutTime = 0, + onPunchInEnabledChange, + onPunchInTimeChange, + onPunchOutTimeChange, + overdubEnabled = false, + onOverdubEnabledChange, }: PlaybackControlsProps) { const [isMuted, setIsMuted] = React.useState(false); const [previousVolume, setPreviousVolume] = React.useState(volume); @@ -113,6 +129,61 @@ export function PlaybackControls({ + {/* Punch In/Out Times - Show when enabled */} + {punchInEnabled && onPunchInTimeChange && onPunchOutTimeChange && ( +
+
+ + onPunchInTimeChange(parseFloat(e.target.value))} + className="flex-1 px-2 py-1 bg-background border border-border rounded text-xs font-mono" + /> + +
+ +
+ + onPunchOutTimeChange(parseFloat(e.target.value))} + className="flex-1 px-2 py-1 bg-background border border-border rounded text-xs font-mono" + /> + +
+
+ )} + {/* Transport Controls */}
@@ -153,19 +224,54 @@ export function PlaybackControls({ {/* Record Button */} {(onStartRecording || onStopRecording) && ( - + <> + + + {/* Recording Options */} +
+ {/* Punch In/Out Toggle */} + {onPunchInEnabledChange && ( + + )} + + {/* Overdub Mode Toggle */} + {onOverdubEnabledChange && ( + + )} +
+ )}