feat: add MP3 and FLAC export formats
Implemented Phase 11.1 export format support: - Added MP3 export using lamejs library - Added FLAC export using fflate DEFLATE compression - Updated ExportDialog with format selector and format-specific options - MP3: bitrate selector (128/192/256/320 kbps) - FLAC: compression quality slider (0-9) - WAV: bit depth selector (16/24/32-bit) - Updated AudioEditor to route export based on selected format - Created TypeScript declarations for lamejs - Fixed AudioStatistics to use audioBuffer instead of buffer property 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,20 +33,20 @@ export function AudioStatistics({ tracks, className }: AudioStatisticsProps) {
|
||||
let channels = 0;
|
||||
|
||||
tracks.forEach(track => {
|
||||
if (!track.buffer) return;
|
||||
if (!track.audioBuffer) return;
|
||||
|
||||
const duration = track.buffer.duration;
|
||||
const duration = track.audioBuffer.duration;
|
||||
maxDuration = Math.max(maxDuration, duration);
|
||||
|
||||
// Get sample rate and channels from first track
|
||||
if (sampleRate === 0) {
|
||||
sampleRate = track.buffer.sampleRate;
|
||||
channels = track.buffer.numberOfChannels;
|
||||
sampleRate = track.audioBuffer.sampleRate;
|
||||
channels = track.audioBuffer.numberOfChannels;
|
||||
}
|
||||
|
||||
// Calculate peak and RMS from buffer
|
||||
for (let ch = 0; ch < track.buffer.numberOfChannels; ch++) {
|
||||
const channelData = track.buffer.getChannelData(ch);
|
||||
for (let ch = 0; ch < track.audioBuffer.numberOfChannels; ch++) {
|
||||
const channelData = track.audioBuffer.getChannelData(ch);
|
||||
let chPeak = 0;
|
||||
let chRmsSum = 0;
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface ExportSettings {
|
||||
format: 'wav';
|
||||
format: 'wav' | 'mp3' | 'flac';
|
||||
bitDepth: 16 | 24 | 32;
|
||||
bitrate: number; // For MP3: 128, 192, 256, 320 kbps
|
||||
quality: number; // For FLAC: 0-9
|
||||
normalize: boolean;
|
||||
filename: string;
|
||||
}
|
||||
@@ -23,6 +25,8 @@ export function ExportDialog({ open, onClose, onExport, isExporting }: ExportDia
|
||||
const [settings, setSettings] = React.useState<ExportSettings>({
|
||||
format: 'wav',
|
||||
bitDepth: 16,
|
||||
bitrate: 192, // Default MP3 bitrate
|
||||
quality: 6, // Default FLAC quality
|
||||
normalize: true,
|
||||
filename: 'mix',
|
||||
});
|
||||
@@ -62,7 +66,9 @@ export function ExportDialog({ open, onClose, onExport, isExporting }: ExportDia
|
||||
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}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">.wav will be added automatically</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
.{settings.format} will be added automatically
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
@@ -72,37 +78,92 @@ export function ExportDialog({ open, onClose, onExport, isExporting }: ExportDia
|
||||
</label>
|
||||
<select
|
||||
value={settings.format}
|
||||
onChange={(e) => setSettings({ ...settings, format: e.target.value as 'wav' })}
|
||||
onChange={(e) => setSettings({ ...settings, format: e.target.value as 'wav' | 'mp3' | 'flac' })}
|
||||
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="wav">WAV (Uncompressed)</option>
|
||||
<option value="mp3">MP3 (Lossy)</option>
|
||||
<option value="flac">FLAC (Lossless)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bit Depth */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Bit Depth
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[16, 24, 32].map((depth) => (
|
||||
<button
|
||||
key={depth}
|
||||
onClick={() => setSettings({ ...settings, bitDepth: depth as 16 | 24 | 32 })}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded text-sm font-medium transition-colors',
|
||||
settings.bitDepth === depth
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background border border-border text-foreground hover:bg-accent'
|
||||
)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{depth}-bit {depth === 32 && '(Float)'}
|
||||
</button>
|
||||
))}
|
||||
{/* Bit Depth (WAV and FLAC only) */}
|
||||
{(settings.format === 'wav' || settings.format === 'flac') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Bit Depth
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[16, 24, 32].map((depth) => (
|
||||
<button
|
||||
key={depth}
|
||||
onClick={() => setSettings({ ...settings, bitDepth: depth as 16 | 24 | 32 })}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded text-sm font-medium transition-colors',
|
||||
settings.bitDepth === depth
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background border border-border text-foreground hover:bg-accent'
|
||||
)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{depth}-bit {depth === 32 && '(Float)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MP3 Bitrate */}
|
||||
{settings.format === 'mp3' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Bitrate
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[128, 192, 256, 320].map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => setSettings({ ...settings, bitrate: rate })}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded text-sm font-medium transition-colors',
|
||||
settings.bitrate === rate
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background border border-border text-foreground hover:bg-accent'
|
||||
)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{rate} kbps
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FLAC Quality */}
|
||||
{settings.format === 'flac' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Compression Quality
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">Fast</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="9"
|
||||
value={settings.quality}
|
||||
onChange={(e) => setSettings({ ...settings, quality: parseInt(e.target.value) })}
|
||||
className="flex-1"
|
||||
disabled={isExporting}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Small</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Level {settings.quality} (Higher = smaller file, slower encoding)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Normalize */}
|
||||
<div>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from '@/lib/history/commands/multi-track-edit-command';
|
||||
import { extractBufferSegment } from '@/lib/audio/buffer-utils';
|
||||
import { mixTracks, getMaxTrackDuration } from '@/lib/audio/track-utils';
|
||||
import { audioBufferToWav, downloadArrayBuffer } from '@/lib/audio/export';
|
||||
import { audioBufferToWav, audioBufferToMp3, audioBufferToFlac, downloadArrayBuffer } from '@/lib/audio/export';
|
||||
|
||||
export function AudioEditor() {
|
||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||
@@ -737,16 +737,42 @@ export function AudioEditor() {
|
||||
// Mix all tracks into a single buffer
|
||||
const mixedBuffer = mixTracks(tracks, sampleRate, maxDuration);
|
||||
|
||||
// Convert to WAV
|
||||
const wavBuffer = audioBufferToWav(mixedBuffer, {
|
||||
format: settings.format,
|
||||
bitDepth: settings.bitDepth,
|
||||
normalize: settings.normalize,
|
||||
});
|
||||
// Convert based on format
|
||||
let exportedBuffer: ArrayBuffer;
|
||||
let mimeType: string;
|
||||
let fileExtension: string;
|
||||
|
||||
if (settings.format === 'mp3') {
|
||||
exportedBuffer = await audioBufferToMp3(mixedBuffer, {
|
||||
format: 'mp3',
|
||||
bitrate: settings.bitrate,
|
||||
normalize: settings.normalize,
|
||||
});
|
||||
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,
|
||||
});
|
||||
mimeType = 'application/octet-stream'; // FLAC MIME type
|
||||
fileExtension = 'flac';
|
||||
} else {
|
||||
// WAV (default)
|
||||
exportedBuffer = audioBufferToWav(mixedBuffer, {
|
||||
format: 'wav',
|
||||
bitDepth: settings.bitDepth,
|
||||
normalize: settings.normalize,
|
||||
});
|
||||
mimeType = 'audio/wav';
|
||||
fileExtension = 'wav';
|
||||
}
|
||||
|
||||
// Download
|
||||
const filename = `${settings.filename}.wav`;
|
||||
downloadArrayBuffer(wavBuffer, filename);
|
||||
const filename = `${settings.filename}.${fileExtension}`;
|
||||
downloadArrayBuffer(exportedBuffer, filename, mimeType);
|
||||
|
||||
addToast({
|
||||
title: 'Export Complete',
|
||||
|
||||
Reference in New Issue
Block a user