feat(tts): dual-engine voice list (kokoro + pocket-tts)

Expose all available voices from both TTS engines via the /api/voices
endpoint. AudioPlayer and profile voice-selector now group voices by
engine and show a labelled optgroup. Voice type carries an engine field
so the chapter-reader can route synthesis to the correct backend.
This commit is contained in:
Admin
2026-03-28 14:32:06 +05:00
parent 9c8849c6cd
commit 98e4a87432
9 changed files with 272 additions and 92 deletions

View File

@@ -4,6 +4,7 @@ import type { RequestHandler } from './$types';
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import type { Voice } from '$lib/types';
/**
* GET /api/chapter/[slug]/[n]
@@ -48,11 +49,11 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
: '';
let voices: string[] = [];
let voices: Voice[] = [];
try {
const vRes = await backendFetch('/api/voices');
if (vRes.ok) {
const d = (await vRes.json()) as { voices: string[] };
const d = (await vRes.json()) as { voices: Voice[] };
voices = d.voices ?? [];
}
} catch {
@@ -85,10 +86,10 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
const chapterIdx = chapters.find((c) => c.number === n);
if (!chapterIdx) error(404, `Chapter ${n} not found`);
let voices: string[] = [];
let voices: Voice[] = [];
try {
if (voicesRes?.ok) {
const data = (await voicesRes.json()) as { voices: string[] };
const data = (await voicesRes.json()) as { voices: Voice[] };
voices = data.voices ?? [];
}
} catch {

View File

@@ -1,11 +1,12 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
import type { Voice } from '$lib/types';
/**
* GET /api/voices
* Proxies the voice list from the backend Kokoro.
* Returns { voices: string[] }
* Proxies the voice list from the backend (Kokoro + pocket-tts).
* Returns { voices: Voice[] }
*/
export const GET: RequestHandler = async () => {
try {
@@ -13,7 +14,7 @@ export const GET: RequestHandler = async () => {
if (!res.ok) {
return json({ voices: [] });
}
const data = (await res.json()) as { voices: string[] };
const data = (await res.json()) as { voices: Voice[] };
return json({ voices: data.voices ?? [] });
} catch {
return json({ voices: [] });

View File

@@ -4,6 +4,7 @@ import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import type { Voice } from '$lib/types';
export const load: PageServerLoad = async ({ params, url, locals }) => {
const { slug } = params;
@@ -43,11 +44,11 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
: '';
// Fetch voices (non-critical for preview)
let voices: string[] = [];
let voices: Voice[] = [];
try {
const vRes = await backendFetch('/api/voices');
if (vRes.ok) {
const d = (await vRes.json()) as { voices: string[] };
const d = (await vRes.json()) as { voices: Voice[] };
voices = d.voices ?? [];
}
} catch {
@@ -93,11 +94,11 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const chapterIdx = chapters.find((c) => c.number === n);
if (!chapterIdx) error(404, `Chapter ${n} not found`);
// Parse voices — fall back to a minimal default list on error
let voices: string[] = [];
// Parse voices — fall back to empty list on error
let voices: Voice[] = [];
try {
if (voicesRes?.ok) {
const data = (await voicesRes.json()) as { voices: string[] };
const data = (await voicesRes.json()) as { voices: Voice[] };
voices = data.voices ?? [];
}
} catch {

View File

@@ -5,6 +5,7 @@
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { browser } from '$app/environment';
import type { Voice } from '$lib/types';
let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -56,14 +57,18 @@
}
// ── Settings ────────────────────────────────────────────────────────────────
let voices = $state<string[]>([]);
let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false);
// Derived: voices grouped by engine
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
// Load voices on mount
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: string[] }) => {
.then((d: { voices: Voice[] }) => {
voices = d.voices ?? [];
voicesLoaded = true;
})
@@ -276,9 +281,20 @@
bind:value={voice}
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
>
{#each voices as v}
<option value={v}>{v}</option>
{/each}
{#if kokoroVoices.length > 0}
<optgroup label="Kokoro (GPU)">
{#each kokoroVoices as v}
<option value={v.id}>{v.id}</option>
{/each}
</optgroup>
{/if}
{#if pocketVoices.length > 0}
<optgroup label="Pocket TTS (CPU)">
{#each pocketVoices as v}
<option value={v.id}>{v.id}</option>
{/each}
</optgroup>
{/if}
</select>
{/if}
</div>