diff --git a/public/locales/en/converters.json b/public/locales/en/converters.json new file mode 100644 index 0000000..eb48c4d --- /dev/null +++ b/public/locales/en/converters.json @@ -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." + } +} diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 789e161..b6c69fb 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -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" diff --git a/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts b/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts deleted file mode 100644 index 07ae026..0000000 --- a/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts +++ /dev/null @@ -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 - ); - }); -}); diff --git a/src/pages/tools/audio/AAC-MP3/index.tsx b/src/pages/tools/audio/AAC-MP3/index.tsx deleted file mode 100644 index bab58cc..0000000 --- a/src/pages/tools/audio/AAC-MP3/index.tsx +++ /dev/null @@ -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(null); - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); - - const compute = async ( - _optionsValues: {}, - input: File | null - ): Promise => { - 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 ( - - } - resultComponent={ - - } - initialValues={{}} - getGroups={null} - setInput={setInput} - compute={compute} - toolInfo={{ title: `What is a ${title}?`, description: longDescription }} - /> - ); -} diff --git a/src/pages/tools/audio/AAC-MP3/meta.ts b/src/pages/tools/audio/AAC-MP3/meta.ts deleted file mode 100644 index 5252bda..0000000 --- a/src/pages/tools/audio/AAC-MP3/meta.ts +++ /dev/null @@ -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')) -}); diff --git a/src/pages/tools/audio/AAC-MP3/service.ts b/src/pages/tools/audio/AAC-MP3/service.ts deleted file mode 100644 index f3d37d6..0000000 --- a/src/pages/tools/audio/AAC-MP3/service.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/src/pages/tools/audio/index.ts b/src/pages/tools/audio/index.ts index 12341b4..3596a1c 100644 --- a/src/pages/tools/audio/index.ts +++ b/src/pages/tools/audio/index.ts @@ -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 ]; diff --git a/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts b/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts new file mode 100644 index 0000000..ae4ae6e --- /dev/null +++ b/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts @@ -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' + ); + }); +}); diff --git a/src/pages/tools/converters/audio-converter/index.tsx b/src/pages/tools/converters/audio-converter/index.tsx new file mode 100644 index 0000000..10f55a5 --- /dev/null +++ b/src/pages/tools/converters/audio-converter/index.tsx @@ -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(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + // Explicitly type getGroups to match GetGroupsType + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + id: 'output-format', + title: t('audioConverter.outputFormat', 'Output Format'), + description: t( + 'audioConverter.outputFormatDescription', + 'Select the desired output audio format' + ), + component: ( + + + 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' + )} + /> + + ) + } + ]; + + const compute = async ( + values: InitialValuesType, + inputFile: File | null + ): Promise => { + 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 ( + + } + resultComponent={ + + } + initialValues={initialValues} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ + title: `What is a ${title}?`, + description: longDescription + }} + /> + ); +} diff --git a/src/pages/tools/converters/audio-converter/meta.ts b/src/pages/tools/converters/audio-converter/meta.ts new file mode 100644 index 0000000..757fed4 --- /dev/null +++ b/src/pages/tools/converters/audio-converter/meta.ts @@ -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')) +}); diff --git a/src/pages/tools/converters/audio-converter/service.ts b/src/pages/tools/converters/audio-converter/service.ts new file mode 100644 index 0000000..e2676c3 --- /dev/null +++ b/src/pages/tools/converters/audio-converter/service.ts @@ -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 { + 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 }); +} diff --git a/src/pages/tools/converters/index.ts b/src/pages/tools/converters/index.ts new file mode 100644 index 0000000..59ce53b --- /dev/null +++ b/src/pages/tools/converters/index.ts @@ -0,0 +1,3 @@ +import { tool as convertersAudioConverter } from './audio-converter/meta'; + +export const convertersTools = [convertersAudioConverter]; diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx index ed19326..4f776a1 100644 --- a/src/tools/defineTool.tsx +++ b/src/tools/defineTool.tsx @@ -30,7 +30,8 @@ export type ToolCategory = | 'pdf' | 'image-generic' | 'audio' - | 'xml'; + | 'xml' + | 'converters'; export interface DefinedTool { type: ToolCategory; diff --git a/src/tools/index.ts b/src/tools/index.ts index dd1b723..fff2b76 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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