feat: implement Phase 8.1 - audio recording infrastructure

Added recording capabilities to the multi-track editor:
- useRecording hook with MediaRecorder API integration
- Audio input device enumeration and selection
- Microphone permission handling
- Input level monitoring with RMS calculation
- InputLevelMeter component with visual feedback
- Record-enable button per track with pulsing indicator
- Real-time input level display when recording

Recording infrastructure is complete. Next: integrate into AudioEditor
for global recording control and buffer storage.

🤖 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:37:01 +01:00
parent 166385d29a
commit 5e6c61d951
3 changed files with 383 additions and 1 deletions

View File

@@ -0,0 +1,85 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export interface InputLevelMeterProps {
level: number; // 0.0 to 1.0
orientation?: 'horizontal' | 'vertical';
className?: string;
}
export function InputLevelMeter({
level,
orientation = 'horizontal',
className,
}: InputLevelMeterProps) {
// Clamp level between 0 and 1
const clampedLevel = Math.max(0, Math.min(1, level));
// Calculate color based on level
const getColor = (level: number): string => {
if (level > 0.9) return 'bg-red-500';
if (level > 0.7) return 'bg-yellow-500';
return 'bg-green-500';
};
const isHorizontal = orientation === 'horizontal';
return (
<div
className={cn(
'relative bg-muted rounded-sm overflow-hidden',
isHorizontal ? 'h-4 w-full' : 'w-4 h-full',
className
)}
>
{/* Level bar */}
<div
className={cn(
'absolute transition-all duration-75 ease-out',
getColor(clampedLevel),
isHorizontal ? 'h-full left-0 top-0' : 'w-full bottom-0 left-0'
)}
style={{
[isHorizontal ? 'width' : 'height']: `${clampedLevel * 100}%`,
}}
/>
{/* Clip indicator (at 90%) */}
{clampedLevel > 0.9 && (
<div
className={cn(
'absolute bg-red-600 animate-pulse',
isHorizontal
? 'right-0 top-0 w-1 h-full'
: 'bottom-0 left-0 h-1 w-full'
)}
/>
)}
{/* Tick marks */}
<div
className={cn(
'absolute inset-0 flex',
isHorizontal ? 'flex-row' : 'flex-col-reverse'
)}
>
{[0.25, 0.5, 0.75].map((tick) => (
<div
key={tick}
className={cn(
'absolute bg-background/30',
isHorizontal
? 'h-full w-px top-0'
: 'w-full h-px left-0'
)}
style={{
[isHorizontal ? 'left' : 'bottom']: `${tick * 100}%`,
}}
/>
))}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, CircleArrowOutUpRight, Upload, Plus } from 'lucide-react';
import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, CircleArrowOutUpRight, Upload, Plus, Mic } from 'lucide-react';
import type { Track as TrackType } from '@/types/track';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils/cn';
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';
export interface TrackProps {
track: TrackType;
@@ -31,6 +32,9 @@ export interface TrackProps {
onUpdateEffect?: (effectId: string, parameters: any) => void;
onAddEffect?: (effectType: EffectType) => void;
onSelectionChange?: (selection: { start: number; end: number } | null) => void;
onToggleRecordEnable?: () => void;
isRecording?: boolean;
recordingLevel?: number;
}
export function Track({
@@ -54,6 +58,9 @@ export function Track({
onUpdateEffect,
onAddEffect,
onSelectionChange,
onToggleRecordEnable,
isRecording = false,
recordingLevel = 0,
}: TrackProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -439,6 +446,22 @@ export function Track({
)}
</div>
{/* Record Enable Button */}
{onToggleRecordEnable && (
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleRecordEnable}
title="Arm track for recording"
className={cn(
track.recordEnabled && 'bg-red-500/20 hover:bg-red-500/30',
isRecording && 'animate-pulse'
)}
>
<Mic className={cn('h-4 w-4', track.recordEnabled && 'text-red-500')} />
</Button>
)}
{/* Solo Button */}
<Button
variant="ghost"
@@ -523,6 +546,22 @@ export function Track({
: `R${Math.round(track.pan * 100)}`}
</span>
</div>
{/* Input Level Meter (shown when recording or record-enabled) */}
{(track.recordEnabled || isRecording) && (
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground flex items-center gap-1 w-16 flex-shrink-0">
<Mic className="h-3 w-3" />
Input
</label>
<div className="flex-1">
<InputLevelMeter level={recordingLevel} orientation="horizontal" />
</div>
<span className="text-xs text-muted-foreground w-10 text-right flex-shrink-0">
{Math.round(recordingLevel * 100)}%
</span>
</div>
)}
</>
)}
</div>