diff --git a/PLAN.md b/PLAN.md index 1d100ab..bbc5598 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,7 +2,7 @@ ## Progress Overview -**Current Status**: Phase 7 In Progress (Multi-Track Support - Core Features Complete) +**Current Status**: Phase 8 Complete (Recording with Overdub/Punch & Settings) ### Completed Phases - ✅ **Phase 1**: Project Setup & Core Infrastructure (95% complete) @@ -107,7 +107,7 @@ - 🔲 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):** +**Recording Features (Phase 8 - Complete):** - ✅ Microphone permission request - ✅ Audio input device selection - ✅ Input level meter with professional dB scale @@ -117,11 +117,16 @@ - ✅ Recording indicator with pulse animation - ✅ Punch-in/Punch-out controls (time-based recording region) - ✅ Overdub mode (layer recordings by mixing audio) +- ✅ Input gain control (0.0-2.0 with dB display, adjustable in real-time) +- ✅ Mono/Stereo recording selection +- ✅ Sample rate matching (44.1kHz, 48kHz, 96kHz) +- ✅ Recording settings panel shown when track is armed ### Next Steps - **Phase 6**: Audio effects ✅ COMPLETE (Basic + Filters + Dynamics + Time-Based + Advanced + Chain Management) - **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) +- **Phase 8**: Recording functionality ✅ COMPLETE (Audio input, controls, settings with overdub/punch) +- **Phase 9**: Automation (NEXT) --- @@ -611,11 +616,12 @@ audio-ui/ - [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 -- [ ] Input gain control -- [ ] Mono/Stereo recording -- [ ] File naming conventions +#### 8.3 Recording Settings ✓ +- [x] Sample rate matching (44.1kHz, 48kHz, 96kHz) +- [x] Input gain control (0.0-2.0 with dB display) +- [x] Mono/Stereo recording selection +- [x] Real-time gain adjustment during recording +- 🔲 File naming conventions (Future: Auto-name recorded tracks) ### Phase 9: Automation diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 7d29f66..ea447fe 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -47,9 +47,13 @@ export function AudioEditor() { // Recording hook const { state: recordingState, + settings: recordingSettings, startRecording, stopRecording, requestPermission, + setInputGain, + setRecordMono, + setSampleRate, } = useRecording(); // Multi-track hooks @@ -689,6 +693,10 @@ export function AudioEditor() { recordingTrackId={recordingTrackId} recordingLevel={recordingState.inputLevel} trackLevels={trackLevels} + recordingSettings={recordingSettings} + onInputGainChange={setInputGain} + onRecordMonoChange={setRecordMono} + onSampleRateChange={setSampleRate} /> diff --git a/components/recording/RecordingSettings.tsx b/components/recording/RecordingSettings.tsx new file mode 100644 index 0000000..7c429e5 --- /dev/null +++ b/components/recording/RecordingSettings.tsx @@ -0,0 +1,106 @@ +'use client'; + +import * as React from 'react'; +import { Volume2, Radio } from 'lucide-react'; +import { Slider } from '@/components/ui/Slider'; +import { cn } from '@/lib/utils/cn'; +import type { RecordingSettings as RecordingSettingsType } from '@/lib/hooks/useRecording'; + +export interface RecordingSettingsProps { + settings: RecordingSettingsType; + onInputGainChange: (gain: number) => void; + onRecordMonoChange: (mono: boolean) => void; + onSampleRateChange: (sampleRate: number) => void; + className?: string; +} + +const SAMPLE_RATES = [44100, 48000, 96000]; + +export function RecordingSettings({ + settings, + onInputGainChange, + onRecordMonoChange, + onSampleRateChange, + className, +}: RecordingSettingsProps) { + return ( +
+
Recording Settings
+ + {/* Input Gain */} +
+ +
+ +
+ + {settings.inputGain === 1 ? '0 dB' : `${(20 * Math.log10(settings.inputGain)).toFixed(1)} dB`} + +
+ + {/* Mono/Stereo Toggle */} +
+ +
+ + +
+
+ + {/* Sample Rate Selection */} +
+ +
+ {SAMPLE_RATES.map((rate) => ( + + ))} +
+
+
+ ); +} diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index ac4917c..1517c40 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -10,6 +10,8 @@ import { EffectBrowser } from '@/components/effects/EffectBrowser'; import { EffectDevice } from '@/components/effects/EffectDevice'; import { createEffect, type EffectType } from '@/lib/audio/effects/chain'; import { InputLevelMeter } from '@/components/recording/InputLevelMeter'; +import { RecordingSettings } from '@/components/recording/RecordingSettings'; +import type { RecordingSettings as RecordingSettingsType } from '@/lib/hooks/useRecording'; export interface TrackProps { track: TrackType; @@ -36,6 +38,10 @@ export interface TrackProps { isRecording?: boolean; recordingLevel?: number; playbackLevel?: number; + recordingSettings?: RecordingSettingsType; + onInputGainChange?: (gain: number) => void; + onRecordMonoChange?: (mono: boolean) => void; + onSampleRateChange?: (sampleRate: number) => void; } export function Track({ @@ -63,6 +69,10 @@ export function Track({ isRecording = false, recordingLevel = 0, playbackLevel = 0, + recordingSettings, + onInputGainChange, + onRecordMonoChange, + onSampleRateChange, }: TrackProps) { const canvasRef = React.useRef(null); const containerRef = React.useRef(null); @@ -584,6 +594,16 @@ export function Track({ })()} + + {/* Recording Settings - Show when track is armed */} + {track.recordEnabled && recordingSettings && onInputGainChange && onRecordMonoChange && onSampleRateChange && ( + + )} )} diff --git a/components/tracks/TrackList.tsx b/components/tracks/TrackList.tsx index 6875a34..ab31bd4 100644 --- a/components/tracks/TrackList.tsx +++ b/components/tracks/TrackList.tsx @@ -7,6 +7,7 @@ import { Track } from './Track'; import { ImportTrackDialog } from './ImportTrackDialog'; import type { Track as TrackType } from '@/types/track'; import { createEffect, type EffectType, EFFECT_NAMES } from '@/lib/audio/effects/chain'; +import type { RecordingSettings } from '@/lib/hooks/useRecording'; export interface TrackListProps { tracks: TrackType[]; @@ -25,6 +26,10 @@ export interface TrackListProps { recordingTrackId?: string | null; recordingLevel?: number; trackLevels?: Record; + recordingSettings?: RecordingSettings; + onInputGainChange?: (gain: number) => void; + onRecordMonoChange?: (mono: boolean) => void; + onSampleRateChange?: (sampleRate: number) => void; } export function TrackList({ @@ -44,6 +49,10 @@ export function TrackList({ recordingTrackId, recordingLevel = 0, trackLevels = {}, + recordingSettings, + onInputGainChange, + onRecordMonoChange, + onSampleRateChange, }: TrackListProps) { const [importDialogOpen, setImportDialogOpen] = React.useState(false); @@ -167,6 +176,10 @@ export function TrackList({ isRecording={recordingTrackId === track.id} recordingLevel={recordingTrackId === track.id ? recordingLevel : 0} playbackLevel={trackLevels[track.id] || 0} + recordingSettings={recordingSettings} + onInputGainChange={onInputGainChange} + onRecordMonoChange={onRecordMonoChange} + onSampleRateChange={onSampleRateChange} /> ))} diff --git a/lib/hooks/useRecording.ts b/lib/hooks/useRecording.ts index a43cc13..f98eb9a 100644 --- a/lib/hooks/useRecording.ts +++ b/lib/hooks/useRecording.ts @@ -9,8 +9,15 @@ export interface RecordingState { inputLevel: number; } +export interface RecordingSettings { + inputGain: number; // 0.0 to 2.0 (1.0 = unity) + recordMono: boolean; // true = mono, false = stereo + sampleRate: number; // target sample rate (44100, 48000, etc.) +} + export interface UseRecordingReturn { state: RecordingState; + settings: RecordingSettings; startRecording: () => Promise; stopRecording: () => Promise; pauseRecording: () => void; @@ -18,6 +25,9 @@ export interface UseRecordingReturn { getInputDevices: () => Promise; selectInputDevice: (deviceId: string) => Promise; requestPermission: () => Promise; + setInputGain: (gain: number) => void; + setRecordMono: (mono: boolean) => void; + setSampleRate: (sampleRate: number) => void; } export function useRecording(): UseRecordingReturn { @@ -28,9 +38,16 @@ export function useRecording(): UseRecordingReturn { inputLevel: 0, }); + const [settings, setSettings] = React.useState({ + inputGain: 1.0, + recordMono: false, + sampleRate: 48000, + }); + const mediaRecorderRef = React.useRef(null); const audioContextRef = React.useRef(null); const analyserRef = React.useRef(null); + const gainNodeRef = React.useRef(null); const streamRef = React.useRef(null); const chunksRef = React.useRef([]); const startTimeRef = React.useRef(0); @@ -128,16 +145,25 @@ export function useRecording(): UseRecordingReturn { const stream = await navigator.mediaDevices.getUserMedia(constraints); streamRef.current = stream; - // Create audio context and analyser for level monitoring - const audioContext = new AudioContext(); + // Create audio context with target sample rate + const audioContext = new AudioContext({ sampleRate: settings.sampleRate }); audioContextRef.current = audioContext; const source = audioContext.createMediaStreamSource(stream); + + // Create gain node for input gain control + const gainNode = audioContext.createGain(); + gainNode.gain.value = settings.inputGain; + gainNodeRef.current = gainNode; + + // Create analyser for level monitoring const analyser = audioContext.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.3; - source.connect(analyser); + // Connect: source -> gain -> analyser + source.connect(gainNode); + gainNode.connect(analyser); analyserRef.current = analyser; // Create MediaRecorder @@ -169,7 +195,7 @@ export function useRecording(): UseRecordingReturn { console.error('Failed to start recording:', error); throw error; } - }, [monitorInputLevel]); + }, [monitorInputLevel, settings.sampleRate, settings.inputGain]); // Stop recording and return AudioBuffer const stopRecording = React.useCallback(async (): Promise => { @@ -192,7 +218,28 @@ export function useRecording(): UseRecordingReturn { try { const arrayBuffer = await blob.arrayBuffer(); const audioContext = new AudioContext(); - const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + let audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + // Convert to mono if requested + if (settings.recordMono && audioBuffer.numberOfChannels > 1) { + const monoBuffer = audioContext.createBuffer( + 1, + audioBuffer.length, + audioBuffer.sampleRate + ); + const monoData = monoBuffer.getChannelData(0); + + // Mix all channels to mono by averaging + for (let i = 0; i < audioBuffer.length; i++) { + let sum = 0; + for (let channel = 0; channel < audioBuffer.numberOfChannels; channel++) { + sum += audioBuffer.getChannelData(channel)[i]; + } + monoData[i] = sum / audioBuffer.numberOfChannels; + } + + audioBuffer = monoBuffer; + } // Clean up isMonitoringRef.current = false; @@ -219,7 +266,7 @@ export function useRecording(): UseRecordingReturn { mediaRecorder.stop(); }); - }, []); + }, [settings.recordMono]); // Pause recording const pauseRecording = React.useCallback(() => { @@ -272,8 +319,26 @@ export function useRecording(): UseRecordingReturn { }; }, []); + // Settings setters + const setInputGain = React.useCallback((gain: number) => { + setSettings((prev) => ({ ...prev, inputGain: Math.max(0, Math.min(2, gain)) })); + // Update gain node if recording + if (gainNodeRef.current) { + gainNodeRef.current.gain.value = Math.max(0, Math.min(2, gain)); + } + }, []); + + const setRecordMono = React.useCallback((mono: boolean) => { + setSettings((prev) => ({ ...prev, recordMono: mono })); + }, []); + + const setSampleRate = React.useCallback((sampleRate: number) => { + setSettings((prev) => ({ ...prev, sampleRate })); + }, []); + return { state, + settings, startRecording, stopRecording, pauseRecording, @@ -281,5 +346,8 @@ export function useRecording(): UseRecordingReturn { getInputDevices, selectInputDevice, requestPermission, + setInputGain, + setRecordMono, + setSampleRate, }; }