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:
2025-11-19 07:47:56 +01:00
parent c6ff313050
commit 38a2b2962d
2 changed files with 126 additions and 42 deletions

View File

@@ -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>

View File

@@ -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)}
/>
</>
);