mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-19 14:09:31 +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'
|
||||
| 'csv'
|
||||
| 'pdf'
|
||||
| 'image-generic';
|
||||
| 'image-generic'
|
||||
| 'audio';
|
||||
|
||||
export interface DefinedTool {
|
||||
type: ToolCategory;
|
||||
|
@@ -4,6 +4,7 @@ import { DefinedTool, ToolCategory } from './defineTool';
|
||||
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';
|
||||
@@ -23,7 +24,8 @@ const toolCategoriesOrder: ToolCategory[] = [
|
||||
'number',
|
||||
'png',
|
||||
'time',
|
||||
'gif'
|
||||
'gif',
|
||||
'audio'
|
||||
];
|
||||
export const tools: DefinedTool[] = [
|
||||
...imageTools,
|
||||
@@ -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: '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
|
||||
|
Reference in New Issue
Block a user