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 {
|
export interface ExportSettings {
|
||||||
format: 'wav' | 'mp3' | 'flac';
|
format: 'wav' | 'mp3' | 'flac';
|
||||||
|
scope: 'project' | 'selection' | 'tracks'; // Export scope
|
||||||
bitDepth: 16 | 24 | 32;
|
bitDepth: 16 | 24 | 32;
|
||||||
bitrate: number; // For MP3: 128, 192, 256, 320 kbps
|
bitrate: number; // For MP3: 128, 192, 256, 320 kbps
|
||||||
quality: number; // For FLAC: 0-9
|
quality: number; // For FLAC: 0-9
|
||||||
@@ -19,11 +20,13 @@ export interface ExportDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onExport: (settings: ExportSettings) => void;
|
onExport: (settings: ExportSettings) => void;
|
||||||
isExporting?: boolean;
|
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>({
|
const [settings, setSettings] = React.useState<ExportSettings>({
|
||||||
format: 'wav',
|
format: 'wav',
|
||||||
|
scope: 'project',
|
||||||
bitDepth: 16,
|
bitDepth: 16,
|
||||||
bitrate: 192, // Default MP3 bitrate
|
bitrate: 192, // Default MP3 bitrate
|
||||||
quality: 6, // Default FLAC quality
|
quality: 6, // Default FLAC quality
|
||||||
@@ -88,6 +91,30 @@ export function ExportDialog({ open, onClose, onExport, isExporting }: ExportDia
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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) */}
|
{/* Bit Depth (WAV and FLAC only) */}
|
||||||
{(settings.format === 'wav' || settings.format === 'flac') && (
|
{(settings.format === 'wav' || settings.format === 'flac') && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -730,57 +730,113 @@ export function AudioEditor() {
|
|||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get max duration and sample rate
|
|
||||||
const maxDuration = getMaxTrackDuration(tracks);
|
|
||||||
const sampleRate = tracks[0]?.audioBuffer?.sampleRate || 44100;
|
const sampleRate = tracks[0]?.audioBuffer?.sampleRate || 44100;
|
||||||
|
|
||||||
// Mix all tracks into a single buffer
|
// Helper function to convert and download a buffer
|
||||||
const mixedBuffer = mixTracks(tracks, sampleRate, maxDuration);
|
const convertAndDownload = async (buffer: AudioBuffer, filename: string) => {
|
||||||
|
let exportedBuffer: ArrayBuffer;
|
||||||
|
let mimeType: string;
|
||||||
|
let fileExtension: string;
|
||||||
|
|
||||||
// Convert based on format
|
if (settings.format === 'mp3') {
|
||||||
let exportedBuffer: ArrayBuffer;
|
exportedBuffer = await audioBufferToMp3(buffer, {
|
||||||
let mimeType: string;
|
format: 'mp3',
|
||||||
let fileExtension: string;
|
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') {
|
const fullFilename = `${filename}.${fileExtension}`;
|
||||||
exportedBuffer = await audioBufferToMp3(mixedBuffer, {
|
downloadArrayBuffer(exportedBuffer, fullFilename, mimeType);
|
||||||
format: 'mp3',
|
return fullFilename;
|
||||||
bitrate: settings.bitrate,
|
};
|
||||||
normalize: settings.normalize,
|
|
||||||
|
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';
|
} else if (settings.scope === 'selection') {
|
||||||
fileExtension = 'mp3';
|
// Export selected region
|
||||||
} else if (settings.format === 'flac') {
|
const selectedTrack = tracks.find(t => t.selection);
|
||||||
exportedBuffer = await audioBufferToFlac(mixedBuffer, {
|
if (!selectedTrack || !selectedTrack.selection) {
|
||||||
format: 'flac',
|
addToast({
|
||||||
bitDepth: settings.bitDepth,
|
title: 'No Selection',
|
||||||
quality: settings.quality,
|
description: 'No region selected for export',
|
||||||
normalize: settings.normalize,
|
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 {
|
} else {
|
||||||
// WAV (default)
|
// Export entire project (mix all tracks)
|
||||||
exportedBuffer = audioBufferToWav(mixedBuffer, {
|
const maxDuration = getMaxTrackDuration(tracks);
|
||||||
format: 'wav',
|
const mixedBuffer = mixTracks(tracks, sampleRate, maxDuration);
|
||||||
bitDepth: settings.bitDepth,
|
const filename = await convertAndDownload(mixedBuffer, settings.filename);
|
||||||
normalize: settings.normalize,
|
|
||||||
|
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);
|
setExportDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Export failed:', error);
|
console.error('Export failed:', error);
|
||||||
@@ -1219,6 +1275,7 @@ export function AudioEditor() {
|
|||||||
onClose={() => setExportDialogOpen(false)}
|
onClose={() => setExportDialogOpen(false)}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
|
hasSelection={tracks.some(t => t.selection !== null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user