42f9b8fe3c
* 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>
233 lines
8.8 KiB
TypeScript
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();
|
|
});
|
|
});
|