feat: Initialize k-convert application with core structure, UI components, and conversion logic.

This commit is contained in:
2026-01-30 14:42:32 +01:00
commit 95ee627f1d
86 changed files with 9506 additions and 0 deletions

207
src/workers/audio.worker.ts Normal file
View File

@@ -0,0 +1,207 @@
// =============================================================================
// Audio Worker
// Handles audio conversions using FFmpeg WASM
// =============================================================================
import * as Comlink from 'comlink';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
type AudioFormat = 'mp3' | 'wav' | 'ogg' | 'm4a' | 'flac';
let ffmpeg: FFmpeg | null = null;
let ffmpegLoading: Promise<void> | null = null;
async function ensureFFmpegLoaded(): Promise<FFmpeg> {
if (ffmpeg?.loaded) return ffmpeg;
if (ffmpegLoading) {
await ffmpegLoading;
return ffmpeg!;
}
ffmpegLoading = (async () => {
ffmpeg = new FFmpeg();
// Load FFmpeg core from CDN with proper CORS
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
})();
await ffmpegLoading;
return ffmpeg!;
}
function getFFmpegArgs(
_inputFormat: AudioFormat,
outputFormat: AudioFormat,
bitrate?: number
): string[] {
const args: string[] = [];
// Codec selection based on output format
switch (outputFormat) {
case 'mp3':
args.push('-c:a', 'libmp3lame');
args.push('-b:a', `${bitrate || 192}k`);
break;
case 'wav':
args.push('-c:a', 'pcm_s16le');
break;
case 'ogg':
args.push('-c:a', 'libvorbis');
args.push('-q:a', '4'); // Quality level 0-10
break;
case 'm4a':
args.push('-c:a', 'aac');
args.push('-b:a', `${bitrate || 192}k`);
break;
case 'flac':
args.push('-c:a', 'flac');
args.push('-compression_level', '5');
break;
}
return args;
}
function getExtension(format: AudioFormat): string {
return format;
}
function getMimeType(format: AudioFormat): string {
const mimeMap: Record<AudioFormat, string> = {
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
flac: 'audio/flac',
};
return mimeMap[format];
}
const audioConverter = {
/**
* Convert audio file using FFmpeg
*/
async convert(
arrayBuffer: ArrayBuffer,
inputFormat: AudioFormat,
outputFormat: AudioFormat,
bitrate?: number,
onProgress?: (progress: number) => void
): Promise<Blob> {
const ff = await ensureFFmpegLoaded();
const inputExt = getExtension(inputFormat);
const outputExt = getExtension(outputFormat);
const inputFileName = `input.${inputExt}`;
const outputFileName = `output.${outputExt}`;
// Register progress callback if provided
if (onProgress) {
ff.on('progress', ({ progress }) => {
onProgress(Math.round(progress * 100));
});
}
try {
// Write input file to FFmpeg's virtual filesystem
await ff.writeFile(inputFileName, new Uint8Array(arrayBuffer));
// Build FFmpeg command
const codecArgs = getFFmpegArgs(inputFormat, outputFormat, bitrate);
await ff.exec([
'-i', inputFileName,
...codecArgs,
'-y', // Overwrite output
outputFileName,
]);
// Read output file
const data = await ff.readFile(outputFileName);
// Cleanup
await ff.deleteFile(inputFileName);
await ff.deleteFile(outputFileName);
// Convert to regular ArrayBuffer to avoid SharedArrayBuffer issues
const buffer = new ArrayBuffer((data as Uint8Array).length);
new Uint8Array(buffer).set(data as Uint8Array);
return new Blob([buffer], { type: getMimeType(outputFormat) });
} catch (error) {
// Attempt cleanup on error
try {
await ff.deleteFile(inputFileName);
await ff.deleteFile(outputFileName);
} catch {
// Ignore cleanup errors
}
throw error;
}
},
/**
* Extract audio from video file (useful for iPhone MOV files)
*/
async extractAudio(
arrayBuffer: ArrayBuffer,
inputExtension: string,
outputFormat: AudioFormat,
bitrate?: number,
onProgress?: (progress: number) => void
): Promise<Blob> {
const ff = await ensureFFmpegLoaded();
const inputFileName = `input.${inputExtension}`;
const outputExt = getExtension(outputFormat);
const outputFileName = `output.${outputExt}`;
if (onProgress) {
ff.on('progress', ({ progress }) => {
onProgress(Math.round(progress * 100));
});
}
try {
await ff.writeFile(inputFileName, new Uint8Array(arrayBuffer));
const codecArgs = getFFmpegArgs('m4a', outputFormat, bitrate);
await ff.exec([
'-i', inputFileName,
'-vn', // No video
...codecArgs,
'-y',
outputFileName,
]);
const data = await ff.readFile(outputFileName);
await ff.deleteFile(inputFileName);
await ff.deleteFile(outputFileName);
// Convert to regular ArrayBuffer
const buffer = new ArrayBuffer((data as Uint8Array).length);
new Uint8Array(buffer).set(data as Uint8Array);
return new Blob([buffer], { type: getMimeType(outputFormat) });
} catch (error) {
try {
await ff.deleteFile(inputFileName);
await ff.deleteFile(outputFileName);
} catch {
// Ignore cleanup errors
}
throw error;
}
},
};
Comlink.expose(audioConverter);
export type AudioConverterWorker = typeof audioConverter;