feat: refactor to multi-track only editor with sidebar controls

Major refactor to simplify the editor and focus exclusively on multi-track editing:

**AudioEditor Changes:**
- Removed single-file waveform view and useAudioPlayer
- Removed all single-file editing operations (cut, copy, paste, trim)
- Simplified to multi-track only with track selection support
- Added selectedTrackId state for track-specific effect chain
- Reduced from ~1500 lines to ~290 lines

**SidePanel Changes:**
- Complete rewrite with 2 tabs: Tracks and Effect Chain
- Tracks tab shows all tracks with inline controls (volume, pan, solo, mute)
- Click tracks to select/deselect
- Effect Chain tab shows effects for selected track
- Removed old file/history/info/effects tabs
- Sidebar now wider (320px) to accommodate inline track controls

**TrackList/Track Changes:**
- Added track selection support (isSelected/onSelect props)
- Visual feedback with ring border when track is selected
- Click anywhere on track to select it

**Workflow:**
1. Import or add audio tracks
2. Select a track in the sidebar or main view
3. Apply effects to selected track via Effect Chain tab
4. Adjust track controls (volume, pan, solo, mute) in Tracks tab

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-17 22:17:09 +01:00
parent 4735b5fb00
commit de8a3ff187
4 changed files with 356 additions and 1946 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,43 +2,34 @@
import * as React from 'react';
import {
FileAudio,
History,
Info,
ChevronLeft,
ChevronRight,
Upload,
Download,
X,
Sparkles,
Plus,
Trash2,
Link2,
FolderOpen,
Trash2,
Layers,
Plus,
Volume2,
Music2,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Slider } from '@/components/ui/Slider';
import { cn } from '@/lib/utils/cn';
import { formatDuration } from '@/lib/audio/decoder';
import type { Selection } from '@/types/selection';
import type { HistoryState } from '@/lib/history/history-manager';
import type { EffectChain, ChainEffect, EffectPreset } from '@/lib/audio/effects/chain';
import type { Track } from '@/types/track';
import type { EffectChain, EffectPreset } from '@/lib/audio/effects/chain';
import { EffectRack } from '@/components/effects/EffectRack';
import { PresetManager } from '@/components/effects/PresetManager';
import type { Track } from '@/types/track';
export interface SidePanelProps {
// File info
fileName: string | null;
audioBuffer: AudioBuffer | null;
onFileSelect: (file: File) => void;
onClear: () => void;
// Selection info
selection: Selection | null;
// History info
historyState: HistoryState;
tracks: Track[];
selectedTrackId: string | null;
onSelectTrack: (trackId: string | null) => void;
onAddTrack: () => void;
onImportTracks: () => void;
onUpdateTrack: (trackId: string, updates: Partial<Track>) => void;
onRemoveTrack: (trackId: string) => void;
onClearTracks: () => void;
// Effect chain
effectChain: EffectChain;
@@ -51,44 +42,18 @@ export interface SidePanelProps {
onDeletePreset: (presetId: string) => void;
onClearChain: () => void;
// Effects handlers
onNormalize: () => void;
onFadeIn: () => void;
onFadeOut: () => void;
onReverse: () => void;
onLowPassFilter: () => void;
onHighPassFilter: () => void;
onBandPassFilter: () => void;
onCompressor: () => void;
onLimiter: () => void;
onGate: () => void;
onDelay: () => void;
onReverb: () => void;
onChorus: () => void;
onFlanger: () => void;
onPhaser: () => void;
onPitchShift: () => void;
onTimeStretch: () => void;
onDistortion: () => void;
onBitcrusher: () => void;
// Multi-track
tracks?: Track[];
onAddTrack?: () => void;
onImportTracks?: () => void;
onConvertToTrack?: () => void;
onClearTracks?: () => void;
className?: string;
}
export function SidePanel({
fileName,
audioBuffer,
onFileSelect,
onClear,
selection,
historyState,
tracks,
selectedTrackId,
onSelectTrack,
onAddTrack,
onImportTracks,
onUpdateTrack,
onRemoveTrack,
onClearTracks,
effectChain,
effectPresets,
onToggleEffect,
@@ -98,47 +63,13 @@ export function SidePanel({
onLoadPreset,
onDeletePreset,
onClearChain,
onNormalize,
onFadeIn,
onFadeOut,
onReverse,
onLowPassFilter,
onHighPassFilter,
onBandPassFilter,
onCompressor,
onLimiter,
onGate,
onDelay,
onReverb,
onChorus,
onFlanger,
onPhaser,
onPitchShift,
onTimeStretch,
onDistortion,
onBitcrusher,
tracks,
onAddTrack,
onImportTracks,
onConvertToTrack,
onClearTracks,
className,
}: SidePanelProps) {
const [isCollapsed, setIsCollapsed] = React.useState(false);
const [activeTab, setActiveTab] = React.useState<'file' | 'chain' | 'history' | 'info' | 'effects' | 'tracks'>('file');
const [activeTab, setActiveTab] = React.useState<'tracks' | 'chain'>('tracks');
const [presetDialogOpen, setPresetDialogOpen] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleFileClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onFileSelect(file);
}
};
const selectedTrack = tracks.find((t) => t.id === selectedTrackId);
if (isCollapsed) {
return (
@@ -161,17 +92,17 @@ export function SidePanel({
}
return (
<div className={cn('w-64 bg-card border-r border-border flex flex-col', className)}>
<div className={cn('w-80 bg-card border-r border-border flex flex-col', className)}>
{/* Header */}
<div className="flex items-center justify-between p-2 border-b border-border">
<div className="flex items-center gap-1">
<Button
variant={activeTab === 'file' ? 'secondary' : 'ghost'}
variant={activeTab === 'tracks' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('file')}
title="File"
onClick={() => setActiveTab('tracks')}
title="Tracks"
>
<FileAudio className="h-4 w-4" />
<Music2 className="h-4 w-4" />
</Button>
<Button
variant={activeTab === 'chain' ? 'secondary' : 'ghost'}
@@ -181,38 +112,6 @@ export function SidePanel({
>
<Link2 className="h-4 w-4" />
</Button>
<Button
variant={activeTab === 'effects' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('effects')}
title="Effects"
>
<Sparkles className="h-4 w-4" />
</Button>
<Button
variant={activeTab === 'tracks' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('tracks')}
title="Tracks"
>
<Layers className="h-4 w-4" />
</Button>
<Button
variant={activeTab === 'history' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('history')}
title="History"
>
<History className="h-4 w-4" />
</Button>
<Button
variant={activeTab === 'info' ? 'secondary' : 'ghost'}
size="icon-sm"
onClick={() => setActiveTab('info')}
title="Info"
>
<Info className="h-4 w-4" />
</Button>
</div>
<Button
variant="ghost"
@@ -226,62 +125,172 @@ export function SidePanel({
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar">
{activeTab === 'file' && (
{activeTab === 'tracks' && (
<>
{/* Track Actions */}
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Audio File
Multi-Track Editor
</h3>
{audioBuffer ? (
<div className="space-y-2">
<div className="p-2 bg-secondary/30 rounded text-xs">
<div className="font-medium text-foreground truncate" title={fileName || 'Unknown'}>
{fileName || 'Unknown'}
</div>
<div className="text-muted-foreground mt-1">
Duration: {formatDuration(audioBuffer.duration)}
</div>
<div className="text-muted-foreground">
Channels: {audioBuffer.numberOfChannels}
</div>
<div className="text-muted-foreground">
Sample Rate: {audioBuffer.sampleRate} Hz
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={onClear}
className="w-full"
>
<X className="h-3.5 w-3.5 mr-1.5" />
Clear File
</Button>
</div>
) : (
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="outline"
size="sm"
onClick={handleFileClick}
className="w-full"
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Load Audio File
</Button>
<div className="text-xs text-muted-foreground">
Or drag and drop an audio file onto the waveform area.
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onAddTrack}
className="flex-1"
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Track
</Button>
<Button
variant="outline"
size="sm"
onClick={onImportTracks}
className="flex-1"
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Import
</Button>
</div>
{tracks.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={onClearTracks}
className="w-full"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5 text-destructive" />
Clear All Tracks
</Button>
)}
</div>
{/* Track List */}
{tracks.length > 0 ? (
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Tracks ({tracks.length})
</h3>
<div className="space-y-2">
{tracks.map((track) => {
const isSelected = selectedTrackId === track.id;
return (
<div
key={track.id}
className={cn(
'p-3 rounded-lg border transition-colors cursor-pointer',
isSelected
? 'bg-primary/10 border-primary'
: 'bg-secondary/30 border-border hover:border-primary/50'
)}
onClick={() => onSelectTrack(isSelected ? null : track.id)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{track.name}
</div>
{track.audioBuffer && (
<div className="text-xs text-muted-foreground">
{formatDuration(track.audioBuffer.duration)}
</div>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
onRemoveTrack(track.id);
}}
title="Remove track"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
{/* Track Controls - Always visible */}
<div className="space-y-2">
{/* Volume */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground flex items-center gap-1">
<Volume2 className="h-3 w-3" />
Volume
</label>
<span className="text-xs text-muted-foreground">
{Math.round(track.volume * 100)}%
</span>
</div>
<Slider
value={track.volume}
onChange={(value) => onUpdateTrack(track.id, { volume: value })}
min={0}
max={2}
step={0.01}
/>
</div>
{/* Pan */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground">Pan</label>
<span className="text-xs text-muted-foreground">
{track.pan === 0
? 'C'
: track.pan < 0
? `L${Math.round(Math.abs(track.pan) * 100)}`
: `R${Math.round(track.pan * 100)}`}
</span>
</div>
<Slider
value={track.pan}
onChange={(value) => onUpdateTrack(track.id, { pan: value })}
min={-1}
max={1}
step={0.01}
/>
</div>
{/* Solo / Mute */}
<div className="flex gap-2">
<Button
variant={track.solo ? 'default' : 'outline'}
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdateTrack(track.id, { solo: !track.solo });
}}
className="flex-1 text-xs"
>
Solo
</Button>
<Button
variant={track.mute ? 'default' : 'outline'}
size="sm"
onClick={(e) => {
e.stopPropagation();
onUpdateTrack(track.id, { mute: !track.mute });
}}
className="flex-1 text-xs"
>
Mute
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
<div className="text-center py-8">
<Music2 className="h-12 w-12 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground mb-4">
No tracks yet. Add or import audio files to get started.
</p>
</div>
)}
</>
)}
@@ -290,6 +299,9 @@ export function SidePanel({
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Effect Chain
{selectedTrack && (
<span className="text-primary ml-2">({selectedTrack.name})</span>
)}
</h3>
<div className="flex gap-1">
<Button
@@ -312,380 +324,37 @@ export function SidePanel({
)}
</div>
</div>
<EffectRack
chain={effectChain}
onToggleEffect={onToggleEffect}
onRemoveEffect={onRemoveEffect}
onReorderEffects={onReorderEffects}
/>
<PresetManager
open={presetDialogOpen}
onClose={() => setPresetDialogOpen(false)}
currentChain={effectChain}
presets={effectPresets}
onSavePreset={onSavePreset}
onLoadPreset={onLoadPreset}
onDeletePreset={onDeletePreset}
onExportPreset={() => {}}
onImportPreset={(preset) => onSavePreset(preset)}
/>
</div>
)}
{activeTab === 'history' && (
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Edit History
</h3>
{historyState.historySize > 0 ? (
<div className="space-y-1 text-xs">
<div className="p-2 bg-secondary/30 rounded">
<div className="text-foreground">
{historyState.historySize} action{historyState.historySize !== 1 ? 's' : ''}
</div>
{historyState.undoDescription && (
<div className="text-muted-foreground mt-1">
Next undo: {historyState.undoDescription}
</div>
)}
{historyState.redoDescription && (
<div className="text-muted-foreground mt-1">
Next redo: {historyState.redoDescription}
</div>
)}
</div>
{!selectedTrack ? (
<div className="text-center py-8">
<Link2 className="h-12 w-12 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">
Select a track to apply effects
</p>
</div>
) : (
<div className="text-xs text-muted-foreground">
No history available. Edit operations will appear here.
</div>
<>
<EffectRack
chain={effectChain}
onToggleEffect={onToggleEffect}
onRemoveEffect={onRemoveEffect}
onReorderEffects={onReorderEffects}
/>
<PresetManager
open={presetDialogOpen}
onClose={() => setPresetDialogOpen(false)}
currentChain={effectChain}
presets={effectPresets}
onSavePreset={onSavePreset}
onLoadPreset={onLoadPreset}
onDeletePreset={onDeletePreset}
onExportPreset={() => {}}
onImportPreset={(preset) => onSavePreset(preset)}
/>
</>
)}
</div>
)}
{activeTab === 'info' && (
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Selection Info
</h3>
{selection ? (
<div className="p-2 bg-secondary/30 rounded text-xs">
<div className="text-foreground font-medium">Selection Active</div>
<div className="text-muted-foreground mt-1">
Duration: {formatDuration(selection.end - selection.start)}
</div>
<div className="text-muted-foreground">
Start: {formatDuration(selection.start)}
</div>
<div className="text-muted-foreground">
End: {formatDuration(selection.end)}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
No selection. Drag on the waveform to select a region.
</div>
)}
</div>
)}
{activeTab === 'effects' && (
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Basic Effects
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onNormalize}
className="w-full justify-start text-xs"
>
Normalize
</Button>
<Button
variant="outline"
size="sm"
onClick={onFadeIn}
className="w-full justify-start text-xs"
>
Fade In
</Button>
<Button
variant="outline"
size="sm"
onClick={onFadeOut}
className="w-full justify-start text-xs"
>
Fade Out
</Button>
<Button
variant="outline"
size="sm"
onClick={onReverse}
className="w-full justify-start text-xs"
>
Reverse
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply effects.
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Filters
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onLowPassFilter}
className="w-full justify-start text-xs"
>
Low-Pass Filter
</Button>
<Button
variant="outline"
size="sm"
onClick={onHighPassFilter}
className="w-full justify-start text-xs"
>
High-Pass Filter
</Button>
<Button
variant="outline"
size="sm"
onClick={onBandPassFilter}
className="w-full justify-start text-xs"
>
Band-Pass Filter
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply filters.
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Dynamics Processing
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onCompressor}
className="w-full justify-start text-xs"
>
Compressor
</Button>
<Button
variant="outline"
size="sm"
onClick={onLimiter}
className="w-full justify-start text-xs"
>
Limiter
</Button>
<Button
variant="outline"
size="sm"
onClick={onGate}
className="w-full justify-start text-xs"
>
Gate/Expander
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply dynamics processing.
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Time-Based Effects
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onDelay}
className="w-full justify-start text-xs"
>
Delay/Echo
</Button>
<Button
variant="outline"
size="sm"
onClick={onReverb}
className="w-full justify-start text-xs"
>
Reverb
</Button>
<Button
variant="outline"
size="sm"
onClick={onChorus}
className="w-full justify-start text-xs"
>
Chorus
</Button>
<Button
variant="outline"
size="sm"
onClick={onFlanger}
className="w-full justify-start text-xs"
>
Flanger
</Button>
<Button
variant="outline"
size="sm"
onClick={onPhaser}
className="w-full justify-start text-xs"
>
Phaser
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply time-based effects.
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Advanced Effects
</h3>
{audioBuffer ? (
<div className="space-y-1.5">
<Button
variant="outline"
size="sm"
onClick={onPitchShift}
className="w-full justify-start text-xs"
>
Pitch Shifter
</Button>
<Button
variant="outline"
size="sm"
onClick={onTimeStretch}
className="w-full justify-start text-xs"
>
Time Stretch
</Button>
<Button
variant="outline"
size="sm"
onClick={onDistortion}
className="w-full justify-start text-xs"
>
Distortion
</Button>
<Button
variant="outline"
size="sm"
onClick={onBitcrusher}
className="w-full justify-start text-xs"
>
Bitcrusher
</Button>
</div>
) : (
<div className="text-xs text-muted-foreground">
Load an audio file to apply advanced effects.
</div>
)}
</div>
</div>
)}
{activeTab === 'tracks' && (
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
Multi-Track Mode
</h3>
{tracks && tracks.length > 0 ? (
<div className="space-y-2">
<div className="p-2 bg-secondary/30 rounded text-xs">
<div className="text-foreground font-medium">
{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'} active
</div>
<div className="text-muted-foreground mt-1">
Switch to Tracks view to manage
</div>
</div>
{onClearTracks && (
<Button
variant="outline"
size="sm"
onClick={onClearTracks}
className="w-full"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5 text-destructive" />
Clear All Tracks
</Button>
)}
</div>
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
Add tracks to work with multiple audio files simultaneously.
</div>
{audioBuffer && onConvertToTrack && (
<Button
variant="outline"
size="sm"
onClick={onConvertToTrack}
className="w-full"
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Convert Current to Track
</Button>
)}
{onAddTrack && (
<Button
variant="outline"
size="sm"
onClick={onAddTrack}
className="w-full"
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Empty Track
</Button>
)}
{onImportTracks && (
<Button
variant="outline"
size="sm"
onClick={onImportTracks}
className="w-full"
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Import Audio Files
</Button>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);

View File

@@ -10,6 +10,8 @@ export interface TrackProps {
zoom: number;
currentTime: number;
duration: number;
isSelected?: boolean;
onSelect?: () => void;
onToggleMute: () => void;
onToggleSolo: () => void;
onToggleCollapse: () => void;
@@ -25,6 +27,8 @@ export function Track({
zoom,
currentTime,
duration,
isSelected,
onSelect,
onToggleMute,
onToggleSolo,
onToggleCollapse,
@@ -121,7 +125,13 @@ export function Track({
if (track.collapsed) {
return (
<div className="border-b border-border">
<div
className={cn(
'border-b border-border cursor-pointer',
isSelected && 'ring-2 ring-primary ring-inset'
)}
onClick={onSelect}
>
<TrackHeader
track={track}
onToggleMute={onToggleMute}
@@ -140,9 +150,10 @@ export function Track({
<div
ref={containerRef}
className={cn(
'border-b border-border',
track.selected && 'ring-2 ring-primary ring-inset'
'border-b border-border cursor-pointer',
isSelected && 'ring-2 ring-primary ring-inset'
)}
onClick={onSelect}
>
<TrackHeader
track={track}

View File

@@ -12,6 +12,8 @@ export interface TrackListProps {
zoom: number;
currentTime: number;
duration: number;
selectedTrackId?: string | null;
onSelectTrack?: (trackId: string | null) => void;
onAddTrack: () => void;
onImportTrack?: (buffer: AudioBuffer, name: string) => void;
onRemoveTrack: (trackId: string) => void;
@@ -24,6 +26,8 @@ export function TrackList({
zoom,
currentTime,
duration,
selectedTrackId,
onSelectTrack,
onAddTrack,
onImportTrack,
onRemoveTrack,
@@ -78,6 +82,8 @@ export function TrackList({
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() =>
onUpdateTrack(track.id, { mute: !track.mute })
}