mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-11-10 18:19:52 +05:30
feat: implement audio converter tool with support for MP3, AAC, and WAV formats
This commit is contained in:
parent
8fc9081487
commit
dffabc8134
14 changed files with 328 additions and 159 deletions
8
public/locales/en/converters.json
Normal file
8
public/locales/en/converters.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"audioconverter": {
|
||||
"title": "Audio Converter",
|
||||
"description": "Convert audio files between different formats.",
|
||||
"shortDescription": "Convert audio files to various formats.",
|
||||
"longDescription": "This tool allows you to convert audio files from one format to another, supporting a wide range of audio formats for seamless conversion."
|
||||
}
|
||||
}
|
||||
|
|
@ -17,11 +17,23 @@
|
|||
"fileCopied": "File copied",
|
||||
"selectFileDescription": "Click here to select a {{type}} from your device, press Ctrl+V to use a {{type}} from your clipboard, or drag and drop a file from desktop"
|
||||
},
|
||||
"converters": {
|
||||
"audioconverter": {
|
||||
"title": "Audio Converter",
|
||||
"description": "Convert audio files between different formats.",
|
||||
"shortDescription": "Convert audio files to various formats.",
|
||||
"longDescription": "This tool allows you to convert audio files from one format to another, supporting a wide range of audio formats for seamless conversion."
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"audio": {
|
||||
"description": "Tools for working with audio – extract audio from video, adjusting audio speed, merging multiple audio files and much more.",
|
||||
"title": "Audio Tools"
|
||||
},
|
||||
"converters": {
|
||||
"description": "Tools for converting data between different formats – convert images, audio, video, text, and more.",
|
||||
"title": "Converter Tools"
|
||||
},
|
||||
"csv": {
|
||||
"description": "Tools for working with CSV files - convert CSV to different formats, manipulate CSV data, validate CSV structure, and process CSV files efficiently.",
|
||||
"title": "CSV Tools"
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock FFmpeg and fetchFile
|
||||
vi.mock('@ffmpeg/ffmpeg', () => ({
|
||||
FFmpeg: vi.fn().mockImplementation(() => ({
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
exec: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockReturnValue(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
unlink: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('@ffmpeg/util', () => ({
|
||||
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]))
|
||||
}));
|
||||
|
||||
// Import service
|
||||
import { AACtoMp3 } from './service';
|
||||
|
||||
describe('convertAACtoMP3', () => {
|
||||
it('should return a new MP3 File when given a valid AAC file', async () => {
|
||||
const mockAACData = new Uint8Array([0, 1, 2, 3, 4, 5]);
|
||||
const mockFile = new File([mockAACData], 'sample.aac', {
|
||||
type: 'audio/aac'
|
||||
});
|
||||
|
||||
const result = await AACtoMp3(mockFile);
|
||||
|
||||
expect(result).toBeInstanceOf(File);
|
||||
expect(result.name).toBe('sample.mp3');
|
||||
expect(result.type).toBe('audio/mpeg');
|
||||
});
|
||||
|
||||
it('should throw error if file type is not AAC', async () => {
|
||||
const mockFile = new File(['dummy'], 'song.wav', {
|
||||
type: 'audio/wav'
|
||||
});
|
||||
|
||||
await expect(() => AACtoMp3(mockFile)).rejects.toThrowError(
|
||||
'Only .aac files are allowed.' // FIXED to match actual error
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolAudioInput from '@components/input/ToolAudioInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
|
||||
import { AACtoMp3 } from './service';
|
||||
|
||||
export default function AACMP3({ title, longDescription }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const compute = async (
|
||||
_optionsValues: {},
|
||||
input: File | null
|
||||
): Promise<void> => {
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
if (!input.name.toLowerCase().endsWith('.aac')) {
|
||||
setInput(null);
|
||||
alert('please upload .aac files are allowed.');
|
||||
setResult(null);
|
||||
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const resultFile = await AACtoMp3(input);
|
||||
setResult(resultFile);
|
||||
} catch (error) {
|
||||
console.error('Conversion failed:', error);
|
||||
setResult(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolAudioInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
title={'Upload Your AAC File'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult value={result} title={'Mp3 Output'} loading={loading} />
|
||||
}
|
||||
initialValues={{}}
|
||||
getGroups={null}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('audio', {
|
||||
i18n: {
|
||||
name: 'audio:AACMP3.title',
|
||||
description: 'audio:AACMP3.description',
|
||||
shortDescription: 'audio:AACMP3.shortDescription',
|
||||
longDescription: 'audio:AACMP3.longDescription'
|
||||
},
|
||||
path: 'AAC-MP3',
|
||||
icon: 'bi:filetype-mp3',
|
||||
keywords: ['AAC', 'MP3', 'convert', 'audio', 'file conversion'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
let isLoaded = false;
|
||||
|
||||
export async function AACtoMp3(input: File): Promise<File> {
|
||||
if (!isLoaded) {
|
||||
await ffmpeg.load();
|
||||
isLoaded = true;
|
||||
}
|
||||
|
||||
const inName = 'input.aac';
|
||||
const outName = 'output.mp3';
|
||||
|
||||
await ffmpeg.writeFile(inName, await fetchFile(input));
|
||||
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
inName,
|
||||
'-c:a',
|
||||
'libmp3lame',
|
||||
'-b:a',
|
||||
'192k',
|
||||
outName
|
||||
]);
|
||||
|
||||
const data = await ffmpeg.readFile(outName);
|
||||
|
||||
const mp3 = new File([data], input.name.replace(/\.aac$/i, '.mp3'), {
|
||||
type: 'audio/mpeg'
|
||||
});
|
||||
|
||||
return mp3;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { tool as audioAACMP3 } from './AAC-MP3/meta';
|
||||
import { tool as audioMergeAudio } from './merge-audio/meta';
|
||||
import { tool as audioTrim } from './trim/meta';
|
||||
import { tool as audioChangeSpeed } from './change-speed/meta';
|
||||
|
|
@ -8,6 +7,5 @@ export const audioTools = [
|
|||
audioExtractAudio,
|
||||
audioChangeSpeed,
|
||||
audioTrim,
|
||||
audioMergeAudio,
|
||||
audioAACMP3
|
||||
audioMergeAudio
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { expect, describe, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock FFmpeg since it doesn't support Node.js in tests
|
||||
vi.mock('@ffmpeg/ffmpeg', () => ({
|
||||
FFmpeg: vi.fn().mockImplementation(() => ({
|
||||
loaded: false,
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
exec: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(new Uint8Array([10, 20, 30, 40, 50])),
|
||||
deleteFile: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('@ffmpeg/util', () => ({
|
||||
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([10, 20, 30, 40, 50]))
|
||||
}));
|
||||
|
||||
import { convertAudio } from './service';
|
||||
|
||||
describe('convertAudio', () => {
|
||||
let mockInputFile: File;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockAudioData = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
mockInputFile = new File([mockAudioData], 'input.aac', {
|
||||
type: 'audio/aac'
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert to MP3 format correctly', async () => {
|
||||
const outputFormat = 'mp3' as const;
|
||||
const result = await convertAudio(mockInputFile, outputFormat);
|
||||
|
||||
expect(result).toBeInstanceOf(File);
|
||||
expect(result.name).toBe('input.mp3'); // base name + outputFormat extension
|
||||
expect(result.type).toBe('audio/mpeg');
|
||||
});
|
||||
|
||||
it('should convert to AAC format correctly', async () => {
|
||||
const outputFormat = 'aac' as const;
|
||||
const result = await convertAudio(mockInputFile, outputFormat);
|
||||
|
||||
expect(result).toBeInstanceOf(File);
|
||||
expect(result.name).toBe('input.aac');
|
||||
expect(result.type).toBe('audio/aac');
|
||||
});
|
||||
|
||||
it('should convert to WAV format correctly', async () => {
|
||||
const outputFormat = 'wav' as const;
|
||||
const result = await convertAudio(mockInputFile, outputFormat);
|
||||
|
||||
expect(result).toBeInstanceOf(File);
|
||||
expect(result.name).toBe('input.wav');
|
||||
expect(result.type).toBe('audio/wav');
|
||||
});
|
||||
|
||||
it('should throw error for unsupported formats', async () => {
|
||||
// @ts-expect-error - intentionally passing unsupported format
|
||||
await expect(convertAudio(mockInputFile, 'flac')).rejects.toThrow(
|
||||
'Unsupported output format'
|
||||
);
|
||||
});
|
||||
});
|
||||
112
src/pages/tools/converters/audio-converter/index.tsx
Normal file
112
src/pages/tools/converters/audio-converter/index.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolAudioInput from '@components/input/ToolAudioInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import SelectWithDesc from '@components/options/SelectWithDesc';
|
||||
import { convertAudio } from './service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
|
||||
type InitialValuesType = {
|
||||
outputFormat: 'mp3' | 'aac' | 'wav';
|
||||
};
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
outputFormat: 'mp3'
|
||||
};
|
||||
|
||||
export default function AudioConverter({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const { t } = useTranslation('audio');
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Explicitly type getGroups to match GetGroupsType<InitialValuesType>
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
id: 'output-format',
|
||||
title: t('audioConverter.outputFormat', 'Output Format'),
|
||||
description: t(
|
||||
'audioConverter.outputFormatDescription',
|
||||
'Select the desired output audio format'
|
||||
),
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.outputFormat}
|
||||
onChange={(value) =>
|
||||
updateField(
|
||||
'outputFormat',
|
||||
value as InitialValuesType['outputFormat']
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{ label: 'MP3', value: 'mp3' },
|
||||
{ label: 'AAC', value: 'aac' },
|
||||
{ label: 'WAV', value: 'wav' }
|
||||
]}
|
||||
description={t(
|
||||
'audioConverter.outputFormatDescription',
|
||||
'Select the desired output audio format'
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const compute = async (
|
||||
values: InitialValuesType,
|
||||
inputFile: File | null
|
||||
): Promise<void> => {
|
||||
if (!inputFile) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const resultFile = await convertAudio(inputFile, values.outputFormat);
|
||||
setResult(resultFile);
|
||||
} catch (error) {
|
||||
console.error('Conversion failed:', error);
|
||||
setResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolAudioInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
title={t('audioConverter.uploadAudio', 'Upload Your Audio File')}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
value={result}
|
||||
title={t('audioConverter.outputTitle', 'Converted Audio')}
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{
|
||||
title: `What is a ${title}?`,
|
||||
description: longDescription
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
src/pages/tools/converters/audio-converter/meta.ts
Normal file
15
src/pages/tools/converters/audio-converter/meta.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('converters', {
|
||||
i18n: {
|
||||
name: 'translation:converters.audioconverter.title',
|
||||
description: 'translation:converters.audioconverter.description',
|
||||
shortDescription: 'translation:converters.audioconverter.shortDescription',
|
||||
longDescription: 'translation:converters.audioconverter.longDescription'
|
||||
},
|
||||
path: 'audio-converter',
|
||||
icon: 'mdi:music-note-outline',
|
||||
keywords: ['audio', 'converter'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
||||
100
src/pages/tools/converters/audio-converter/service.ts
Normal file
100
src/pages/tools/converters/audio-converter/service.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
let isLoaded = false;
|
||||
|
||||
async function loadFFmpeg() {
|
||||
if (!isLoaded) {
|
||||
await ffmpeg.load();
|
||||
isLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts input audio file to selected output format ('mp3', 'aac', or 'wav').
|
||||
* Supports any input audio file type accepted by FFmpeg.
|
||||
*
|
||||
* @param input - Source audio File
|
||||
* @param outputFormat - 'mp3' | 'aac' | 'wav'
|
||||
* @returns Converted audio File
|
||||
*/
|
||||
export async function convertAudio(
|
||||
input: File,
|
||||
outputFormat: 'mp3' | 'aac' | 'wav'
|
||||
): Promise<File> {
|
||||
await loadFFmpeg();
|
||||
|
||||
// Use the original input extension for input filename
|
||||
const inputExtMatch = input.name.match(/\.[^.]+$/);
|
||||
const inputExt = inputExtMatch ? inputExtMatch[0] : '.audio';
|
||||
|
||||
const inputFileName = `input${inputExt}`;
|
||||
const outputFileName = `output.${outputFormat}`;
|
||||
|
||||
// Write the input file to FFmpeg FS
|
||||
await ffmpeg.writeFile(inputFileName, await fetchFile(input));
|
||||
|
||||
// Build the FFmpeg args depending on the output format
|
||||
// You can customize the codec and bitrate options per format here
|
||||
let args: string[];
|
||||
|
||||
switch (outputFormat) {
|
||||
case 'mp3':
|
||||
args = [
|
||||
'-i',
|
||||
inputFileName,
|
||||
'-c:a',
|
||||
'libmp3lame',
|
||||
'-b:a',
|
||||
'192k',
|
||||
outputFileName
|
||||
];
|
||||
break;
|
||||
|
||||
case 'aac':
|
||||
args = [
|
||||
'-i',
|
||||
inputFileName,
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
'192k',
|
||||
outputFileName
|
||||
];
|
||||
break;
|
||||
|
||||
case 'wav':
|
||||
args = ['-i', inputFileName, '-c:a', 'pcm_s16le', outputFileName];
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported output format: ${outputFormat}`);
|
||||
}
|
||||
|
||||
// Execute ffmpeg with arguments
|
||||
await ffmpeg.exec(args);
|
||||
|
||||
// Read the output file from FFmpeg FS
|
||||
const data = await ffmpeg.readFile(outputFileName);
|
||||
|
||||
// Determine MIME type by outputFormat
|
||||
let mimeType = '';
|
||||
switch (outputFormat) {
|
||||
case 'mp3':
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
case 'aac':
|
||||
mimeType = 'audio/aac';
|
||||
break;
|
||||
case 'wav':
|
||||
mimeType = 'audio/wav';
|
||||
break;
|
||||
}
|
||||
|
||||
// Create a new File with the original name but new extension
|
||||
const baseName = input.name.replace(/\.[^.]+$/, '');
|
||||
const convertedFileName = `${baseName}.${outputFormat}`;
|
||||
|
||||
return new File([data], convertedFileName, { type: mimeType });
|
||||
}
|
||||
3
src/pages/tools/converters/index.ts
Normal file
3
src/pages/tools/converters/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { tool as convertersAudioConverter } from './audio-converter/meta';
|
||||
|
||||
export const convertersTools = [convertersAudioConverter];
|
||||
|
|
@ -30,7 +30,8 @@ export type ToolCategory =
|
|||
| 'pdf'
|
||||
| 'image-generic'
|
||||
| 'audio'
|
||||
| 'xml';
|
||||
| 'xml'
|
||||
| 'converters';
|
||||
|
||||
export interface DefinedTool {
|
||||
type: ToolCategory;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { timeTools } from '../pages/tools/time';
|
|||
import { IconifyIcon } from '@iconify/react';
|
||||
import { pdfTools } from '../pages/tools/pdf';
|
||||
import { xmlTools } from '../pages/tools/xml';
|
||||
import { convertersTools } from '../pages/tools/converters';
|
||||
import { TFunction } from 'i18next';
|
||||
import { FullI18nKey, I18nNamespaces } from '../i18n';
|
||||
|
||||
|
|
@ -30,7 +31,8 @@ const toolCategoriesOrder: ToolCategory[] = [
|
|||
'png',
|
||||
'time',
|
||||
'xml',
|
||||
'gif'
|
||||
'gif',
|
||||
'converters'
|
||||
];
|
||||
export const tools: DefinedTool[] = [
|
||||
...imageTools,
|
||||
|
|
@ -43,7 +45,8 @@ export const tools: DefinedTool[] = [
|
|||
...numberTools,
|
||||
...timeTools,
|
||||
...audioTools,
|
||||
...xmlTools
|
||||
...xmlTools,
|
||||
...convertersTools
|
||||
];
|
||||
const categoriesConfig: {
|
||||
type: ToolCategory;
|
||||
|
|
@ -134,6 +137,12 @@ const categoriesConfig: {
|
|||
icon: 'mdi-light:xml',
|
||||
value: 'translation:categories.xml.description',
|
||||
title: 'translation:categories.xml.title'
|
||||
},
|
||||
{
|
||||
type: 'converters',
|
||||
icon: 'mdi:swap-horizontal',
|
||||
value: 'translation:categories.converters.description',
|
||||
title: 'translation:categories.converters.title'
|
||||
}
|
||||
];
|
||||
// use for changelogs
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue