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

@@ -47,6 +47,7 @@ import (
"github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/novelfire/htmlutil" "github.com/libnovel/backend/internal/novelfire/htmlutil"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/scraper" "github.com/libnovel/backend/internal/scraper"
) )
@@ -703,7 +704,7 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
// ── Voices ───────────────────────────────────────────────────────────────────── // ── Voices ─────────────────────────────────────────────────────────────────────
// handleVoices handles GET /api/voices. // handleVoices handles GET /api/voices.
// Returns {"voices": [...]} — fetched from Kokoro with built-in fallback. // Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) { func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())}) writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())})
} }
@@ -763,8 +764,8 @@ const voiceSampleText = "Hello! This is a preview of what I sound like. I hope y
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}. // handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
// If the sample has not been generated yet it synthesises it on the fly via // If the sample has not been generated yet it synthesises it on the fly via
// Kokoro, stores the result in MinIO, and returns the presigned URL — so the // the appropriate TTS engine (Kokoro for kokoro voices, pocket-tts for
// caller always gets a playable URL in a single request. // pocket-tts voices), stores the result in MinIO, and returns the presigned URL.
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
voice := r.PathValue("voice") voice := r.PathValue("voice")
if voice == "" { if voice == "" {
@@ -777,7 +778,20 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
// Generate sample on demand when it is not in MinIO yet. // Generate sample on demand when it is not in MinIO yet.
if !s.deps.AudioStore.AudioExists(r.Context(), key) { if !s.deps.AudioStore.AudioExists(r.Context(), key) {
s.deps.Log.Info("generating voice sample on demand", "voice", voice) s.deps.Log.Info("generating voice sample on demand", "voice", voice)
mp3, err := s.deps.Kokoro.GenerateAudio(r.Context(), voiceSampleText, voice)
var (
mp3 []byte
err error
)
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
mp3, err = s.deps.PocketTTS.GenerateAudio(r.Context(), voiceSampleText, voice)
} else {
mp3, err = s.deps.Kokoro.GenerateAudio(r.Context(), voiceSampleText, voice)
}
if err != nil { if err != nil {
s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err) s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err)
jsonError(w, http.StatusInternalServerError, "voice sample generation failed") jsonError(w, http.StatusInternalServerError, "voice sample generation failed")
@@ -1148,9 +1162,9 @@ func stripMarkdown(src string) string {
// ── Hardcoded Kokoro voice fallback ─────────────────────────────────────────── // ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
// kokoroVoices is the built-in fallback list used when the Kokoro service is // kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
// unavailable. Matches the list in the old scraper helpers.go. // the Kokoro service is unavailable.
var kokoroVoices = []string{ var kokoroVoiceIDs = []string{
// American English // American English
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia", "af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",

View File

@@ -30,8 +30,10 @@ import (
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/libnovel/backend/internal/bookstore" "github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/taskqueue" "github.com/libnovel/backend/internal/taskqueue"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
@@ -59,9 +61,12 @@ type Dependencies struct {
// SearchIndex provides full-text book search via Meilisearch. // SearchIndex provides full-text book search via Meilisearch.
// If nil, the local-only fallback search is used. // If nil, the local-only fallback search is used.
SearchIndex meili.Client SearchIndex meili.Client
// Kokoro is the TTS client (used for voice list only in the backend; // Kokoro is the Kokoro TTS client (used for voice list only in the backend;
// audio generation is done by the runner). // audio generation is done by the runner).
Kokoro kokoro.Client Kokoro kokoro.Client
// PocketTTS is the pocket-tts client (used for voice list only in the backend;
// audio generation is done by the runner).
PocketTTS pockettts.Client
// Log is the structured logger. // Log is the structured logger.
Log *slog.Logger Log *slog.Logger
} }
@@ -84,7 +89,7 @@ type Server struct {
// voiceMu guards cachedVoices. Populated lazily on first GET /api/voices. // voiceMu guards cachedVoices. Populated lazily on first GET /api/voices.
voiceMu sync.RWMutex voiceMu sync.RWMutex
cachedVoices []string cachedVoices []domain.Voice
} }
// New creates a Server from cfg and deps. // New creates a Server from cfg and deps.
@@ -264,10 +269,10 @@ func jsonError(w http.ResponseWriter, status int, msg string) {
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
} }
// voices returns the list of available Kokoro voices. On the first call it // voices returns the merged list of available voices from Kokoro and pocket-tts.
// fetches from the Kokoro service and caches the result. Falls back to the // On the first call it fetches from both services and caches the result.
// hardcoded list on error. // Falls back to the hardcoded Kokoro list on error.
func (s *Server) voices(ctx context.Context) []string { func (s *Server) voices(ctx context.Context) []domain.Voice {
s.voiceMu.RLock() s.voiceMu.RLock()
cached := s.cachedVoices cached := s.cachedVoices
s.voiceMu.RUnlock() s.voiceMu.RUnlock()
@@ -275,23 +280,89 @@ func (s *Server) voices(ctx context.Context) []string {
return cached return cached
} }
if s.deps.Kokoro == nil {
return kokoroVoices
}
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second) fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
list, err := s.deps.Kokoro.ListVoices(fetchCtx)
if err != nil || len(list) == 0 { var result []domain.Voice
s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err)
return kokoroVoices // ── Kokoro voices ─────────────────────────────────────────────────────────
var kokoroIDs []string
if s.deps.Kokoro != nil {
ids, err := s.deps.Kokoro.ListVoices(fetchCtx)
if err != nil || len(ids) == 0 {
s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err)
ids = kokoroVoiceIDs
} else {
s.deps.Log.Info("backend: fetched kokoro voices", "count", len(ids))
}
kokoroIDs = ids
} else {
kokoroIDs = kokoroVoiceIDs
}
for _, id := range kokoroIDs {
result = append(result, kokoroVoice(id))
}
// ── Pocket-TTS voices ─────────────────────────────────────────────────────
if s.deps.PocketTTS != nil {
ids, err := s.deps.PocketTTS.ListVoices(fetchCtx)
if err != nil {
s.deps.Log.Warn("backend: could not fetch pocket-tts voices", "err", err)
} else {
for _, id := range ids {
result = append(result, pocketTTSVoice(id))
}
s.deps.Log.Info("backend: fetched pocket-tts voices", "count", len(ids))
}
} }
s.voiceMu.Lock() s.voiceMu.Lock()
s.cachedVoices = list s.cachedVoices = result
s.voiceMu.Unlock() s.voiceMu.Unlock()
s.deps.Log.Info("backend: fetched kokoro voices", "count", len(list)) return result
return list }
// kokoroVoice builds a domain.Voice for a Kokoro voice ID.
// The two-character prefix encodes language and gender:
//
// af/am → en-us f/m | bf/bm → en-gb f/m
// ef/em → es f/m | ff → fr f
// hf/hm → hi f/m | if/im → it f/m
// jf/jm → ja f/m | pf/pm → pt f/m
// zf/zm → zh f/m
func kokoroVoice(id string) domain.Voice {
type meta struct{ lang, gender string }
prefixMap := map[string]meta{
"af": {"en-us", "f"}, "am": {"en-us", "m"},
"bf": {"en-gb", "f"}, "bm": {"en-gb", "m"},
"ef": {"es", "f"}, "em": {"es", "m"},
"ff": {"fr", "f"},
"hf": {"hi", "f"}, "hm": {"hi", "m"},
"if": {"it", "f"}, "im": {"it", "m"},
"jf": {"ja", "f"}, "jm": {"ja", "m"},
"pf": {"pt", "f"}, "pm": {"pt", "m"},
"zf": {"zh", "f"}, "zm": {"zh", "m"},
}
if len(id) >= 2 {
if m, ok := prefixMap[id[:2]]; ok {
return domain.Voice{ID: id, Engine: "kokoro", Lang: m.lang, Gender: m.gender}
}
}
return domain.Voice{ID: id, Engine: "kokoro", Lang: "en", Gender: ""}
}
// pocketTTSVoice builds a domain.Voice for a pocket-tts voice ID.
// All pocket-tts voices are English audiobook narrators.
func pocketTTSVoice(id string) domain.Voice {
femaleVoices := map[string]struct{}{
"alba": {}, "fantine": {}, "cosette": {}, "eponine": {},
"azelma": {}, "anna": {}, "vera": {}, "mary": {}, "jane": {}, "eve": {},
}
gender := "m"
if _, ok := femaleVoices[id]; ok {
gender = "f"
}
return domain.Voice{ID: id, Engine: "pocket-tts", Lang: "en", Gender: gender}
} }
// handleHealth handles GET /health. // handleHealth handles GET /health.

