Merge pull request #165 from AshAnand34/audio-feature-addition

feat: Adding Audio processing features
This commit is contained in:
Ibrahima G. Coulibaly
2025-07-08 22:13:50 +01:00
committed by GitHub
26 changed files with 1417 additions and 19 deletions

27
.idea/workspace.xml generated
View File

@@ -21,7 +21,7 @@
<option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="fork/y1hao/dark" />
<entry key="$PROJECT_DIR$" value="fork/y1hao/grouping" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -508,15 +508,8 @@
<workItem from="1749047510481" duration="879000" />
<workItem from="1751846528195" duration="4358000" />
<workItem from="1751852868038" duration="680000" />
<workItem from="1751893034799" duration="1192000" />
</task>
<task id="LOCAL-00159" summary="refactor: sum">
<option name="closed" value="true" />
<created>1741544086061</created>
<option name="number" value="00159" />
<option name="presentableId" value="LOCAL-00159" />
<option name="project" value="LOCAL" />
<updated>1741544086061</updated>
<workItem from="1751893034799" duration="1868000" />
<workItem from="1751991143661" duration="1750000" />
</task>
<task id="LOCAL-00160" summary="fix: tools by category scroll">
<option name="closed" value="true" />
@@ -902,7 +895,15 @@
<option name="project" value="LOCAL" />
<updated>1751893722720</updated>
</task>
<option name="localTasksCounter" value="208" />
<task id="LOCAL-00208" summary="feat: groupBy tools autocomplete">
<option name="closed" value="true" />
<created>1751894332017</created>
<option name="number" value="00208" />
<option name="presentableId" value="LOCAL-00208" />
<option name="project" value="LOCAL" />
<updated>1751894332017</updated>
</task>
<option name="localTasksCounter" value="209" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -949,7 +950,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="refactor: compress pdf" />
<MESSAGE value="refactor: lib" />
<MESSAGE value="fix: path" />
<MESSAGE value="fix: vite worker format" />
@@ -974,7 +974,8 @@
<MESSAGE value="fix: tsc" />
<MESSAGE value="chore: remove .codebuddy" />
<MESSAGE value="chore: add array key" />
<option name="LAST_COMMIT_MESSAGE" value="chore: add array key" />
<MESSAGE value="feat: groupBy tools autocomplete" />
<option name="LAST_COMMIT_MESSAGE" value="feat: groupBy tools autocomplete" />
</component>
<component name="VgoProject">
<integration-enabled>false</integration-enabled>

View File

@@ -0,0 +1,46 @@
import React, { useRef } from 'react';
import { Box, Typography } from '@mui/material';
import BaseFileInput from './BaseFileInput';
import { BaseFileInputProps } from './file-input-utils';
interface AudioFileInputProps extends Omit<BaseFileInputProps, 'accept'> {
accept?: string[];
}
export default function ToolAudioInput({
accept = ['audio/*', '.mp3', '.wav', '.aac'],
...props
}: AudioFileInputProps) {
const audioRef = useRef<HTMLAudioElement>(null);
return (
<BaseFileInput {...props} type={'audio'} accept={accept}>
{({ preview }) => (
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}
>
{preview ? (
<audio
ref={audioRef}
src={preview}
style={{ maxWidth: '100%' }}
controls
/>
) : (
<Typography variant="body2" color="textSecondary">
Drag & drop or import an audio file
</Typography>
)}
</Box>
)}
</BaseFileInput>
);
}

View File

