Files
readest/apps/readest-app/src/__tests__/services/tts-controller-timeline.test.ts
T
Huang Xin 42f9b8fe3c feat(tts): gapless Web Audio playback engine for Edge TTS with chapter timeline and seek (#4931)
* feat(tts): add PCM speech-bounds detection for sentence audio trimming

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add WSOLA time-stretch for pitch-preserved playback rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add sentence duration store with per-voice speaking-rate calibration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tts): serve edge audio as ArrayBuffer with in-flight fetch dedup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add WebAudioPlayer with gapless chunk scheduling and backpressure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): play edge TTS through gapless Web Audio pipeline

Replaces the per-sentence audio element with trimmed, time-stretched
buffers scheduled on the shared AudioContext. Marks dispatch at audible
time so schedule-ahead cannot run foliate's cursor past the voice; a
decode failure or missing audio skips the chunk instead of wedging the
session; pause and resume ride context suspend and resume with no iOS
rewind hack; the object-URL cache is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add section timeline with measured and estimated sentence durations

Includes the foliate-js submodule bump for the getSentences export
(fork branch feat/tts-get-sentences; fork PR must merge before this
lands so the pinned SHA resolves).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): expose section playback position and sentence-snapped seeking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): surface playback position and seek in the media session

Position state is clamped, never skipped, so the lock-screen scrubber
stays live when estimates overshoot; seekto units map per backend
(native ms, web seconds). The AudioContext warms up in the tts-speak
gesture path before any await, and the silent keep-alive element now
runs on all platforms so desktop hardware media keys survive the
removal of the per-sentence audio element.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add seekable chapter progress bar to the TTS panel

The scrubber joins the transport cluster with a thin range-xs track and
flanking tabular time labels so it cannot be misgrabbed for the chunky
rate slider (which persists a global setting). States: reserved
disabled slot until the lazy timeline lands, persists across chapter
transitions, optimistic thumb with failure toast, monotonic position,
tilde-prefixed estimated totals, sentence-event updates under e-ink.
The popup grows only when a timeline-capable client is active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record deferred TTS listening-engine follow-ups

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record background TTS decoupling design decisions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): slim the panel scrubber to a native track with remaining time

Match the footer Jump to Location slider (plain native range: thin
track, small thumb) instead of the chunky daisyUI pill, show remaining
time with a minus prefix on the right, and drop the This chapter
caption. Popup height shrinks accordingly. Verified live in Chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: pin foliate-js to merged main with getSentences export

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tts): catch autoplay rejection from the keep-alive element

Running the silent keep-alive on all platforms exposed an un-awaited
play() that headless Chromium rejects without a user gesture, failing
CI on unhandled rejections while every test passed. The keep-alive is
best-effort; the production path is gesture-qualified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:50:22 +02:00

233 lines
8.8 KiB
TypeScript