View File

@@ -60,6 +60,20 @@ type RankingItem struct {
Updated time.Time `json:"updated,omitempty"` Updated time.Time `json:"updated,omitempty"`
} }
// ── Voice types ───────────────────────────────────────────────────────────────
// Voice describes a single text-to-speech voice available in the system.
type Voice struct {
// ID is the voice identifier passed to TTS clients (e.g. "af_bella", "alba").
ID string `json:"id"`
// Engine is "kokoro" or "pocket-tts".
Engine string `json:"engine"`
// Lang is the primary language tag (e.g. "en-us", "en-gb", "en", "es", "fr").
Lang string `json:"lang"`
// Gender is "f" or "m".
Gender string `json:"gender"`
}
// ── Storage record types ────────────────────────────────────────────────────── // ── Storage record types ──────────────────────────────────────────────────────
// ChapterInfo is a lightweight chapter descriptor stored in the index. // ChapterInfo is a lightweight chapter descriptor stored in the index.

View File

@@ -51,6 +51,7 @@
import { audioStore } from '$lib/audio.svelte'; import { audioStore } from '$lib/audio.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
interface Props { interface Props {
slug: string; slug: string;
@@ -63,8 +64,8 @@
nextChapter?: number | null; nextChapter?: number | null;
/** Full chapter list for the book (number + title). Written into the store. */ /** Full chapter list for the book (number + title). Written into the store. */
chapters?: { number: number; title: string }[]; chapters?: { number: number; title: string }[];
/** List of available voices from the Kokoro API. */ /** List of available voices from the backend. */
voices?: string[]; voices?: Voice[];
} }
let { let {
@@ -78,6 +79,10 @@
voices = [] voices = []
}: Props = $props(); }: 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 ──────────────────────────────────────────────── // ── Voice selector state ────────────────────────────────────────────────
let showVoicePanel = $state(false); let showVoicePanel = $state(false);
/** Voice whose sample is currently being fetched or playing. */ /** Voice whose sample is currently being fetched or playing. */
@@ -86,10 +91,33 @@
let sampleAudio = $state<HTMLAudioElement | null>(null); let sampleAudio = $state<HTMLAudioElement | null>(null);
/** /**
* Human-readable label for a voice ID. * Human-readable label for a voice.
* e.g. "af_bella" → "Bella (US F)" | "bm_george" → "George (UK M)" * 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> = { const langMap: Record<string, string> = {
af: 'US', am: 'US', af: 'US', am: 'US',
bf: 'UK', bm: 'UK', bf: 'UK', bm: 'UK',
@@ -112,9 +140,8 @@
pf: 'F', pm: 'M', pf: 'F', pm: 'M',
zf: 'F', zm: 'M', zf: 'F', zm: 'M',
}; };
const prefix = v.slice(0, 2); const prefix = id.slice(0, 2);
const name = v.slice(3); const name = id.slice(3);
// Capitalise and strip legacy v0 prefix.
const displayName = name const displayName = name
.replace(/^v0/, '') .replace(/^v0/, '')
.replace(/^([a-z])/, (c: string) => c.toUpperCase()); .replace(/^([a-z])/, (c: string) => c.toUpperCase());
@@ -627,6 +654,52 @@
<svelte:window onkeydown={handleKeyDown} /> <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="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 justify-between gap-2 mb-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -674,50 +747,25 @@
</Button> </Button>
</div> </div>
<div class="max-h-64 overflow-y-auto"> <div class="max-h-64 overflow-y-auto">
{#each voices as v (v)} <!-- Kokoro (GPU) section -->
<div {#if kokoroVoices.length > 0}
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')} <div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50">
role="button" <span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Kokoro (GPU)</span>
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>
</div> </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>
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50"> <div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
<p class="text-xs text-zinc-500"> <p class="text-xs text-zinc-500">

View File

@@ -6,6 +6,20 @@
* safe to import in both server and client code. * 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 ───────────────────────────────────────────────────────────────── // ── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment { export interface BookComment {

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
import type { PageData, ActionData } from './$types'; import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte'; import { audioStore } from '$lib/audio.svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import type { Voice } from '$lib/types';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -56,14 +57,18 @@
} }
// ── Settings ──────────────────────────────────────────────────────────────── // ── Settings ────────────────────────────────────────────────────────────────
let voices = $state<string[]>([]); let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false); 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 // Load voices on mount
$effect(() => { $effect(() => {
fetch('/api/voices') fetch('/api/voices')
.then((r) => r.json()) .then((r) => r.json())
.then((d: { voices: string[] }) => { .then((d: { voices: Voice[] }) => {
voices = d.voices ?? []; voices = d.voices ?? [];
voicesLoaded = true; voicesLoaded = true;
}) })
@@ -276,9 +281,20 @@
bind:value={voice} 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" 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} {#if kokoroVoices.length > 0}
<option value={v}>{v}</option> <optgroup label="Kokoro (GPU)">
{/each} {#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> </select>
{/if} {/if}
</div> </div>