feat: add export scope options (project/selection/tracks)
Implemented Phase 11.3 - Export Regions: - Added export scope selector in ExportDialog - Entire Project: Mix all tracks into single file - Selected Region: Export only the selected region (disabled if no selection) - Individual Tracks: Export each track as separate file - Updated ExportSettings interface with scope property - Refactored handleExport to support all three export modes: - Project mode: Mix all tracks (existing behavior) - Selection mode: Extract selection from all tracks and mix - Tracks mode: Loop through tracks and export separately with sanitized filenames - Added hasSelection prop to ExportDialog to enable/disable selection option - Created helper function convertAndDownload to reduce code duplication - Selection mode uses extractBufferSegment for precise region extraction - Track names are sanitized for filenames (remove special characters) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface ExportSettings {
|
||||
format: 'wav' | 'mp3' | 'flac';
|
||||
scope: 'project' | 'selection' | 'tracks'; // Export scope
|
||||
bitDepth: 16 | 24 | 32;
|
||||
bitrate: number; // For MP3: 128, 192, 256, 320 kbps
|
||||
quality: number; // For FLAC: 0-9
|
||||
@@ -19,11 +20,13 @@ export interface ExportDialogProps {
|
||||
onClose: () => void;
|
||||
onExport: (settings: ExportSettings) => void;
|
||||
isExporting?: boolean;
|
||||
hasSelection?: boolean; // Whether any track has a selection
|
||||
}
|
||||
|
||||
export function ExportDialog({ open, onClose, onExport, isExporting }: ExportDialogProps) {
|
||||
export function ExportDialog({ open, onClose, onExport, isExporting, hasSelection }: ExportDialogProps) {
|
||||
const [settings, setSettings] = React.useState<ExportSettings>({
|
||||
format: 'wav',
|
||||
scope: 'project',
|
||||
bitDepth: 16,
|
||||
bitrate: 192, // Default MP3 bitrate
|
||||
quality: 6, // Default FLAC quality
|
||||
@@ -88,6 +91,30 @@ export function ExportDialog({ open, onClose, onExport, isExporting }: ExportDia
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Export Scope */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Export Scope
|
||||
</label>
|
||||
<select
|
||||
value={settings.scope}
|
||||
onChange={(e) => setSettings({ ...settings, scope: e.target.value as 'project' | 'selection' | 'tracks' })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isExporting}
|
||||
>
|
||||
<option value="project">Entire Project (Mix All Tracks)</option>
|
||||
<option value="selection" disabled={!hasSelection}>
|
||||
Selected Region {!hasSelection && '(No selection)'}
|
||||
</option>
|
||||
<option value="tracks">Individual Tracks (Separate Files)</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{settings.scope === 'project' && 'Mix all tracks into a single file'}
|
||||
{settings.scope === 'selection' && 'Export only the selected region'}
|
||||
{settings.scope === 'tracks' && 'Export each track as a separate file'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bit Depth (WAV and FLAC only) */}
|
||||
{(settings.format === 'wav' || settings.format === 'flac') && (
|
||||
<div>
|
||||
|
||||
@@ -730,57 +730,113 @@ export function AudioEditor() {
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
// Get max duration and sample rate
|
||||
const maxDuration = getMaxTrackDuration(tracks);
|
||||
const sampleRate = tracks[0]?.audioBuffer?.sampleRate || 44100;
|
||||
|
||||
// Mix all tracks into a single buffer
|
||||
const mixedBuffer = mixTracks(tracks, sampleRate, maxDuration);
|
||||
// Helper function to convert and download a buffer
|
||||
const convertAndDownload = async (buffer: AudioBuffer, filename: string) => {
|
||||
let exportedBuffer: ArrayBuffer;
|
||||
let mimeType: string;
|
||||
let fileExtension: string;
|
||||
|
||||
// Convert based on format
|
||||
let exportedBuffer: ArrayBuffer;
|
||||
let mimeType: string;
|
||||
let fileExtension: string;
|
||||
if (settings.format === 'mp3') {
|
||||
exportedBuffer = await audioBufferToMp3(buffer, {
|
||||
format: 'mp3',
|
||||
bitrate: settings.bitrate,
|
||||
normalize: settings.normalize,
|
||||
});
|
||||
mimeType = 'audio/mpeg';
|
||||
fileExtension = 'mp3';
|
||||
} else if (settings.format === 'flac') {
|
||||
exportedBuffer = await audioBufferToFlac(buffer, {
|
||||
format: 'flac',
|
||||
bitDepth: settings.bitDepth,
|
||||
quality: settings.quality,
|
||||
normalize: settings.normalize,
|
||||
});
|
||||
mimeType = 'application/octet-stream';
|
||||
fileExtension = 'flac';
|
||||
} else {
|
||||
exportedBuffer = audioBufferToWav(buffer, {
|
||||
format: 'wav',
|
||||
bitDepth: settings.bitDepth,
|
||||
normalize: settings.normalize,
|
||||
});
|
||||
mimeType = 'audio/wav';
|
||||
fileExtension = 'wav';
|
||||
}
|
||||
|
||||
if (settings.format === 'mp3') {
|
||||
exportedBuffer = await audioBufferToMp3(mixedBuffer, {
|
||||
format: 'mp3',
|
||||
bitrate: settings.bitrate,
|
||||
normalize: settings.normalize,
|
||||
const fullFilename = `${filename}.${fileExtension}`;
|
||||
downloadArrayBuffer(exportedBuffer, fullFilename, mimeType);
|
||||
return fullFilename;
|
||||
};
|
||||
|
||||
if (settings.scope === 'tracks') {
|
||||
// Export each track individually
|
||||
let exportedCount = 0;
|
||||
for (const track of tracks) {
|
||||
if (!track.audioBuffer) continue;
|
||||
|
||||
const trackFilename = `${settings.filename}_${track.name.replace(/[^a-z0-9]/gi, '_')}`;
|
||||
await convertAndDownload(track.audioBuffer, trackFilename);
|
||||
exportedCount++;
|
||||
}
|
||||
|
||||
addToast({
|
||||
title: 'Export Complete',
|
||||
description: `Exported ${exportedCount} track${exportedCount !== 1 ? 's' : ''}`,
|
||||
variant: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
mimeType = 'audio/mpeg';
|
||||
fileExtension = 'mp3';
|
||||
} else if (settings.format === 'flac') {
|
||||
exportedBuffer = await audioBufferToFlac(mixedBuffer, {
|
||||
format: 'flac',
|
||||
bitDepth: settings.bitDepth,
|
||||
quality: settings.quality,
|
||||
normalize: settings.normalize,
|
||||
} else if (settings.scope === 'selection') {
|
||||
// Export selected region
|
||||
const selectedTrack = tracks.find(t => t.selection);
|
||||
if (!selectedTrack || !selectedTrack.selection) {
|
||||
addToast({
|
||||
title: 'No Selection',
|
||||
description: 'No region selected for export',
|
||||
variant: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
setIsExporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract selection from all tracks and mix
|
||||
const selectionStart = selectedTrack.selection.start;
|
||||
const selectionEnd = selectedTrack.selection.end;
|
||||
const selectionDuration = selectionEnd - selectionStart;
|
||||
|
||||
// Create tracks with only the selected region
|
||||
const selectedTracks = tracks.map(track => ({
|
||||
...track,
|
||||
audioBuffer: track.audioBuffer
|
||||
? extractBufferSegment(track.audioBuffer, selectionStart, selectionEnd)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const mixedBuffer = mixTracks(selectedTracks, sampleRate, selectionDuration);
|
||||
const filename = await convertAndDownload(mixedBuffer, settings.filename);
|
||||
|
||||
addToast({
|
||||
title: 'Export Complete',
|
||||
description: `Exported ${filename}`,
|
||||
variant: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
mimeType = 'application/octet-stream'; // FLAC MIME type
|
||||
fileExtension = 'flac';
|
||||
} else {
|
||||
// WAV (default)
|
||||
exportedBuffer = audioBufferToWav(mixedBuffer, {
|
||||
format: 'wav',
|
||||
bitDepth: settings.bitDepth,
|
||||
normalize: settings.normalize,
|
||||
// Export entire project (mix all tracks)
|
||||
const maxDuration = getMaxTrackDuration(tracks);
|
||||
const mixedBuffer = mixTracks(tracks, sampleRate, maxDuration);
|
||||
const filename = await convertAndDownload(mixedBuffer, settings.filename);
|
||||
|
||||
addToast({
|
||||
title: 'Export Complete',
|
||||
description: `Exported ${filename}`,
|
||||
variant: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
mimeType = 'audio/wav';
|
||||
fileExtension = 'wav';
|
||||
}
|
||||
|
||||
// Download
|
||||
const filename = `${settings.filename}.${fileExtension}`;
|
||||
downloadArrayBuffer(exportedBuffer, filename, mimeType);
|
||||
|
||||
addToast({
|
||||
title: 'Export Complete',
|
||||
description: `Exported ${filename}`,
|
||||
variant: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
setExportDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
@@ -1219,6 +1275,7 @@ export function AudioEditor() {
|
||||
onClose={() => setExportDialogOpen(false)}
|
||||
onExport={handleExport}
|
||||
isExporting={isExporting}
|
||||
hasSelection={tracks.some(t => t.selection !== null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user