import { beforeEach, describe, expect, test, vi } from 'vitest';
import { TTSController } from '@/services/tts/TTSController';
import { TTSClient, TTSMessageEvent } from '@/services/tts/TTSClient';
import { recordMeasuredDuration } from '@/services/tts/ttsDuration';
import { FoliateView } from '@/types/view';
// --- Heavy clients replaced with light fakes (same pattern as the main
// controller suite); foliate tts.js provides a REAL-shaped getSentences fake
// that yields ranges from a jsdom document.
const makeMockClient = (name: string): TTSClient => ({
name,
initialized: true,
init: vi.fn().mockResolvedValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
speak: vi.fn().mockImplementation(async function* (): AsyncIterable<TTSMessageEvent> {
yield { code: 'end', message: 'done' };
}),
pause: vi.fn().mockResolvedValue(true),
resume: vi.fn().mockResolvedValue(true),
stop: vi.fn().mockResolvedValue(undefined),
setPrimaryLang: vi.fn(),
setRate: vi.fn().mockResolvedValue(undefined),
setPitch: vi.fn().mockResolvedValue(undefined),
setVoice: vi.fn().mockResolvedValue(undefined),
getAllVoices: vi.fn().mockResolvedValue([]),
getVoices: vi.fn().mockResolvedValue([]),
getGranularities: vi.fn().mockReturnValue(['sentence']),
supportsWordBoundaries: vi.fn().mockReturnValue(true),
getVoiceId: vi.fn().mockReturnValue('timeline-ctrl-voice'),
getSpeakingLang: vi.fn().mockReturnValue('en'),
getChunkPosition: vi.fn().mockReturnValue(0.5),
});
vi.mock('@/services/tts/WebSpeechClient', () => ({
WebSpeechClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, makeMockClient('web-speech'));
}),
}));
vi.mock('@/services/tts/EdgeTTSClient', () => ({
EdgeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, makeMockClient('edge-tts'));
}),
}));
vi.mock('@/services/tts/NativeTTSClient', () => ({
NativeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, makeMockClient('native'));
}),
}));
vi.mock('@/services/tts/TTSUtils', () => ({
TTSUtils: {
getPreferredClient: vi.fn().mockReturnValue('edge-tts'),
setPreferredClient: vi.fn(),
setPreferredVoice: vi.fn(),
getPreferredVoice: vi.fn().mockReturnValue(null),
},
}));
vi.mock('foliate-js/overlayer.js', () => ({
Overlayer: { highlight: 'highlightFn' },
}));
// Sentences of the fake section; ends avoid foliate's short-word
// abbreviation merge so counts stay predictable.
const S0 = 'The opening sentence of the chapter reads aloud smoothly.';
const S1 = 'A following sentence continues the passage without pause.';
const S2 = 'The final sentence wraps the paragraph completely together.';
let sectionDoc: Document;
let sentenceRanges: Range[] = [];
const buildSectionDoc = () => {
const parser = new DOMParser();
sectionDoc = parser.parseFromString(
`<!DOCTYPE html><html lang="en"><body><p id="p">${S0} ${S1} ${S2}</p></body></html>`,
'text/html',
);
const textNode = sectionDoc.getElementById('p')!.firstChild!;
const full = textNode.textContent!;
sentenceRanges = [S0, S1, S2].map((s) => {
const start = full.indexOf(s);
const range = sectionDoc.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, start + s.length);
return range;
});
};
vi.mock('foliate-js/tts.js', () => ({
TTS: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, {
start: vi.fn().mockReturnValue('<speak>hello</speak>'),
resume: vi.fn().mockReturnValue('<speak>hello</speak>'),
// End-of-section after one paragraph so auto-advance terminates instead
// of looping the controller forever in tests.
next: vi.fn().mockReturnValue(undefined),
prev: vi.fn().mockReturnValue(undefined),
from: vi.fn().mockReturnValue('<speak>from</speak>'),
setMark: vi.fn().mockImplementation(() => sentenceRanges[0]!.cloneRange()),
getLastRange: vi.fn().mockImplementation(() => sentenceRanges[0]!.cloneRange()),
doc: null,
});
}),
getSentences: vi.fn().mockImplementation(function* () {
for (let i = 0; i < sentenceRanges.length; i++) {
yield { blockIndex: 0, markName: String(i), range: sentenceRanges[i]! };
}
}),
}));
vi.mock('foliate-js/text-walker.js', () => ({ textWalker: vi.fn() }));
vi.mock('@/utils/ssml', () => ({
filterSSMLWithLang: vi.fn((ssml: string) => ssml),
parseSSMLMarks: vi.fn(() => ({
plainText: 'hello',
marks: [{ offset: 0, name: '0', text: 'hello', language: 'en' }],
})),
}));
vi.mock('@/utils/node', () => ({ createRejectFilter: vi.fn(() => () => 1) }));
vi.mock('@/utils/lang', () => ({
isValidLang: vi.fn(() => true),
isCJKLang: vi.fn(() => false),
}));
const makeView = () => {
const contents = {
doc: sectionDoc,
index: 0,
overlayer: { add: vi.fn(), remove: vi.fn() },
};
return {
book: { sections: [{ createDocument: vi.fn().mockResolvedValue(sectionDoc) }] },
renderer: { getContents: () => [contents], primaryIndex: 0 },
language: { isCJK: false },
getCFI: vi.fn().mockReturnValue('epubcfi(/6/2!/4/2)'),
resolveCFI: vi.fn().mockReturnValue({ anchor: () => sentenceRanges[0] }),
tts: null,
} as unknown as FoliateView;
};
describe('TTSController section timeline', () => {
let controller: TTSController;
beforeEach(async () => {
vi.spyOn(console, 'log').mockImplementation(() => {});
buildSectionDoc();
controller = new TTSController(null, makeView());
await controller.init();
await controller.initViewTTS(0);
});
test('ensureTimeline builds lazily for the edge client and caches per section', async () => {
const timeline = await controller.ensureTimeline();
expect(timeline).not.toBeNull();
expect(timeline!.length).toBe(3);
expect(await controller.ensureTimeline()).toBe(timeline);
});
test('getPlaybackInfo composes sentence position with the chunk clock', async () => {
recordMeasuredDuration('timeline-ctrl-voice', S0, 4);
recordMeasuredDuration('timeline-ctrl-voice', S1, 6);
await controller.ensureTimeline();
// getLastRange resolves to sentence 0; client chunk position is 0.5s.
const info = controller.getPlaybackInfo();
expect(info).not.toBeNull();
expect(info!.position).toBeCloseTo(0.5, 5);
expect(info!.duration).toBeGreaterThan(10);
expect(info!.measuredFraction).toBeGreaterThan(0);
});
test('getPlaybackInfo is null before the timeline is built (reserved-slot state)', () => {
expect(controller.getPlaybackInfo()).toBeNull();
});
test('getPlaybackInfo is null for non-edge clients', async () => {
await controller.setVoice('', 'en'); // empty voice id: falls through to web client
controller.ttsClient = controller.ttsWebClient;
expect(await controller.ensureTimeline()).toBeNull();
expect(controller.getPlaybackInfo()).toBeNull();
});
test('seekToTime snaps to the sentence and speaks from its range while playing', async () => {
recordMeasuredDuration('timeline-ctrl-voice', S0, 4);
recordMeasuredDuration('timeline-ctrl-voice', S1, 6);
await controller.ensureTimeline();
controller.state = 'playing';
await controller.seekToTime(5); // inside sentence 1
const tts = controller.view.tts as unknown as { from: ReturnType<typeof vi.fn> };
expect(tts.from).toHaveBeenCalledTimes(1);
const arg = tts.from.mock.calls[0]![0] as Range;
expect(arg.toString()).toBe(S1);
});
test('seekToTime while paused stays paused and still navigates', async () => {
await controller.ensureTimeline();
controller.state = 'paused';
await controller.seekToTime(0);
expect(controller.state).toBe('forward-paused');
const tts = controller.view.tts as unknown as { from: ReturnType<typeof vi.fn> };
expect(tts.from).toHaveBeenCalled();
});
test('seekToTime past the end clamps to the last sentence', async () => {
await controller.ensureTimeline();
controller.state = 'playing';
await controller.seekToTime(9999);
const tts = controller.view.tts as unknown as { from: ReturnType<typeof vi.fn> };
const arg = tts.from.mock.calls[0]![0] as Range;
expect(arg.toString()).toBe(S2);
});
test('setRate rescales the timeline', async () => {
recordMeasuredDuration('timeline-ctrl-voice', S0, 4);
recordMeasuredDuration('timeline-ctrl-voice', S1, 4);
recordMeasuredDuration('timeline-ctrl-voice', S2, 4);
const timeline = await controller.ensureTimeline();
const before = timeline!.getDuration();
await controller.setRate(2);
expect(timeline!.getDuration()).toBeCloseTo(before / 2, 5);
});
test('shutdown drops the timeline', async () => {
await controller.ensureTimeline();
await controller.shutdown();
expect(controller.getPlaybackInfo()).toBeNull();
});
});