@@ -0,0 +1,172 @@
import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import { isArray } from 'lodash';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
interface MultiAudioInputComponentProps {
accept: string[];
title?: string;
type: 'audio';
value: MultiAudioInput[];
onChange: (file: MultiAudioInput[]) => void;
}
export interface MultiAudioInput {
file: File;
order: number;
}
export default function ToolMultipleAudioInput({
value,
onChange,
accept,
title,
type
}: MultiAudioInputComponentProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files)
onChange([
...value,
...Array.from(files).map((file) => ({ file, order: value.length }))
]);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
function handleClear() {
onChange([]);
}
function fileNameTruncate(fileName: string) {
const maxLength = 15;
if (fileName.length > maxLength) {
return fileName.slice(0, maxLength) + '...';
}
return fileName;
}
const sortList = () => {
const list = [...value];
list.sort((a, b) => a.order - b.order);
onChange(list);
};
const reorderList = (sourceIndex: number, destinationIndex: number) => {
if (destinationIndex === sourceIndex) {
return;
}
const list = [...value];
if (destinationIndex === 0) {
list[sourceIndex].order = list[0].order - 1;
sortList();
return;
}
if (destinationIndex === list.length - 1) {
list[sourceIndex].order = list[list.length - 1].order + 1;
sortList();
return;
}
if (destinationIndex < sourceIndex) {
list[sourceIndex].order =
(list[destinationIndex].order + list[destinationIndex - 1].order) / 2;
sortList();
return;
}
list[sourceIndex].order =
(list[destinationIndex].order + list[destinationIndex + 1].order) / 2;
sortList();
};
return (
<Box>
<InputHeader
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
/>
<Box
sx={{
width: '100%',
height: '300px',
border: value?.length ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'background.paper',
position: 'relative'
}}
>
<Box
width="100%"
height="100%"
sx={{
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
position: 'relative'
}}
>
{value?.length ? (
value.map((file, index) => (
<Box
key={index}
sx={{
margin: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '200px',
border: 1,
borderRadius: 1,
padding: 1
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<MusicNoteIcon />
<Typography sx={{ marginLeft: 1 }}>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
</Box>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
No files selected
</Typography>
)}
</Box>
</Box>
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple={true}
/>
</Box>
);
}

View File

@@ -0,0 +1,46 @@
import { expect, describe, it, vi } from 'vitest';
// Mock FFmpeg since it doesn't support Node.js
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([1, 2, 3, 4, 5])),
deleteFile: vi.fn().mockResolvedValue(undefined)
}))
}));
vi.mock('@ffmpeg/util', () => ({
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]))
}));
import { changeAudioSpeed } from './service';
import { InitialValuesType } from './types';
describe('changeAudioSpeed', () => {
it('should return a new File with the correct name and type', async () => {
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.mp3', {
type: 'audio/mp3'
});
const options: InitialValuesType = {
newSpeed: 2,
outputFormat: 'mp3'
};
const result = await changeAudioSpeed(mockFile, options);
expect(result).toBeInstanceOf(File);
expect(result?.name).toBe('test-2x.mp3');
expect(result?.type).toBe('audio/mp3');
});
it('should return null if input is null', async () => {
const options: InitialValuesType = {
newSpeed: 2,
outputFormat: 'mp3'
};
const result = await changeAudioSpeed(null, options);
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,120 @@
import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolAudioInput from '@components/input/ToolAudioInput';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import RadioWithTextField from '@components/options/RadioWithTextField';
import { changeAudioSpeed } from './service';
const initialValues: InitialValuesType = {
newSpeed: 2,
outputFormat: 'mp3'
};
const formatOptions = [
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'WAV', value: 'wav' }
];
export default function ChangeSpeed({
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: InitialValuesType,
input: File | null
) => {
setLoading(true);
try {
const newFile = await changeAudioSpeed(input, optionsValues);
setResult(newFile);
} catch (err) {
setResult(null);
} finally {
setLoading(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'New Audio Speed',
component: (
<Box>
<TextFieldWithDesc
value={values.newSpeed.toString()}
onOwnChange={(val) => updateField('newSpeed', Number(val))}
description="Default multiplier: 2 means 2x faster"
type="number"
/>
</Box>
)
},
{
title: 'Output Format',
component: (
<Box mt={2}>
<RadioGroup
row
value={values.outputFormat}
onChange={(e) =>
updateField(
'outputFormat',
e.target.value as 'mp3' | 'aac' | 'wav'
)
}
>
{formatOptions.map((opt) => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio />}
label={opt.label}
/>
))}
</RadioGroup>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolAudioInput
value={input}
onChange={setInput}
title={'Input Audio'}
/>
}
resultComponent={
loading ? (
<ToolFileResult title="Setting Speed" value={null} loading={true} />
) : (
<ToolFileResult
title="Edited Audio"
value={result}
extension={result ? result.name.split('.').pop() : undefined}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('audio', {
name: 'Change speed',
path: 'change-speed',
icon: 'material-symbols-light:speed-outline',
description:
'This online utility lets you change the speed of an audio. You can speed it up or slow it down.',
shortDescription: 'Quickly change audio speed',
keywords: ['change', 'speed'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,80 @@
import { InitialValuesType } from './types';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
function computeAudioFilter(speed: number): string {
if (speed <= 2 && speed >= 0.5) {
return `atempo=${speed}`;
}
const filters: string[] = [];
let remainingSpeed = speed;
while (remainingSpeed > 2.0) {
filters.push('atempo=2.0');
remainingSpeed /= 2.0;
}
while (remainingSpeed < 0.5) {
filters.push('atempo=0.5');
remainingSpeed /= 0.5;
}
filters.push(`atempo=${remainingSpeed.toFixed(2)}`);
return filters.join(',');
}
export async function changeAudioSpeed(
input: File | null,
options: InitialValuesType
): Promise<File | null> {
if (!input) return null;
const { newSpeed, outputFormat } = options;
let ffmpeg: FFmpeg | null = null;
let ffmpegLoaded = false;
try {
ffmpeg = new FFmpeg();
if (!ffmpegLoaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
ffmpegLoaded = true;
}
const fileName = input.name;
const outputName = `output.${outputFormat}`;
await ffmpeg.writeFile(fileName, await fetchFile(input));
const audioFilter = computeAudioFilter(newSpeed);
let args = ['-i', fileName, '-filter:a', audioFilter];
if (outputFormat === 'mp3') {
args.push('-b:a', '192k', '-f', 'mp3', outputName);
} else if (outputFormat === 'aac') {
args.push('-c:a', 'aac', '-b:a', '192k', '-f', 'adts', outputName);
} else if (outputFormat === 'wav') {
args.push(
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
'-f',
'wav',
outputName
);
}
await ffmpeg.exec(args);
const data = await ffmpeg.readFile(outputName);
let mimeType = 'audio/mp3';
if (outputFormat === 'aac') mimeType = 'audio/aac';
if (outputFormat === 'wav') mimeType = 'audio/wav';
const blob = new Blob([data], { type: mimeType });
const newFile = new File(
[blob],
fileName.replace(/\.[^/.]+$/, `-${newSpeed}x.${outputFormat}`),
{ type: mimeType }
);
await ffmpeg.deleteFile(fileName);
await ffmpeg.deleteFile(outputName);
return newFile;
} catch (err) {
console.error(`Failed to process audio: ${err}`);
return null;
}
}

View File

@@ -0,0 +1,4 @@
export type InitialValuesType = {
newSpeed: number;
outputFormat: 'mp3' | 'aac' | 'wav';
};

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
// Mock the service module BEFORE importing it
vi.mock('./service', () => ({
extractAudioFromVideo: vi.fn(async (input, options) => {
const ext = options.outputFormat;
return new File([new Blob(['audio data'])], `mock_audio.${ext}`, {
type: `audio/${ext}`
});
})
}));
import { extractAudioFromVideo } from './service';
import { InitialValuesType } from './types';
function createMockVideoFile(): File {
return new File(['video data'], 'test.mp4', { type: 'video/mp4' });
}
describe('extractAudioFromVideo (mocked)', () => {
let videoFile: File;
beforeAll(() => {
videoFile = createMockVideoFile();
});
it('should extract audio as AAC', async () => {
const options: InitialValuesType = { outputFormat: 'aac' };
const audioFile = await extractAudioFromVideo(videoFile, options);
expect(audioFile).toBeInstanceOf(File);
expect(audioFile.name.endsWith('.aac')).toBe(true);
expect(audioFile.type).toBe('audio/aac');
});
it('should extract audio as MP3', async () => {
const options: InitialValuesType = { outputFormat: 'mp3' };
const audioFile = await extractAudioFromVideo(videoFile, options);
expect(audioFile).toBeInstanceOf(File);
expect(audioFile.name.endsWith('.mp3')).toBe(true);
expect(audioFile.type).toBe('audio/mp3');
});
it('should extract audio as WAV', async () => {
const options: InitialValuesType = { outputFormat: 'wav' };
const audioFile = await extractAudioFromVideo(videoFile, options);
expect(audioFile).toBeInstanceOf(File);
expect(audioFile.name.endsWith('.wav')).toBe(true);
expect(audioFile.type).toBe('audio/wav');
});
});

View File

@@ -0,0 +1,91 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { extractAudioFromVideo } from './service';
import { InitialValuesType } from './types';
import ToolVideoInput from '@components/input/ToolVideoInput';
import { GetGroupsType } from '@components/options/ToolOptions';
import ToolFileResult from '@components/result/ToolFileResult';
import SelectWithDesc from '@components/options/SelectWithDesc';
const initialValues: InitialValuesType = {
outputFormat: 'aac'
};
export default function ExtractAudio({
title,
longDescription
}: ToolComponentProps) {
const [file, setFile] = useState<File | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => {
return [
{
title: 'Output Format',
component: (
<Box>
<SelectWithDesc
selected={values.outputFormat}
onChange={(value) => {
updateField('outputFormat', value.toString());
}}
options={[
{ label: 'AAC', value: 'aac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'WAV', value: 'wav' }
]}
description={
'Select the format for the audio to be extracted as.'
}
/>
</Box>
)
}
];
};
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
try {
setLoading(true);
const audioFileObj = await extractAudioFromVideo(input, values);
setAudioFile(audioFileObj);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return (
<ToolContent
title={title}
input={file}
inputComponent={
<ToolVideoInput value={file} onChange={setFile} title={'Input Video'} />
}
resultComponent={
loading ? (
<ToolFileResult
title={'Extracting Audio'}
value={null}
loading={true}
/>
) : (
<ToolFileResult title={'Extracted Audio'} value={audioFile} />
)
}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
setInput={setFile}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('audio', {
name: 'Extract audio',
path: 'extract-audio',
icon: 'mdi:music-note',
description:
'Extract the audio track from a video file and save it as a separate audio file in your chosen format (AAC, MP3, WAV).',
shortDescription:
'Extract audio from video files (MP4, MOV, etc.) to AAC, MP3, or WAV.',
keywords: [
'extract',
'audio',
'video',
'mp3',
'aac',
'wav',
'audio extraction',
'media',
'convert'
],
longDescription:
'This tool allows you to extract the audio track from a video file (such as MP4, MOV, AVI, etc.) and save it as a standalone audio file in your preferred format (AAC, MP3, or WAV). Useful for podcasts, music, or any scenario where you need just the audio from a video.',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,70 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { InitialValuesType } from './types';
const ffmpeg = new FFmpeg();
export async function extractAudioFromVideo(
input: File,
options: InitialValuesType
): Promise<File> {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
const inputName = 'input.mp4';
await ffmpeg.writeFile(inputName, await fetchFile(input));
const configuredOutputAudioFormat = options.outputFormat;
const outputName = `output.${configuredOutputAudioFormat}`;
const args: string[] = ['-i', inputName, '-vn'];
if (configuredOutputAudioFormat === 'mp3') {
args.push(
'-ar',
'44100',
'-ac',
'2',
'-b:a',
'192k',
'-f',
'mp3',
outputName
);
} else if (configuredOutputAudioFormat === 'wav') {
args.push(
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
'-f',
'wav',
outputName
);
} else {
// Default to AAC or copy
args.push('-acodec', 'copy', outputName);
}
await ffmpeg.exec(args);
const extractedAudio = await ffmpeg.readFile(outputName);
return new File(
[
new Blob([extractedAudio], {
type: `audio/${configuredOutputAudioFormat}`
})
],
`${input.name.replace(
/\.[^/.]+$/,
''
)}_audio.${configuredOutputAudioFormat}`,
{ type: `audio/${configuredOutputAudioFormat}` }
);
}

View File

@@ -0,0 +1,3 @@
export type InitialValuesType = {
outputFormat: string;
};

View File

@@ -0,0 +1,11 @@
import { tool as audioMergeAudio } from './merge-audio/meta';
import { tool as audioTrim } from './trim/meta';
import { tool as audioChangeSpeed } from './change-speed/meta';
import { tool as audioExtractAudio } from './extract-audio/meta';
export const audioTools = [
audioExtractAudio,
audioChangeSpeed,
audioTrim,
audioMergeAudio
];

View File

@@ -0,0 +1,112 @@
import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolMultipleAudioInput, {
MultiAudioInput
} from '@components/input/ToolMultipleAudioInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { mergeAudioFiles } from './service';
const initialValues: InitialValuesType = {
outputFormat: 'mp3'
};
const formatOptions = [
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'WAV', value: 'wav' }
];
export default function MergeAudio({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<MultiAudioInput[]>([]);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = async (
optionsValues: InitialValuesType,
input: MultiAudioInput[]
) => {
if (input.length === 0) return;
setLoading(true);
try {
const files = input.map((item) => item.file);
const mergedFile = await mergeAudioFiles(files, optionsValues);
setResult(mergedFile);
} catch (err) {
console.error(`Failed to merge audio: ${err}`);
setResult(null);
} finally {
setLoading(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'Output Format',
component: (
<Box mt={2}>
<RadioGroup
row
value={values.outputFormat}
onChange={(e) =>
updateField(
'outputFormat',
e.target.value as 'mp3' | 'aac' | 'wav'
)
}
>
{formatOptions.map((opt) => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio />}
label={opt.label}
/>
))}
</RadioGroup>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolMultipleAudioInput
value={input}
onChange={setInput}
accept={['audio/*', '.mp3', '.wav', '.aac']}
title={'Input Audio Files'}
type="audio"
/>
}
resultComponent={
loading ? (
<ToolFileResult title="Merging Audio" value={null} loading={true} />
) : (
<ToolFileResult
title="Merged Audio"
value={result}
extension={result ? result.name.split('.').pop() : undefined}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -0,0 +1,73 @@
import { expect, describe, it, vi } from 'vitest';
// Mock FFmpeg since it doesn't support Node.js
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([1, 2, 3, 4, 5])),
deleteFile: vi.fn().mockResolvedValue(undefined)
}))
}));
vi.mock('@ffmpeg/util', () => ({
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]))
}));
import { mergeAudioFiles } from './service';
describe('mergeAudioFiles', () => {
it('should merge multiple audio files', async () => {
// Create mock audio files
const mockAudioData1 = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockAudioData2 = new Uint8Array([6, 7, 8, 9, 10, 11]);
const mockFile1 = new File([mockAudioData1], 'test1.mp3', {
type: 'audio/mp3'
});
const mockFile2 = new File([mockAudioData2], 'test2.mp3', {
type: 'audio/mp3'
});
const options = {
outputFormat: 'mp3' as const
};
const result = await mergeAudioFiles([mockFile1, mockFile2], options);
expect(result).toBeInstanceOf(File);
expect(result.name).toBe('merged_audio.mp3');
expect(result.type).toBe('audio/mp3');
});
it('should handle different output formats', async () => {
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.wav', {
type: 'audio/wav'
});
const options = {
outputFormat: 'aac' as const
};
const result = await mergeAudioFiles([mockFile], options);
expect(result).toBeInstanceOf(File);
expect(result.name).toBe('merged_audio.aac');
expect(result.type).toBe('audio/aac');
});
it('should throw error when no input files provided', async () => {
const options = {
outputFormat: 'mp3' as const
};
try {
await mergeAudioFiles([], options);
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('No input files provided');
}
});
});

View File

@@ -0,0 +1,26 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('audio', {
name: 'Merge Audio',
path: 'merge-audio',
icon: 'fluent:merge-20-regular',
description:
'Combine multiple audio files into a single audio file by concatenating them in sequence.',
shortDescription: 'Merge multiple audio files into one (MP3, AAC, WAV).',
keywords: [
'merge',
'audio',
'combine',
'concatenate',
'join',
'mp3',
'aac',
'wav',
'audio editing',
'multiple files'
],
longDescription:
'This tool allows you to merge multiple audio files into a single file by concatenating them in the order you upload them. Perfect for combining podcast segments, music tracks, or any audio files that need to be joined together. Supports various audio formats including MP3, AAC, and WAV.',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,115 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { InitialValuesType } from './types';
const ffmpeg = new FFmpeg();
export async function mergeAudioFiles(
inputs: File[],
options: InitialValuesType
): Promise<File> {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
if (inputs.length === 0) {
throw new Error('No input files provided');
}
const { outputFormat } = options;
const outputName = `output.${outputFormat}`;
// 1. Convert all inputs to WAV
const tempWavNames: string[] = [];
for (let i = 0; i < inputs.length; i++) {
const inputName = `input${i}`;
const tempWavName = `temp${i}.wav`;
await ffmpeg.writeFile(inputName, await fetchFile(inputs[i]));
await ffmpeg.exec([
'-i',
inputName,
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
tempWavName
]);
tempWavNames.push(tempWavName);
await ffmpeg.deleteFile(inputName);
}
// 2. Create file list for concat
const fileListName = 'filelist.txt';
const fileListContent = tempWavNames
.map((name) => `file '${name}'`)
.join('\n');
await ffmpeg.writeFile(fileListName, fileListContent);
// 3. Concatenate WAV files
const concatWav = 'concat.wav';
await ffmpeg.exec([
'-f',
'concat',
'-safe',
'0',
'-i',
fileListName,
'-c',
'copy',
concatWav
]);
// 4. Convert concatenated WAV to requested output format
let finalOutput = concatWav;
if (outputFormat !== 'wav') {
const args = ['-i', concatWav];
if (outputFormat === 'mp3') {
args.push(
'-ar',
'44100',
'-ac',
'2',
'-b:a',
'192k',
'-f',
'mp3',
outputName
);
} else if (outputFormat === 'aac') {
args.push('-c:a', 'aac', '-b:a', '192k', '-f', 'adts', outputName);
}
await ffmpeg.exec(args);
finalOutput = outputName;
}
const mergedAudio = await ffmpeg.readFile(finalOutput);
let mimeType = 'audio/wav';
if (outputFormat === 'mp3') mimeType = 'audio/mp3';
if (outputFormat === 'aac') mimeType = 'audio/aac';
// Clean up files
for (const tempWavName of tempWavNames) {
await ffmpeg.deleteFile(tempWavName);
}
await ffmpeg.deleteFile(fileListName);
await ffmpeg.deleteFile(concatWav);
if (outputFormat !== 'wav') {
await ffmpeg.deleteFile(outputName);
}
return new File(
[
new Blob([mergedAudio], {
type: mimeType
})
],
`merged_audio.${outputFormat}`,
{ type: mimeType }
);
}

View File

@@ -0,0 +1,3 @@
export type InitialValuesType = {
outputFormat: 'mp3' | 'aac' | 'wav';
};

View File

@@ -0,0 +1,128 @@
import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolAudioInput from '@components/input/ToolAudioInput';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { trimAudio } from './service';
const initialValues: InitialValuesType = {
startTime: '00:00:00',
endTime: '00:01:00',
outputFormat: 'mp3'
};
const formatOptions = [
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'WAV', value: 'wav' }
];
export default function Trim({ 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: InitialValuesType,
input: File | null
) => {
if (!input) return;
setLoading(true);
try {
const trimmedFile = await trimAudio(input, optionsValues);
setResult(trimmedFile);
} catch (err) {
console.error(`Failed to trim audio: ${err}`);
setResult(null);
} finally {
setLoading(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'Time Settings',
component: (
<Box>
<TextFieldWithDesc
value={values.startTime}
onOwnChange={(val) => updateField('startTime', val)}
description="Start time in format HH:MM:SS (e.g., 00:00:30)"
label="Start Time"
/>
<Box mt={2}>
<TextFieldWithDesc
value={values.endTime}
onOwnChange={(val) => updateField('endTime', val)}
description="End time in format HH:MM:SS (e.g., 00:01:30)"
label="End Time"
/>
</Box>
</Box>
)
},
{
title: 'Output Format',
component: (
<Box mt={2}>
<RadioGroup
row
value={values.outputFormat}
onChange={(e) =>
updateField(
'outputFormat',
e.target.value as 'mp3' | 'aac' | 'wav'
)
}
>
{formatOptions.map((opt) => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio />}
label={opt.label}
/>
))}
</RadioGroup>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolAudioInput
value={input}
onChange={setInput}
title={'Input Audio'}
/>
}
resultComponent={
loading ? (
<ToolFileResult title="Trimming Audio" value={null} loading={true} />
) : (
<ToolFileResult
title="Trimmed Audio"
value={result}
extension={result ? result.name.split('.').pop() : undefined}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -0,0 +1,27 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('audio', {
name: 'Trim Audio',
path: 'trim',
icon: 'mdi:scissors-cutting',
description:
'Cut and trim audio files to extract specific segments by specifying start and end times.',
shortDescription:
'Trim audio files to extract specific time segments (MP3, AAC, WAV).',
keywords: [
'trim',
'audio',
'cut',
'segment',
'extract',
'mp3',
'aac',
'wav',
'audio editing',
'time'
],
longDescription:
'This tool allows you to trim audio files by specifying start and end times. You can extract specific segments from longer audio files, remove unwanted parts, or create shorter clips. Supports various audio formats including MP3, AAC, and WAV. Perfect for podcast editing, music production, or any audio editing needs.',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,108 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { InitialValuesType } from './types';
const ffmpeg = new FFmpeg();
export async function trimAudio(
input: File,
options: InitialValuesType
): Promise<File> {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
const inputName = 'input.mp3';
await ffmpeg.writeFile(inputName, await fetchFile(input));
const { startTime, endTime, outputFormat } = options;
const outputName = `output.${outputFormat}`;
// Build FFmpeg arguments for trimming
let args: string[] = [
'-i',
inputName,
'-ss',
startTime, // Start time
'-to',
endTime, // End time
'-c',
'copy' // Copy without re-encoding for speed
];
// Add format-specific arguments
if (outputFormat === 'mp3') {
args = [
'-i',
inputName,
'-ss',
startTime,
'-to',
endTime,
'-ar',
'44100',
'-ac',
'2',
'-b:a',
'192k',
'-f',
'mp3',
outputName
];
} else if (outputFormat === 'aac') {
args = [
'-i',
inputName,
'-ss',
startTime,
'-to',
endTime,
'-c:a',
'aac',
'-b:a',
'192k',
'-f',
'adts',
outputName
];
} else if (outputFormat === 'wav') {
args = [
'-i',
inputName,
'-ss',
startTime,
'-to',
endTime,
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
'-f',
'wav',
outputName
];
}
await ffmpeg.exec(args);
const trimmedAudio = await ffmpeg.readFile(outputName);
let mimeType = 'audio/mp3';
if (outputFormat === 'aac') mimeType = 'audio/aac';
if (outputFormat === 'wav') mimeType = 'audio/wav';
return new File(
[
new Blob([trimmedAudio], {
type: mimeType
})
],
`${input.name.replace(/\.[^/.]+$/, '')}_trimmed.${outputFormat}`,
{ type: mimeType }
);
}

View File

@@ -0,0 +1,58 @@
import { expect, describe, it, vi } from 'vitest';
// Mock FFmpeg since it doesn't support Node.js
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([1, 2, 3, 4, 5])),
deleteFile: vi.fn().mockResolvedValue(undefined)
}))
}));
vi.mock('@ffmpeg/util', () => ({
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]))
}));
import { trimAudio } from './service';
describe('trimAudio', () => {
it('should trim audio file with valid time parameters', async () => {
// Create a mock audio file
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.mp3', {
type: 'audio/mp3'
});
const options = {
startTime: '00:00:10',
endTime: '00:00:20',
outputFormat: 'mp3' as const
};
const result = await trimAudio(mockFile, options);
expect(result).toBeInstanceOf(File);
expect(result.name).toContain('_trimmed.mp3');
expect(result.type).toBe('audio/mp3');
});
it('should handle different output formats', async () => {
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.wav', {
type: 'audio/wav'
});
const options = {
startTime: '00:00:00',
endTime: '00:00:30',
outputFormat: 'wav' as const
};
const result = await trimAudio(mockFile, options);
expect(result).toBeInstanceOf(File);
expect(result.name).toContain('_trimmed.wav');
expect(result.type).toBe('audio/wav');
});
});

