diff --git a/src/components/input/ToolAudioInput.tsx b/src/components/input/ToolAudioInput.tsx new file mode 100644 index 0000000..c9090a7 --- /dev/null +++ b/src/components/input/ToolAudioInput.tsx @@ -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 { + accept?: string[]; +} + +export default function ToolAudioInput({ + accept = ['audio/*', '.mp3', '.wav', '.aac'], + ...props +}: AudioFileInputProps) { + const audioRef = useRef(null); + + return ( + + {({ preview }) => ( + + {preview ? ( + + )} + + ); +} diff --git a/src/pages/tools/audio/extract-audio/extract-audio.service.test.ts b/src/pages/tools/audio/extract-audio/extract-audio.service.test.ts new file mode 100644 index 0000000..88624b9 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/extract-audio.service.test.ts @@ -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'); + }); +}); diff --git a/src/pages/tools/audio/extract-audio/index.tsx b/src/pages/tools/audio/extract-audio/index.tsx new file mode 100644 index 0000000..77e0f48 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/index.tsx @@ -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(null); + const [audioFile, setAudioFile] = useState(null); + const [loading, setLoading] = useState(false); + + // Tool Options section for output format + const getGroups: GetGroupsType = ({ + values, + updateField + }) => { + return [ + { + title: 'Output Format', + component: ( + + { + 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.' + } + /> + + ) + } + ]; + }; + + // 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 ( + + } + resultComponent={ + loading ? ( + + ) : ( + + ) + } + initialValues={initialValues} + getGroups={getGroups} + compute={compute} + toolInfo={{ title: `What is ${title}?`, description: longDescription }} + setInput={setFile} + /> + ); +} diff --git a/src/pages/tools/audio/extract-audio/meta.ts b/src/pages/tools/audio/extract-audio/meta.ts new file mode 100644 index 0000000..a89afe8 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/meta.ts @@ -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')) +}); diff --git a/src/pages/tools/audio/extract-audio/service.ts b/src/pages/tools/audio/extract-audio/service.ts new file mode 100644 index 0000000..36ec1c2 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/service.ts @@ -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 { + 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}` } + ); +} diff --git a/src/pages/tools/audio/extract-audio/types.ts b/src/pages/tools/audio/extract-audio/types.ts new file mode 100644 index 0000000..d6d4f36 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/types.ts @@ -0,0 +1,3 @@ +export type InitialValuesType = { + outputFormat: string; +}; diff --git a/src/pages/tools/audio/index.ts b/src/pages/tools/audio/index.ts new file mode 100644 index 0000000..40b2828 --- /dev/null +++ b/src/pages/tools/audio/index.ts @@ -0,0 +1,2 @@ +import { tool as audioExtractAudio } from './extract-audio/meta'; +export const audioTools = [audioExtractAudio]; diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx index 0ea3935..57a0e0f 100644 --- a/src/tools/defineTool.tsx +++ b/src/tools/defineTool.tsx @@ -24,7 +24,8 @@ export type ToolCategory = | 'time' | 'csv' | 'pdf' - | 'image-generic'; + | 'image-generic' + | 'audio'; export interface DefinedTool { type: ToolCategory; diff --git a/src/tools/index.ts b/src/tools/index.ts index 08a2cf8..3ebd631 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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