From 321484c670adac541c352a6d097c89f332dff744 Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Tue, 8 Jul 2025 12:05:40 -0700 Subject: [PATCH] refactor(audio): rename and enhance audio speed change functionality with improved tests --- .../change-speed/change-speed.service.test.ts | 21 +++- src/pages/tools/audio/change-speed/index.tsx | 71 +---------- src/pages/tools/audio/change-speed/service.ts | 78 +++++++++++- src/pages/tools/audio/merge-audio/service.ts | 117 +++++++++++------- 4 files changed, 162 insertions(+), 125 deletions(-) diff --git a/src/pages/tools/audio/change-speed/change-speed.service.test.ts b/src/pages/tools/audio/change-speed/change-speed.service.test.ts index cf2c91f..0ebd463 100644 --- a/src/pages/tools/audio/change-speed/change-speed.service.test.ts +++ b/src/pages/tools/audio/change-speed/change-speed.service.test.ts @@ -16,11 +16,11 @@ vi.mock('@ffmpeg/util', () => ({ fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])) })); -import { main } from './service'; +import { changeAudioSpeed } from './service'; import { InitialValuesType } from './types'; -describe('changeSpeed (main)', () => { - it('should return the input file unchanged (mock implementation)', () => { +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' @@ -29,7 +29,18 @@ describe('changeSpeed (main)', () => { newSpeed: 2, outputFormat: 'mp3' }; - const result = main(mockFile, options); - expect(result).toBe(mockFile); + 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(); }); }); diff --git a/src/pages/tools/audio/change-speed/index.tsx b/src/pages/tools/audio/change-speed/index.tsx index de4ef26..e799814 100644 --- a/src/pages/tools/audio/change-speed/index.tsx +++ b/src/pages/tools/audio/change-speed/index.tsx @@ -7,9 +7,8 @@ import { InitialValuesType } from './types'; import ToolAudioInput from '@components/input/ToolAudioInput'; import ToolFileResult from '@components/result/ToolFileResult'; import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; -import { FFmpeg } from '@ffmpeg/ffmpeg'; -import { fetchFile } from '@ffmpeg/util'; import RadioWithTextField from '@components/options/RadioWithTextField'; +import { changeAudioSpeed } from './service'; const initialValues: InitialValuesType = { newSpeed: 2, @@ -30,81 +29,15 @@ export default function ChangeSpeed({ const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); - // FFmpeg only supports a tempo between 0.5 and 2.0, so we chain filters - const 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(','); - }; - const compute = async ( optionsValues: InitialValuesType, input: File | null ) => { - if (!input) return; - const { newSpeed, outputFormat } = optionsValues; - let ffmpeg: FFmpeg | null = null; - let ffmpegLoaded = false; setLoading(true); 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); + const newFile = await changeAudioSpeed(input, optionsValues); setResult(newFile); } catch (err) { - console.error(`Failed to process audio: ${err}`); setResult(null); } finally { setLoading(false); diff --git a/src/pages/tools/audio/change-speed/service.ts b/src/pages/tools/audio/change-speed/service.ts index 18d6dd6..c6e6663 100644 --- a/src/pages/tools/audio/change-speed/service.ts +++ b/src/pages/tools/audio/change-speed/service.ts @@ -1,8 +1,80 @@ import { InitialValuesType } from './types'; +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; -export function main( +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 -): File | null { - return input; +): Promise { + 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; + } } diff --git a/src/pages/tools/audio/merge-audio/service.ts b/src/pages/tools/audio/merge-audio/service.ts index 3c9da4e..802bf04 100644 --- a/src/pages/tools/audio/merge-audio/service.ts +++ b/src/pages/tools/audio/merge-audio/service.ts @@ -22,65 +22,86 @@ export async function mergeAudioFiles( const { outputFormat } = options; const outputName = `output.${outputFormat}`; - // Write all input files to FFmpeg - const inputNames: string[] = []; + // 1. Convert all inputs to WAV + const tempWavNames: string[] = []; for (let i = 0; i < inputs.length; i++) { - const inputName = `input${i}.mp3`; + const inputName = `input${i}`; + const tempWavName = `temp${i}.wav`; 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 - const 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( + await ffmpeg.exec([ + '-i', + inputName, '-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) { + 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(outputName); + await ffmpeg.deleteFile(concatWav); + if (outputFormat !== 'wav') { + await ffmpeg.deleteFile(outputName); + } return new File( [