diff --git a/backend/internal/backend/handlers.go b/backend/internal/backend/handlers.go index 5c8a4d2..871fdb9 100644 --- a/backend/internal/backend/handlers.go +++ b/backend/internal/backend/handlers.go @@ -47,6 +47,7 @@ import ( "github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/novelfire/htmlutil" + "github.com/libnovel/backend/internal/pockettts" "github.com/libnovel/backend/internal/scraper" ) @@ -703,7 +704,7 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) { // ── 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) { 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}. // 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 -// caller always gets a playable URL in a single request. +// the appropriate TTS engine (Kokoro for kokoro voices, pocket-tts for +// pocket-tts voices), stores the result in MinIO, and returns the presigned URL. func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) { voice := r.PathValue("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. if !s.deps.AudioStore.AudioExists(r.Context(), key) { 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 { s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err) jsonError(w, http.StatusInternalServerError, "voice sample generation failed") @@ -1148,9 +1162,9 @@ func stripMarkdown(src string) string { // ── Hardcoded Kokoro voice fallback ─────────────────────────────────────────── -// kokoroVoices is the built-in fallback list used when the Kokoro service is -// unavailable. Matches the list in the old scraper helpers.go. -var kokoroVoices = []string{ +// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when +// the Kokoro service is unavailable. +var kokoroVoiceIDs = []string{ // American English "af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 1e087f8..c4d1d6e 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -30,8 +30,10 @@ import ( sentryhttp "github.com/getsentry/sentry-go/http" "github.com/libnovel/backend/internal/bookstore" + "github.com/libnovel/backend/internal/domain" "github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/meili" + "github.com/libnovel/backend/internal/pockettts" "github.com/libnovel/backend/internal/taskqueue" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -59,9 +61,12 @@ type Dependencies struct { // SearchIndex provides full-text book search via Meilisearch. // If nil, the local-only fallback search is used. 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). 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 *slog.Logger } @@ -84,7 +89,7 @@ type Server struct { // voiceMu guards cachedVoices. Populated lazily on first GET /api/voices. voiceMu sync.RWMutex - cachedVoices []string + cachedVoices []domain.Voice } // 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}) } -// voices returns the list of available Kokoro voices. On the first call it -// fetches from the Kokoro service and caches the result. Falls back to the -// hardcoded list on error. -func (s *Server) voices(ctx context.Context) []string { +// voices returns the merged list of available voices from Kokoro and pocket-tts. +// On the first call it fetches from both services and caches the result. +// Falls back to the hardcoded Kokoro list on error. +func (s *Server) voices(ctx context.Context) []domain.Voice { s.voiceMu.RLock() cached := s.cachedVoices s.voiceMu.RUnlock() @@ -275,23 +280,89 @@ func (s *Server) voices(ctx context.Context) []string { return cached } - if s.deps.Kokoro == nil { - return kokoroVoices - } - fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - list, err := s.deps.Kokoro.ListVoices(fetchCtx) - if err != nil || len(list) == 0 { - s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err) - return kokoroVoices + + var result []domain.Voice + + // ── 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.cachedVoices = list + s.cachedVoices = result s.voiceMu.Unlock() - s.deps.Log.Info("backend: fetched kokoro voices", "count", len(list)) - return list + return result +} + +// 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. diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index a582e17..226ef44 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -60,6 +60,20 @@ type RankingItem struct { 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 ────────────────────────────────────────────────────── // ChapterInfo is a lightweight chapter descriptor stored in the index. diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index 7e4516a..0aa9a3b 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -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(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 = { 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 @@ + +{#snippet voiceRow(v: import('$lib/types').Voice)} +
selectVoice(v.id)} + onkeydown={(e) => e.key === 'Enter' && selectVoice(v.id)} + > + +
+ {#if audioStore.voice === v.id} + + + + {/if} +
+ + + + {voiceLabel(v)} + + {v.id} + + + +
+{/snippet} +
@@ -674,50 +747,25 @@
- {#each voices as v (v)} -
selectVoice(v)} - onkeydown={(e) => e.key === 'Enter' && selectVoice(v)} - > - -
- {#if audioStore.voice === v} - - - - {/if} -
- - - - {voiceLabel(v)} - - {v} - - - + + {#if kokoroVoices.length > 0} +
+ Kokoro (GPU)
- {/each} + {#each kokoroVoices as v (v.id)} + {@render voiceRow(v)} + {/each} + {/if} + + + {#if pocketVoices.length > 0} +
+ Pocket TTS (CPU) +
+ {#each pocketVoices as v (v.id)} + {@render voiceRow(v)} + {/each} + {/if}

diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 0a22742..d91c12d 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -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 { diff --git a/ui/src/routes/api/chapter/[slug]/[n]/+server.ts b/ui/src/routes/api/chapter/[slug]/[n]/+server.ts index f79492b..b13c7df 100644 --- a/ui/src/routes/api/chapter/[slug]/[n]/+server.ts +++ b/ui/src/routes/api/chapter/[slug]/[n]/+server.ts @@ -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 }) => { ? '

' + chapterData.text.replace(/\n{2,}/g, '

').replace(/\n/g, '
') + '

' : ''; - 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 { diff --git a/ui/src/routes/api/voices/+server.ts b/ui/src/routes/api/voices/+server.ts index 8438ed4..e174889 100644 --- a/ui/src/routes/api/voices/+server.ts +++ b/ui/src/routes/api/voices/+server.ts @@ -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: [] }); diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts index 911e531..ce8f2a5 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts @@ -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 { diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index 16905ab..2735877 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -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([]); + let voices = $state([]); 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} - - {/each} + {#if kokoroVoices.length > 0} + + {#each kokoroVoices as v} + + {/each} + + {/if} + {#if pocketVoices.length > 0} + + {#each pocketVoices as v} + + {/each} + + {/if} {/if}