refactor(audio): rename and enhance audio speed change functionality with improved tests

This commit is contained in:
AshAnand34
2025-07-08 12:05:40 -07:00
parent 881b707392
commit 321484c670
4 changed files with 162 additions and 125 deletions

View File

@@ -16,11 +16,11 @@ vi.mock('@ffmpeg/util', () => ({
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])) fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]))
})); }));
import { main } from './service'; import { changeAudioSpeed } from './service';
import { InitialValuesType } from './types'; import { InitialValuesType } from './types';
describe('changeSpeed (main)', () => { describe('changeAudioSpeed', () => {
it('should return the input file unchanged (mock implementation)', () => { it('should return a new File with the correct name and type', async () => {
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]); const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.mp3', { const mockFile = new File([mockAudioData], 'test.mp3', {
type: 'audio/mp3' type: 'audio/mp3'
@@ -29,7 +29,18 @@ describe('changeSpeed (main)', () => {
newSpeed: 2, newSpeed: 2,
outputFormat: 'mp3' outputFormat: 'mp3'
}; };
const result = main(mockFile, options); const result = await changeAudioSpeed(mockFile, options);
expect(result).toBe(mockFile); 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();
}); });
}); });

View File

@@ -7,9 +7,8 @@ import { InitialValuesType } from './types';
import ToolAudioInput from '@components/input/ToolAudioInput'; import ToolAudioInput from '@components/input/ToolAudioInput';
import ToolFileResult from '@components/result/ToolFileResult'; import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import RadioWithTextField from '@components/options/RadioWithTextField'; import RadioWithTextField from '@components/options/RadioWithTextField';
import { changeAudioSpeed } from './service';
const initialValues: InitialValuesType = { const initialValues: InitialValuesType = {
newSpeed: 2, newSpeed: 2,
@@ -30,81 +29,15 @@ export default function ChangeSpeed({
const [result, setResult] = useState<File | null>(null); const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false); 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 ( const compute = async (
optionsValues: InitialValuesType, optionsValues: InitialValuesType,
input: File | null input: File | null
) => { ) => {
if (!input) return;
const { newSpeed, outputFormat } = optionsValues;
let ffmpeg: FFmpeg | null = null;
let ffmpegLoaded = false;
setLoading(true); setLoading(true);
try { try {
ffmpeg = new FFmpeg(); const newFile = await changeAudioSpeed(input, optionsValues);
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);
setResult(newFile); setResult(newFile);
} catch (err) { } catch (err) {
console.error(`Failed to process audio: ${err}`);
setResult(null); setResult(null);
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -1,8 +1,80 @@
import { InitialValuesType } from './types'; 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, input: File | null,
options: InitialValuesType options: InitialValuesType
): File | null { ): Promise<File | null> {
return input; 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;
}
} }

View File

@@ -22,23 +22,52 @@ export async function mergeAudioFiles(
const { outputFormat } = options; const { outputFormat } = options;
const outputName = `output.${outputFormat}`; const outputName = `output.${outputFormat}`;
// Write all input files to FFmpeg // 1. Convert all inputs to WAV
const inputNames: string[] = []; const tempWavNames: string[] = [];
for (let i = 0; i < inputs.length; i++) { 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])); await ffmpeg.writeFile(inputName, await fetchFile(inputs[i]));
inputNames.push(inputName); await ffmpeg.exec([
'-i',
inputName,
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
tempWavName
]);
tempWavNames.push(tempWavName);
await ffmpeg.deleteFile(inputName);
} }
// Create a file list for concatenation // 2. Create file list for concat
const fileListName = 'filelist.txt'; const fileListName = 'filelist.txt';
const fileListContent = inputNames.map((name) => `file '${name}'`).join('\n'); const fileListContent = tempWavNames
.map((name) => `file '${name}'`)
.join('\n');
await ffmpeg.writeFile(fileListName, fileListContent); await ffmpeg.writeFile(fileListName, fileListContent);
// Build FFmpeg arguments for merging // 3. Concatenate WAV files
const args: string[] = ['-f', 'concat', '-safe', '0', '-i', fileListName]; const concatWav = 'concat.wav';
await ffmpeg.exec([
'-f',
'concat',
'-safe',
'0',
'-i',
fileListName,
'-c',
'copy',
concatWav
]);
// Add format-specific arguments // 4. Convert concatenated WAV to requested output format
let finalOutput = concatWav;
if (outputFormat !== 'wav') {
const args = ['-i', concatWav];
if (outputFormat === 'mp3') { if (outputFormat === 'mp3') {
args.push( args.push(
'-ar', '-ar',
@@ -53,34 +82,26 @@ export async function mergeAudioFiles(
); );
} else if (outputFormat === 'aac') { } else if (outputFormat === 'aac') {
args.push('-c:a', 'aac', '-b:a', '192k', '-f', 'adts', outputName); args.push('-c:a', 'aac', '-b:a', '192k', '-f', 'adts', outputName);
} else if (outputFormat === 'wav') { }
args.push( await ffmpeg.exec(args);
'-acodec', finalOutput = outputName;
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
'-f',
'wav',
outputName
);
} }
await ffmpeg.exec(args); const mergedAudio = await ffmpeg.readFile(finalOutput);
const mergedAudio = await ffmpeg.readFile(outputName); let mimeType = 'audio/wav';
if (outputFormat === 'mp3') mimeType = 'audio/mp3';
let mimeType = 'audio/mp3';
if (outputFormat === 'aac') mimeType = 'audio/aac'; if (outputFormat === 'aac') mimeType = 'audio/aac';
if (outputFormat === 'wav') mimeType = 'audio/wav';
// Clean up files // Clean up files
for (const inputName of inputNames) { for (const tempWavName of tempWavNames) {
await ffmpeg.deleteFile(inputName); await ffmpeg.deleteFile(tempWavName);
} }
await ffmpeg.deleteFile(fileListName); await ffmpeg.deleteFile(fileListName);
await ffmpeg.deleteFile(concatWav);
if (outputFormat !== 'wav') {
await ffmpeg.deleteFile(outputName); await ffmpeg.deleteFile(outputName);
}
return new File( return new File(
[ [