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

@@ -51,6 +51,7 @@
import { audioStore } from '$lib/audio.svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
interface Props {
slug: string;
@@ -63,8 +64,8 @@
nextChapter?: number | null;
/** Full chapter list for the book (number + title). Written into the store. */
chapters?: { number: number; title: string }[];
/** List of available voices from the Kokoro API. */
voices?: string[];
/** List of available voices from the backend. */
voices?: Voice[];
}
let {
@@ -78,6 +79,10 @@
voices = []
}: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
// ── Voice selector state ────────────────────────────────────────────────
let showVoicePanel = $state(false);
/** Voice whose sample is currently being fetched or playing. */
@@ -86,10 +91,33 @@
let sampleAudio = $state<HTMLAudioElement | null>(null);
/**
* Human-readable label for a voice ID.
* e.g. "af_bella" → "Bella (US F)" | "bm_george" → "George (UK M)"
* Human-readable label for a voice.
* Kokoro: "af_bella" → "Bella (US F)"
* Pocket-TTS: "alba" → "Alba (EN F)"
* Falls back gracefully if called with a bare string (e.g. from the store default).
*/
function voiceLabel(v: string): string {
function voiceLabel(v: Voice | string): string {
// Handle plain string IDs stored in audioStore.voice
if (typeof v === 'string') {
// Try to match against the voices list
const found = voices.find((x) => x.id === v);
if (found) return voiceLabel(found);
// Bare kokoro ID fallback (legacy / default "af_bella")
return kokoroLabelFromId(v);
}
if (v.engine === 'pocket-tts') {
const langLabel = v.lang.toUpperCase().replace('-', '');
const genderLabel = v.gender.toUpperCase();
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return `${name} (${langLabel} ${genderLabel})`;
}
// Kokoro
return kokoroLabelFromId(v.id);
}
function kokoroLabelFromId(id: string): string {
const langMap: Record<string, string> = {
af: 'US', am: 'US',
bf: 'UK', bm: 'UK',
@@ -112,9 +140,8 @@
pf: 'F', pm: 'M',
zf: 'F', zm: 'M',
};
const prefix = v.slice(0, 2);
const name = v.slice(3);
// Capitalise and strip legacy v0 prefix.
const prefix = id.slice(0, 2);
const name = id.slice(3);
const displayName = name
.replace(/^v0/, '')
.replace(/^([a-z])/, (c: string) => c.toUpperCase());
@@ -627,6 +654,52 @@
<svelte:window onkeydown={handleKeyDown} />
<!-- ── Voice row snippet (reused in both engine sections) ──────────────── -->
{#snippet voiceRow(v: import('$lib/types').Voice)}
<div
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-amber-400/10')}
role="button"
tabindex="0"
onclick={() => selectVoice(v.id)}
onkeydown={(e) => e.key === 'Enter' && selectVoice(v.id)}
>
<!-- Selected indicator -->
<div class="w-4 flex-shrink-0">
{#if audioStore.voice === v.id}
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{/if}
</div>
<!-- Voice name -->
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
{voiceLabel(v)}
</span>
<span class="text-zinc-600 text-xs font-mono">{v.id}</span>
<!-- Sample play button -->
<Button
variant="ghost"
size="icon"
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
>
{#if samplePlayingVoice === v.id}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z"/>
</svg>
{:else}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</Button>
</div>
{/snippet}
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2">
@@ -674,50 +747,25 @@
</Button>
</div>
<div class="max-h-64 overflow-y-auto">
{#each voices as v (v)}
<div
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v && 'bg-amber-400/10')}
role="button"
tabindex="0"
onclick={() => selectVoice(v)}
onkeydown={(e) => e.key === 'Enter' && selectVoice(v)}
>
<!-- Selected indicator -->
<div class="w-4 flex-shrink-0">
{#if audioStore.voice === v}
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{/if}
</div>
<!-- Voice name -->
<span class={cn('flex-1 text-xs', audioStore.voice === v ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
{voiceLabel(v)}
</span>
<span class="text-zinc-600 text-xs font-mono">{v}</span>
<!-- Sample play button (stop propagation so click doesn't select) -->
<Button
variant="ghost"
size="icon"
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
onclick={(e) => { e.stopPropagation(); playSample(v); }}
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
>
{#if samplePlayingVoice === v}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z"/>
</svg>
{:else}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</Button>
<!-- Kokoro (GPU) section -->
{#if kokoroVoices.length > 0}
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50">
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Kokoro (GPU)</span>
</div>
{/each}
{#each kokoroVoices as v (v.id)}
{@render voiceRow(v)}
{/each}
{/if}
<!-- Pocket TTS (CPU) section -->
{#if pocketVoices.length > 0}
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50 {kokoroVoices.length > 0 ? 'border-t border-zinc-700' : ''}">
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Pocket TTS (CPU)</span>
</div>
{#each pocketVoices as v (v.id)}
{@render voiceRow(v)}
{/each}
{/if}
</div>
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
<p class="text-xs text-zinc-500">

View File

@@ -6,6 +6,20 @@
* safe to import in both server and client code.
*/
// ── Voice ─────────────────────────────────────────────────────────────────────
/** A single TTS voice returned by GET /api/voices. */
export interface Voice {
/** Voice identifier passed to TTS clients (e.g. "af_bella", "alba"). */
id: string;
/** TTS engine: "kokoro" | "pocket-tts". */
engine: string;
/** Primary language tag (e.g. "en-us", "en-gb", "en", "es", "fr"). */
lang: string;
/** Gender: "f" | "m". */
gender: string;
}
// ── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {

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>