View File

@@ -0,0 +1,5 @@
export type InitialValuesType = {
startTime: string;
endTime: string;
outputFormat: 'mp3' | 'aac' | 'wav';
};

View File

@@ -24,7 +24,8 @@ export type ToolCategory =
| 'time'
| 'csv'
| 'pdf'
| 'image-generic';
| 'image-generic'
| 'audio';
export interface DefinedTool {
type: ToolCategory;

View File

@@ -1,9 +1,10 @@
import { stringTools } from '../pages/tools/string';
import { imageTools } from '../pages/tools/image';
import { DefinedTool, ToolCategory } from './defineTool';
import { capitalizeFirstLetter } from '../utils/string';
import { capitalizeFirstLetter } from '@utils/string';
import { numberTools } from '../pages/tools/number';
import { videoTools } from '../pages/tools/video';
import { audioTools } from 'pages/tools/audio';
import { listTools } from '../pages/tools/list';
import { Entries } from 'type-fest';
import { jsonTools } from '../pages/tools/json';
@@ -14,15 +15,16 @@ import { pdfTools } from '../pages/tools/pdf';
const toolCategoriesOrder: ToolCategory[] = [
'image-generic',
'string',
'json',
'pdf',
'string',
'video',
'time',
'audio',
'json',
'list',
'csv',
'number',
'png',
'time',
'gif'
];
export const tools: DefinedTool[] = [
@@ -34,7 +36,8 @@ export const tools: DefinedTool[] = [
...csvTools,
...videoTools,
...numberTools,
...timeTools
...timeTools,
...audioTools
];
const categoriesConfig: {
type: ToolCategory;
@@ -115,6 +118,12 @@ const categoriesConfig: {
icon: 'material-symbols-light:image-outline-rounded',
value:
'Tools for working with pictures compress, resize, crop, convert to JPG, rotate, remove background and much more.'
},
{
type: 'audio',
icon: 'ic:twotone-audiotrack',
value:
'Tools for working with audio extract audio from video, adjusting audio speed, merging multiple audio files and much more.'
}
];
// use for changelogs