feat: integrate multi-track functionality into main AudioEditor
Added comprehensive multi-track support to the main application: - Added "Tracks" tab to SidePanel with track management controls - Integrated useMultiTrack and useMultiTrackPlayer hooks into AudioEditor - Added view mode switching between waveform and tracks views - Implemented "Convert to Track" to convert current audio buffer to track - Added TrackList view with multi-track playback controls - Wired up ImportTrackDialog for importing multiple audio files Users can now: - Click "Tracks" tab in side panel to access multi-track mode - Convert current audio to a track - Import multiple audio files as tracks - View and manage tracks in dedicated TrackList view - Play multiple tracks simultaneously with individual controls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import type { CommandAction } from '@/components/ui/CommandPalette';
|
|||||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||||
import { useHistory } from '@/lib/hooks/useHistory';
|
import { useHistory } from '@/lib/hooks/useHistory';
|
||||||
import { useEffectChain } from '@/lib/hooks/useEffectChain';
|
import { useEffectChain } from '@/lib/hooks/useEffectChain';
|
||||||
|
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
|
||||||
|
import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { Slider } from '@/components/ui/Slider';
|
import { Slider } from '@/components/ui/Slider';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
@@ -52,6 +54,8 @@ import { EffectParameterDialog, type FilterParameters } from '@/components/effec
|
|||||||
import { DynamicsParameterDialog, type DynamicsParameters, type DynamicsType } from '@/components/effects/DynamicsParameterDialog';
|
import { DynamicsParameterDialog, type DynamicsParameters, type DynamicsType } from '@/components/effects/DynamicsParameterDialog';
|
||||||
import { TimeBasedParameterDialog, type TimeBasedParameters, type TimeBasedType } from '@/components/effects/TimeBasedParameterDialog';
|
import { TimeBasedParameterDialog, type TimeBasedParameters, type TimeBasedType } from '@/components/effects/TimeBasedParameterDialog';
|
||||||
import { AdvancedParameterDialog, type AdvancedParameters, type AdvancedType } from '@/components/effects/AdvancedParameterDialog';
|
import { AdvancedParameterDialog, type AdvancedParameters, type AdvancedType } from '@/components/effects/AdvancedParameterDialog';
|
||||||
|
import { TrackList } from '@/components/tracks/TrackList';
|
||||||
|
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
|
||||||
|
|
||||||
const EFFECT_LABELS: Record<string, string> = {
|
const EFFECT_LABELS: Record<string, string> = {
|
||||||
lowpass: 'Low-Pass Filter',
|
lowpass: 'Low-Pass Filter',
|
||||||
@@ -76,6 +80,10 @@ const EFFECT_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AudioEditor() {
|
export function AudioEditor() {
|
||||||
|
// View mode state
|
||||||
|
const [viewMode, setViewMode] = React.useState<'waveform' | 'tracks'>('waveform');
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
// Zoom and scroll state
|
// Zoom and scroll state
|
||||||
const [zoom, setZoom] = React.useState(1);
|
const [zoom, setZoom] = React.useState(1);
|
||||||
const [scrollOffset, setScrollOffset] = React.useState(0);
|
const [scrollOffset, setScrollOffset] = React.useState(0);
|
||||||
@@ -141,6 +149,27 @@ export function AudioEditor() {
|
|||||||
} = useEffectChain();
|
} = useEffectChain();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
// Multi-track hooks
|
||||||
|
const {
|
||||||
|
tracks,
|
||||||
|
addTrack,
|
||||||
|
addTrackFromBuffer,
|
||||||
|
removeTrack,
|
||||||
|
updateTrack,
|
||||||
|
clearTracks,
|
||||||
|
} = useMultiTrack();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isPlaying: isMultiTrackPlaying,
|
||||||
|
currentTime: multiTrackCurrentTime,
|
||||||
|
duration: multiTrackDuration,
|
||||||
|
play: playMultiTrack,
|
||||||
|
pause: pauseMultiTrack,
|
||||||
|
stop: stopMultiTrack,
|
||||||
|
seek: seekMultiTrack,
|
||||||
|
togglePlayPause: toggleMultiTrackPlayPause,
|
||||||
|
} = useMultiTrackPlayer(tracks);
|
||||||
|
|
||||||
const handleFileSelect = async (file: File) => {
|
const handleFileSelect = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
await loadFile(file);
|
await loadFile(file);
|
||||||
@@ -176,6 +205,41 @@ export function AudioEditor() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Multi-track handlers
|
||||||
|
const handleConvertToTrack = () => {
|
||||||
|
if (!audioBuffer) return;
|
||||||
|
|
||||||
|
const trackName = fileName || 'Audio Track';
|
||||||
|
addTrackFromBuffer(audioBuffer, trackName);
|
||||||
|
setViewMode('tracks');
|
||||||
|
addToast({
|
||||||
|
title: 'Converted to Track',
|
||||||
|
description: `"${trackName}" added to tracks`,
|
||||||
|
variant: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportTracks = () => {
|
||||||
|
setImportDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
|
||||||
|
addTrackFromBuffer(buffer, name);
|
||||||
|
setViewMode('tracks');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearTracks = () => {
|
||||||
|
clearTracks();
|
||||||
|
setViewMode('waveform');
|
||||||
|
addToast({
|
||||||
|
title: 'Tracks Cleared',
|
||||||
|
description: 'All tracks have been removed',
|
||||||
|
variant: 'info',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Drag and drop handlers
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1321,6 +1385,11 @@ export function AudioEditor() {
|
|||||||
onTimeStretch={handleTimeStretch}
|
onTimeStretch={handleTimeStretch}
|
||||||
onDistortion={handleDistortion}
|
onDistortion={handleDistortion}
|
||||||
onBitcrusher={handleBitcrusher}
|
onBitcrusher={handleBitcrusher}
|
||||||
|
tracks={tracks}
|
||||||
|
onAddTrack={addTrack}
|
||||||
|
onImportTracks={handleImportTracks}
|
||||||
|
onConvertToTrack={handleConvertToTrack}
|
||||||
|
onClearTracks={handleClearTracks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main canvas area */}
|
{/* Main canvas area */}
|
||||||
@@ -1332,6 +1401,41 @@ export function AudioEditor() {
|
|||||||
<p className="text-sm text-muted-foreground">Loading audio file...</p>
|
<p className="text-sm text-muted-foreground">Loading audio file...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : viewMode === 'tracks' ? (
|
||||||
|
<>
|
||||||
|
{/* Multi-Track View */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<TrackList
|
||||||
|
tracks={tracks}
|
||||||
|
zoom={zoom}
|
||||||
|
currentTime={multiTrackCurrentTime}
|
||||||
|
duration={multiTrackDuration}
|
||||||
|
onAddTrack={addTrack}
|
||||||
|
onImportTrack={handleImportTrack}
|
||||||
|
onRemoveTrack={removeTrack}
|
||||||
|
onUpdateTrack={updateTrack}
|
||||||
|
onSeek={seekMultiTrack}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multi-Track Playback Controls */}
|
||||||
|
<div className="border-t border-border bg-card p-3">
|
||||||
|
<PlaybackControls
|
||||||
|
isPlaying={isMultiTrackPlaying}
|
||||||
|
isPaused={!isMultiTrackPlaying}
|
||||||
|
currentTime={multiTrackCurrentTime}
|
||||||
|
duration={multiTrackDuration}
|
||||||
|
volume={1}
|
||||||
|
onPlay={playMultiTrack}
|
||||||
|
onPause={pauseMultiTrack}
|
||||||
|
onStop={stopMultiTrack}
|
||||||
|
onSeek={seekMultiTrack}
|
||||||
|
onVolumeChange={() => {}}
|
||||||
|
currentTimeFormatted={`${Math.floor(multiTrackCurrentTime / 60)}:${String(Math.floor(multiTrackCurrentTime % 60)).padStart(2, '0')}`}
|
||||||
|
durationFormatted={`${Math.floor(multiTrackDuration / 60)}:${String(Math.floor(multiTrackDuration % 60)).padStart(2, '0')}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : audioBuffer ? (
|
) : audioBuffer ? (
|
||||||
<>
|
<>
|
||||||
{/* Waveform - takes maximum space */}
|
{/* Waveform - takes maximum space */}
|
||||||
@@ -1450,6 +1554,13 @@ export function AudioEditor() {
|
|||||||
effectType={advancedDialogType}
|
effectType={advancedDialogType}
|
||||||
onApply={handleAdvancedApply}
|
onApply={handleAdvancedApply}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Import Track Dialog */}
|
||||||
|
<ImportTrackDialog
|
||||||
|
open={importDialogOpen}
|
||||||
|
onClose={() => setImportDialogOpen(false)}
|
||||||
|
onImportTrack={handleImportTrack}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
Link2,
|
Link2,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
@@ -23,6 +25,7 @@ import type { HistoryState } from '@/lib/history/history-manager';
|
|||||||
import type { EffectChain, ChainEffect, EffectPreset } from '@/lib/audio/effects/chain';
|
import type { EffectChain, ChainEffect, EffectPreset } from '@/lib/audio/effects/chain';
|
||||||
import { EffectRack } from '@/components/effects/EffectRack';
|
import { EffectRack } from '@/components/effects/EffectRack';
|
||||||
import { PresetManager } from '@/components/effects/PresetManager';
|
import { PresetManager } from '@/components/effects/PresetManager';
|
||||||
|
import type { Track } from '@/types/track';
|
||||||
|
|
||||||
export interface SidePanelProps {
|
export interface SidePanelProps {
|
||||||
// File info
|
// File info
|
||||||
@@ -69,6 +72,13 @@ export interface SidePanelProps {
|
|||||||
onDistortion: () => void;
|
onDistortion: () => void;
|
||||||
onBitcrusher: () => void;
|
onBitcrusher: () => void;
|
||||||
|
|
||||||
|
// Multi-track
|
||||||
|
tracks?: Track[];
|
||||||
|
onAddTrack?: () => void;
|
||||||
|
onImportTracks?: () => void;
|
||||||
|
onConvertToTrack?: () => void;
|
||||||
|
onClearTracks?: () => void;
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +117,15 @@ export function SidePanel({
|
|||||||
onTimeStretch,
|
onTimeStretch,
|
||||||
onDistortion,
|
onDistortion,
|
||||||
onBitcrusher,
|
onBitcrusher,
|
||||||
|
tracks,
|
||||||
|
onAddTrack,
|
||||||
|
onImportTracks,
|
||||||
|
onConvertToTrack,
|
||||||
|
onClearTracks,
|
||||||
className,
|
className,
|
||||||
}: SidePanelProps) {
|
}: SidePanelProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||||
const [activeTab, setActiveTab] = React.useState<'file' | 'chain' | 'history' | 'info' | 'effects'>('file');
|
const [activeTab, setActiveTab] = React.useState<'file' | 'chain' | 'history' | 'info' | 'effects' | 'tracks'>('file');
|
||||||
const [presetDialogOpen, setPresetDialogOpen] = React.useState(false);
|
const [presetDialogOpen, setPresetDialogOpen] = React.useState(false);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -174,6 +189,14 @@ export function SidePanel({
|
|||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'tracks' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setActiveTab('tracks')}
|
||||||
|
title="Tracks"
|
||||||
|
>
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === 'history' ? 'secondary' : 'ghost'}
|
variant={activeTab === 'history' ? 'secondary' : 'ghost'}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -591,6 +614,78 @@ export function SidePanel({
|
|||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user