Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion src/tts/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,86 @@ export function parseWavHeader(wav: Buffer): WavHeader {
};
}

/** ffmpeg's `atempo` filter accepts a factor in [0.5, 2.0] per instance and
* errors outside it, so a larger change has to be split across several
* instances whose product is the requested speed. */
const ATEMPO_MIN = 0.5;
const ATEMPO_MAX = 2;

/** Widest range the chain will honour, i.e. two `atempo` stages either way.
* Past this the voice stops being intelligible, so a request that far out is
* far more likely a typo than an intent. */
const SPEED_MIN = ATEMPO_MIN * ATEMPO_MIN;
const SPEED_MAX = ATEMPO_MAX * ATEMPO_MAX;

/**
* Normalise a requested `speed` before it reaches the atempo chain.
*
* Throws on values the chain cannot converge on: it divides by the stage
* factor each pass, so `0`, a negative, or `Infinity` would loop forever —
* a hang mid-TTS with no output and no error. `NaN` escapes both loops and
* emits a literal `atempo=NaN`, which makes ffmpeg exit non-zero inside
* `execFileSync`. Both are misconfiguration, so they fail loudly.
*
* Merely extreme values are clamped rather than rejected — the intent is
* legible even when the number is silly.
*/
function guardSpeed(speed: number): number {
if (!Number.isFinite(speed) || speed <= 0) {
throw new Error(`TTS speed must be a positive finite number, got ${speed}`);
}
const capped = Math.min(SPEED_MAX, Math.max(SPEED_MIN, speed));
if (capped !== speed) {
console.warn(`Warning: TTS speed ${speed} clamped to ${capped}`);
}
return capped;
}

/** Trim floating-point noise so the filter string stays readable and ffmpeg
* never sees something like `atempo=1.7999999999999998`. */
function fmt(n: number): string {
return String(Number(n.toFixed(6)));
}

/**
* Build the ffmpeg args that change playback rate to `speed`.
*
* `atempo` rather than `asetrate`: it resamples in the time domain, so the
* voice speeds up without shifting pitch. Returns an empty array at 1x so the
* default path spawns ffmpeg with no filter at all.
*/
export function buildAtempoChain(speed: number): string[] {
if (speed === 1) return [];
speed = guardSpeed(speed);

const stages: number[] = [];
let remaining = speed;
while (remaining > ATEMPO_MAX) {
stages.push(ATEMPO_MAX);
remaining /= ATEMPO_MAX;
}
while (remaining < ATEMPO_MIN) {
stages.push(ATEMPO_MIN);
remaining /= ATEMPO_MIN;
}
stages.push(remaining);

return ['-filter:a', stages.map(s => `atempo=${fmt(s)}`).join(',')];
}

/**
* Convert arbitrary audio (MP3, OGG, PCM, etc.) to Argo's WAV format
* (mono, Float32, 24kHz) using ffmpeg.
*
* `speed` is applied here because engines that render server-side (ElevenLabs,
* Gemini) have no native rate control — this conversion is the only place the
* rate can change. Engines with their own speed parameter must not use it.
*/
export function convertToWav(audioBuffer: Buffer): Buffer {
export function convertToWav(audioBuffer: Buffer, speed = 1): Buffer {
const { execFileSync } = childProcess;
const result = execFileSync('ffmpeg', [
'-i', 'pipe:0',
...buildAtempoChain(speed),
'-f', 'wav',
'-acodec', 'pcm_f32le',
'-ac', '1',
Expand Down
5 changes: 3 additions & 2 deletions src/tts/engines/elevenlabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ export class ElevenLabsEngine implements TTSEngine {
}
const mp3Buffer = Buffer.concat(chunks);

// Convert MP3 to Argo WAV format
// Convert MP3 to Argo WAV format. ElevenLabs has no speed parameter, so
// the rate change rides along with the conversion.
const { convertToWav } = await import('../engine.js');
return convertToWav(mp3Buffer);
return convertToWav(mp3Buffer, options.speed ?? 1);
}
}
5 changes: 3 additions & 2 deletions src/tts/engines/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ export class GeminiEngine implements TTSEngine {

const audioBuffer = Buffer.from(audioPart.inlineData.data, 'base64');

// Convert to Argo WAV format
// Convert to Argo WAV format. Gemini has no speed parameter, so the rate
// change rides along with the conversion.
const { convertToWav } = await import('../engine.js');
return convertToWav(audioBuffer);
return convertToWav(audioBuffer, options.speed ?? 1);
}
}
121 changes: 121 additions & 0 deletions tests/tts/speed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildAtempoChain, convertToWav } from '../../src/tts/engine.js';
import { execFileSync } from 'node:child_process';

