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>
This commit is contained in:
Huang Xin
2026-07-05 17:50:22 +09:00
committed by GitHub
parent 727f6150a6
commit 42f9b8fe3c
30 changed files with 3328 additions and 412 deletions
+31
View File
@@ -0,0 +1,31 @@
# TODOS
Deferred items from the Edge TTS Web Audio plan review (/autoplan, 2026-07-04).
Each was explicitly deferred, not forgotten — see the Decision Audit Trail in
`.agents/plans/2026-07-03-edge-tts-webaudio.md`.
## TTS listening engine follow-ups
- [ ] Cross-section gapless playback: preload and schedule the next section's first
sentence so chapter boundaries are as seamless as sentence boundaries. (M)
- [ ] Buffer-ahead indicator in the TTS scrubber (show the prefetched region,
YouTube-style). (S)
- [ ] Lock-screen ±10s seek offsets in addition to prev/next sentence. (S)
- [ ] Persist measured sentence durations per book so a reopened chapter starts
with an exact timeline instead of estimates. (S)
- [ ] Worker offload for decode + WSOLA if device profiling shows main-thread jank
on low-end Android. (S)
- [ ] Sticky TTSBar scrubber (panel + lock screen only in the first release;
recorded as deliberate in decision #24). (S)
- [ ] Provider-agnostic voice source hedge: local neural TTS (e.g. Piper/Kokoro
WASM) plugging into WebAudioPlayer/SectionTimeline — the engine is designed
for this; see "Strategic framing" in the plan. (L)
- [ ] Background chapter prefetch (convert timeline estimates to exact durations
ahead of playback). (M)
- [ ] Background TTS: decouple session ownership from the reader view via an
app-level TTSSessionManager so closing the book keeps TTS playing
(headless text supply via section.createDocument(), CFI re-anchoring for
highlights on reattach, library now-playing pill). Decided matrix: close
book = keep playing; reopen same book = seamless reattach (adopt session,
redispatchPosition, lazy doc swap at next section); open a DIFFERENT
book = TTS stops; explicit stop / sleep timer = stops. (M)
@@ -148,6 +148,9 @@ vi.mock('@/services/tts', () => ({
getVoices: vi.fn().mockResolvedValue([]),
getVoiceId: vi.fn().mockReturnValue(''),
redispatchPosition: vi.fn(),
ensureTimeline: vi.fn().mockResolvedValue(null),
getPlaybackInfo: vi.fn().mockReturnValue(null),
seekToTime: vi.fn().mockResolvedValue(undefined),
state: 'idle',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
@@ -155,12 +158,17 @@ vi.mock('@/services/tts', () => ({
});
ttsControllerInstances.push(this);
}),
ensureSharedAudioContext: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/libs/mediaSession', () => ({
TauriMediaSession: class {},
}));
const { mockMediaSessionRef } = vi.hoisted(() => ({
mockMediaSessionRef: { current: null as unknown },
}));
vi.mock('@/utils/ssml', () => ({
genSSMLRaw: vi.fn((s: string) => `<speak>${s}</speak>`),
parseSSMLLang: vi.fn(() => 'en'),
@@ -205,7 +213,7 @@ const { mockDeinitMediaSession } = vi.hoisted(() => ({
vi.mock('@/app/reader/hooks/useTTSMediaSession', () => ({
useTTSMediaSession: () => ({
mediaSessionRef: { current: null },
mediaSessionRef: mockMediaSessionRef,
unblockAudio: vi.fn(),
releaseUnblockAudio: vi.fn(),
initMediaSession: vi.fn().mockResolvedValue(undefined),
@@ -479,3 +487,103 @@ describe('useTTSControl handleHighlightMark cross-section navigation', () => {
expect(mockView.goTo).not.toHaveBeenCalled();
});
});
describe('useTTSControl media-session position and seek', () => {
interface FakeWebMediaSession {
setActionHandler: ReturnType<typeof vi.fn>;
setPositionState: ReturnType<typeof vi.fn>;
handlers: Map<string, (details: MediaSessionActionDetails) => void>;
metadata: unknown;
playbackState: string;
}
const makeFakeMediaSession = (): FakeWebMediaSession => {
const handlers = new Map<string, (details: MediaSessionActionDetails) => void>();
return {
handlers,
metadata: null,
playbackState: 'none',
setActionHandler: vi.fn((action: string, cb: (d: MediaSessionActionDetails) => void) => {
handlers.set(action, cb);
}),
setPositionState: vi.fn(),
};
};
type ControllerMock = {
ensureTimeline: ReturnType<typeof vi.fn>;
getPlaybackInfo: ReturnType<typeof vi.fn>;
seekToTime: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
ttsControllerInstances.length = 0;
pendingInitResolvers.length = 0;
mockMediaSessionRef.current = makeFakeMediaSession();
});
afterEach(() => {
mockMediaSessionRef.current = null;
cleanup();
});
const startTTS = async () => {
render(<Harness />);
await act(async () => {
const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
for (let i = 0; i < 10; i++) await Promise.resolve();
while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
await p;
});
return ttsControllerInstances[0] as ControllerMock;
};
it('registers a seekto handler that seeks the controller in seconds', async () => {
const controller = await startTTS();
const fake = mockMediaSessionRef.current as FakeWebMediaSession;
expect(fake.handlers.has('seekto')).toBe(true);
await act(async () => {
fake.handlers.get('seekto')!({ seekTime: 42 } as MediaSessionActionDetails);
for (let i = 0; i < 5; i++) await Promise.resolve();
});
expect(controller.seekToTime).toHaveBeenCalledWith(42);
});
it('pushes clamped position state on tts-speak-mark', async () => {
const controller = await startTTS();
controller.getPlaybackInfo.mockReturnValue({
position: 20,
duration: 10,
measuredFraction: 1,
});
const markListener = controller.addEventListener.mock.calls.find(
([type]) => type === 'tts-speak-mark',
)?.[1] as (e: Event) => void;
expect(markListener).toBeDefined();
await act(async () => {
markListener(new CustomEvent('tts-speak-mark', { detail: { text: 'hi', name: '0' } }));
for (let i = 0; i < 5; i++) await Promise.resolve();
});
const fake = mockMediaSessionRef.current as FakeWebMediaSession;
expect(fake.setPositionState).toHaveBeenCalledWith({
duration: 10,
position: 10, // clamped to duration, never skipped
playbackRate: 1,
});
});
it('does not push position state while the timeline is unavailable', async () => {
const controller = await startTTS();
controller.getPlaybackInfo.mockReturnValue(null);
const markListener = controller.addEventListener.mock.calls.find(
([type]) => type === 'tts-speak-mark',
)?.[1] as (e: Event) => void;
await act(async () => {
markListener(new CustomEvent('tts-speak-mark', { detail: { text: 'hi', name: '0' } }));
for (let i = 0; i < 5; i++) await Promise.resolve();
});
const fake = mockMediaSessionRef.current as FakeWebMediaSession;
expect(fake.setPositionState).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const fetchWithAuthMock = vi.fn();
vi.mock('@/utils/fetch', () => ({
fetchWithAuth: (...args: unknown[]) => fetchWithAuthMock(...args),
}));
vi.mock('@/services/environment', () => ({
getAPIBaseUrl: () => 'http://api.test',
isTauriAppPlatform: () => false,
}));
import {
EdgeSpeechTTS,
type EdgeTTSPayload,
hashTTSPayload,
serializeWordBoundaries,
WORD_BOUNDARIES_HEADER,
} from '@/libs/edgeTTS';
const makePayload = (text: string): EdgeTTSPayload => ({
lang: 'en',
text,
voice: 'en-US-AriaNeural',
rate: 1.0,
pitch: 1.0,
});
const makeResponse = () =>
new Response(new Uint8Array([1, 2, 3, 4]).buffer, {
status: 200,
headers: {
[WORD_BOUNDARIES_HEADER]: serializeWordBoundaries([
{ offset: 1_000_000, duration: 4_000_000, text: 'hello' },
]),
},
});
describe('hashTTSPayload', () => {
test('is stable for equal payload content', () => {
expect(hashTTSPayload(makePayload('abc'))).toBe(hashTTSPayload(makePayload('abc')));
});
test('differs when payload fields differ', () => {
expect(hashTTSPayload(makePayload('abc'))).not.toBe(hashTTSPayload(makePayload('abd')));
expect(hashTTSPayload({ ...makePayload('abc'), pitch: 1.2 })).not.toBe(
hashTTSPayload(makePayload('abc')),
);
});
});
describe('createAudioData', () => {
beforeEach(() => {
fetchWithAuthMock.mockReset();
fetchWithAuthMock.mockImplementation(async () => makeResponse());
});
test('returns audio bytes and boundaries from the network on first call', async () => {
const tts = new EdgeSpeechTTS('https');
const { data, boundaries } = await tts.createAudioData(makePayload('first call text'));
expect(new Uint8Array(data)).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(boundaries).toHaveLength(1);
expect(boundaries[0]!.text).toBe('hello');
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
});
test('serves the second call from cache with a fresh, non-detached buffer', async () => {
const tts = new EdgeSpeechTTS('https');
const payload = makePayload('cache hit text');
const first = await tts.createAudioData(payload);
const second = await tts.createAudioData(payload);
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
// WebKit's decodeAudioData detaches its input; every call must get its
// own copy so replay from cache cannot hand out a detached buffer.
expect(second.data).not.toBe(first.data);
expect(first.data.byteLength).toBe(4);
expect(second.data.byteLength).toBe(4);
expect(second.boundaries).toHaveLength(1);
});
test('deduplicates concurrent in-flight fetches for the same payload', async () => {
let release: (() => void) | undefined;
fetchWithAuthMock.mockImplementation(
() =>
new Promise((resolve) => {
release = () => resolve(makeResponse());
}),
);
const tts = new EdgeSpeechTTS('https');
const payload = makePayload('concurrent text');
const p1 = tts.createAudioData(payload);
const p2 = tts.createAudioData(payload);
await Promise.resolve();
release!();
const [r1, r2] = await Promise.all([p1, p2]);
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
expect(new Uint8Array(r1.data)).toEqual(new Uint8Array(r2.data));
});
test('a failed fetch is not cached; the next call retries', async () => {
fetchWithAuthMock.mockImplementationOnce(async () => {
throw new Error('network down');
});
const tts = new EdgeSpeechTTS('https');
const payload = makePayload('retry after failure');
await expect(tts.createAudioData(payload)).rejects.toThrow('network down');
const { data } = await tts.createAudioData(payload);
expect(data.byteLength).toBe(4);
expect(fetchWithAuthMock).toHaveBeenCalledTimes(2);
});
});
@@ -78,7 +78,7 @@ const makeMetadataFrame = (text: string, offset: number, duration: number) =>
],
});
describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', () => {
describe('EdgeSpeechTTS.createAudioData word boundaries (browser WebSocket path)', () => {
beforeEach(() => {
wsState.instances.length = 0;
(URL as unknown as { createObjectURL?: (blob: Blob) => string }).createObjectURL = vi.fn(
@@ -97,7 +97,7 @@ describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', (
pitch: 1.0,
};
const promise = tts.createAudio(payload);
const promise = tts.createAudioData(payload);
await vi.waitFor(() => expect(wsState.instances.length).toBe(1));
const ws = wsState.instances[0]!;
// In a browser (jsdom has `window`), the WebSocket constructor must be
@@ -110,8 +110,8 @@ describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', (
ws.emit('message', { data: makeMetadataFrame('brave', 6000000, 4000000) });
ws.emit('message', { data: 'Path:turn.end\r\n\r\n' });
const { url, boundaries } = await promise;
expect(url).toBe('blob:mock-object-url');
const { data, boundaries } = await promise;
expect(new Uint8Array(data)).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(boundaries).toEqual([
{ offset: 1000000, duration: 4000000, text: 'Hello' },
{ offset: 6000000, duration: 4000000, text: 'brave' },
@@ -119,9 +119,9 @@ describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', (
// A second call for the same payload is served from the cache: no new
// WebSocket connection, same boundaries.
const cached = await tts.createAudio(payload);
const cached = await tts.createAudioData(payload);
expect(wsState.instances.length).toBe(1);
expect(cached.url).toBe('blob:mock-object-url');
expect(new Uint8Array(cached.data)).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(cached.boundaries).toEqual(boundaries);
});
@@ -129,7 +129,7 @@ describe('EdgeSpeechTTS.createAudio word boundaries (browser WebSocket path)', (
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
const tts = new EdgeSpeechTTS('wss');
const promise = tts.createAudio({
const promise = tts.createAudioData({
lang: 'en',
text: 'No metadata here',
voice: 'en-US-AriaNeural',
@@ -171,7 +171,7 @@ describe('word-boundary header (de)serialization', () => {
});
});
describe('EdgeSpeechTTS.createAudio over the HTTPS proxy (word boundaries via header)', () => {
describe('EdgeSpeechTTS.createAudioData over the HTTPS proxy (word boundaries via header)', () => {
beforeEach(() => {
httpState.headers = {};
httpState.body = new Uint8Array([1, 2, 3]);
@@ -191,7 +191,7 @@ describe('EdgeSpeechTTS.createAudio over the HTTPS proxy (word boundaries via he
httpState.headers = { [WORD_BOUNDARIES_HEADER]: serializeWordBoundaries(boundaries) };
const tts = new EdgeSpeechTTS('https');
const result = await tts.createAudio({
const result = await tts.createAudioData({
lang: 'en',
text: 'Hello world https',
voice: 'en-US-AriaNeural',
@@ -204,7 +204,7 @@ describe('EdgeSpeechTTS.createAudio over the HTTPS proxy (word boundaries via he
test('returns empty boundaries when the proxy omits the header', async () => {
const { EdgeSpeechTTS } = await import('@/libs/edgeTTS');
const tts = new EdgeSpeechTTS('https');
const result = await tts.createAudio({
const result = await tts.createAudioData({
lang: 'en',
text: 'No header https',
voice: 'en-US-AriaNeural',
@@ -0,0 +1,302 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { TTSMessageEvent } from '@/services/tts/TTSClient';
import type { TTSController } from '@/services/tts/TTSController';
import { FakeAudioContext } from './tts-fake-audio';
// Mock control shared with the hoisted module mock.
type MockAudioData = {
data: ArrayBuffer;
boundaries: Array<{ offset: number; duration: number; text: string }>;
};
let createAudioDataBehavior: (payloadText: string) => Promise<MockAudioData>;
let parsedMarks: Array<{ name: string; text: string; language: string }> = [];
vi.mock('@/libs/edgeTTS', () => {
const voices = [{ id: 'en-US-AriaNeural', name: 'Aria', lang: 'en-US' }];
return {
EdgeSpeechTTS: class MockEdgeSpeechTTS {
static voices = voices;
create = vi.fn().mockResolvedValue(undefined);
createAudioData = vi
.fn()
.mockImplementation((payload: { text: string }) => createAudioDataBehavior(payload.text));
},
EDGE_TTS_PROTOCOL: 'wss',
};
});
vi.mock('@/utils/ssml', () => ({
parseSSMLMarks: vi.fn(() => ({ marks: parsedMarks })),
}));
vi.mock('@/utils/misc', () => ({
getUserLocale: vi.fn((lang: string) => (lang === 'en' ? 'en-US' : lang)),
}));
vi.mock('@/services/tts/TTSUtils', () => ({
TTSUtils: {
getPreferredVoice: vi.fn(() => null),
sortVoicesPreferLocaleFunc: () => () => 0,
},
}));
const consoleSpy = {
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
};
void consoleSpy;
// One second of fake audio bytes: the fake decoder maps 1 byte -> 1 sample at
// 24kHz, and all-zero samples make findSpeechBounds keep the full range.
const audioOf = (seconds: number): MockAudioData => ({
data: new ArrayBuffer(Math.round(seconds * 24000)),
boundaries: [],
});
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
let rafCallbacks: Map<number, FrameRequestCallback>;
let rafId = 0;
const runRaf = () => {
const cbs = [...rafCallbacks.values()];
rafCallbacks.clear();
for (const cb of cbs) cb(0);
};
interface MockController {
dispatchSpeakMark: ReturnType<typeof vi.fn>;
prepareSpeakWords: ReturnType<typeof vi.fn>;
dispatchSpeakWord: ReturnType<typeof vi.fn>;
}
type EdgeClientClass = typeof import('@/services/tts/EdgeTTSClient').EdgeTTSClient;
describe('EdgeTTSClient Web Audio playback', () => {
let EdgeTTSClient: EdgeClientClass;
let controller: MockController;
beforeEach(async () => {
vi.resetModules();
FakeAudioContext.instances = [];
rafCallbacks = new Map();
vi.stubGlobal('AudioContext', FakeAudioContext);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.set(++rafId, cb);
return rafId;
});
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
rafCallbacks.delete(id);
});
createAudioDataBehavior = async () => audioOf(1);
parsedMarks = [
{ name: '0', text: 'First sentence.', language: 'en' },
{ name: '1', text: 'Second sentence.', language: 'en' },
];
controller = {
dispatchSpeakMark: vi.fn(),
prepareSpeakWords: vi.fn(),
dispatchSpeakWord: vi.fn(),
};
({ EdgeTTSClient } = await import('@/services/tts/EdgeTTSClient'));
});
afterEach(() => {
vi.unstubAllGlobals();
});
const startClient = async () => {
const client = new EdgeTTSClient(controller as unknown as TTSController);
await client.init();
return client;
};
const collectSpeak = (client: InstanceType<EdgeClientClass>, signal: AbortSignal) => {
const events: TTSMessageEvent[] = [];
const done = (async () => {
for await (const event of client.speak('<ssml/>', signal)) {
events.push(event);
}
})();
return { events, done };
};
const ctx = () => FakeAudioContext.instances[0]!;
test('plays marks gaplessly: boundary per audible chunk, one final end', async () => {
const client = await startClient();
const { events, done } = collectSpeak(client, new AbortController().signal);
await flush();
await flush();
// Both chunks scheduled ahead, but only chunk 0 is audible: exactly one
// mark dispatched so foliate's cursor tracks the voice, not the fetcher.
expect(ctx().sources.length).toBe(2);
expect(controller.dispatchSpeakMark).toHaveBeenCalledTimes(1);
expect(events.filter((e) => e.code === 'boundary')).toHaveLength(1);
await ctx().advanceTo(1.1); // chunk 0 ends (starts at 0.03, 1s long)
await flush();
expect(controller.dispatchSpeakMark).toHaveBeenCalledTimes(2);
await ctx().advanceTo(3); // chunk 1 ends
await done;
expect(events.map((e) => e.code)).toEqual(['boundary', 'boundary', 'end']);
expect(events[0]!.mark).toBe('0');
expect(events[1]!.mark).toBe('1');
});
test('chunks are scheduled with a rate-scaled gap and no element restarts', async () => {
const client = await startClient();
await client.setRate(1); // gap = 0.15 / 1
const { done } = collectSpeak(client, new AbortController().signal);
await flush();
await flush();
const [first, second] = ctx().sources;
expect(second!.startedAt! - first!.endTime).toBeCloseTo(0.15, 5);
await ctx().advanceTo(5);
await done;
});
test('word tracking follows the audio clock and survives pause/resume', async () => {
createAudioDataBehavior = async () => ({
data: new ArrayBuffer(48000), // 2s
boundaries: [
{ offset: 1_000_000, duration: 4_000_000, text: 'Hello' }, // 0.1s
{ offset: 6_000_000, duration: 4_000_000, text: 'brave' }, // 0.6s
{ offset: 11_000_000, duration: 4_000_000, text: 'world' }, // 1.1s
],
});
parsedMarks = [{ name: '0', text: 'Hello brave world', language: 'en' }];
const client = await startClient();
const { done } = collectSpeak(client, new AbortController().signal);
await flush();
await flush();
expect(controller.prepareSpeakWords).toHaveBeenCalledWith(['Hello', 'brave', 'world']);
ctx().currentTime = 0.03 + 0.15;
runRaf();
expect(controller.dispatchSpeakWord).toHaveBeenLastCalledWith(0);
await client.pause();
expect(ctx().state).toBe('suspended');
await client.resume();
expect(ctx().state).toBe('running');
ctx().currentTime = 0.03 + 0.7;
runRaf();
expect(controller.dispatchSpeakWord).toHaveBeenLastCalledWith(1);
// Same index is not re-dispatched.
const calls = controller.dispatchSpeakWord.mock.calls.length;
runRaf();
expect(controller.dispatchSpeakWord.mock.calls.length).toBe(calls);
await ctx().advanceTo(5);
await done;
});
test('abort mid-stream yields Aborted and stops all sources', async () => {
const client = await startClient();
const abortController = new AbortController();
const { events, done } = collectSpeak(client, abortController.signal);
await flush();
await flush();
abortController.abort();
await done;
expect(events.at(-1)).toMatchObject({ code: 'error', message: 'Aborted' });
expect(ctx().sources.every((s) => s.stopped)).toBe(true);
});
test('a no-audio mark is skipped and the session continues', async () => {
createAudioDataBehavior = async (text: string) => {
if (text === 'First sentence.') throw new Error('No audio data received.');
return audioOf(1);
};
const client = await startClient();
const { events, done } = collectSpeak(client, new AbortController().signal);
await flush();
await flush();
await ctx().advanceTo(5);
await done;
const codes = events.map((e) => e.code);
expect(codes.filter((c) => c === 'boundary')).toHaveLength(1);
expect(codes.at(-1)).toBe('end');
});
test('a decode failure is treated like no-audio: warn, skip, continue', async () => {
// The context exists once the scheduler's first fetch runs (ensureContext
// precedes it), so the first fetch installs a decoder that fails exactly
// once — the first mark's decode dies, the second succeeds.
let installed = false;
createAudioDataBehavior = async () => {
if (!installed) {
installed = true;
const context = FakeAudioContext.instances[0]!;
const original = context.decodeImpl;
let failed = false;
context.decodeImpl = async (data) => {
if (!failed) {
failed = true;
throw new Error('bad mp3');
}
return original(data);
};
}
return audioOf(1);
};
const client = await startClient();
const { events, done } = collectSpeak(client, new AbortController().signal);
await flush();
await flush();
await ctx().advanceTo(10);
await done;
const codes = events.map((e) => e.code);
expect(codes.filter((c) => c === 'boundary')).toHaveLength(1); // only mark 1 played
expect(codes.at(-1)).toBe('end');
});
test('all marks failing still ends the session with end (no wedge)', async () => {
createAudioDataBehavior = async () => {
throw new Error('No audio data received.');
};
const client = await startClient();
const { events, done } = collectSpeak(client, new AbortController().signal);
await done; // zero chunks scheduled; session-end fires synchronously
const codes = events.map((e) => e.code);
expect(codes.at(-1)).toBe('end');
expect(codes).not.toContain('boundary');
});
test('a hard fetch error yields error and terminates', async () => {
createAudioDataBehavior = async () => {
throw new Error('network exploded');
};
const client = await startClient();
const { events, done } = collectSpeak(client, new AbortController().signal);
await done;
expect(events.at(-1)).toMatchObject({ code: 'error', message: 'network exploded' });
}, 10000);
test('pause without a session is a no-op returning true', async () => {
const client = await startClient();
expect(await client.pause()).toBe(true);
expect(await client.resume()).toBe(true);
});
test('getChunkPosition reports trim-relative clamped seconds', async () => {
const client = await startClient();
parsedMarks = [{ name: '0', text: 'Only sentence.', language: 'en' }];
const { done } = collectSpeak(client, new AbortController().signal);
await flush();
await flush();
ctx().currentTime = 0.03 + 0.4;
const pos = client.getChunkPosition();
expect(pos).not.toBeNull();
expect(pos!).toBeGreaterThan(0.3);
expect(pos!).toBeLessThanOrEqual(1);
await ctx().advanceTo(5);
await done;
expect(client.getChunkPosition()).toBeNull();
});
});
@@ -3,14 +3,13 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
// Shared mock control: tests can override createBehavior to change how create() behaves
let createBehavior: () => Promise<undefined> = () => Promise.resolve(undefined);
// Shared mock control for createAudioUrl() and parsed SSML marks
let createAudioUrlBehavior = vi.fn<() => Promise<string>>(() => Promise.resolve('blob:mock-url'));
type MockAudioResult = {
url: string;
// Shared mock control for createAudioData() and parsed SSML marks
type MockAudioData = {
data: ArrayBuffer;
boundaries: Array<{ offset: number; duration: number; text: string }>;
};
let createAudioBehavior = vi.fn<() => Promise<MockAudioResult>>(() =>
Promise.resolve({ url: 'blob:mock-url', boundaries: [] }),
let createAudioDataBehavior = vi.fn<() => Promise<MockAudioData>>(() =>
Promise.resolve({ data: new ArrayBuffer(8), boundaries: [] }),
);
let parsedMarks: Array<{ name: string; text: string; language: string }> = [];
@@ -27,8 +26,7 @@ vi.mock('@/libs/edgeTTS', () => {
EdgeSpeechTTS: class MockEdgeSpeechTTS {
static voices = voices;
create = vi.fn().mockImplementation(() => createBehavior());
createAudioUrl = vi.fn().mockImplementation(() => createAudioUrlBehavior());
createAudio = vi.fn().mockImplementation(() => createAudioBehavior());
createAudioData = vi.fn().mockImplementation(() => createAudioDataBehavior());
},
EDGE_TTS_PROTOCOL: 'wss',
};
@@ -70,9 +68,8 @@ describe('EdgeTTSClient', () => {
beforeEach(() => {
createBehavior = () => Promise.resolve(undefined);
createAudioUrlBehavior = vi.fn<() => Promise<string>>(() => Promise.resolve('blob:mock-url'));
createAudioBehavior = vi.fn<() => Promise<MockAudioResult>>(() =>
Promise.resolve({ url: 'blob:mock-url', boundaries: [] }),
createAudioDataBehavior = vi.fn<() => Promise<MockAudioData>>(() =>
Promise.resolve({ data: new ArrayBuffer(8), boundaries: [] }),
);
parsedMarks = [];
client = new EdgeTTSClient();
@@ -431,14 +428,14 @@ describe('EdgeTTSClient', () => {
}
};
test('retries createAudioUrl up to 3 times when preload fails', async () => {
test('retries createAudioData up to 3 times when preload fails', async () => {
await client.init();
parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }];
createAudioUrlBehavior = vi.fn(() => Promise.reject(new Error('network error')));
createAudioDataBehavior = vi.fn(() => Promise.reject(new Error('network error')));
await consumePreload(client, new AbortController().signal);
expect(createAudioUrlBehavior).toHaveBeenCalledTimes(3);
expect(createAudioDataBehavior).toHaveBeenCalledTimes(3);
});
test('does not retry when the first preload attempt succeeds', async () => {
@@ -447,37 +444,47 @@ describe('EdgeTTSClient', () => {
await consumePreload(client, new AbortController().signal);
expect(createAudioUrlBehavior).toHaveBeenCalledTimes(1);
expect(createAudioDataBehavior).toHaveBeenCalledTimes(1);
});
test('stops retrying once an attempt succeeds', async () => {
await client.init();
parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }];
let calls = 0;
createAudioUrlBehavior = vi.fn(() => {
createAudioDataBehavior = vi.fn(() => {
calls++;
return calls < 2
? Promise.reject(new Error('network error'))
: Promise.resolve('blob:mock-url');
: Promise.resolve({ data: new ArrayBuffer(8), boundaries: [] });
});
await consumePreload(client, new AbortController().signal);
expect(createAudioUrlBehavior).toHaveBeenCalledTimes(2);
expect(createAudioDataBehavior).toHaveBeenCalledTimes(2);
});
test('does not retry a permanent no-audio failure', async () => {
await client.init();
parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }];
createAudioDataBehavior = vi.fn(() => Promise.reject(new Error('No audio data received.')));
await consumePreload(client, new AbortController().signal);
expect(createAudioDataBehavior).toHaveBeenCalledTimes(1);
});
test('stops retrying once the signal is aborted', async () => {
await client.init();
parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }];
const controller = new AbortController();
createAudioUrlBehavior = vi.fn(() => {
createAudioDataBehavior = vi.fn(() => {
controller.abort();
return Promise.reject(new Error('network error'));
});
await consumePreload(client, controller.signal);
expect(createAudioUrlBehavior).toHaveBeenCalledTimes(1);
expect(createAudioDataBehavior).toHaveBeenCalledTimes(1);
});
});
@@ -496,137 +503,4 @@ describe('EdgeTTSClient', () => {
await expect(client.stop()).resolves.toBeUndefined();
});
});
describe('word boundary tracking during playback', () => {
class MockAudio {
static instances: MockAudio[] = [];
src = '';
currentTime = 0;
preload = '';
playbackRate = 1;
onended: ((e?: Event) => void) | null = null;
onerror: ((e?: unknown) => void) | null = null;
constructor() {
MockAudio.instances.push(this);
}
setAttribute() {}
play() {
return Promise.resolve();
}
pause() {}
}
let rafCallbacks: Map<number, FrameRequestCallback>;
let rafId = 0;
const runRaf = () => {
const cbs = [...rafCallbacks.values()];
rafCallbacks.clear();
for (const cb of cbs) cb(0);
};
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
let mockController: {
dispatchSpeakMark: ReturnType<typeof vi.fn>;
prepareSpeakWords: ReturnType<typeof vi.fn>;
dispatchSpeakWord: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
MockAudio.instances = [];
rafCallbacks = new Map();
vi.stubGlobal('Audio', MockAudio);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCallbacks.set(++rafId, cb);
return rafId;
});
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
rafCallbacks.delete(id);
});
mockController = {
dispatchSpeakMark: vi.fn(),
prepareSpeakWords: vi.fn(),
dispatchSpeakWord: vi.fn(),
};
client = new EdgeTTSClient(mockController as unknown as TTSController);
parsedMarks = [{ name: '0', text: 'Hello brave world', language: 'en' }];
createAudioBehavior = vi.fn(() =>
Promise.resolve({
url: 'blob:mock-url',
boundaries: [
{ offset: 1_000_000, duration: 4_000_000, text: 'Hello' },
{ offset: 6_000_000, duration: 4_000_000, text: 'brave' },
{ offset: 11_000_000, duration: 4_000_000, text: 'world' },
],
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const startSpeak = async () => {
await client.init();
const it = client.speak('<ssml/>', new AbortController().signal);
const first = await it.next();
expect((first.value as { code: string }).code).toBe('boundary');
const resultPromise = it.next();
await flush();
const audio = MockAudio.instances.at(-1)!;
return { it, resultPromise, audio };
};
test('prepares speak words and dispatches word indexes as playback advances', async () => {
const { resultPromise, audio } = await startSpeak();
expect(mockController.prepareSpeakWords).toHaveBeenCalledWith(['Hello', 'brave', 'world']);
audio.currentTime = 0.11;
runRaf();
expect(mockController.dispatchSpeakWord).toHaveBeenCalledWith(0);
audio.currentTime = 0.65;
runRaf();
expect(mockController.dispatchSpeakWord).toHaveBeenLastCalledWith(1);
// Same word index is not re-dispatched on subsequent frames.
const callCount = mockController.dispatchSpeakWord.mock.calls.length;
runRaf();
expect(mockController.dispatchSpeakWord.mock.calls.length).toBe(callCount);
audio.onended?.();
const result = await resultPromise;
expect((result.value as { code: string }).code).toBe('end');
});
test('stops dispatching after the chunk ends', async () => {
const { resultPromise, audio } = await startSpeak();
audio.currentTime = 0.11;
runRaf();
const callCount = mockController.dispatchSpeakWord.mock.calls.length;
audio.onended?.();
await resultPromise;
audio.currentTime = 1.2;
runRaf();
expect(mockController.dispatchSpeakWord.mock.calls.length).toBe(callCount);
});
test('hands empty words to the controller and does not track when no boundaries', async () => {
createAudioBehavior = vi.fn(() => Promise.resolve({ url: 'blob:mock-url', boundaries: [] }));
const { resultPromise, audio } = await startSpeak();
// Empty words are still forwarded so the controller can draw the
// sentence-highlight fallback; no per-word tracking is started.
expect(mockController.prepareSpeakWords).toHaveBeenCalledWith([]);
audio.currentTime = 0.5;
runRaf();
expect(mockController.dispatchSpeakWord).not.toHaveBeenCalled();
audio.onended?.();
await resultPromise;
});
});
});
@@ -0,0 +1,232 @@
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();
});
});
@@ -0,0 +1,120 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
calibrateVoiceRate,
defaultCharsPerSecond,
estimateSentenceSeconds,
getMeasuredDuration,
recordMeasuredDuration,
recordProvisionalDuration,
} from '@/services/tts/ttsDuration';
const VOICE = 'en-US-AriaNeural';
afterEach(() => {
localStorage.clear();
vi.unstubAllGlobals();
});
describe('measured duration store', () => {
test('records and retrieves a duration by voice and text', () => {
recordMeasuredDuration(VOICE, 'The quick brown fox jumps.', 2.4);
expect(getMeasuredDuration(VOICE, 'The quick brown fox jumps.')).toBe(2.4);
});
test('punctuation and case variants hit the same key', () => {
recordMeasuredDuration(VOICE, 'Hello—world! Again…', 1.7);
expect(getMeasuredDuration(VOICE, 'hello world again')).toBe(1.7);
expect(getMeasuredDuration(VOICE, 'Hello, world; AGAIN')).toBe(1.7);
});
test('different voices do not share durations', () => {
recordMeasuredDuration(VOICE, 'Shared sentence text here.', 2.0);
expect(getMeasuredDuration('fr-FR-DeniseNeural', 'Shared sentence text here.')).toBeUndefined();
});
test('provisional never overwrites measured; measured overwrites provisional', () => {
recordProvisionalDuration(VOICE, 'A provisional sentence sample.', 3.0);
expect(getMeasuredDuration(VOICE, 'A provisional sentence sample.')).toBe(3.0);
recordMeasuredDuration(VOICE, 'A provisional sentence sample.', 2.5);
expect(getMeasuredDuration(VOICE, 'A provisional sentence sample.')).toBe(2.5);
recordProvisionalDuration(VOICE, 'A provisional sentence sample.', 9.9);
expect(getMeasuredDuration(VOICE, 'A provisional sentence sample.')).toBe(2.5);
});
});
describe('defaultCharsPerSecond', () => {
test('CJK languages are far denser per character than Latin', () => {
expect(defaultCharsPerSecond('zh-CN')).toBeLessThan(defaultCharsPerSecond('en-US'));
expect(defaultCharsPerSecond('ja')).toBeLessThan(defaultCharsPerSecond('fr'));
expect(defaultCharsPerSecond('ko')).toBeLessThan(defaultCharsPerSecond('de-DE'));
});
});
describe('estimateSentenceSeconds', () => {
test('uses the measured duration when available', () => {
recordMeasuredDuration(VOICE, 'A measured sentence for estimates.', 4.2);
expect(estimateSentenceSeconds('A measured sentence for estimates.', 'en', VOICE)).toBe(4.2);
});
test('falls back to the script default without calibration', () => {
const text = 'x'.repeat(150);
const est = estimateSentenceSeconds(text, 'en', 'uncalibrated-voice');
expect(est).toBeCloseTo(150 / defaultCharsPerSecond('en'), 3);
});
test('prefers the calibrated per-voice rate over the default', () => {
const voice = 'calibration-voice';
// Voice speaks 20 normalized chars per second.
calibrateVoiceRate(voice, 'a'.repeat(40), 2);
calibrateVoiceRate(voice, 'b'.repeat(60), 3);
const est = estimateSentenceSeconds('c'.repeat(100), 'en', voice);
expect(est).toBeGreaterThan(100 / 25);
expect(est).toBeLessThan(100 / 15);
});
test('calibration converges toward the observed rate', () => {
const voice = 'converging-voice';
for (let i = 0; i < 20; i++) {
calibrateVoiceRate(voice, 'a'.repeat(50), 5); // 10 cps observed
}
const est = estimateSentenceSeconds('d'.repeat(100), 'en', voice);
expect(est).toBeGreaterThan(100 / 12);
expect(est).toBeLessThan(100 / 8);
});
test('short texts are skipped for calibration', () => {
const voice = 'short-skip-voice';
calibrateVoiceRate(voice, 'hi', 60); // absurd sample must be ignored
const est = estimateSentenceSeconds('e'.repeat(150), 'en', voice);
expect(est).toBeCloseTo(150 / defaultCharsPerSecond('en'), 3);
});
test('empty text estimates zero', () => {
expect(estimateSentenceSeconds('', 'en', VOICE)).toBe(0);
});
});
describe('persistence', () => {
test('per-voice calibration survives a module reload via localStorage', async () => {
calibrateVoiceRate('persisted-voice', 'a'.repeat(50), 5);
vi.resetModules();
const fresh = await import('@/services/tts/ttsDuration');
const est = fresh.estimateSentenceSeconds('f'.repeat(100), 'en', 'persisted-voice');
expect(est).toBeGreaterThan(100 / 16);
});
test('does not throw when localStorage is unavailable', () => {
vi.stubGlobal('localStorage', {
getItem: () => {
throw new Error('denied');
},
setItem: () => {
throw new Error('denied');
},
clear: () => {},
});
expect(() => calibrateVoiceRate('blocked-voice', 'a'.repeat(50), 5)).not.toThrow();
expect(() => estimateSentenceSeconds('g'.repeat(100), 'en', 'blocked-voice')).not.toThrow();
});
});
@@ -0,0 +1,138 @@
// Fake Web Audio primitives for jsdom tests. The fake clock is manual:
// advanceTo(t) fires due onended callbacks in end-time order, awaiting
// microtasks between each so schedulers and waiters can react, matching how
// the real audio clock interleaves with the JS task queue.
import type {
TTSAudioBuffer,
TTSAudioBufferSourceNode,
TTSAudioContext,
} from '@/services/tts/WebAudioPlayer';
export class FakeAudioBuffer implements TTSAudioBuffer {
constructor(
public samples: Float32Array,
public readonly sampleRate: number,
) {}
get length() {
return this.samples.length;
}
get duration() {
return this.samples.length / this.sampleRate;
}
getChannelData(_channel: number): Float32Array {
return this.samples;
}
copyToChannel(source: Float32Array, _channel: number): void {
this.samples.set(source.subarray(0, this.samples.length));
}
}
export class FakeSourceNode implements TTSAudioBufferSourceNode {
buffer: TTSAudioBuffer | null = null;
onended: (() => void) | null = null;
startedAt: number | null = null;
stopped = false;
connected = false;
endedFired = false;
constructor(private ctx: FakeAudioContext) {}
connect(_destination: unknown): void {
this.connected = true;
}
disconnect(): void {
this.connected = false;
}
start(when = 0): void {
this.startedAt = Math.max(when, this.ctx.currentTime);
this.ctx.sources.push(this);
}
stop(_when?: number): void {
this.stopped = true;
this.onended?.();
}
get endTime(): number {
return (this.startedAt ?? 0) + (this.buffer?.duration ?? 0);
}
}
export class FakeAudioContext implements TTSAudioContext {
// Constructed instances, newest last — client tests stub the global
// AudioContext with this class and need a handle on the context the shared
// singleton created internally.
static instances: FakeAudioContext[] = [];
currentTime = 0;
state = 'running';
destination = {};
onstatechange: (() => void) | null = null;
sources: FakeSourceNode[] = [];
resumeCalls = 0;
suspendCalls = 0;
closeCalls = 0;
sampleRate: number;
// Overridable resume behavior for interruption-refusal tests.
resumeImpl: (() => void) | null = null;
decodeImpl: (data: ArrayBuffer) => Promise<TTSAudioBuffer> = async (data) =>
new FakeAudioBuffer(new Float32Array(data.byteLength), this.sampleRate);
constructor(sampleRate = 24000) {
this.sampleRate = sampleRate;
FakeAudioContext.instances.push(this);
}
async resume(): Promise<void> {
this.resumeCalls++;
if (this.resumeImpl) {
this.resumeImpl();
} else {
this.state = 'running';
}
this.onstatechange?.();
}
async suspend(): Promise<void> {
this.suspendCalls++;
this.state = 'suspended';
this.onstatechange?.();
}
async close(): Promise<void> {
this.closeCalls++;
this.state = 'closed';
this.onstatechange?.();
}
createBufferSource(): FakeSourceNode {
return new FakeSourceNode(this);
}
createBuffer(_channels: number, length: number, sampleRate: number): FakeAudioBuffer {
return new FakeAudioBuffer(new Float32Array(length), sampleRate);
}
decodeAudioData(data: ArrayBuffer): Promise<TTSAudioBuffer> {
return this.decodeImpl(data);
}
setState(state: string): void {
this.state = state;
this.onstatechange?.();
}
async advanceTo(t: number): Promise<void> {
for (;;) {
const due = this.sources
.filter((s) => s.startedAt !== null && !s.stopped && !s.endedFired && s.endTime <= t)
.sort((a, b) => a.endTime - b.endTime)[0];
if (!due) break;
this.currentTime = Math.max(this.currentTime, due.endTime);
due.endedFired = true;
due.onended?.();
await Promise.resolve();
await Promise.resolve();
}
this.currentTime = Math.max(this.currentTime, t);
await Promise.resolve();
await Promise.resolve();
}
}
export const makeBuffer = (seconds: number, sampleRate = 24000): FakeAudioBuffer =>
new FakeAudioBuffer(new Float32Array(Math.round(seconds * sampleRate)), sampleRate);
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'vitest';
import { findSpeechBounds } from '@/services/tts/pcm';
const SR = 24000;
const makeSignal = (
leadingSilenceSec: number,
speechSec: number,
trailingSilenceSec: number,
noiseFloor = 0,
) => {
const total = Math.round((leadingSilenceSec + speechSec + trailingSilenceSec) * SR);
const samples = new Float32Array(total);
const speechStart = Math.round(leadingSilenceSec * SR);
const speechEnd = speechStart + Math.round(speechSec * SR);
for (let i = 0; i < total; i++) {
if (i >= speechStart && i < speechEnd) {
samples[i] = 0.3 * Math.sin((2 * Math.PI * 440 * i) / SR);
} else if (noiseFloor > 0) {
// Deterministic pseudo-noise below the detection threshold, emulating
// MP3 decoder dither/ringing in "silent" passages.
samples[i] = noiseFloor * Math.sin((2 * Math.PI * 1731 * i) / SR + i * 0.7);
}
}
return samples;
};
describe('findSpeechBounds', () => {
test('trims leading and trailing silence with head/tail pads', () => {
const samples = makeSignal(0.5, 1.0, 0.8);
const { startSec, endSec } = findSpeechBounds(samples, SR);
expect(startSec).toBeGreaterThan(0.47 - 1e-6);
expect(startSec).toBeLessThan(0.53);
expect(endSec).toBeGreaterThan(1.44);
expect(endSec).toBeLessThan(1.56 + 1e-6);
expect(endSec).toBeGreaterThan(startSec);
});
test('ignores a realistic decoder noise floor in silent passages', () => {
const samples = makeSignal(0.5, 1.0, 0.8, 0.0008);
const { startSec, endSec } = findSpeechBounds(samples, SR);
expect(startSec).toBeGreaterThan(0.4);
expect(startSec).toBeLessThan(0.53);
expect(endSec).toBeGreaterThan(1.44);
expect(endSec).toBeLessThan(1.6);
});
test('all-silence input returns the full range', () => {
const samples = new Float32Array(SR); // 1s of zeros
const { startSec, endSec } = findSpeechBounds(samples, SR);
expect(startSec).toBe(0);
expect(endSec).toBeCloseTo(1, 5);
});
test('empty input returns zero bounds', () => {
const { startSec, endSec } = findSpeechBounds(new Float32Array(0), SR);
expect(startSec).toBe(0);
expect(endSec).toBe(0);
});
test('speech reaching the buffer edges clamps to the buffer', () => {
const samples = makeSignal(0, 0.5, 0);
const { startSec, endSec } = findSpeechBounds(samples, SR);
expect(startSec).toBe(0);
expect(endSec).toBeCloseTo(0.5, 2);
});
});
@@ -0,0 +1,145 @@
import { describe, expect, test } from 'vitest';
import { textWalker } from 'foliate-js/text-walker.js';
import { getSentences } from 'foliate-js/tts.js';
import { SectionTimeline, type TimelineSentence } from '@/services/tts/SectionTimeline';
import { recordMeasuredDuration } from '@/services/tts/ttsDuration';
// Note: foliate's segmenter merges sentences ending in short (<=3 letter)
// words as suspected abbreviations, so every fixture sentence ends long.
const SENTENCE_A0 = 'The quick brown fox jumps over the lazy hound.';
const SENTENCE_A1 = 'A second sentence follows the first one closely.';
const SENTENCE_B0 = 'Another paragraph starts a new block of text.';
const SENTENCE_B1 = 'It also carries a couple of sentences inside.';
const makeDoc = (): Document => {
const parser = new DOMParser();
return parser.parseFromString(
`<!DOCTYPE html><html lang="en"><body>` +
`<p>${SENTENCE_A0} ${SENTENCE_A1}</p>` +
`<p>${SENTENCE_B0} ${SENTENCE_B1}</p>` +
`</body></html>`,
'text/html',
);
};
const enumerate = (doc: Document): TimelineSentence[] => {
const sentences: TimelineSentence[] = [];
for (const { blockIndex, markName, range } of getSentences(doc, textWalker, null, 'sentence')) {
sentences.push({ blockIndex, markName, range, text: range.toString() });
}
return sentences;
};
describe('SectionTimeline', () => {
test('enumerates sentences via foliate getSentences with block-scoped marks', () => {
const sentences = enumerate(makeDoc());
expect(sentences).toHaveLength(4);
expect(sentences[0]!.blockIndex).toBe(0);
expect(sentences[2]!.blockIndex).toBe(1);
// Mark names restart per block, matching foliate's per-block mark naming.
expect(sentences[0]!.markName).toBe(sentences[2]!.markName);
expect(sentences[0]!.text.trim()).toBe(SENTENCE_A0);
expect(sentences[3]!.text.trim()).toBe(SENTENCE_B1);
});
test('mixes measured and estimated durations after refresh', () => {
const voice = 'timeline-voice-mixed';
const sentences = enumerate(makeDoc());
const timeline = new SectionTimeline(sentences, 'en', voice);
const estimatedTotal = timeline.getDuration();
expect(estimatedTotal).toBeGreaterThan(0);
recordMeasuredDuration(voice, SENTENCE_A0, 10);
timeline.refresh();
const mixedTotal = timeline.getDuration();
// Sentence A0's estimate (~3s at 15cps) was replaced by the 10s measured value.
expect(mixedTotal).toBeGreaterThan(estimatedTotal + 5);
expect(timeline.getMeasuredFraction()).toBeGreaterThan(0);
expect(timeline.getMeasuredFraction()).toBeLessThan(1);
});
test('positionAt sums prior durations plus the within-sentence offset', () => {
const voice = 'timeline-voice-position';
const sentences = enumerate(makeDoc());
recordMeasuredDuration(voice, SENTENCE_A0, 4);
recordMeasuredDuration(voice, SENTENCE_A1, 6);
const timeline = new SectionTimeline(sentences, 'en', voice);
expect(timeline.positionAt(0, 1.5)).toBeCloseTo(1.5, 5);
expect(timeline.positionAt(1, 2)).toBeCloseTo(4 + 2, 5);
// Clamps out-of-range indexes.
expect(timeline.positionAt(-1, 0)).toBe(0);
expect(timeline.positionAt(99, 0)).toBeCloseTo(timeline.getDuration(), 5);
});
test('rate rescales duration and position without touching stored data', () => {
const voice = 'timeline-voice-rate';
const sentences = enumerate(makeDoc());
recordMeasuredDuration(voice, SENTENCE_A0, 4);
const timeline = new SectionTimeline(sentences, 'en', voice);
const at1 = timeline.getDuration();
timeline.setRate(2);
expect(timeline.getDuration()).toBeCloseTo(at1 / 2, 5);
expect(timeline.positionAt(1, 0)).toBeCloseTo(4 / 2, 5);
timeline.setRate(0.5);
expect(timeline.getDuration()).toBeCloseTo(at1 * 2, 5);
});
test('sentenceAtTime maps seconds to the sentence, clamping past the end', () => {
const voice = 'timeline-voice-seek';
const sentences = enumerate(makeDoc());
for (const [text, dur] of [
[SENTENCE_A0, 4],
[SENTENCE_A1, 6],
[SENTENCE_B0, 5],
[SENTENCE_B1, 5],
] as const) {
recordMeasuredDuration(voice, text, dur);
}
const timeline = new SectionTimeline(sentences, 'en', voice);
expect(timeline.sentenceAtTime(0)?.index).toBe(0);
expect(timeline.sentenceAtTime(4.5)?.index).toBe(1);
expect(timeline.sentenceAtTime(11)?.index).toBe(2);
// Past the (possibly over-estimated) end: clamp to the last sentence so a
// lock-screen scrub past the real end is not a dead gesture.
expect(timeline.sentenceAtTime(999)?.index).toBe(3);
expect(timeline.sentenceAtTime(-1)?.index).toBe(0);
// Rate applies: at 2x, 5.5 real seconds is 11 media seconds = sentence 2.
timeline.setRate(2);
expect(timeline.sentenceAtTime(5.5)?.index).toBe(2);
});
test('sentenceAtTime returns null for an empty timeline', () => {
const timeline = new SectionTimeline([], 'en', 'timeline-voice-empty');
expect(timeline.sentenceAtTime(0)).toBeNull();
expect(timeline.getDuration()).toBe(0);
});
test('indexOfRange finds exact ranges, sub-ranges, and rejects foreign docs', () => {
const doc = makeDoc();
const sentences = enumerate(doc);
const timeline = new SectionTimeline(sentences, 'en', 'timeline-voice-range');
expect(timeline.indexOfRange(sentences[2]!.range)).toBe(2);
// A collapsed range inside sentence 3 still resolves to sentence 3.
const sub = sentences[3]!.range.cloneRange();
sub.collapse(false);
expect(timeline.indexOfRange(sub)).toBe(3);
const foreign = makeDoc().createRange();
expect(timeline.indexOfRange(foreign)).toBe(-1);
});
test('setVoice re-estimates unmeasured entries under the new voice', () => {
const voiceA = 'timeline-voice-a';
const voiceB = 'timeline-voice-b';
const sentences = enumerate(makeDoc());
recordMeasuredDuration(voiceA, SENTENCE_A0, 20);
const timeline = new SectionTimeline(sentences, 'en', voiceA);
const withMeasured = timeline.getDuration();
timeline.setVoice(voiceB);
// Voice B has no measured data: everything estimates again.
expect(timeline.getDuration()).toBeLessThan(withMeasured);
expect(timeline.getMeasuredFraction()).toBe(0);
});
});
@@ -0,0 +1,103 @@
import { describe, expect, test } from 'vitest';
import { timeStretch } from '@/services/tts/timeStretch';
const SR = 24000;
const makeSpeechLike = (seconds: number) => {
const n = Math.round(seconds * SR);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
const t = i / SR;
// Multi-sine with slow amplitude modulation, roughly speech-shaped.
const env = 0.5 + 0.5 * Math.sin(2 * Math.PI * 3 * t);
out[i] =
env *
(0.3 * Math.sin(2 * Math.PI * 220 * t) +
0.2 * Math.sin(2 * Math.PI * 470 * t) +
0.1 * Math.sin(2 * Math.PI * 910 * t));
}
return out;
};
const makeSine = (seconds: number, freq: number) => {
const n = Math.round(seconds * SR);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
out[i] = 0.5 * Math.sin((2 * Math.PI * freq * i) / SR);
}
return out;
};
const zeroCrossingsPerSec = (samples: Float32Array) => {
let crossings = 0;
for (let i = 1; i < samples.length; i++) {
if (samples[i - 1]! <= 0 !== samples[i]! <= 0) crossings++;
}
return crossings / (samples.length / SR);
};
describe('timeStretch', () => {
test('tempo 1.5 shortens output to ~1/1.5 of input length', () => {
const input = makeSpeechLike(3);
const output = timeStretch(input, SR, 1.5);
const ratio = output.length / input.length;
expect(ratio).toBeGreaterThan((1 / 1.5) * 0.95);
expect(ratio).toBeLessThan((1 / 1.5) * 1.05);
});
test('tempo 0.75 lengthens output to ~1/0.75 of input length', () => {
const input = makeSpeechLike(3);
const output = timeStretch(input, SR, 0.75);
const ratio = output.length / input.length;
expect(ratio).toBeGreaterThan((1 / 0.75) * 0.95);
expect(ratio).toBeLessThan((1 / 0.75) * 1.05);
});
test('preserves pitch: zero-crossing rate unchanged at tempo 1.5', () => {
const input = makeSine(1, 440);
const output = timeStretch(input, SR, 1.5);
const inputZps = zeroCrossingsPerSec(input); // ~880
const outputZps = zeroCrossingsPerSec(output);
// A resampler would land near 1320; WSOLA must stay near the input rate.
expect(outputZps).toBeGreaterThan(inputZps * 0.97);
expect(outputZps).toBeLessThan(inputZps * 1.03);
});
test('tempo 1 returns an equal-length copy, not the same reference', () => {
const input = makeSpeechLike(1);
const output = timeStretch(input, SR, 1);
expect(output).not.toBe(input);
expect(output.length).toBe(input.length);
expect(output[1234]).toBe(input[1234]);
});
test('all-zero input produces finite output (zero-energy correlation guard)', () => {
const input = new Float32Array(SR); // 1s of digital silence
const output = timeStretch(input, SR, 1.5);
expect(output.length).toBeGreaterThan(0);
expect(output.every((s) => Number.isFinite(s))).toBe(true);
});
test('slow tempo 0.2 produces ~5x length with finite samples', () => {
const input = makeSpeechLike(1);
const output = timeStretch(input, SR, 0.2);
const ratio = output.length / input.length;
expect(ratio).toBeGreaterThan(5 * 0.95);
expect(ratio).toBeLessThan(5 * 1.05);
expect(output.every((s) => Number.isFinite(s))).toBe(true);
});
test('very short input is returned as a copy (below two frames)', () => {
const input = makeSine(0.05, 440); // 50ms < 2 x 40ms frames
const output = timeStretch(input, SR, 2);
expect(output.length).toBe(input.length);
});
test('does not mutate its input (input may be a subarray view)', () => {
const input = makeSpeechLike(1);
const snapshot = input.slice();
timeStretch(input, SR, 1.5);
expect(input).toEqual(snapshot);
});
});
@@ -0,0 +1,312 @@
import { afterEach, describe, expect, test } from 'vitest';
import { WebAudioPlayer, type WebAudioPlayerEvent } from '@/services/tts/WebAudioPlayer';
import { FakeAudioContext, makeBuffer } from './tts-fake-audio';
const SAFETY = 0.03;
const setVisibility = (value: 'visible' | 'hidden') => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => value,
});
};
afterEach(() => {
setVisibility('visible');
});
const setup = () => {
const ctx = new FakeAudioContext();
const player = new WebAudioPlayer(() => ctx);
const events: WebAudioPlayerEvent[] = [];
return { ctx, player, events, onEvent: (e: WebAudioPlayerEvent) => events.push(e) };
};
describe('WebAudioPlayer scheduling', () => {
test('chunks are scheduled contiguously with the requested gap', async () => {
const { ctx, player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0.5 });
player.scheduleChunk(gen, makeBuffer(3), { trimStartSec: 0, mediaScale: 1, gapSec: 0.5 });
expect(ctx.sources[0]!.startedAt).toBeCloseTo(SAFETY, 5);
expect(ctx.sources[1]!.startedAt).toBeCloseTo(SAFETY + 2 + 0.5, 5);
});
test('chunk-start fires at schedule for index 0 and on prior onended after', async () => {
const { ctx, player, events, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0.2 });
expect(events).toEqual([{ type: 'chunk-start', chunkIndex: 0 }]);
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0.2 });
expect(events).toHaveLength(1);
await ctx.advanceTo(SAFETY + 2);
expect(events).toEqual([
{ type: 'chunk-start', chunkIndex: 0 },
{ type: 'chunk-start', chunkIndex: 1 },
]);
});
test('stale-generation scheduleChunk is a no-op', async () => {
const { ctx, player, events, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.startSession(() => {});
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
expect(ctx.sources).toHaveLength(0);
expect(events).toHaveLength(0);
});
});
describe('WebAudioPlayer backpressure', () => {
test('third chunk waits until the first finishes while visible', async () => {
const { ctx, player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
let resolved: boolean | null = null;
const wait = player.waitUntilReady(gen).then((r) => {
resolved = r;
return r;
});
await Promise.resolve();
expect(resolved).toBeNull();
await ctx.advanceTo(SAFETY + 2);
expect(await wait).toBe(true);
});
test('hidden visibility deepens the pending-chunk budget to 5', async () => {
setVisibility('hidden');
const { player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
for (let i = 0; i < 4; i++) {
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
}
expect(await player.waitUntilReady(gen)).toBe(true);
});
test('seconds cap blocks scheduling far ahead even under the chunk budget', async () => {
setVisibility('hidden');
const { ctx, player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(30), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
player.scheduleChunk(gen, makeBuffer(31), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
let resolved: boolean | null = null;
const wait = player.waitUntilReady(gen).then((r) => {
resolved = r;
return r;
});
await Promise.resolve();
expect(resolved).toBeNull(); // 61s ahead > 60s cap, though only 2 < 5 chunks
await ctx.advanceTo(SAFETY + 30);
expect(await wait).toBe(true);
});
test('waitUntilReady returns false for a stale generation', async () => {
const { player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.startSession(() => {});
expect(await player.waitUntilReady(gen)).toBe(false);
});
});
describe('WebAudioPlayer session end', () => {
test('session-end fires after endSession + last onended', async () => {
const { ctx, player, events, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
player.endSession(gen);
expect(events.filter((e) => e.type === 'session-end')).toHaveLength(0);
await ctx.advanceTo(SAFETY + 1);
expect(events.filter((e) => e.type === 'session-end')).toHaveLength(1);
});
test('endSession with zero scheduled chunks fires session-end synchronously', async () => {
const { player, events, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.endSession(gen);
expect(events).toEqual([{ type: 'session-end' }]);
});
test('endSession after the last onended already fired still ends the session', async () => {
const { ctx, player, events, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
await ctx.advanceTo(SAFETY + 1);
expect(events.filter((e) => e.type === 'session-end')).toHaveLength(0);
player.endSession(gen);
expect(events.filter((e) => e.type === 'session-end')).toHaveLength(1);
});
test('session-end is emitted exactly once', async () => {
const { ctx, player, events, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
player.endSession(gen);
player.endSession(gen);
await ctx.advanceTo(SAFETY + 1);
await ctx.advanceTo(SAFETY + 2);
expect(events.filter((e) => e.type === 'session-end')).toHaveLength(1);
});
});
describe('WebAudioPlayer abort', () => {
test('abort stops pending sources, resolves waiters false, silences events', async () => {
const { ctx, player, events, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
for (let i = 0; i < 3; i++) {
player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
}
const wait = player.waitUntilReady(gen);
player.abortSession();
expect(await wait).toBe(false);
expect(ctx.sources.every((s) => s.stopped)).toBe(true);
const countBefore = events.length;
await ctx.advanceTo(100);
expect(events).toHaveLength(countBefore);
});
test('double abortSession is idempotent', async () => {
const { player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
player.abortSession();
expect(() => player.abortSession()).not.toThrow();
});
test('abort storm: 10 rapid startSession calls leave one live session, no stale events', async () => {
const { ctx, player, onEvent } = setup();
await player.ensureContext();
const gens: number[] = [];
const staleEvents: WebAudioPlayerEvent[] = [];
for (let i = 0; i < 10; i++) {
const gen = player.startSession(i === 9 ? onEvent : (e) => staleEvents.push(e));
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
gens.push(gen);
}
for (const gen of gens.slice(0, 9)) {
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
expect(await player.waitUntilReady(gen)).toBe(false);
}
// Stale sessions emitted only their own synchronous chunk-start 0 before
// being aborted; nothing new after the storm.
const staleCount = staleEvents.length;
await ctx.advanceTo(50);
expect(staleEvents).toHaveLength(staleCount);
expect(await player.waitUntilReady(gens[9]!)).toBe(true);
});
});
describe('WebAudioPlayer pause/resume and interruption', () => {
test('pauseContext suspends; resumeContext resumes', async () => {
const { ctx, player } = setup();
await player.ensureContext();
player.startSession(() => {});
await player.pauseContext();
expect(ctx.state).toBe('suspended');
await player.resumeContext();
expect(ctx.state).toBe('running');
});
test('auto-resumes on unexpected suspension while a session is live', async () => {
const { ctx, player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
const before = ctx.resumeCalls;
ctx.setState('interrupted');
await Promise.resolve();
expect(ctx.resumeCalls).toBeGreaterThan(before);
});
test('user pause suppresses auto-resume', async () => {
const { ctx, player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0, mediaScale: 1, gapSec: 0 });
await player.pauseContext();
const before = ctx.resumeCalls;
ctx.setState('interrupted');
await Promise.resolve();
expect(ctx.resumeCalls).toBe(before);
});
test('resumeContext throws when the context refuses to run again', async () => {
const { ctx, player } = setup();
await player.ensureContext();
await player.pauseContext();
ctx.resumeImpl = () => {
ctx.state = 'interrupted';
};
await expect(player.resumeContext()).rejects.toThrow();
});
});
describe('WebAudioPlayer playback position', () => {
test('maps playback time through trim offset and media scale', async () => {
const { ctx, player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
// 2s output chunk covering original media [0.3, 3.3) stretched 1.5x.
player.scheduleChunk(gen, makeBuffer(2), { trimStartSec: 0.3, mediaScale: 1.5, gapSec: 0.2 });
await ctx.advanceTo(SAFETY + 1);
const pos = player.getPlaybackPosition(gen);
expect(pos?.chunkIndex).toBe(0);
expect(pos?.mediaTimeSec).toBeCloseTo(0.3 + 1 * 1.5, 3);
});
test('clamps before the first chunk and in the inter-chunk gap', async () => {
const { ctx, player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0.2, mediaScale: 1, gapSec: 1 });
player.scheduleChunk(gen, makeBuffer(1), { trimStartSec: 0.1, mediaScale: 1, gapSec: 1 });
// Before the first chunk starts.
expect(player.getPlaybackPosition(gen)?.mediaTimeSec).toBeCloseTo(0.2, 5);
// Inside the gap after chunk 0 (ends at SAFETY+1; next starts SAFETY+2).
ctx.currentTime = SAFETY + 1.5;
const pos = player.getPlaybackPosition(gen);
expect(pos?.chunkIndex).toBe(0);
expect(pos?.mediaTimeSec).toBeCloseTo(0.2 + 1, 5);
});
test('returns null for a stale generation or empty session', async () => {
const { player, onEvent } = setup();
await player.ensureContext();
const gen = player.startSession(onEvent);
expect(player.getPlaybackPosition(gen)).toBeNull();
player.startSession(() => {});
expect(player.getPlaybackPosition(gen)).toBeNull();
});
});
describe('WebAudioPlayer buffers', () => {
test('createMonoBuffer preserves samples and sample rate', async () => {
const { player } = setup();
const samples = new Float32Array([0.1, -0.2, 0.3, -0.4]);
const buffer = await player.createMonoBuffer(samples, 48000);
expect(buffer.sampleRate).toBe(48000);
expect(buffer.length).toBe(4);
expect(Array.from(buffer.getChannelData(0))).toEqual(Array.from(samples));
});
test('decode resolves through the context decoder at its sample rate', async () => {
const ctx = new FakeAudioContext(48000);
const player = new WebAudioPlayer(() => ctx);
const buffer = await player.decode(new ArrayBuffer(96000));
expect(buffer.sampleRate).toBe(48000);
expect(buffer.duration).toBeCloseTo(2, 5);
});
});
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'vitest';
import { formatPlaybackTime } from '@/utils/time';
describe('formatPlaybackTime', () => {
test('formats minutes and seconds by default', () => {
expect(formatPlaybackTime(0)).toBe('0:00');
expect(formatPlaybackTime(5)).toBe('0:05');
expect(formatPlaybackTime(62)).toBe('1:02');
expect(formatPlaybackTime(600)).toBe('10:00');
});
test('formats hours when the value reaches one hour', () => {
expect(formatPlaybackTime(3600)).toBe('1:00:00');
expect(formatPlaybackTime(3600 + 61)).toBe('1:01:01');
});
test('forceHours pins both labels of a row to the same layout', () => {
// Both labels use the format chosen by the TOTAL's magnitude so the row
// never re-layouts when the elapsed time crosses an hour.
expect(formatPlaybackTime(62, true)).toBe('0:01:02');
});
test('clamps negative and non-finite input to zero', () => {
expect(formatPlaybackTime(-5)).toBe('0:00');
expect(formatPlaybackTime(Number.NaN)).toBe('0:00');
expect(formatPlaybackTime(Number.POSITIVE_INFINITY)).toBe('0:00');
});
test('truncates fractional seconds', () => {
expect(formatPlaybackTime(59.9)).toBe('0:59');
});
});
@@ -15,7 +15,11 @@ import TTSIcon from './TTSIcon';
import TTSBar from './TTSBar';
const POPUP_WIDTH = 282;
// The popup is fixed-height and non-scrolling: these must track the panel's
// content or rows get clipped. The taller variant fits the scrubber row that
// only timeline-capable (Edge) playback shows.
const POPUP_HEIGHT = 160;
const POPUP_HEIGHT_WITH_PROGRESS = 184;
const POPUP_PADDING = 10;
interface TTSControlProps {
@@ -46,13 +50,17 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const popupPadding = useResponsiveSize(POPUP_PADDING);
const maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const popupBaseHeight = useResponsiveSize(POPUP_HEIGHT);
const popupProgressHeight = useResponsiveSize(POPUP_HEIGHT_WITH_PROGRESS);
const tts = useTTSControl({
bookKey,
onRequestHidePanel: () => setShowPanel(false),
});
const hasPlaybackInfo = tts.ttsClientsInited && tts.handleSupportsPlaybackInfo();
const popupHeight = hasPlaybackInfo ? popupProgressHeight : popupBaseHeight;
useEffect(() => {
if (tts.showBackToCurrentTTSLocation) {
setShouldMountBackButton(true);
@@ -230,6 +238,9 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
onGetVoiceId={tts.handleGetVoiceId}
onSelectTimeout={tts.handleSelectTimeout}
onToogleTTSBar={tts.handleToggleTTSBar}
onSeek={tts.handleSeekTo}
onGetPlaybackInfo={tts.handleGetPlaybackInfo}
hasTimeline={hasPlaybackInfo}
/>
</Popup>
)}
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useState, ChangeEvent, useEffect } from 'react';
import { useState, ChangeEvent, useEffect, useRef, useCallback } from 'react';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md';
import { TbChevronCompactDown, TbChevronCompactUp } from 'react-icons/tb';
import { RiVoiceAiFill } from 'react-icons/ri';
@@ -11,6 +11,14 @@ import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getLanguageName } from '@/utils/lang';
import { formatPlaybackTime } from '@/utils/time';
import { eventDispatcher } from '@/utils/event';
type TTSPlaybackInfo = {
position: number;
duration: number;
measuredFraction: number;
};
type TTSPanelProps = {
bookKey: string;
@@ -27,6 +35,161 @@ type TTSPanelProps = {
onGetVoiceId: () => string;
onSelectTimeout: (bookKey: string, value: number) => void;
onToogleTTSBar: () => void;
onSeek: (seconds: number) => Promise<void>;
onGetPlaybackInfo: () => TTSPlaybackInfo | null;
hasTimeline: boolean;
};
// Suppress poll updates briefly after a seek so the optimistic thumb never
// snaps back while the cold-seek fetch completes.
const SEEK_SUPPRESS_MS = 2000;
// Small backward drifts come from estimate refinement, not playback — hold the
// displayed position monotonic; larger jumps are deliberate (seek, chapter).
const MONOTONIC_SLACK_SEC = 3;
// Chapter progress row: elapsed / thin scrubber / total. Lives between the
// rate slider and the transport cluster; the thin range-xs track plus flanking
// time labels keep it visually distinct from the chunky tick-labeled rate
// slider one block up (misgrabbing THAT persists a global setting).
const TTSProgressRow = ({
bookKey,
isEink,
onSeek,
onGetPlaybackInfo,
}: {
bookKey: string;
isEink: boolean;
onSeek: (seconds: number) => Promise<void>;
onGetPlaybackInfo: () => TTSPlaybackInfo | null;
}) => {
const _ = useTranslation();
const [info, setInfo] = useState<TTSPlaybackInfo | null>(null);
const [stale, setStale] = useState(true);
const [displayTotal, setDisplayTotal] = useState<number | null>(null);
const [dragValue, setDragValue] = useState<number | null>(null);
const dragValueRef = useRef<number | null>(null);
const suppressUntilRef = useRef(0);
const lastPositionRef = useRef(0);
const keyboardCommitRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refresh = useCallback(() => {
if (dragValueRef.current !== null) return;
if (Date.now() < suppressUntilRef.current) return;
const next = onGetPlaybackInfo();
if (!next) {
// Keep the last-known values rendered (disabled) across chapter
// transitions and while the lazy timeline builds — no row blink.
setStale(true);
return;
}
let position = next.position;
if (
position < lastPositionRef.current &&
lastPositionRef.current - position < MONOTONIC_SLACK_SEC
) {
position = lastPositionRef.current;
}
lastPositionRef.current = position;
setInfo({ ...next, position });
setStale(false);
// Quantize the displayed total: only follow estimate drift when it moves
// by more than 2%, so the chapter length reads stable, not twitchy.
setDisplayTotal((prev) =>
prev === null || Math.abs(next.duration - prev) / prev > 0.02 ? next.duration : prev,
);
}, [onGetPlaybackInfo]);
useEffect(() => {
refresh();
if (isEink) {
// No 1s repaints on e-ink: follow sentence-level position events only.
const handler = (event: CustomEvent) => {
const detail = event.detail as { bookKey?: string; kind?: string };
if (detail.bookKey === bookKey && detail.kind === 'sentence') refresh();
};
eventDispatcher.on('tts-position', handler);
return () => eventDispatcher.off('tts-position', handler);
}
const interval = setInterval(refresh, 1000);
return () => clearInterval(interval);
}, [refresh, isEink, bookKey]);
const commitSeek = (seconds: number) => {
dragValueRef.current = null;
setDragValue(null);
// Optimistic thumb: land on the target immediately and hold it there
// while the seek resolves; never snap back.
const previous = lastPositionRef.current;
suppressUntilRef.current = Date.now() + SEEK_SUPPRESS_MS;
lastPositionRef.current = seconds;
setInfo((prev) => (prev ? { ...prev, position: seconds } : prev));
onSeek(seconds).catch(() => {
// A silent snap-back is the exact violation the optimistic thumb
// prevents — restore visibly and say why.
suppressUntilRef.current = 0;
lastPositionRef.current = previous;
setInfo((prev) => (prev ? { ...prev, position: previous } : prev));
eventDispatcher.dispatch('toast', { message: _('Failed to seek'), type: 'error' });
});
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
// React fires range onChange continuously during a drag; track the value
// and commit only on pointer/key release.
const value = parseFloat(e.target.value);
dragValueRef.current = value;
setDragValue(value);
};
const handlePointerCommit = () => {
if (dragValueRef.current !== null) commitSeek(dragValueRef.current);
};
const handleKeyUp = () => {
// Holding an arrow key must not fire a network seek per press.
if (keyboardCommitRef.current) clearTimeout(keyboardCommitRef.current);
keyboardCommitRef.current = setTimeout(() => {
if (dragValueRef.current !== null) commitSeek(dragValueRef.current);
}, 500);
};
const total = displayTotal ?? info?.duration ?? 0;
const position = Math.min(dragValue ?? info?.position ?? 0, total);
const ready = info !== null && total > 0;
const forceHours = total >= 3600;
const step = Math.max(5, Math.round(total / 100)) || 5;
const elapsedLabel = ready ? formatPlaybackTime(position, forceHours) : '--:--';
const remainingLabel = ready
? `-${formatPlaybackTime(Math.max(total - position, 0), forceHours)}`
: '--:--';
return (
<div className={clsx('flex w-full items-center gap-2 py-1', stale && 'opacity-60')}>
<span className='min-w-9 text-center text-xs tabular-nums'>{elapsedLabel}</span>
{/* Plain native range, matching the footer's Jump to Location slider:
thin track, small thumb, visually unmistakable for the chunky rate
slider above. */}
<input
className='text-base-content min-w-0 grow'
type='range'
min={0}
max={total || 1}
step={step}
value={position}
disabled={!ready || stale}
onChange={handleChange}
onPointerUp={handlePointerCommit}
onTouchEnd={handlePointerCommit}
onKeyUp={handleKeyUp}
aria-label={_('Chapter progress')}
aria-valuetext={_('{{elapsed}} of {{total}}', {
elapsed: elapsedLabel,
total: ready ? formatPlaybackTime(total, forceHours) : '--:--',
})}
/>
<span className='min-w-10 text-center text-xs tabular-nums'>{remainingLabel}</span>
</div>
);
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
@@ -116,6 +279,9 @@ const TTSPanel = ({
onGetVoiceId,
onSelectTimeout,
onToogleTTSBar,
onSeek,
onGetPlaybackInfo,
hasTimeline,
}: TTSPanelProps) => {
const _ = useTranslation();
const { envConfig } = useEnv();
@@ -233,6 +399,14 @@ const TTSPanel = ({
<span className='text-center'>{_('Fast')}</span>
</div>
</div>
{hasTimeline && (
<TTSProgressRow
bookKey={bookKey}
isEink={viewSettings?.isEink ?? false}
onSeek={onSeek}
onGetPlaybackInfo={onGetPlaybackInfo}
/>
)}
<div className='flex items-center justify-between space-x-2'>
<button
onClick={() => onBackward()}
@@ -9,7 +9,13 @@ import { useProofreadStore } from '@/store/proofreadStore';
import { TransformContext } from '@/services/transformers/types';
import { proofreadTransformer } from '@/services/transformers/proofread';
import { useTranslation } from '@/hooks/useTranslation';
import { TTSController, TTSMark, TTSHighlightOptions, TTSVoicesGroup } from '@/services/tts';
import {
ensureSharedAudioContext,
TTSController,
TTSMark,
TTSHighlightOptions,
TTSVoicesGroup,
} from '@/services/tts';
import { TauriMediaSession } from '@/libs/mediaSession';
import { eventDispatcher } from '@/utils/event';
import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml';
@@ -240,6 +246,8 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
});
}
}
void updateMediaSessionPosition();
};
const handleHighlightMark = (e: Event) => {
@@ -556,9 +564,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
previousSectionLabelRef.current = undefined;
setTTSEnabled(bookKey, false);
getView(bookKey)?.deselect();
if (appService?.isMobile) {
releaseUnblockAudio();
}
releaseUnblockAudio();
// Tear down the controller, the lock-screen media session, and the
// background-audio session best-effort and IN PARALLEL. The controller's
@@ -640,12 +646,17 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
}
try {
// Gesture-path audio unlocks, BEFORE any network/plugin await: WebKit
// rejects AudioContext.resume() outside the user-gesture window, and
// speak() itself only runs after preprocessing and preload fetches.
// The silent keep-alive element runs on ALL platforms — desktop
// Chromium only surfaces hardware media keys while an
// HTMLMediaElement is playing, and Edge playback no longer has one.
unblockAudio();
void ensureSharedAudioContext();
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: true });
}
if (appService?.isMobile) {
unblockAudio();
}
await initMediaSession();
setTtsClientsInitialized(false);
@@ -704,6 +715,64 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
}
};
// Push the section timeline's position/duration to the media session so the
// lock screen shows a live scrubber. Guarded against non-finite durations
// (empty/estimating timelines) and the position is CLAMPED, never skipped:
// skipping would freeze the lock-screen position when estimates overshoot.
const updateMediaSessionPosition = useCallback(async () => {
const ttsController = ttsControllerRef.current;
const mediaSession = mediaSessionRef.current;
if (!ttsController || !mediaSession) return;
await ttsController.ensureTimeline();
const info = ttsController.getPlaybackInfo();
if (!info || !Number.isFinite(info.duration) || info.duration <= 0) return;
const position = Math.min(Math.max(info.position, 0), info.duration);
if (mediaSession instanceof TauriMediaSession) {
await mediaSession.updatePlaybackState({
playing: ttsControllerRef.current?.state === 'playing',
position: Math.round(position * 1000),
duration: Math.round(info.duration * 1000),
});
} else if ('setPositionState' in mediaSession) {
try {
mediaSession.setPositionState({
duration: info.duration,
position,
playbackRate: 1,
});
} catch {
// Some engines reject transiently inconsistent states; the next mark
// updates again.
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sentence-snapped seek used by the lock-screen scrubber and the panel.
const handleSeekTo = useCallback(
async (seconds: number) => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
await ttsController.seekToTime(seconds);
void updateMediaSessionPosition();
},
[updateMediaSessionPosition],
);
const handleGetPlaybackInfo = useCallback(() => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return null;
// Kick the lazy timeline build (off the playback critical path); the
// first polls return null until it lands and the UI shows a
// disabled/reserved row for that state.
void ttsController.ensureTimeline();
return ttsController.getPlaybackInfo();
}, []);
const handleSupportsPlaybackInfo = useCallback(() => {
return ttsControllerRef.current?.supportsPlaybackInfo() ?? false;
}, []);
// Playback callbacks
const handleTogglePlay = useCallback(async () => {
const ttsController = ttsControllerRef.current;
@@ -879,8 +948,28 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
mediaSession.setActionHandler('previoustrack', () => {
handleBackward();
});
// Seek: units differ per backend — the native plugin reports
// milliseconds, navigator.mediaSession reports seconds. Both clamp in
// TTSController.seekToTime via the timeline (past-the-end lands on the
// last sentence, never a dead gesture).
if (mediaSession instanceof TauriMediaSession) {
mediaSession.setActionHandler('seekto', ((positionMs: number) => {
handleSeekTo(positionMs / 1000);
}) as (position: number) => void);
} else {
try {
mediaSession.setActionHandler('seekto', (details: MediaSessionActionDetails) => {
if (typeof details.seekTime === 'number') {
handleSeekTo(details.seekTime);
}
});
} catch {
// 'seekto' unsupported on this engine; the in-app scrubber covers it.
}
}
}
}, [handleTogglePlay, handlePause, handleForward, handleBackward, mediaSessionRef]);
}, [handleTogglePlay, handlePause, handleForward, handleBackward, handleSeekTo, mediaSessionRef]);
return {
isPlaying,
@@ -907,6 +996,9 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
handleSelectTimeout,
handleToggleTTSBar,
handleBackToCurrentTTSLocation,
handleSeekTo,
handleGetPlaybackInfo,
handleSupportsPlaybackInfo,
refreshTtsLang,
};
};
@@ -34,7 +34,13 @@ export const useTTSMediaSession = ({ bookKey }: UseTTSMediaSessionProps) => {
unblockerAudioRef.current.preload = 'auto';
unblockerAudioRef.current.loop = true;
unblockerAudioRef.current.src = SILENCE_DATA;
unblockerAudioRef.current.play();
unblockerAudioRef.current.play().catch((err) => {
// Autoplay policy rejects play() outside a user gesture (headless test
// runs, programmatic TTS starts). The keep-alive is best-effort: the
// production path calls this inside the tts-speak gesture handler, and
// a rejection here must not surface as an unhandled rejection.
console.warn('Keep-alive audio blocked:', err);
});
};
const releaseUnblockAudio = () => {
+42 -22
View File
@@ -343,7 +343,7 @@ export const parseWordBoundariesHeader = (value: string | null): TTSWordBoundary
}
};
const hashPayload = (payload: EdgeTTSPayload): string => {
export const hashTTSPayload = (payload: EdgeTTSPayload): string => {
const base = JSON.stringify(payload);
return md5(base);
};
@@ -353,12 +353,15 @@ export type EDGE_TTS_PROTOCOL = 'wss' | 'https';
export class EdgeSpeechTTS {
static voices = genVoiceList(EDGE_TTS_VOICES);
private static audioCache = new LRUCache<string, Blob>(200);
private static audioUrlCache = new LRUCache<string, string>(200, (_, url) => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
private static boundariesCache = new LRUCache<string, TTSWordBoundary[]>(200);
// In-flight fetches keyed by payload hash. The LRU dedupes storage, not
// requests: the playback scheduler and the preload paths race for the same
// sentences at every paragraph start, and without this map each racer opens
// its own WSS connection for the same audio.
private static inflight = new Map<
string,
Promise<{ blob: Blob; boundaries: TTSWordBoundary[] }>
>();
private protocol: EDGE_TTS_PROTOCOL = 'wss';
constructor(protocol?: EDGE_TTS_PROTOCOL) {
@@ -740,25 +743,42 @@ export class EdgeSpeechTTS {
return this.#fetchEdgeSpeech(payload);
}
async createAudio(
// Fetch (or reuse) the audio blob + boundaries for a payload, deduplicating
// both stored results (LRU) and in-flight requests (inflight map).
async #fetchAndCache(
payload: EdgeTTSPayload,
): Promise<{ url: string; boundaries: TTSWordBoundary[] }> {
const cacheKey = hashPayload(payload);
const cachedUrl = EdgeSpeechTTS.audioUrlCache.get(cacheKey);
if (cachedUrl) {
return { url: cachedUrl, boundaries: EdgeSpeechTTS.boundariesCache.get(cacheKey) ?? [] };
): Promise<{ blob: Blob; boundaries: TTSWordBoundary[] }> {
const cacheKey = hashTTSPayload(payload);
const cachedBlob = EdgeSpeechTTS.audioCache.get(cacheKey);
if (cachedBlob) {
return { blob: cachedBlob, boundaries: EdgeSpeechTTS.boundariesCache.get(cacheKey) ?? [] };
}
const pending = EdgeSpeechTTS.inflight.get(cacheKey);
if (pending) return pending;
const promise = (async () => {
const { response, boundaries } = await this.#fetchEdgeSpeech(payload);
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' });
EdgeSpeechTTS.audioCache.set(cacheKey, blob);
EdgeSpeechTTS.boundariesCache.set(cacheKey, boundaries);
return { blob, boundaries };
})();
EdgeSpeechTTS.inflight.set(cacheKey, promise);
try {
return await promise;
} finally {
EdgeSpeechTTS.inflight.delete(cacheKey);
}
const { response, boundaries } = await this.#fetchEdgeSpeech(payload);
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' });
const objectUrl = URL.createObjectURL(blob);
EdgeSpeechTTS.audioCache.set(cacheKey, blob);
EdgeSpeechTTS.audioUrlCache.set(cacheKey, objectUrl);
EdgeSpeechTTS.boundariesCache.set(cacheKey, boundaries);
return { url: objectUrl, boundaries };
}
async createAudioUrl(payload: EdgeTTSPayload): Promise<string> {
return (await this.createAudio(payload)).url;
// Audio bytes for Web Audio decoding. The cache keeps a Blob and every call
// mints a fresh ArrayBuffer copy via blob.arrayBuffer() — WebKit's
// decodeAudioData detaches its input, so handing out a shared buffer would
// break replay from cache on Safari.
async createAudioData(
payload: EdgeTTSPayload,
): Promise<{ data: ArrayBuffer; boundaries: TTSWordBoundary[] }> {
const { blob, boundaries } = await this.#fetchAndCache(payload);
return { data: await blob.arrayBuffer(), boundaries };
}
}
+342 -193
View File
@@ -2,12 +2,63 @@ import { getUserLocale } from '@/utils/misc';
import { isSameLang } from '@/utils/lang';
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload, EDGE_TTS_PROTOCOL, TTSWordBoundary } from '@/libs/edgeTTS';
import { TTSGranularity, TTSVoice, TTSVoicesGroup } from './types';
import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types';
import { AppService } from '@/types/system';
import { parseSSMLMarks } from '@/utils/ssml';
import { TTSController } from './TTSController';
import { TTSUtils } from './TTSUtils';
import { findBoundaryIndexAtTime } from './wordHighlight';
import { findSpeechBounds } from './pcm';
import { timeStretch } from './timeStretch';
import {
calibrateVoiceRate,
recordMeasuredDuration,
recordProvisionalDuration,
} from './ttsDuration';
import { TTSAudioBuffer, WebAudioPlayer, WebAudioPlayerEvent } from './WebAudioPlayer';
// Playback pipeline: fetch MP3 (cached at rate 1.0) -> decode -> trim silence
// -> WSOLA time-stretch to the playback rate -> schedule gaplessly on the
// shared AudioContext. Marks are dispatched when a chunk becomes AUDIBLE
// (player chunk-start events ride source onended, which keeps working with
// the screen off), not when it is fetched — schedule-ahead would otherwise
// run foliate's mark cursor ahead of the voice and break prev/next/resume.
// Natural pause between sentences, replacing Edge's baked-in ~300ms trailing
// silence. Divided by the playback rate so pauses shrink with speed (#2033's
// "gaps don't scale" complaint).
const INTER_SENTENCE_GAP_SEC = 0.15;
const TICKS_PER_SECOND = 10_000_000;
interface ChunkMeta {
mark: TTSMark;
boundaries: TTSWordBoundary[];
trimStartSec: number;
trimmedDurationSec: number;
}
type SpeakQueueEvent =
| { kind: 'chunk-start'; index: number }
| { kind: 'chunk-skip'; markName: string }
| { kind: 'session-end' }
| { kind: 'error'; message: string };
class AsyncQueue<T> {
#items: T[] = [];
#resolvers: Array<(item: T) => void> = [];
push(item: T): void {
const resolve = this.#resolvers.shift();
if (resolve) resolve(item);
else this.#items.push(item);
}
next(): Promise<T> {
const item = this.#items.shift();
if (item !== undefined) return Promise.resolve(item);
return new Promise((resolve) => this.#resolvers.push(resolve));
}
}
export class EdgeTTSClient implements TTSClient {
name = 'edge-tts';
@@ -23,11 +74,11 @@ export class EdgeTTSClient implements TTSClient {
#pitch = 1.0;
#edgeTTS: EdgeSpeechTTS | null = null;
#audioElement: HTMLAudioElement | null = null;
#player = new WebAudioPlayer();
#activeGeneration: number | null = null;
#activeQueue: AsyncQueue<SpeakQueueEvent> | null = null;
#chunkMeta: ChunkMeta[] = [];
#isPlaying = false;
#pausedAt = 0;
#startedAt = 0;
#fadeCompensation: number | null = null;
#wordTrackingRafId: number | null = null;
constructor(controller?: TTSController, appService?: AppService | null) {
@@ -62,24 +113,29 @@ export class EdgeTTSClient implements TTSClient {
}
getPayload = (lang: string, text: string, voiceId: string) => {
// Rate stays 1.0 so the MP3 cache is rate-independent; the playback rate
// is applied client-side via time-stretch.
return { lang, text, voice: voiceId, rate: 1.0, pitch: this.#pitch } as EdgeTTSPayload;
};
// Edge TTS websocket requests fail intermittently; retry the preload a few times
// before giving up so a single transient failure doesn't stall playback.
#createAudioUrlWithRetry = async (
// Edge TTS websocket requests fail intermittently; retry a few times before
// giving up so a single transient failure doesn't stall playback. The
// "No audio data received." failure is permanent for a given sentence, so it
// rethrows immediately for the caller's skip path.
#createAudioDataWithRetry = async (
payload: EdgeTTSPayload,
signal: AbortSignal,
maxAttempts = 3,
): Promise<string | undefined> => {
): Promise<{ data: ArrayBuffer; boundaries: TTSWordBoundary[] } | undefined> => {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal.aborted) return undefined;
try {
return await this.#edgeTTS?.createAudioUrl(payload);
return await this.#edgeTTS?.createAudioData(payload);
} catch (err) {
if (err instanceof Error && err.message === 'No audio data received.') throw err;
lastError = err;
console.warn(`Edge TTS preload attempt ${attempt}/${maxAttempts} failed`, err);
console.warn(`Edge TTS fetch attempt ${attempt}/${maxAttempts} failed`, err);
if (attempt < maxAttempts && !signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
}
@@ -88,6 +144,25 @@ export class EdgeTTSClient implements TTSClient {
throw lastError;
};
#recordDurations = (
voiceId: string,
text: string,
boundaries: TTSWordBoundary[],
trimmedDurationSec?: number,
) => {
if (trimmedDurationSec !== undefined) {
// Canonical: decode-time trimmed duration; also feeds the per-voice
// speaking-rate calibration used by timeline estimates.
recordMeasuredDuration(voiceId, text, trimmedDurationSec);
calibrateVoiceRate(voiceId, text, trimmedDurationSec);
return;
}
const last = boundaries[boundaries.length - 1];
if (last) {
recordProvisionalDuration(voiceId, text, (last.offset + last.duration) / TICKS_PER_SECOND);
}
};
getVoiceIdFromLang = async (lang: string) => {
const preferredVoiceId = TTSUtils.getPreferredVoice(this.name, lang);
const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId);
@@ -103,179 +178,258 @@ export class EdgeTTSClient implements TTSClient {
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
if (preload) {
// preload the first 2 marks immediately and the rest in the background
const maxImmediate = 2;
for (let i = 0; i < Math.min(maxImmediate, marks.length); i++) {
if (signal.aborted) break;
const mark = marks[i]!;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#currentVoiceId = voiceId;
try {
await this.#createAudioUrlWithRetry(
this.getPayload(voiceLang, mark.text, voiceId),
signal,
);
} catch (err) {
console.warn('Error preloading mark', i, err);
}
}
if (marks.length > maxImmediate) {
(async () => {
for (let i = maxImmediate; i < marks.length; i++) {
const mark = marks[i]!;
try {
if (signal.aborted) break;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
await this.#createAudioUrlWithRetry(
this.getPayload(voiceLang, mark.text, voiceId),
signal,
);
} catch (err) {
console.warn('Error preloading mark (bg)', i, err);
}
}
})();
}
yield {
code: 'end',
message: 'Preload finished',
} as TTSMessageEvent;
yield* this.#preload(marks, signal);
return;
}
await this.stopInternal();
// Reuse the same Audio element inside the ssml session
if (!this.#audioElement) {
this.#audioElement = new Audio();
}
const audio = this.#audioElement;
audio.setAttribute('x-webkit-airplay', 'deny');
audio.preload = 'auto';
for (const mark of marks) {
this.controller?.dispatchSpeakMark(mark);
let abortHandler: null | (() => void) = null;
try {
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#speakingLang = voiceLang;
const audioResult = await this.#edgeTTS?.createAudio(
this.getPayload(voiceLang, mark.text, voiceId),
);
const audioUrl = audioResult?.url;
const boundaries = audioResult?.boundaries ?? [];
if (signal.aborted) {
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
break;
}
const queue = new AsyncQueue<SpeakQueueEvent>();
const chunkMeta: ChunkMeta[] = [];
this.#activeQueue = queue;
this.#chunkMeta = chunkMeta;
yield {
code: 'boundary',
message: `Start chunk: ${mark.name}`,
mark: mark.name,
} as TTSMessageEvent;
// startSession before ensureContext: starting a session declares playback
// intent, clearing any lingering user-pause so the context may resume.
const generation = this.#player.startSession((event: WebAudioPlayerEvent) => {
if (event.type === 'chunk-start') {
queue.push({ kind: 'chunk-start', index: event.chunkIndex });
} else if (event.type === 'session-end') {
queue.push({ kind: 'session-end' });
} else {
queue.push({ kind: 'error', message: event.message });
}
});
this.#activeGeneration = generation;
await this.#player.ensureContext();
this.#isPlaying = true;
const result = await new Promise<TTSMessageEvent>((resolve) => {
const cleanUp = () => {
this.#stopWordTracking();
audio.onended = null;
audio.onerror = null;
audio.src = '';
};
let resolved = false;
const handleEnded = () => {
if (resolved) return;
resolved = true;
cleanUp();
resolve({ code: 'end', message: `Chunk finished: ${mark.name}` });
};
this.#runScheduler(marks, signal, generation, queue, chunkMeta);
abortHandler = () => {
cleanUp();
resolve({ code: 'error', message: 'Aborted' });
};
if (signal.aborted) {
abortHandler();
return;
} else {
signal.addEventListener('abort', abortHandler);
}
audio.onended = handleEnded;
audio.onerror = (e) => {
cleanUp();
console.warn('Audio playback error:', e);
resolve({ code: 'error', message: 'Audio playback error' });
};
this.#isPlaying = true;
audio.src = audioUrl || '';
this.#startWordTracking(audio, boundaries);
if (!this.appService?.isLinuxApp) {
audio.playbackRate = this.#rate;
}
audio
.play()
.then(() => {
if (this.appService?.isLinuxApp) {
audio.playbackRate = this.#rate;
}
})
.catch((err) => {
cleanUp();
console.error('Failed to play audio:', err);
resolve({ code: 'error', message: 'Playback failed: ' + err.message });
});
});
yield result;
} catch (error) {
if (error instanceof Error && error.message === 'No audio data received.') {
console.warn('No audio data received for:', mark.text);
yield { code: 'end', message: `Chunk finished: ${mark.name}` } as TTSMessageEvent;
continue;
}
const message = error instanceof Error ? error.message : String(error);
console.warn('TTS error for mark:', mark.text, message);
yield { code: 'error', message } as TTSMessageEvent;
break;
} finally {
if (abortHandler) {
signal.removeEventListener('abort', abortHandler);
let abortHandler: (() => void) | null = null;
try {
if (signal.aborted) {
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
return;
}
abortHandler = () => queue.push({ kind: 'error', message: 'Aborted' });
signal.addEventListener('abort', abortHandler);
for (;;) {
const event = await queue.next();
if (event.kind === 'chunk-start') {
const meta = chunkMeta[event.index];
if (!meta) continue;
this.controller?.dispatchSpeakMark(meta.mark);
this.#startWordTracking(generation, event.index, meta);
yield {
code: 'boundary',
message: `Start chunk: ${meta.mark.name}`,
mark: meta.mark.name,
} as TTSMessageEvent;
} else if (event.kind === 'chunk-skip') {
yield {
code: 'end',
message: `Chunk skipped: ${event.markName}`,
} as TTSMessageEvent;
} else if (event.kind === 'session-end') {
yield { code: 'end', message: 'Speak finished' } as TTSMessageEvent;
return;
} else {
yield { code: 'error', message: event.message } as TTSMessageEvent;
return;
}
}
} finally {
// The controller aborts the signal after every successful paragraph; a
// lingering listener would push a stale 'Aborted' into a dead queue.
if (abortHandler) signal.removeEventListener('abort', abortHandler);
this.#stopWordTracking();
this.#isPlaying = false;
if (this.#activeGeneration === generation) {
this.#activeGeneration = null;
this.#activeQueue = null;
this.#player.abortSession();
}
}
await this.stopInternal();
}
// Follow playback time against the word-boundary metadata of the current
// chunk and tell the controller which word is being spoken so it can
// highlight it. Word offsets are in media time, so audio.playbackRate and
// pause/resume (including the resume rewind on iOS) are handled naturally
// by polling audio.currentTime.
#startWordTracking(audio: HTMLAudioElement, boundaries: TTSWordBoundary[]) {
async *#preload(marks: TTSMark[], signal: AbortSignal) {
// Fetch the first couple of marks immediately and the rest in the
// background; the in-flight dedup in EdgeSpeechTTS keeps this from racing
// duplicate requests against the playback scheduler.
const maxImmediate = 2;
for (let i = 0; i < Math.min(maxImmediate, marks.length); i++) {
if (signal.aborted) break;
const mark = marks[i]!;
const voiceId = await this.getVoiceIdFromLang(mark.language);
this.#currentVoiceId = voiceId;
try {
const audio = await this.#createAudioDataWithRetry(
this.getPayload(mark.language, mark.text, voiceId),
signal,
);
if (audio) this.#recordDurations(voiceId, mark.text, audio.boundaries);
} catch (err) {
console.warn('Error preloading mark', i, err);
}
}
if (marks.length > maxImmediate) {
(async () => {
for (let i = maxImmediate; i < marks.length; i++) {
const mark = marks[i]!;
try {
if (signal.aborted) break;
const voiceId = await this.getVoiceIdFromLang(mark.language);
const audio = await this.#createAudioDataWithRetry(
this.getPayload(mark.language, mark.text, voiceId),
signal,
);
if (audio) this.#recordDurations(voiceId, mark.text, audio.boundaries);
} catch (err) {
console.warn('Error preloading mark (bg)', i, err);
}
}
})();
}
yield {
code: 'end',
message: 'Preload finished',
} as TTSMessageEvent;
}
// Detached scheduler: fetches, prepares, and schedules chunks ahead of the
// playhead under the player's backpressure. Never throws; failures surface
// through the event queue.
async #runScheduler(
marks: TTSMark[],
signal: AbortSignal,
generation: number,
queue: AsyncQueue<SpeakQueueEvent>,
chunkMeta: ChunkMeta[],
): Promise<void> {
const rate = this.#rate;
try {
for (const mark of marks) {
if (signal.aborted || this.#activeGeneration !== generation) return;
// Voices resolve per mark: mixed-language sections speak (and record
// durations under) the voice actually used for each sentence.
const voiceId = await this.getVoiceIdFromLang(mark.language);
this.#speakingLang = mark.language;
this.#currentVoiceId = voiceId;
const payload = this.getPayload(mark.language, mark.text, voiceId);
let audio: { data: ArrayBuffer; boundaries: TTSWordBoundary[] } | undefined;
try {
audio = await this.#createAudioDataWithRetry(payload, signal);
} catch (error) {
if (error instanceof Error && error.message === 'No audio data received.') {
console.warn('No audio data received for:', mark.text);
queue.push({ kind: 'chunk-skip', markName: mark.name });
continue;
}
const message = error instanceof Error ? error.message : String(error);
console.warn('TTS error for mark:', mark.text, message);
queue.push({ kind: 'error', message });
return;
}
if (!audio || signal.aborted || this.#activeGeneration !== generation) return;
this.#recordDurations(voiceId, mark.text, audio.boundaries);
let prepared: {
buffer: TTSAudioBuffer;
trimStartSec: number;
trimmedDurationSec: number;
};
try {
prepared = await this.#prepareChunkBuffer(audio.data, rate);
} catch (error) {
// Malformed MP3 must not dead-end the session: same UX as no-audio.
console.warn('Failed to decode TTS audio for:', mark.text, error);
queue.push({ kind: 'chunk-skip', markName: mark.name });
continue;
}
this.#recordDurations(voiceId, mark.text, audio.boundaries, prepared.trimmedDurationSec);
const ready = await this.#player.waitUntilReady(generation);
if (!ready || signal.aborted) return;
chunkMeta.push({
mark,
boundaries: audio.boundaries,
trimStartSec: prepared.trimStartSec,
trimmedDurationSec: prepared.trimmedDurationSec,
});
this.#player.scheduleChunk(generation, prepared.buffer, {
trimStartSec: prepared.trimStartSec,
mediaScale: prepared.trimmedDurationSec / prepared.buffer.duration,
gapSec: INTER_SENTENCE_GAP_SEC / rate,
});
}
if (!signal.aborted && this.#activeGeneration === generation) {
// Fires session-end synchronously when every mark was skipped or the
// last chunk already ended, so the session always terminates.
this.#player.endSession(generation);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
queue.push({ kind: 'error', message });
}
}
async #prepareChunkBuffer(
data: ArrayBuffer,
rate: number,
): Promise<{ buffer: TTSAudioBuffer; trimStartSec: number; trimmedDurationSec: number }> {
// decodeAudioData resamples to the context rate (44.1/48kHz on real
// devices, not the stream's 24kHz) — all math below must use the decoded
// buffer's sampleRate.
const decoded = await this.#player.decode(data);
const sampleRate = decoded.sampleRate;
const channel = decoded.getChannelData(0);
const bounds = findSpeechBounds(channel, sampleRate);
const startSample = Math.floor(bounds.startSec * sampleRate);
const endSample = Math.min(channel.length, Math.ceil(bounds.endSec * sampleRate));
// A subarray is a view; timeStretch never writes its input and
// createMonoBuffer copies, so no mutation can reach the decoded buffer.
const trimmed = channel.subarray(startSample, endSample);
const trimmedDurationSec = trimmed.length / sampleRate;
const samples = rate !== 1 ? timeStretch(trimmed, sampleRate, rate) : trimmed;
const buffer = await this.#player.createMonoBuffer(samples, sampleRate);
return { buffer, trimStartSec: startSample / sampleRate, trimmedDurationSec };
}
// Poll the audio clock (visual concern only, so rAF throttling with the
// screen off is fine) and tell the controller which word is being spoken.
// The player reports original (rate-1.0) media time, so Edge's boundary
// ticks need no rescaling for trim or rate.
#startWordTracking(generation: number, chunkIndex: number, meta: ChunkMeta): void {
this.#stopWordTracking();
const controller = this.controller;
if (!controller) return;
// Always hand the words to the controller — with boundaries it highlights
// word-by-word; with none it draws the sentence highlight that was
// suppressed at mark dispatch (see TTSController.prepareSpeakWords).
controller.prepareSpeakWords(boundaries.map((boundary) => boundary.text));
if (!boundaries.length) return;
controller.prepareSpeakWords(meta.boundaries.map((boundary) => boundary.text));
if (!meta.boundaries.length) return;
let lastIndex = -1;
const tick = () => {
const index = findBoundaryIndexAtTime(boundaries, audio.currentTime);
if (index !== lastIndex && index >= 0) {
lastIndex = index;
controller.dispatchSpeakWord(index);
const pos = this.#player.getPlaybackPosition(generation);
// Guard the one-frame window around a transition where this tick still
// holds the previous chunk's boundaries.
if (pos && pos.chunkIndex === chunkIndex) {
const index = findBoundaryIndexAtTime(meta.boundaries, pos.mediaTimeSec);
if (index !== lastIndex && index >= 0) {
lastIndex = index;
controller.dispatchSpeakWord(index);
}
}
this.#wordTrackingRafId = requestAnimationFrame(tick);
};
this.#wordTrackingRafId = requestAnimationFrame(tick);
}
#stopWordTracking() {
#stopWordTracking(): void {
if (this.#wordTrackingRafId !== null) {
cancelAnimationFrame(this.#wordTrackingRafId);
this.#wordTrackingRafId = null;
@@ -283,35 +437,16 @@ export class EdgeTTSClient implements TTSClient {
}
async pause() {
if (!this.#isPlaying || !this.#audioElement) return true;
this.#pausedAt = this.#audioElement.currentTime - this.#startedAt;
await this.#audioElement.pause();
this.#isPlaying = false;
if (!this.#isPlaying) return true;
await this.#player.pauseContext();
return true;
}
#getFadeCompensation() {
if (this.#fadeCompensation !== null) return this.#fadeCompensation;
const userAgent = navigator.userAgent;
const isSafari = /Safari/.test(userAgent) && !/Chrome/.test(userAgent);
const isIOS = /iPad|iPhone|iPod/.test(userAgent);
if (isSafari || isIOS) {
this.#fadeCompensation = 0.2;
} else {
this.#fadeCompensation = 0.0;
}
return this.#fadeCompensation;
}
async resume() {
if (this.#isPlaying || !this.#audioElement) return true;
const fadeCompensation = this.#getFadeCompensation();
this.#audioElement.currentTime = Math.max(0, this.#audioElement.currentTime - fadeCompensation);
await this.#audioElement.play();
this.#isPlaying = true;
this.#startedAt = this.#audioElement.currentTime - this.#pausedAt;
// Throws when the context refuses to run again (iOS post-interruption);
// the controller's catch stops playback visibly instead of showing
// "playing" over silence.
await this.#player.resumeContext();
return true;
}
@@ -322,20 +457,33 @@ export class EdgeTTSClient implements TTSClient {
private async stopInternal() {
this.#stopWordTracking();
this.#isPlaying = false;
this.#pausedAt = 0;
this.#startedAt = 0;
if (this.#audioElement) {
this.#audioElement.pause();
this.#audioElement.currentTime = 0;
if (this.#audioElement?.onended) {
this.#audioElement.onended(new Event('stopped'));
}
this.#audioElement.src = '';
if (this.#activeGeneration !== null) {
this.#activeGeneration = null;
// Unblock a generator awaiting the queue; without this a stop() outside
// the abort path would leave the consumer parked forever.
this.#activeQueue?.push({ kind: 'error', message: 'Aborted' });
this.#activeQueue = null;
this.#player.abortSession();
}
}
getChunkPosition(): number | null {
const generation = this.#activeGeneration;
if (generation === null) return null;
const pos = this.#player.getPlaybackPosition(generation);
if (!pos) return null;
const meta = this.#chunkMeta[pos.chunkIndex];
if (!meta) return null;
// Trim-relative and clamped: the section timeline sums TRIMMED durations,
// while the player reports untrimmed media time (kept that way for word
// boundaries).
return Math.min(Math.max(pos.mediaTimeSec - meta.trimStartSec, 0), meta.trimmedDurationSec);
}
async setRate(rate: number) {
// The Edge TTS API uses rate in [0.5 .. 2.0].
// Applied client-side via WSOLA time-stretch at schedule time; takes
// effect on the next speak() session (the controller restarts playback on
// rate changes).
this.#rate = rate;
}
@@ -397,8 +545,9 @@ export class EdgeTTSClient implements TTSClient {
}
async shutdown(): Promise<void> {
await this.stopInternal();
await this.#player.shutdown();
this.initialized = false;
this.#audioElement = null;
this.#voices = [];
}
}
@@ -0,0 +1,150 @@
// Virtual playback timeline over one section's sentences.
//
// The section is never concatenated into a real audio stream; this table plus
// prefix-summed durations IS the "whole chapter audio" the scrubber and the
// media session expose. Durations are stored at rate 1.0 and resolved
// per-sentence as measured (exact, from the duration store) or estimated
// (per-voice calibration falling back to script defaults); outputs divide by
// the playback rate at read time so rate changes are a pure rescale.
//
// Timeline sentences come from foliate's getSentences enumeration, which uses
// the same walker/filter/granularity as the live TTS instance — ranges are
// document-ordered and 1:1 with the marks playback produces.
import { estimateSentenceSeconds, getMeasuredDuration } from './ttsDuration';
export interface TimelineSentence {
blockIndex: number;
markName: string;
range: Range;
text: string;
}
export class SectionTimeline {
#sentences: TimelineSentence[];
#lang: string;
#voiceId: string;
#rate = 1;
#durations: number[] = [];
#measured: boolean[] = [];
#prefix: number[] = [];
#total = 0;
constructor(sentences: TimelineSentence[], lang: string, voiceId: string) {
this.#sentences = sentences;
this.#lang = lang;
this.#voiceId = voiceId;
this.refresh();
}
get length(): number {
return this.#sentences.length;
}
setVoice(voiceId: string): void {
if (voiceId === this.#voiceId) return;
this.#voiceId = voiceId;
this.refresh();
}
setRate(rate: number): void {
if (Number.isFinite(rate) && rate > 0) this.#rate = rate;
}
// Re-pull measured durations (playback and preload record them continuously)
// and rebuild the prefix sums.
refresh(): void {
const n = this.#sentences.length;
this.#durations = new Array<number>(n);
this.#measured = new Array<boolean>(n);
this.#prefix = new Array<number>(n);
let sum = 0;
for (let i = 0; i < n; i++) {
const sentence = this.#sentences[i]!;
const measured = getMeasuredDuration(this.#voiceId, sentence.text);
const duration =
measured ?? estimateSentenceSeconds(sentence.text, this.#lang, this.#voiceId);
this.#durations[i] = duration;
this.#measured[i] = measured !== undefined;
this.#prefix[i] = sum;
sum += duration;
}
this.#total = sum;
}
// Seconds at the current rate.
getDuration(): number {
return this.#total / this.#rate;
}
// Duration-weighted fraction of the timeline backed by measured (exact)
// values — the scrubber shows "~" while this is low.
getMeasuredFraction(): number {
if (this.#total <= 0) return 0;
let measuredSum = 0;
for (let i = 0; i < this.#durations.length; i++) {
if (this.#measured[i]) measuredSum += this.#durations[i]!;
}
return measuredSum / this.#total;
}
// Position (seconds at the current rate) of a sentence start plus an offset
// within it, given in rate-1.0 media seconds.
positionAt(index: number, withinMediaSec = 0): number {
if (this.#sentences.length === 0 || index < 0) return 0;
if (index >= this.#sentences.length) return this.getDuration();
const clampedWithin = Math.min(Math.max(withinMediaSec, 0), this.#durations[index]!);
return (this.#prefix[index]! + clampedWithin) / this.#rate;
}
// Map seconds (at the current rate) to a sentence, clamping past-the-end
// seeks to the last sentence so an over-estimated total is never a dead
// gesture. Null only for an empty timeline.
sentenceAtTime(seconds: number): { index: number; sentence: TimelineSentence } | null {
const n = this.#sentences.length;
if (n === 0) return null;
const target = Math.max(0, seconds) * this.#rate;
let low = 0;
let high = n - 1;
let index = n - 1;
while (low <= high) {
const mid = (low + high) >> 1;
const start = this.#prefix[mid]!;
if (start <= target) {
index = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
// Binary search finds the last sentence starting at or before the target;
// past-the-end targets land on the final sentence by construction.
return { index, sentence: this.#sentences[index]! };
}
// Locate the timeline sentence containing (or last starting at or before)
// the given range. Returns -1 for ranges from another document (stale
// timeline) or ranges before the first sentence.
indexOfRange(range: Range): number {
const n = this.#sentences.length;
if (n === 0) return -1;
try {
let low = 0;
let high = n - 1;
let index = -1;
while (low <= high) {
const mid = (low + high) >> 1;
if (this.#sentences[mid]!.range.compareBoundaryPoints(Range.START_TO_START, range) <= 0) {
index = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
return index;
} catch {
// WrongDocumentError: the section changed under us.
return -1;
}
}
}
@@ -29,4 +29,9 @@ export interface TTSClient {
supportsWordBoundaries(): boolean;
getVoiceId(): string;
getSpeakingLang(): string;
// Playback position within the currently audible sentence, in trimmed media
// seconds at rate 1.0, clamped to [0, sentenceDuration]. Optional: only
// clients with a real audio clock (Edge via Web Audio) implement it; the
// section timeline treats absence as sentence-granularity positions.
getChunkPosition?(): number | null;
}
@@ -13,6 +13,7 @@ import { createRejectFilter } from '@/utils/node';
import { WebSpeechClient } from './WebSpeechClient';
import { NativeTTSClient } from './NativeTTSClient';
import { EdgeTTSClient } from './EdgeTTSClient';
import { SectionTimeline, TimelineSentence } from './SectionTimeline';
import { TTSUtils } from './TTSUtils';
import { TTSClient } from './TTSClient';
import { isValidLang } from '@/utils/lang';
@@ -55,6 +56,31 @@ type TTSState =
const HIGHLIGHT_KEY = 'tts-highlight';
// Node filter shared by the live TTS instance and the timeline enumeration —
// the two MUST segment identically or timeline sentences drift from marks.
const createTTSNodeFilter = () =>
createRejectFilter({
tags: ['rt', 'canvas', 'br'],
// Footnotes/endnotes are hidden in the rendered page (see the
// `.epubtype-footnote`/`aside[epub|type]` rules in getPageLayoutStyles);
// skip them in TTS too, including for background sections whose
// documents are loaded without those styles.
classes: [
'annotationLayer',
'epubtype-footnote',
'duokan-footnote-content',
'duokan-footnote-item',
],
attributeTokens: [
{
tag: 'aside',
attribute: 'epub:type',
tokens: ['footnote', 'endnote', 'note', 'rearnote'],
},
],
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
});
export class TTSController extends EventTarget {
appService: AppService | null = null;
view: FoliateView;
@@ -71,6 +97,15 @@ export class TTSController extends EventTarget {
#ttsSectionIndex: number = -1;
// Virtual section timeline for position/duration/seek (Edge client only).
// Built lazily OFF the playback critical path: enumerating a 2000-sentence
// chapter must never delay first audio.
#sectionTimeline: SectionTimeline | null = null;
#timelineSectionIndex: number = -1;
#currentSentenceIndex: number = -1;
#ttsDoc: Document | null = null;
#ttsGranularity: TTSGranularity = 'sentence';
// Word-level highlight state for the currently spoken chunk. Armed by a
// successful dispatchSpeakMark, populated by prepareSpeakWords when a TTS
// client has word-boundary metadata for the chunk.
@@ -243,6 +278,13 @@ export class TTSController extends EventTarget {
}
}
// The section changed (or is initializing): any previous timeline maps a
// dead document.
this.#sectionTimeline = null;
this.#timelineSectionIndex = -1;
this.#currentSentenceIndex = -1;
this.#ttsDoc = doc;
if (this.view.tts && this.view.tts.doc === doc) {
return true;
}
@@ -254,31 +296,12 @@ export class TTSController extends EventTarget {
if (!supportedGranularities.includes(granularity)) {
granularity = supportedGranularities[0]!;
}
this.#ttsGranularity = granularity;
this.view.tts = new TTS(
doc,
textWalker,
createRejectFilter({
tags: ['rt', 'canvas', 'br'],
// Footnotes/endnotes are hidden in the rendered page (see the
// `.epubtype-footnote`/`aside[epub|type]` rules in getPageLayoutStyles);
// skip them in TTS too, including for background sections whose
// documents are loaded without those styles.
classes: [
'annotationLayer',
'epubtype-footnote',
'duokan-footnote-content',
'duokan-footnote-item',
],
attributeTokens: [
{
tag: 'aside',
attribute: 'epub:type',
tokens: ['footnote', 'endnote', 'note', 'rearnote'],
},
],
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
}),
createTTSNodeFilter(),
this.#getHighlighter(),
granularity,
);
@@ -287,6 +310,86 @@ export class TTSController extends EventTarget {
return true;
}
// Build (or return) the virtual timeline for the current section. Edge-only:
// it is the only client with measurable audio durations and a chunk clock.
// Callers invoke this off the playback path (panel poll, media session).
async ensureTimeline(): Promise<SectionTimeline | null> {
if (this.ttsClient !== this.ttsEdgeClient) return null;
if (this.#sectionTimeline && this.#timelineSectionIndex === this.#ttsSectionIndex) {
return this.#sectionTimeline;
}
const doc = this.#ttsDoc;
if (!doc || this.#ttsSectionIndex < 0) return null;
const { getSentences } = await import('foliate-js/tts.js');
const { textWalker } = await import('foliate-js/text-walker.js');
const sentences: TimelineSentence[] = [];
for (const entry of getSentences(
doc,
textWalker,
createTTSNodeFilter(),
this.#ttsGranularity,
)) {
sentences.push({ ...entry, text: entry.range.toString() });
}
const timeline = new SectionTimeline(
sentences,
this.ttsLang || 'en',
this.ttsClient.getVoiceId(),
);
timeline.setRate(this.ttsRate);
this.#sectionTimeline = timeline;
this.#timelineSectionIndex = this.#ttsSectionIndex;
return timeline;
}
// Whether the active client can ever produce a timeline (Edge only). The
// scrubber renders a reserved disabled slot while true and info is still
// null, and hides entirely while false.
supportsPlaybackInfo(): boolean {
return this.ttsClient === this.ttsEdgeClient;
}
// Position/duration of the current section playback at the current rate.
// Null while no timeline exists (non-Edge client, timeline not yet built,
// or nothing located yet) — the UI reserves a disabled slot for that state.
getPlaybackInfo(): { position: number; duration: number; measuredFraction: number } | null {
if (this.ttsClient !== this.ttsEdgeClient) return null;
const timeline = this.#sectionTimeline;
if (!timeline || this.#timelineSectionIndex !== this.#ttsSectionIndex) return null;
const duration = timeline.getDuration();
if (!Number.isFinite(duration) || duration <= 0) return null;
let index = this.#currentSentenceIndex;
if (index < 0) {
const range = this.view.tts?.getLastRange();
index = range ? timeline.indexOfRange(range) : -1;
}
if (index < 0) return null;
const within = this.ttsClient.getChunkPosition?.() ?? 0;
return {
position: timeline.positionAt(index, within),
duration,
measuredFraction: timeline.getMeasuredFraction(),
};
}
// Sentence-snapped seek through the same navigation machinery as prev/next:
// foliate's from(range) returns the paragraph SSML sliced at the target
// sentence, so highlighting, page-follow, and mark bookkeeping come free.
async seekToTime(seconds: number): Promise<void> {
await this.initViewTTS();
const timeline = await this.ensureTimeline();
if (!timeline) return;
const target = timeline.sentenceAtTime(seconds);
if (!target) return;
const isPlaying = this.state === 'playing';
await this.stop();
if (!isPlaying) this.state = 'forward-paused';
this.#currentSentenceIndex = target.index;
const ssml = this.view.tts?.from(target.sentence.range);
await this.#handleNavigationWithSSML(ssml, isPlaying);
if (!isPlaying) this.reapplyCurrentHighlight();
}
async #initTTSForNextSection(): Promise<boolean> {
const nextIndex = this.#ttsSectionIndex + 1;
const sections = this.view.book.sections;
@@ -605,6 +708,7 @@ export class TTSController extends EventTarget {
async setRate(rate: number) {
this.state = 'setrate-paused';
this.ttsRate = rate;
this.#sectionTimeline?.setRate(rate);
await this.ttsClient.setRate(this.ttsRate);
}
@@ -641,6 +745,9 @@ export class TTSController extends EventTarget {
TTSUtils.setPreferredClient(this.ttsClient.name);
TTSUtils.setPreferredVoice(this.ttsClient.name, lang, voiceId);
await this.ttsClient.setVoice(voiceId);
// A different voice speaks at a different pace: re-estimate the timeline
// under the new voice (measured durations are keyed per voice already).
this.#sectionTimeline?.setVoice(this.ttsClient.getVoiceId());
}
getVoiceId() {
@@ -700,6 +807,12 @@ export class TTSController extends EventTarget {
const range = this.view.tts?.setMark(mark.name);
this.#suppressMarkHighlight = false;
this.#speakWordsArmed = !!range;
if (this.#sectionTimeline && range) {
// Keep the timeline honest as measurements land, then locate the
// audible sentence for position reporting.
this.#sectionTimeline.refresh();
this.#currentSentenceIndex = this.#sectionTimeline.indexOfRange(range);
}
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: { cfi } }));
this.#dispatchPosition(cfi, 'sentence');
@@ -860,6 +973,10 @@ export class TTSController extends EventTarget {
await this.stop();
this.#clearHighlighter();
this.#ttsSectionIndex = -1;
this.#sectionTimeline = null;
this.#timelineSectionIndex = -1;
this.#currentSentenceIndex = -1;
this.#ttsDoc = null;
this.view.tts = null;
if (this.ttsWebClient.initialized) {
await this.ttsWebClient.shutdown();
@@ -0,0 +1,346 @@
// Gapless chunk scheduler on a persistent Web Audio context.
//
// Sentence buffers are scheduled back-to-back into an always-running
// AudioContext so the OS-level output stream never stops between sentences or
// paragraphs — per-sentence track restarts are what let Bluetooth fade-in /
// noise gates swallow the first word (#3851) and what put audible gaps
// between sentences (#2033). Chunk transitions ride source onended callbacks
// (background-safe when rAF and timers are throttled with the screen off);
// word-highlight polling is the only rAF consumer, and it lives in the client.
//
// The real AudioContext is a MODULE-LEVEL SINGLETON shared by all player
// instances and never closed: a fresh TTSController (and thus client+player)
// is constructed per tts-speak, and WebKit caps live AudioContexts (~4 on
// iOS) — per-player contexts would leak until every new one is born suspended
// and TTS goes silent. Sessions are isolated purely by generation tokens.
//
// This module speaks to the context through structural interfaces so jsdom
// tests can drive a fake clock.
export interface TTSAudioBuffer {
readonly sampleRate: number;
readonly length: number;
readonly duration: number;
getChannelData(channel: number): Float32Array;
copyToChannel(source: Float32Array, channel: number): void;
}
export interface TTSAudioBufferSourceNode {
buffer: TTSAudioBuffer | null;
onended: (() => void) | null;
connect(destination: unknown): void;
disconnect(): void;
start(when?: number, offset?: number, duration?: number): void;
stop(when?: number): void;
}
export interface TTSAudioContext {
readonly currentTime: number;
readonly state: string; // 'running' | 'suspended' | 'interrupted' | 'closed'
readonly destination: unknown;
onstatechange: (() => void) | null;
resume(): Promise<void>;
suspend(): Promise<void>;
close(): Promise<void>;
createBufferSource(): TTSAudioBufferSourceNode;
createBuffer(numberOfChannels: number, length: number, sampleRate: number): TTSAudioBuffer;
decodeAudioData(data: ArrayBuffer): Promise<TTSAudioBuffer>;
}
export interface ChunkTiming {
// Leading trim in original (rate-1.0) media time; word boundaries live there.
trimStartSec: number;
// originalTrimmedDuration / outputDuration (≈ playback rate).
mediaScale: number;
// Silence scheduled after this chunk; the caller rate-scales it.
gapSec: number;
}
export type WebAudioPlayerEvent =
| { type: 'chunk-start'; chunkIndex: number }
| { type: 'session-end' }
| { type: 'context-error'; message: string };
interface ScheduledChunk {
index: number;
source: TTSAudioBufferSourceNode;
startTime: number;
duration: number;
timing: ChunkTiming;
ended: boolean;
}
interface PlayerSession {
generation: number;
onEvent: (event: WebAudioPlayerEvent) => void;
chunks: ScheduledChunk[];
nextStartTime: number;
ended: boolean;
endedEmitted: boolean;
waiters: Array<(ready: boolean) => void>;
}
// Small offset so start() never lands in the past between the read of
// currentTime and the schedule call.
const SCHEDULE_SAFETY_SEC = 0.03;
// Screen-off JS throttling must not starve the queue between onended and the
// next schedule, so the pending budget deepens when the page is hidden.
const MAX_PENDING_VISIBLE = 2;
const MAX_PENDING_HIDDEN = 5;
// Bounds decoded PCM at slow rates (0.2x stretches a 30s sentence to 150s).
const MAX_AHEAD_SEC = 60;
let sharedContext: TTSAudioContext | null = null;
const getSharedContext = (): TTSAudioContext => {
if (!sharedContext) {
sharedContext = new AudioContext() as unknown as TTSAudioContext;
}
return sharedContext;
};
// Warm up (create + resume) the shared context. Call this synchronously in a
// user-gesture handler: speak() itself runs after network awaits, outside
// WebKit's gesture window, where resume() can be rejected by autoplay policy.
export const ensureSharedAudioContext = async (): Promise<void> => {
if (typeof AudioContext === 'undefined') return;
try {
const ctx = getSharedContext();
if (ctx.state !== 'running') {
await ctx.resume();
}
} catch (err) {
console.warn('[TTS] audio context warmup failed', err);
}
};
export class WebAudioPlayer {
#createContext: () => TTSAudioContext;
#usesSharedContext: boolean;
#ctx: TTSAudioContext | null = null;
#generation = 0;
#session: PlayerSession | null = null;
#userPaused = false;
constructor(createContext?: () => TTSAudioContext) {
this.#createContext = createContext ?? getSharedContext;
this.#usesSharedContext = !createContext;
}
async ensureContext(): Promise<TTSAudioContext> {
if (!this.#ctx) {
this.#ctx = this.#createContext();
this.#ctx.onstatechange = () => this.#handleStateChange();
}
if (this.#ctx.state !== 'running' && !this.#userPaused) {
await this.#ctx.resume();
}
return this.#ctx;
}
async decode(data: ArrayBuffer): Promise<TTSAudioBuffer> {
const ctx = await this.ensureContext();
return ctx.decodeAudioData(data);
}
async createMonoBuffer(samples: Float32Array, sampleRate: number): Promise<TTSAudioBuffer> {
const ctx = await this.ensureContext();
const buffer = ctx.createBuffer(1, samples.length, sampleRate);
buffer.copyToChannel(samples, 0);
return buffer;
}
startSession(onEvent: (event: WebAudioPlayerEvent) => void): number {
this.abortSession();
const generation = ++this.#generation;
this.#session = {
generation,
onEvent,
chunks: [],
nextStartTime: 0,
ended: false,
endedEmitted: false,
waiters: [],
};
console.log(`[TTS] session ${generation} start`);
return generation;
}
scheduleChunk(generation: number, buffer: TTSAudioBuffer, timing: ChunkTiming): void {
const session = this.#session;
const ctx = this.#ctx;
if (!session || session.generation !== generation || !ctx) return;
const start = Math.max(session.nextStartTime, ctx.currentTime + SCHEDULE_SAFETY_SEC);
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
const chunk: ScheduledChunk = {
index: session.chunks.length,
source,
startTime: start,
duration: buffer.duration,
timing,
ended: false,
};
source.onended = () => this.#handleChunkEnded(session, chunk);
session.chunks.push(chunk);
session.nextStartTime = start + buffer.duration + Math.max(0, timing.gapSec);
source.start(start);
console.log(
`[TTS] schedule ${generation}:${chunk.index} at ${start.toFixed(2)} dur ${buffer.duration.toFixed(2)}`,
);
if (chunk.index === 0) {
session.onEvent({ type: 'chunk-start', chunkIndex: 0 });
}
}
endSession(generation: number): void {
const session = this.#session;
if (!session || session.generation !== generation) return;
session.ended = true;
// Fires synchronously when nothing is unfinished: a session whose marks
// were all skipped (zero chunks) or whose last onended beat endSession
// must still end, or auto-advance dead-ends with controls stuck playing.
this.#maybeEmitSessionEnd(session);
}
abortSession(): void {
const session = this.#session;
if (!session) return;
this.#session = null;
for (const chunk of session.chunks) {
chunk.source.onended = null;
try {
chunk.source.stop();
} catch {
// Sources that never started or already ended throw; irrelevant here.
}
try {
chunk.source.disconnect();
} catch {
// Ignore repeated disconnects.
}
}
const waiters = session.waiters;
session.waiters = [];
for (const waiter of waiters) waiter(false);
console.log(`[TTS] session ${session.generation} abort`);
}
async waitUntilReady(generation: number): Promise<boolean> {
for (;;) {
const session = this.#session;
if (!session || session.generation !== generation) return false;
if (this.#isReadyForMore(session)) return true;
const ready = await new Promise<boolean>((resolve) => {
session.waiters.push(resolve);
});
if (!ready) return false;
}
}
async pauseContext(): Promise<void> {
this.#userPaused = true;
if (this.#ctx && this.#ctx.state === 'running') {
await this.#ctx.suspend();
}
}
async resumeContext(): Promise<void> {
this.#userPaused = false;
const ctx = this.#ctx;
if (!ctx) return;
await ctx.resume();
if (ctx.state !== 'running') {
// iOS can refuse to leave 'interrupted' (e.g. right after a phone
// call); fail loudly so the controller stops visibly instead of
// showing "playing" over silence.
throw new Error(`AudioContext failed to resume (state: ${ctx.state})`);
}
}
isUserPaused(): boolean {
return this.#userPaused;
}
getPlaybackPosition(generation: number): { chunkIndex: number; mediaTimeSec: number } | null {
const session = this.#session;
const ctx = this.#ctx;
if (!session || session.generation !== generation || !ctx) return null;
const first = session.chunks[0];
if (!first) return null;
const t = ctx.currentTime;
let active = first;
for (const chunk of session.chunks) {
if (chunk.startTime <= t) active = chunk;
else break;
}
const within = Math.min(Math.max(t - active.startTime, 0), active.duration);
return {
chunkIndex: active.index,
mediaTimeSec: active.timing.trimStartSec + within * active.timing.mediaScale,
};
}
async shutdown(): Promise<void> {
this.abortSession();
if (this.#ctx && !this.#usesSharedContext) {
// Test-injected contexts are owned by this player; the shared context
// stays alive for the whole page (see module comment).
await this.#ctx.close().catch(() => {});
}
this.#ctx = null;
}
#isReadyForMore(session: PlayerSession): boolean {
const unfinished = session.chunks.reduce((n, c) => n + (c.ended ? 0 : 1), 0);
const limit =
typeof document !== 'undefined' && document.visibilityState === 'hidden'
? MAX_PENDING_HIDDEN
: MAX_PENDING_VISIBLE;
if (unfinished >= limit) return false;
if (this.#ctx && session.chunks.length > 0) {
const aheadSec = session.nextStartTime - this.#ctx.currentTime;
if (aheadSec >= MAX_AHEAD_SEC) return false;
}
return true;
}
#handleChunkEnded(session: PlayerSession, chunk: ScheduledChunk): void {
if (this.#session !== session) return;
chunk.ended = true;
const waiters = session.waiters;
session.waiters = [];
for (const waiter of waiters) waiter(true);
const next = session.chunks[chunk.index + 1];
if (next) {
session.onEvent({ type: 'chunk-start', chunkIndex: next.index });
}
this.#maybeEmitSessionEnd(session);
}
#maybeEmitSessionEnd(session: PlayerSession): void {
if (!session.ended || session.endedEmitted) return;
if (session.chunks.some((c) => !c.ended)) return;
session.endedEmitted = true;
session.onEvent({ type: 'session-end' });
}
#handleStateChange(): void {
const ctx = this.#ctx;
if (!ctx) return;
if (ctx.state === 'running' || this.#userPaused) return;
if (!this.#session) return;
// Unexpected suspension (iOS 'interrupted', route change) during live
// playback: try to keep going. If the OS refuses, the next user action
// surfaces the failure through resumeContext().
console.log(`[TTS] audio context ${ctx.state}; attempting auto-resume`);
ctx.resume().catch((err) => {
console.warn('[TTS] audio context auto-resume failed', err);
this.#session?.onEvent({
type: 'context-error',
message: `AudioContext ${ctx.state}`,
});
});
}
}
@@ -5,3 +5,5 @@ export * from './EdgeTTSClient';
export * from './NativeTTSClient';
export * from './TTSController';
export * from './TTSData';
export { ensureSharedAudioContext } from './WebAudioPlayer';
export { SectionTimeline } from './SectionTimeline';
+47
View File
@@ -0,0 +1,47 @@
// Pure PCM helpers for the Web Audio TTS pipeline.
//
// Decoded MP3 "silence" is dithered ringing (roughly 1e-4 to 1e-3 amplitude),
// not zeros, so speech detection uses an amplitude threshold rather than an
// exact-zero test.
export interface SpeechBounds {
startSec: number;
endSec: number;
}
// ~-46 dBFS: above decoder dither/ringing, below any audible speech onset.
const DEFAULT_SILENCE_THRESHOLD = 0.005;
// Pads keep a natural attack/release around the detected speech.
const HEAD_PAD_SEC = 0.02;
const TAIL_PAD_SEC = 0.05;
export const findSpeechBounds = (
samples: Float32Array,
sampleRate: number,
threshold = DEFAULT_SILENCE_THRESHOLD,
): SpeechBounds => {
if (samples.length === 0 || sampleRate <= 0) {
return { startSec: 0, endSec: 0 };
}
let first = -1;
for (let i = 0; i < samples.length; i++) {
if (Math.abs(samples[i]!) > threshold) {
first = i;
break;
}
}
if (first === -1) {
// All silence: play as-is rather than scheduling a zero-length chunk.
return { startSec: 0, endSec: samples.length / sampleRate };
}
let last = first;
for (let i = samples.length - 1; i >= first; i--) {
if (Math.abs(samples[i]!) > threshold) {
last = i;
break;
}
}
const startSec = Math.max(0, first / sampleRate - HEAD_PAD_SEC);
const endSec = Math.min(samples.length / sampleRate, (last + 1) / sampleRate + TAIL_PAD_SEC);
return { startSec, endSec };
};
@@ -0,0 +1,81 @@
// WSOLA (waveform-similarity overlap-add) time-stretch for speech.
//
// Web Audio has no pitch-preserved playback rate (`preservesPitch` exists only
// on media elements), so sentence buffers are stretched here before scheduling.
// Tempo > 1 speeds speech up (shorter output); pitch is preserved because
// whole waveform frames are re-laid at a different spacing instead of being
// resampled.
//
// Parameters follow SoundTouch's speech defaults: 40ms frames, 8ms overlap,
// +/-15ms similarity search. The search maximizes normalized cross-correlation
// between the candidate frame head and the tail of what has been written, and
// the overlap is linearly cross-faded, which keeps phase continuity through
// each splice.
const FRAME_SEC = 0.04;
const OVERLAP_SEC = 0.008;
const SEEK_SEC = 0.015;
export const timeStretch = (
input: Float32Array,
sampleRate: number,
tempo: number,
): Float32Array => {
const frame = Math.round(FRAME_SEC * sampleRate);
const overlap = Math.round(OVERLAP_SEC * sampleRate);
const seek = Math.round(SEEK_SEC * sampleRate);
if (!(tempo > 0) || tempo === 1 || input.length < 2 * frame) {
return input.slice();
}
const flat = frame - overlap; // samples appended per iteration
const inputAdvance = flat * tempo; // nominal input advance per iteration
const out = new Float32Array(Math.ceil(input.length / tempo) + 2 * frame);
out.set(input.subarray(0, frame), 0);
let outPos = frame;
// Nominal input position accumulates independently of the chosen offsets so
// alignment error never drifts over the sentence.
let inNominal = 0;
for (;;) {
inNominal += inputAdvance;
const target = Math.round(inNominal);
const searchMin = Math.max(0, target - seek);
const searchMax = Math.min(input.length - frame, target + seek);
if (searchMax < searchMin) break;
const refStart = outPos - overlap;
let bestOffset = searchMin;
let bestCorr = -Infinity;
for (let off = searchMin; off <= searchMax; off++) {
let dot = 0;
let energy = 0;
for (let i = 0; i < overlap; i++) {
const a = out[refStart + i]!;
const b = input[off + i]!;
dot += a * b;
energy += b * b;
}
// The epsilon guards the zero-energy window (digital silence) so the
// correlation never divides to NaN.
const corr = dot / Math.sqrt(energy + 1e-12);
if (corr > bestCorr) {
bestCorr = corr;
bestOffset = off;
}
}
for (let i = 0; i < overlap; i++) {
const t = i / overlap;
out[refStart + i] = out[refStart + i]! * (1 - t) + input[bestOffset + i]! * t;
}
const copyStart = bestOffset + overlap;
const copyEnd = Math.min(bestOffset + frame, input.length);
out.set(input.subarray(copyStart, copyEnd), outPos);
outPos += copyEnd - copyStart;
if (copyEnd >= input.length) break;
}
return out.slice(0, outPos);
};
@@ -0,0 +1,123 @@
// Sentence duration bookkeeping for the TTS section timeline.
//
// Three tiers, best data wins:
// 1. Measured durations (exact, from decoded+trimmed audio; provisional
// values derive from word-boundary metadata at fetch time and never
// overwrite a measured value).
// 2. Per-voice chars-per-second calibration (EMA over measured sentences,
// persisted in localStorage so a voice starts calibrated next session).
// 3. Per-script defaults for the cold start.
//
// Keys normalize away punctuation and case because the spoken text (mark text
// after SSML preprocessing) and the timeline text (raw range text) differ only
// in punctuation rewrites; the letter/digit content is identical.
import { md5 } from 'js-md5';
import { isCJKLang } from '@/utils/lang';
import { LRUCache } from '@/utils/lru';
const CALIBRATION_STORAGE_KEY = 'readest-tts-voice-cps';
const EMA_ALPHA = 0.2;
const MIN_CALIBRATION_CHARS = 10;
// Fixed per-utterance overhead floor: even a one-word sentence is not
// instantaneous once Edge's attack/release around speech is counted.
const MIN_SENTENCE_SEC = 0.3;
const CJK_DEFAULT_CPS = 4.5;
const LATIN_DEFAULT_CPS = 15;
interface VoiceCalibration {
cps: number;
n: number;
}
const measured = new LRUCache<string, number>(2048);
const provisional = new Set<string>();
// In-memory calibration mirror; localStorage is best-effort persistence.
let calibrations: Record<string, VoiceCalibration> | null = null;
const normalizeText = (text: string): string =>
text
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
const durationKey = (voiceId: string, text: string): string =>
md5(`${voiceId}|${normalizeText(text)}`);
const loadCalibrations = (): Record<string, VoiceCalibration> => {
if (calibrations) return calibrations;
calibrations = {};
try {
const raw = localStorage.getItem(CALIBRATION_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Record<string, VoiceCalibration>;
for (const [voiceId, cal] of Object.entries(parsed)) {
if (typeof cal?.cps === 'number' && Number.isFinite(cal.cps) && cal.cps > 0) {
calibrations[voiceId] = { cps: cal.cps, n: cal.n || 1 };
}
}
}
} catch {
// localStorage unavailable or corrupt: run with in-memory calibration only.
}
return calibrations;
};
const saveCalibrations = () => {
if (!calibrations) return;
try {
localStorage.setItem(CALIBRATION_STORAGE_KEY, JSON.stringify(calibrations));
} catch {
// Best-effort persistence only.
}
};
export const recordMeasuredDuration = (voiceId: string, text: string, seconds: number): void => {
if (!Number.isFinite(seconds) || seconds <= 0) return;
const key = durationKey(voiceId, text);
measured.set(key, seconds);
provisional.delete(key);
};
// Word-boundary-derived durations are close but not canonical; keep them only
// until a decode-time measurement lands.
export const recordProvisionalDuration = (voiceId: string, text: string, seconds: number): void => {
if (!Number.isFinite(seconds) || seconds <= 0) return;
const key = durationKey(voiceId, text);
if (measured.get(key) !== undefined && !provisional.has(key)) return;
measured.set(key, seconds);
provisional.add(key);
};
export const getMeasuredDuration = (voiceId: string, text: string): number | undefined =>
measured.get(durationKey(voiceId, text));
export const calibrateVoiceRate = (voiceId: string, text: string, seconds: number): void => {
if (!Number.isFinite(seconds) || seconds <= 0) return;
const chars = normalizeText(text).length;
if (chars < MIN_CALIBRATION_CHARS) return;
const cps = chars / seconds;
const all = loadCalibrations();
const existing = all[voiceId];
if (existing) {
existing.cps = existing.cps * (1 - EMA_ALPHA) + cps * EMA_ALPHA;
existing.n += 1;
} else {
all[voiceId] = { cps, n: 1 };
}
saveCalibrations();
};
export const defaultCharsPerSecond = (lang: string): number =>
isCJKLang(lang) ? CJK_DEFAULT_CPS : LATIN_DEFAULT_CPS;
export const estimateSentenceSeconds = (text: string, lang: string, voiceId: string): number => {
const measuredSec = getMeasuredDuration(voiceId, text);
if (measuredSec !== undefined) return measuredSec;
const chars = normalizeText(text).length;
if (chars === 0) return 0;
const calibrated = loadCalibrations()[voiceId];
const cps = calibrated?.cps ?? defaultCharsPerSecond(lang);
return Math.max(MIN_SENTENCE_SEC, chars / cps);
};
+15
View File
@@ -34,3 +34,18 @@ export const initDayjs = (locale: string) => {
dayjs.locale(locale);
dayjs.extend(relativeTime);
};
// Clock-style playback time for the TTS scrubber: m:ss below one hour,
// h:mm:ss above. Pass forceHours so both labels of a row share the format
// chosen by the total's magnitude and the row never re-layouts when the
// elapsed side crosses an hour.
export const formatPlaybackTime = (seconds: number, forceHours = false): string => {
const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0 || forceHours) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${String(secs).padStart(2, '0')}`;
};