mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-19 05:59:34 +02:00
feat: add audio extraction tool to convert video files to audio formats (AAC, MP3, WAV)
This commit is contained in:
46
src/components/input/ToolAudioInput.tsx
Normal file
46
src/components/input/ToolAudioInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
105
src/pages/tools/audio/extract-audio/index.tsx
Normal file
105
src/pages/tools/audio/extract-audio/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
SelectChangeEvent
|
||||||
|
} from '@mui/material';
|
||||||
|
import React, { useState, useEffect, useRef } 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);
|
||||||
|
|
||||||
|
// Tool Options section for output format
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute function for ToolContent (no-op, extraction is handled by effect)
|
||||||
|
const compute = async (values: InitialValuesType, input: File | null) => {
|
||||||
|
if (!input) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const audioFileObj = await extractAudioFromVideo(input, values);
|
||||||
|
await 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}
|
||||||
|
extension={''}
|
||||||
|
loading={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ToolFileResult
|
||||||
|
title={'Extracted Audio'}
|
||||||
|
value={audioFile}
|
||||||
|
extension={initialValues.outputFormat}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
initialValues={initialValues}
|
||||||
|
getGroups={getGroups}
|
||||||
|
compute={compute}
|
||||||
|
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
|
||||||
|
setInput={setFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
26
src/pages/tools/audio/extract-audio/meta.ts
Normal file
26
src/pages/tools/audio/extract-audio/meta.ts
Normal 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'))
|
||||||
|
});
|
70
src/pages/tools/audio/extract-audio/service.ts
Normal file
70
src/pages/tools/audio/extract-audio/service.ts
Normal 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}`;
|
||||||
|
let 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}` }
|
||||||
|
);
|
||||||
|
}
|
3
src/pages/tools/audio/extract-audio/types.ts
Normal file
3
src/pages/tools/audio/extract-audio/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type InitialValuesType = {
|
||||||
|
outputFormat: string;
|
||||||
|
};
|
2
src/pages/tools/audio/index.ts
Normal file
2
src/pages/tools/audio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { tool as audioExtractAudio } from './extract-audio/meta';
|
||||||
|
export const audioTools = [audioExtractAudio];
|
@@ -24,7 +24,8 @@ export type ToolCategory =
|
|||||||
| 'time'
|
| 'time'
|
||||||
| 'csv'
|
| 'csv'
|
||||||
| 'pdf'
|
| 'pdf'
|
||||||
| 'image-generic';
|
| 'image-generic'
|
||||||
|
| 'audio';
|
||||||
|
|
||||||
export interface DefinedTool {
|
export interface DefinedTool {
|
||||||
type: ToolCategory;
|
type: ToolCategory;
|
||||||
|
@@ -4,6 +4,7 @@ import { DefinedTool, ToolCategory } from './defineTool';
|
|||||||
import { capitalizeFirstLetter } from '../utils/string';
|
import { capitalizeFirstLetter } from '../utils/string';
|
||||||
import { numberTools } from '../pages/tools/number';
|
import { numberTools } from '../pages/tools/number';
|
||||||
import { videoTools } from '../pages/tools/video';
|
import { videoTools } from '../pages/tools/video';
|
||||||
|
import { audioTools } from 'pages/tools/audio';
|
||||||
import { listTools } from '../pages/tools/list';
|
import { listTools } from '../pages/tools/list';
|
||||||
import { Entries } from 'type-fest';
|
import { Entries } from 'type-fest';
|
||||||
import { jsonTools } from '../pages/tools/json';
|
import { jsonTools } from '../pages/tools/json';
|
||||||
@@ -23,7 +24,8 @@ const toolCategoriesOrder: ToolCategory[] = [
|
|||||||
'number',
|
'number',
|
||||||
'png',
|
'png',
|
||||||
'time',
|
'time',
|
||||||
'gif'
|
'gif',
|
||||||
|
'audio'
|
||||||
];
|
];
|
||||||
export const tools: DefinedTool[] = [
|
export const tools: DefinedTool[] = [
|
||||||
...imageTools,
|
...imageTools,
|
||||||
@@ -34,7 +36,8 @@ export const tools: DefinedTool[] = [
|
|||||||
...csvTools,
|
...csvTools,
|
||||||
...videoTools,
|
...videoTools,
|
||||||
...numberTools,
|
...numberTools,
|
||||||
...timeTools
|
...timeTools,
|
||||||
|
...audioTools
|
||||||
];
|
];
|
||||||
const categoriesConfig: {
|
const categoriesConfig: {
|
||||||
type: ToolCategory;
|
type: ToolCategory;
|
||||||
@@ -115,6 +118,12 @@ const categoriesConfig: {
|
|||||||
icon: 'material-symbols-light:image-outline-rounded',
|
icon: 'material-symbols-light:image-outline-rounded',
|
||||||
value:
|
value:
|
||||||
'Tools for working with pictures – compress, resize, crop, convert to JPG, rotate, remove background and much more.'
|
'Tools for working with pictures – compress, resize, crop, convert to JPG, rotate, remove background and much more.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'audio',
|
||||||
|
icon: 'rivet-icons:audio',
|
||||||
|
value:
|
||||||
|
'Tools for working with audio – extract audio from video, adjusting audio speed, merging multiple audio files and much more.'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
// use for changelogs
|
// use for changelogs
|
||||||
|
Reference in New Issue
Block a user