mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-20 22:49:33 +02:00
feat: introduce audio merging and trimming tools with support for multiple formats
This commit is contained in:
174
src/components/input/ToolMultipleAudioInput.tsx
Normal file
174
src/components/input/ToolMultipleAudioInput.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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 theme = useTheme();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@@ -0,0 +1,35 @@
|
|||||||
|
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 { main } from './service';
|
||||||
|
import { InitialValuesType } from './types';
|
||||||
|
|
||||||
|
describe('changeSpeed (main)', () => {
|
||||||
|
it('should return the input file unchanged (mock implementation)', () => {
|
||||||
|
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 = main(mockFile, options);
|
||||||
|
expect(result).toBe(mockFile);
|
||||||
|
});
|
||||||
|
});
|
@@ -1,4 +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 audioChangeSpeed } from './change-speed/meta';
|
||||||
import { tool as audioExtractAudio } from './extract-audio/meta';
|
import { tool as audioExtractAudio } from './extract-audio/meta';
|
||||||
|
|
||||||
export const audioTools = [audioExtractAudio, audioChangeSpeed];
|
export const audioTools = [
|
||||||
|
audioExtractAudio,
|
||||||
|
audioChangeSpeed,
|
||||||
|
audioTrim,
|
||||||
|
audioMergeAudio
|
||||||
|
];
|
||||||
|
112
src/pages/tools/audio/merge-audio/index.tsx
Normal file
112
src/pages/tools/audio/merge-audio/index.tsx
Normal 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 a ${title}?`, description: longDescription }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
26
src/pages/tools/audio/merge-audio/meta.ts
Normal file
26
src/pages/tools/audio/merge-audio/meta.ts
Normal 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: 'mdi:music-note-multiple',
|
||||||
|
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'))
|
||||||
|
});
|
94
src/pages/tools/audio/merge-audio/service.ts
Normal file
94
src/pages/tools/audio/merge-audio/service.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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}`;
|
||||||
|
|
||||||
|
// Write all input files to FFmpeg
|
||||||
|
const inputNames: string[] = [];
|
||||||
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
|
const inputName = `input${i}.mp3`;
|
||||||
|
await ffmpeg.writeFile(inputName, await fetchFile(inputs[i]));
|
||||||
|
inputNames.push(inputName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a file list for concatenation
|
||||||
|
const fileListName = 'filelist.txt';
|
||||||
|
const fileListContent = inputNames.map((name) => `file '${name}'`).join('\n');
|
||||||
|
await ffmpeg.writeFile(fileListName, fileListContent);
|
||||||
|
|
||||||
|
// Build FFmpeg arguments for merging
|
||||||
|
let args: string[] = ['-f', 'concat', '-safe', '0', '-i', fileListName];
|
||||||
|
|
||||||
|
// Add format-specific arguments
|
||||||
|
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);
|
||||||
|
} else if (outputFormat === 'wav') {
|
||||||
|
args.push(
|
||||||
|
'-acodec',
|
||||||
|
'pcm_s16le',
|
||||||
|
'-ar',
|
||||||
|
'44100',
|
||||||
|
'-ac',
|
||||||
|
'2',
|
||||||
|
'-f',
|
||||||
|
'wav',
|
||||||
|
outputName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ffmpeg.exec(args);
|
||||||
|
|
||||||
|
const mergedAudio = await ffmpeg.readFile(outputName);
|
||||||
|
|
||||||
|
let mimeType = 'audio/mp3';
|
||||||
|
if (outputFormat === 'aac') mimeType = 'audio/aac';
|
||||||
|
if (outputFormat === 'wav') mimeType = 'audio/wav';
|
||||||
|
|
||||||
|
// Clean up files
|
||||||
|
for (const inputName of inputNames) {
|
||||||
|
await ffmpeg.deleteFile(inputName);
|
||||||
|
}
|
||||||
|
await ffmpeg.deleteFile(fileListName);
|
||||||
|
await ffmpeg.deleteFile(outputName);
|
||||||
|
|
||||||
|
return new File(
|
||||||
|
[
|
||||||
|
new Blob([mergedAudio], {
|
||||||
|
type: mimeType
|
||||||
|
})
|
||||||
|
],
|
||||||
|
`merged_audio.${outputFormat}`,
|
||||||
|
{ type: mimeType }
|
||||||
|
);
|
||||||
|
}
|
3
src/pages/tools/audio/merge-audio/types.ts
Normal file
3
src/pages/tools/audio/merge-audio/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type InitialValuesType = {
|
||||||
|
outputFormat: 'mp3' | 'aac' | 'wav';
|
||||||
|
};
|
128
src/pages/tools/audio/trim/index.tsx
Normal file
128
src/pages/tools/audio/trim/index.tsx
Normal 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 a ${title}?`, description: longDescription }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
27
src/pages/tools/audio/trim/meta.ts
Normal file
27
src/pages/tools/audio/trim/meta.ts
Normal 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'))
|
||||||
|
});
|
108
src/pages/tools/audio/trim/service.ts
Normal file
108
src/pages/tools/audio/trim/service.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
58
src/pages/tools/audio/trim/trim.service.test.ts
Normal file
58
src/pages/tools/audio/trim/trim.service.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
5
src/pages/tools/audio/trim/types.ts
Normal file
5
src/pages/tools/audio/trim/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type InitialValuesType = {
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
outputFormat: 'mp3' | 'aac' | 'wav';
|
||||||
|
};
|
Reference in New Issue
Block a user