// `engine.ts` reads `execFileSync` off the namespace at call time, so the whole
// module has to be replaced — an ESM namespace object cannot be spied on.
vi.mock('node:child_process', async importOriginal => ({
...(await importOriginal<typeof import('node:child_process')>()),
execFileSync: vi.fn(() => Buffer.from('fake-wav')),
}));

/**
* Regression coverage for #38: `TTSEngineOptions.speed` was silently dropped by
* every engine that renders audio server-side and post-converts with ffmpeg
* (ElevenLabs, Gemini). Those engines have no native speed control, so the rate
* change has to happen during the WAV conversion.
*
* `atempo` is the right filter rather than `asetrate`: it resamples in the time
* domain, so speeding up does not raise the pitch. Its cost is a hard 0.5–2.0
* clamp per instance, which is why anything outside that range must be split
* across several chained instances whose product is the requested speed.
*/
describe('buildAtempoChain', () => {
/** Multiply the `atempo=N` values out of a chain back into one speed. */
function effectiveSpeed(args: string[]): number {
const filter = args[args.indexOf('-filter:a') + 1];
return filter
.split(',')
.map(stage => Number(stage.replace('atempo=', '')))
.reduce((a, b) => a * b, 1);
}

it('emits no filter at all for unchanged speed', () => {
expect(buildAtempoChain(1)).toEqual([]);
});

it('emits a single stage for a speed-up inside atempo range', () => {
expect(buildAtempoChain(1.5)).toEqual(['-filter:a', 'atempo=1.5']);
});

it('emits a single stage for a slow-down inside atempo range', () => {
expect(buildAtempoChain(0.75)).toEqual(['-filter:a', 'atempo=0.75']);
});

it('chains stages for a speed-up above the 2.0 per-instance ceiling', () => {
const args = buildAtempoChain(3);
expect(effectiveSpeed(args)).toBeCloseTo(3, 5);
});

it('chains stages for a slow-down below the 0.5 per-instance floor', () => {
const args = buildAtempoChain(0.25);
expect(effectiveSpeed(args)).toBeCloseTo(0.25, 5);
});

it('keeps every individual stage within ffmpeg\'s 0.5-2.0 limit', () => {
for (const speed of [0.25, 0.3, 0.5, 0.9, 1.1, 2, 3, 4]) {
const args = buildAtempoChain(speed);
const stages = args[1].split(',').map(s => Number(s.replace('atempo=', '')));
for (const stage of stages) {
expect(stage).toBeGreaterThanOrEqual(0.5);
expect(stage).toBeLessThanOrEqual(2);
}
}
});
});

describe('buildAtempoChain input guard', () => {
// The chain divides by each stage factor until the remainder lands in range,
// so these values never converge and would hang TTS rather than fail it.
it.each([0, -1, NaN, Infinity])('rejects %p instead of looping forever', speed => {
expect(() => buildAtempoChain(speed)).toThrow(/positive finite number/);
});

it('names the offending value in the error', () => {
expect(() => buildAtempoChain(0)).toThrow('TTS speed must be a positive finite number, got 0');
});

it('caps an absurd speed-up at 4x and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const args = buildAtempoChain(100);
expect(args).toEqual(['-filter:a', 'atempo=2,atempo=2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('clamped to 4'));
warn.mockRestore();
});

it('caps an absurd slow-down at 0.25x and warns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const args = buildAtempoChain(0.01);
expect(args).toEqual(['-filter:a', 'atempo=0.5,atempo=0.5']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('clamped to 0.25'));
warn.mockRestore();
});

it('leaves an in-range speed untouched and stays quiet', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(buildAtempoChain(1.5)).toEqual(['-filter:a', 'atempo=1.5']);
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
});

describe('convertToWav speed', () => {
const execSpy = vi.mocked(execFileSync);

beforeEach(() => {
execSpy.mockClear();
});

it('passes no audio filter when speed is left at the default', () => {
convertToWav(Buffer.from('audio'));
const args = execSpy.mock.calls[0][1] as string[];
expect(args).not.toContain('-filter:a');
});

it('passes the atempo chain to ffmpeg when a speed is requested', () => {
convertToWav(Buffer.from('audio'), 1.25);
const args = execSpy.mock.calls[0][1] as string[];
expect(args).toContain('-filter:a');
expect(args[args.indexOf('-filter:a') + 1]).toBe('atempo=1.25');
});
});
Loading