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]))
}));
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();
});
});

View File

@@ -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<File | null>(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);

View File

@@ -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<File | null> {
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,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(
[