mirror of
https://github.com/readest/readest.git
synced 2026-10-19 10:13:29 +00:00
* 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>
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
// 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);
|
||
}
|