readest/apps/readest-app/vitest.setup.ts
Huang Xin 9bafc11120
feat(tts): custom OpenAI-compatible TTS services (#6746)
* feat(tts): custom OpenAI-compatible TTS services with BYOK

Closes #258.

Adds Settings > TTS > Custom TTS Services (premium, Full Customization):
users configure one or more OpenAI-compatible speech services (base URL,
API key, model, voices, optional instructions). Each service's voices
appear as their own group in the Read Aloud voice picker for every book
language.

- OpenAICompatibleSpeechProvider posts to {baseUrl}/audio/speech (mp3)
  behind BufferedTTSClient, so rate, caching and chapter downloads work
  as for other engines; highlighting is sentence-level (no word timings).
- 401/403/404 end the session, other 4xx skip the sentence, 429/5xx
  retry.
- SpeechProvider gains optional groupVoices (per-service voice groups)
  and cacheScope (model/baseUrl/instructions fold into the cache key so
  edits never replay stale audio).
- Services sync as the new custom_tts replica kind with the API key
  encrypted, with its own Manage Sync category; backups strip the key
  unless credentials are included.

Requires migration 027_replica_custom_tts.sql (replica kind allowlist)
to be deployed before clients ship.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(tts): voice picker that lists every engine dependably

Custom TTS services:
- Voices come from the service: GET {baseUrl}/audio/voices (names or
  {id, name} objects); a service without a listing gets the standard
  OpenAI voices. The editor no longer asks for voice names and Test
  reports how many voices the service offers.
- Custom voices are multilingual (lang `mul`): the picked voice is
  remembered and used for every language. Before, a pick never took
  effect and the first voice always spoke.

Readest Voice:
- Listed for every reader: `GET /api/tts/readest?all=1` returns the whole
  catalog with each voice's tier, and voices the reader's plan lacks are
  shown disabled. Without `all` the route answers released apps as
  before.
- Treated as always on: listing reads the catalog only and never checks
  the voice worker, and the app answers from the last list it received,
  refreshing it in the background, so a failed or slow request never
  hides the engine.
- A voice the plan lacks is never picked automatically, even a remembered
  one. Plus voices list before Pro ones, each by name.
- Dev builds use the production API for Readest Voice, which a local
  server cannot serve.

Voice picker:
- Groups ordered custom services, Readest Voice, Edge TTS, system, Web
  (after a book's own narration).
- An accordion: at most one group open, starting on the group of the
  voice in use, which stays marked and named while folded. The sheet
  keeps one height while folding.
- Badges (Plus, Pro, Multilingual, Expressive) align to the row end;
  Edge's Multilingual/Expressive suffixes become badges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(tts): a Readest voice the plan lacks leads to the upgrade

Tapping a greyed-out Readest voice opens the plans on the user page, or
sign-in for a signed-out reader, instead of doing nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(tts): list a custom service's voices by the book language

Kokoro names its voices by language (af_ American English, zf_ Mandarin,
...): those voices are offered only for books in their language, while a
voice without one (OpenAI's) still reads any language. A voice list that
states a language (lang or language) is used as given. A remembered custom
voice yields to the default engine for a book none of its service's voices
can read, as Readest Voice does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tts): sync a cleared custom TTS key and pick voices across services

- A cleared API key is saved as '' rather than undefined, so it syncs and
  other devices drop their copy instead of keeping the old key.
- The fallback voice for a language is looked up across every voice group,
  not only the first: a custom service without a voice for the book's
  language no longer hides one another service has.
- The auth page browser test mocks the new getReadestVoiceAPIBaseUrl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tts): Readest Voice is available to every reader

The engine's init reported success only when the plan unlocked a voice,
so signed-out and free readers lost the whole engine. It now always
initializes; a voice the plan lacks stays listed but disabled and is
gated where it is picked or played (setVoice, setLang, the language
fallback, and the server). Opening the picker warms a worker only for
a reader with a playable Readest voice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:33:28 +02:00

74 lines
2.8 KiB
TypeScript
Raw Permalink Blame History

// jsdom does not implement the CSS namespace; foliate-js TTS uses CSS.escape
// (mark[name="…"] lookups). Provide the standard polyfill so those paths work.
const globalWithCSS = globalThis as { CSS?: { escape?: (value: string) => string } };
if (!globalWithCSS.CSS) globalWithCSS.CSS = {};
if (typeof globalWithCSS.CSS.escape !== 'function') {
globalWithCSS.CSS.escape = (value: string): string => {
const string = String(value);
const length = string.length;
const firstCodeUnit = string.charCodeAt(0);
let result = '';
let index = -1;
while (++index < length) {
const codeUnit = string.charCodeAt(index);
if (codeUnit === 0x0000) {
result += '<27>';
} else if (
(codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
codeUnit === 0x007f ||
(index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d)
) {
result += '\\' + codeUnit.toString(16) + ' ';
} else if (index === 0 && length === 1 && codeUnit === 0x002d) {
result += '\\' + string.charAt(index);
} else if (
codeUnit >= 0x0080 ||
codeUnit === 0x002d ||
codeUnit === 0x005f ||
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
(codeUnit >= 0x0061 && codeUnit <= 0x007a)
) {
result += string.charAt(index);
} else {
result += '\\' + string.charAt(index);
}
}
return result;
};
}
// matchMedia mock
if (typeof window !== 'undefined' && !window.matchMedia) {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList;
}
// jsdom reports these unimplemented methods to its virtual console even when
// the calling test passes. Tests that need media behavior replace them locally.
if (typeof HTMLMediaElement !== 'undefined') {
HTMLMediaElement.prototype.play = () => Promise.resolve();
HTMLMediaElement.prototype.pause = () => {};
HTMLMediaElement.prototype.load = () => {};
}
// Unit tests never reach the Readest Voice API: a controller built for a
// signed-out reader would otherwise fetch the voice catalog over the network.
// Tests that exercise the request spy on fetch themselves.
if (typeof globalThis.fetch === 'function') {
const realFetch = globalThis.fetch;
globalThis.fetch = (input, init) =>
String(input instanceof Request ? input.url : input).includes('/tts/readest')
? Promise.resolve(Response.json({ error: 'NOT_CONFIGURED' }, { status: 503 }))
: realFetch(input, init);
}