feat: implement audio converter tool with support for MP3, AAC, and WAV formats

This commit is contained in:
Srivarshan-T 2025-07-25 13:54:48 +05:30
commit dffabc8134
14 changed files with 328 additions and 159 deletions

View 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."
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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'))
});

View 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 });
}

View file

@ -0,0 +1,3 @@
import { tool as convertersAudioConverter } from './audio-converter/meta';
export const convertersTools = [convertersAudioConverter];

View file

@ -30,7 +30,8 @@ export type ToolCategory =
| 'pdf'
| 'image-generic'
| 'audio'
| 'xml';
| 'xml'
| 'converters';
export interface DefinedTool {
type: ToolCategory;

View file

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