fix(reader): keep tts playing after a voice or speed change

A voice or speed change invalidated the playing block's end handler, so the next block was never
requested, and a change during loading left the player loading forever. Hand the playing block over
to the new settings, refetch any block playback was waiting on, and cancel superseded requests in
the client and at the provider. Audio that arrives during a pause no longer resumes playback.

Fixes #1734
This commit is contained in:
neon
2026-10-12 23:08:57 +00:00
parent 591dea03a2
commit 9dbe2980dd
12 changed files with 645 additions and 88 deletions
@@ -1,4 +1,5 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { Logger } from '@nestjs/common';
import { OpenAiCompatibleProvider } from './openai-compatible.provider';
import type { StaticVoiceConfig } from './openai-compatible.provider';
@@ -115,6 +116,27 @@ describe('OpenAiCompatibleProvider', () => {
expect(headers['Authorization']).toBeUndefined();
});
it('passes the abort signal to the upstream request', async () => {
const provider = makeProvider();
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse(null));
const { signal } = new AbortController();
await provider.synthesize('hello', 'alloy', 1.0, 'mp3', signal);
expect((fetchSpy.mock.calls[0]![1] as RequestInit).signal).toBe(signal);
});
it('does not log a cancelled request as an error', async () => {
const provider = makeProvider();
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const controller = new AbortController();
controller.abort();
vi.spyOn(global, 'fetch').mockRejectedValue(new DOMException('Aborted', 'AbortError'));
await expect(provider.synthesize('hello', 'alloy', 1.0, 'mp3', controller.signal)).rejects.toThrow('Aborted');
expect(errorSpy).not.toHaveBeenCalled();
});
it('throws when API returns non-ok response', async () => {
const provider = makeProvider();
vi.spyOn(global, 'fetch').mockResolvedValue(mockFetchResponse('Internal Server Error', false, 500));
@@ -89,7 +89,7 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
this.supportsVoiceDiscovery = config.supportsVoiceDiscovery ?? true;
}
async synthesize(text: string, voiceId: string, speed: number, format: string): Promise<Buffer> {
async synthesize(text: string, voiceId: string, speed: number, format: string, signal?: AbortSignal): Promise<Buffer> {
const event = 'tts.openai.synthesize';
const startMs = Date.now();
this.logger.log(
@@ -107,6 +107,7 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
@@ -119,6 +120,12 @@ export class OpenAiCompatibleProvider implements ITtsProvider {
);
return result;
} catch (err) {
if (signal?.aborted) {
this.logger.log(
`[${event}] [end] providerId=${this.providerId} voiceId=${voiceId} durationMs=${Date.now() - startMs} outcome=cancelled - openai-compatible synthesis cancelled`,
);
throw err;
}
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
const error = err instanceof Error ? sanitizeLogValue(err.message) : 'unknown';
this.logger.error(
@@ -7,7 +7,8 @@ export interface ProviderCaptionedSpeech {
}
export interface ITtsProvider {
synthesize(text: string, voiceId: string, speed: number, format?: string): Promise<Buffer>;
/** `signal` aborts the upstream request once nobody is waiting for the audio. */
synthesize(text: string, voiceId: string, speed: number, format?: string, signal?: AbortSignal): Promise<Buffer>;
/**
* Synthesize with word timings when the upstream can produce them.
*
@@ -1,3 +1,5 @@
import { Logger } from '@nestjs/common';
import { TtsSynthesisService } from './tts-synthesis.service';
import type { ITtsProvider } from './providers/tts-provider.interface';
@@ -22,7 +24,7 @@ describe('TtsSynthesisService', () => {
it('should call provider.synthesize and return audio buffer', async () => {
const result = await service.synthesize(provider, '1', 'af_heart', 1.0, 'Hello world', 'mp3');
expect(result).toBeInstanceOf(Buffer);
expect(provider.synthesize).toHaveBeenCalledWith('Hello world', 'af_heart', 1.0, 'mp3');
expect(provider.synthesize).toHaveBeenCalledWith('Hello world', 'af_heart', 1.0, 'mp3', undefined);
});
it('should cache the result with provider+voice+speed+text as key', async () => {
@@ -49,6 +51,27 @@ describe('TtsSynthesisService', () => {
expect(provider.synthesize).toHaveBeenCalledTimes(2);
});
it('passes the abort signal on to the provider', async () => {
const { signal } = new AbortController();
await service.synthesize(provider, '1', 'af_heart', 1.0, 'Hello', 'mp3', signal);
expect(provider.synthesize).toHaveBeenCalledWith('Hello', 'af_heart', 1.0, 'mp3', signal);
});
it('treats a synthesis cancelled by the client as an outcome, not an error, and caches nothing', async () => {
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
const controller = new AbortController();
(provider.synthesize as vi.Mock).mockImplementation(() => {
controller.abort();
return Promise.reject(new DOMException('Aborted', 'AbortError'));
});
await expect(service.synthesize(provider, '1', 'af_heart', 1.0, 'Hello', 'mp3', controller.signal)).rejects.toThrow('Aborted');
expect(errorSpy).not.toHaveBeenCalled();
expect(service.getCacheSize()).toBe(0);
errorSpy.mockRestore();
});
it('should propagate provider errors', async () => {
(provider.synthesize as vi.Mock).mockRejectedValue(new Error('synthesis failed'));
await expect(service.synthesize(provider, '1', 'jenny', 1.0, 'text', 'mp3')).rejects.toThrow('synthesis failed');
@@ -145,7 +168,7 @@ describe('TtsSynthesisService', () => {
it('should synthesize a preview sentence', async () => {
const result = await service.previewVoice(provider, '1', 'af_heart');
expect(result).toBeInstanceOf(Buffer);
expect(provider.synthesize).toHaveBeenCalledWith(expect.any(String), 'af_heart', 1.0, 'mp3');
expect(provider.synthesize).toHaveBeenCalledWith(expect.any(String), 'af_heart', 1.0, 'mp3', undefined);
});
});
@@ -27,7 +27,15 @@ export class TtsSynthesisService {
this.maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES;
}
async synthesize(provider: ITtsProvider, providerId: string, voiceId: string, speed: number, text: string, format = 'mp3'): Promise<Buffer> {
async synthesize(
provider: ITtsProvider,
providerId: string,
voiceId: string,
speed: number,
text: string,
format = 'mp3',
signal?: AbortSignal,
): Promise<Buffer> {
const event = 'tts.synthesis';
const cacheKey = this.buildCacheKey(providerId, voiceId, speed, text, format);
const cached = this.cache.get(cacheKey);
@@ -39,7 +47,7 @@ export class TtsSynthesisService {
const startMs = Date.now();
this.logger.log(`[${event}] [start] providerId=${providerId} voiceId=${voiceId} speed=${speed} textLen=${text.length} - synthesis started`);
try {
const buffer = await provider.synthesize(text, voiceId, speed, format);
const buffer = await provider.synthesize(text, voiceId, speed, format, signal);
this.logger.log(
`[${event}] [end] providerId=${providerId} voiceId=${voiceId} durationMs=${Date.now() - startMs} bytes=${buffer.length} - synthesis completed`,
);
@@ -48,6 +56,12 @@ export class TtsSynthesisService {
}
return buffer;
} catch (err) {
if (signal?.aborted) {
this.logger.log(
`[${event}] [end] providerId=${providerId} voiceId=${voiceId} durationMs=${Date.now() - startMs} outcome=cancelled - synthesis cancelled because the client disconnected`,
);
throw err;
}
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
const error = err instanceof Error ? sanitizeLogValue(err.message) : 'unknown';
this.logger.error(
@@ -1,5 +1,7 @@
import { EventEmitter } from 'events';
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { TtsController } from './tts.controller';
import { TtsService } from './tts.service';
@@ -37,6 +39,78 @@ describe('TtsController', () => {
vi.clearAllMocks();
});
describe('synthesize', () => {
const dto = { text: 'Hello', providerId: '1', voiceId: 'af_heart', speed: 1.0 };
function makeReply() {
const raw = Object.assign(new EventEmitter(), { writableEnded: false });
const reply = {
raw,
header: vi.fn().mockReturnThis(),
send: vi.fn().mockImplementation(() => {
raw.writableEnded = true;
return reply;
}),
};
return reply;
}
function holdSynthesisUntilAborted(): { signal: () => AbortSignal } {
let captured: AbortSignal | undefined;
mockTtsService.synthesize.mockImplementationOnce(
(_dto: unknown, signal: AbortSignal) =>
new Promise((_resolve, reject) => {
captured = signal;
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
}),
);
return { signal: () => captured! };
}
it('sends the synthesized audio with its content type', async () => {
const reply = makeReply();
await controller.synthesize(dto, reply as unknown as FastifyReply);
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'audio/mpeg');
expect(reply.send).toHaveBeenCalledWith(Buffer.from('audio'));
expect(reply.raw.listenerCount('close')).toBe(0);
});
it('cancels the synthesis and sends nothing when the client disconnects first', async () => {
const reply = makeReply();
const held = holdSynthesisUntilAborted();
const pending = controller.synthesize(dto, reply as unknown as FastifyReply);
expect(held.signal().aborted).toBe(false);
reply.raw.emit('close');
await expect(pending).resolves.toBeUndefined();
expect(held.signal().aborted).toBe(true);
expect(reply.send).not.toHaveBeenCalled();
expect(reply.raw.listenerCount('close')).toBe(0);
});
it('does not cancel when the connection closes after the audio was sent', async () => {
const reply = makeReply();
const pending = controller.synthesize(dto, reply as unknown as FastifyReply);
const signal = mockTtsService.synthesize.mock.calls[0]![1] as AbortSignal;
await pending;
reply.raw.writableEnded = true;
reply.raw.emit('close');
expect(signal.aborted).toBe(false);
});
it('rethrows a synthesis failure while the client is still connected', async () => {
mockTtsService.synthesize.mockRejectedValueOnce(new Error('provider down'));
const reply = makeReply();
await expect(controller.synthesize(dto, reply as unknown as FastifyReply)).rejects.toThrow('provider down');
expect(reply.raw.listenerCount('close')).toBe(0);
});
});
describe('synthesizeCaptioned', () => {
it('returns base64 audio with word timings as JSON, not raw bytes', async () => {
const payload = {
+18 -2
View File
@@ -13,10 +13,26 @@ import { TtsService } from './tts.service';
export class TtsController {
constructor(private readonly ttsService: TtsService) {}
/**
* A reader that skips ahead or changes voice drops the request, and the provider call is cancelled
* with it. Not `request.signal`: Fastify aborts that once the request body has been read.
*/
@Post('synthesize')
async synthesize(@Body() dto: SynthesizeDto, @Res() reply: FastifyReply) {
const buffer = await this.ttsService.synthesize(dto);
void reply.header('Content-Type', ttsContentType(dto.format)).header('Content-Length', buffer.length).send(buffer);
const disconnected = new AbortController();
const handleClose = () => {
if (!reply.raw.writableEnded) disconnected.abort();
};
reply.raw.on('close', handleClose);
try {
const buffer = await this.ttsService.synthesize(dto, disconnected.signal);
void reply.header('Content-Type', ttsContentType(dto.format)).header('Content-Length', buffer.length).send(buffer);
} catch (err) {
if (disconnected.signal.aborted) return;
throw err;
} finally {
reply.raw.off('close', handleClose);
}
}
/**
+5 -5
View File
@@ -107,19 +107,19 @@ describe('TtsService', () => {
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
await service.synthesize({ providerId: '1', voiceId: 'alloy', text: 'Hello', speed: 1.0 });
expect(factory.getOpenAiProvider).toHaveBeenCalledWith(DB_PROVIDER);
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3');
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3', undefined);
});
it('should trim voiceId before synthesis', async () => {
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
await service.synthesize({ providerId: '1', voiceId: ' alloy ', text: 'Hello', speed: 1.0 });
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3');
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3', undefined);
});
it('should fall back to the first listed voice when voiceId is missing', async () => {
repo.findProviderById.mockResolvedValue(DB_PROVIDER);
await service.synthesize({ providerId: '1', voiceId: '', text: 'Hello', speed: 1.0 });
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3');
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'alloy', 1.0, 'Hello', 'mp3', undefined);
});
it('should throw NotFoundException when no fallback voices are available', async () => {
@@ -295,7 +295,7 @@ describe('TtsService', () => {
it('synthesizes a curated voice', async () => {
curateProvider();
await service.synthesize({ providerId: '1', voiceId: 'af_heart', text: 'Hello', speed: 1.0 });
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'af_heart', 1.0, 'Hello', 'mp3');
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'af_heart', 1.0, 'Hello', 'mp3', undefined);
});
it('rejects a voice curation has withdrawn', async () => {
@@ -313,7 +313,7 @@ describe('TtsService', () => {
it('accepts any voice id when the provider is not curated', async () => {
repo.findProviderById.mockResolvedValue({ ...DB_PROVIDER, staticVoices: null });
await service.synthesize({ providerId: '1', voiceId: 'af_sky+af_bella', text: 'Hello', speed: 1.0 });
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'af_sky+af_bella', 1.0, 'Hello', 'mp3');
expect(synthesis.synthesize).toHaveBeenCalledWith(OPENAI_PROVIDER_MOCK, '1', 'af_sky+af_bella', 1.0, 'Hello', 'mp3', undefined);
});
it('clears a withdrawn voice from saved preferences', async () => {
+10 -2
View File
@@ -46,10 +46,18 @@ export class TtsService {
// ---- Synthesis ----
async synthesize(dto: SynthesizeDto): Promise<Buffer> {
async synthesize(dto: SynthesizeDto, signal?: AbortSignal): Promise<Buffer> {
const resolved = await this.resolveProvider(dto.providerId);
const voiceId = await this.resolveSynthesisVoiceId(dto.providerId, dto.voiceId, resolved);
return this.synthesisService.synthesize(resolved.provider, dto.providerId, voiceId, dto.speed, dto.text, dto.format ?? DEFAULT_TTS_AUDIO_FORMAT);
return this.synthesisService.synthesize(
resolved.provider,
dto.providerId,
voiceId,
dto.speed,
dto.text,
dto.format ?? DEFAULT_TTS_AUDIO_FORMAT,
signal,
);
}
/**