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:
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user