feat(audio): add voice selector, voice samples, MediaSession cover art, and fix speed/voice bug
- Fix speed/voice bug: AudioPlayer no longer accepts speed/voice as props;
startPlayback() reads audioStore.voice/speed directly instead of overwriting them
- Add GET /api/voices endpoint (Go) proxying Kokoro, cached in-memory
- Add POST /api/audio/voice-samples endpoint (Go) to pre-generate sample clips
for all voices and store them in MinIO under _voice-samples/{voice}.mp3
- Add GET /api/presign/voice-sample/{voice} endpoint (Go)
- Add SvelteKit proxy routes: /api/voices, /api/presign/voice-sample, /api/audio/voice-samples
- Add presignVoiceSample() helper in minio.ts with proper host rewrite
- Pass book.cover through +page.server.ts -> +page.svelte -> AudioPlayer
- Set navigator.mediaSession.metadata on playback start so cover art,
book title, and chapter title appear on phone lock screen / notification
This commit is contained in:
@@ -151,8 +151,19 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
// Presigned URL API (for SvelteKit UI)
|
// Presigned URL API (for SvelteKit UI)
|
||||||
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
|
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
|
||||||
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
||||||
|
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
||||||
// Plain-text chapter content (used server-side for audio generation)
|
// Plain-text chapter content (used server-side for audio generation)
|
||||||
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
||||||
|
// Voices list (proxied from Kokoro)
|
||||||
|
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||||
|
// Voice sample generation — generates a short audio clip for each voice
|
||||||
|
// and stores it in MinIO for UI preview playback.
|
||||||
|
voiceSampleHandler := http.TimeoutHandler(
|
||||||
|
http.HandlerFunc(s.handleGenerateVoiceSamples),
|
||||||
|
15*time.Minute,
|
||||||
|
`{"error":"voice sample generation timed out"}`,
|
||||||
|
)
|
||||||
|
mux.Handle("POST /api/audio/voice-samples", voiceSampleHandler)
|
||||||
// Server-side audio generation via Kokoro /v1/audio/speech.
|
// Server-side audio generation via Kokoro /v1/audio/speech.
|
||||||
// Generation can take several minutes, so wrap in its own timeout handler.
|
// Generation can take several minutes, so wrap in its own timeout handler.
|
||||||
audioGenHandler := http.TimeoutHandler(
|
audioGenHandler := http.TimeoutHandler(
|
||||||
@@ -1446,3 +1457,141 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
|||||||
"indexed": count,
|
"indexed": count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Voices API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// handleVoices handles GET /api/voices.
|
||||||
|
// Returns the list of available Kokoro voices as JSON: {"voices": [...]}
|
||||||
|
func (s *Server) handleVoices(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
voices := s.voices()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"voices": voices})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Voice sample generation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// voiceSampleText is the short passage used for voice sample previews.
|
||||||
|
const voiceSampleText = "The ancient library held secrets older than memory itself, its dust-laden shelves stretching upward into shadow. She reached for the worn leather spine, fingers trembling with anticipation."
|
||||||
|
|
||||||
|
// voiceSampleKey returns the MinIO object key for a voice sample.
|
||||||
|
// Key: _voice-samples/{voice}.mp3
|
||||||
|
func voiceSampleKey(voice string) string {
|
||||||
|
safe := strings.Map(func(r rune) rune {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return '_'
|
||||||
|
}, voice)
|
||||||
|
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenerateVoiceSamples handles POST /api/audio/voice-samples.
|
||||||
|
// It generates short audio samples for each available voice and stores them
|
||||||
|
// in the audio MinIO bucket so the UI can play them during voice selection.
|
||||||
|
// Already-generated samples are skipped (idempotent).
|
||||||
|
// Optional JSON body: {"voices": ["af_bella", ...]} to generate a subset.
|
||||||
|
// Returns: {"generated": [...], "skipped": [...], "failed": [...]}
|
||||||
|
func (s *Server) handleGenerateVoiceSamples(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.kokoroURL == "" {
|
||||||
|
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse optional voice list from body.
|
||||||
|
var body struct {
|
||||||
|
Voices []string `json:"voices"`
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetVoices := body.Voices
|
||||||
|
if len(targetVoices) == 0 {
|
||||||
|
targetVoices = s.voices()
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
Generated []string `json:"generated"`
|
||||||
|
Skipped []string `json:"skipped"`
|
||||||
|
Failed []string `json:"failed"`
|
||||||
|
}
|
||||||
|
var res result
|
||||||
|
|
||||||
|
for _, voice := range targetVoices {
|
||||||
|
key := voiceSampleKey(voice)
|
||||||
|
|
||||||
|
// Skip if already uploaded.
|
||||||
|
if s.store.AudioExists(r.Context(), key) {
|
||||||
|
res.Skipped = append(res.Skipped, voice)
|
||||||
|
s.log.Debug("voice sample already exists, skipping", "voice", voice)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate via Kokoro (speed 1.0 for samples).
|
||||||
|
filename, err := s.generateSpeech(r.Context(), voiceSampleText, voice, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("voice sample generation failed", "voice", voice, "err", err)
|
||||||
|
res.Failed = append(res.Failed, voice)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download from Kokoro and upload to MinIO.
|
||||||
|
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
|
||||||
|
if dlErr != nil {
|
||||||
|
s.log.Warn("voice sample kokoro download failed", "voice", voice, "err", dlErr)
|
||||||
|
res.Failed = append(res.Failed, voice)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if putErr := s.store.PutAudio(r.Context(), key, audioData); putErr != nil {
|
||||||
|
s.log.Warn("voice sample MinIO upload failed", "voice", voice, "key", key, "err", putErr)
|
||||||
|
res.Failed = append(res.Failed, voice)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("voice sample generated", "voice", voice, "key", key)
|
||||||
|
res.Generated = append(res.Generated, voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Generated == nil {
|
||||||
|
res.Generated = []string{}
|
||||||
|
}
|
||||||
|
if res.Skipped == nil {
|
||||||
|
res.Skipped = []string{}
|
||||||
|
}
|
||||||
|
if res.Failed == nil {
|
||||||
|
res.Failed = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
|
||||||
|
// Returns a presigned URL for the voice sample audio file stored in MinIO.
|
||||||
|
// Returns 404 if the sample has not been generated yet.
|
||||||
|
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
|
||||||
|
voice := r.PathValue("voice")
|
||||||
|
if voice == "" {
|
||||||
|
http.Error(w, `{"error":"missing voice"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := voiceSampleKey(voice)
|
||||||
|
|
||||||
|
if !s.store.AudioExists(r.Context(), key) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("presign voice sample failed", "voice", voice, "err", err)
|
||||||
|
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
* 4. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar.
|
* 4. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar.
|
||||||
* On success, presign again and set audioUrl.
|
* On success, presign again and set audioUrl.
|
||||||
*
|
*
|
||||||
|
* ── Voice selection ──────────────────────────────────────────────────────
|
||||||
|
* A "Change voice" panel lets users pick from the available Kokoro voices.
|
||||||
|
* Each voice shows a play button that streams a pre-generated sample from
|
||||||
|
* MinIO (GET /api/presign/voice-sample?voice=...). Samples are generated
|
||||||
|
* server-side via POST /api/audio/voice-samples.
|
||||||
|
*
|
||||||
|
* Changing voice updates audioStore.voice (saved to settings via layout).
|
||||||
|
* The currently loaded chapter audio is NOT re-generated automatically —
|
||||||
|
* the new voice takes effect on next "Play narration" click.
|
||||||
|
*
|
||||||
* ── Pre-fetch (immediate + 90% fallback) ────────────────────────────────
|
* ── Pre-fetch (immediate + 90% fallback) ────────────────────────────────
|
||||||
* When autoNext is on, prefetchNext() is called as soon as the current
|
* When autoNext is on, prefetchNext() is called as soon as the current
|
||||||
* chapter starts playing (via maybeStartPrefetch() at the end of
|
* chapter starts playing (via maybeStartPrefetch() at the end of
|
||||||
@@ -45,10 +55,12 @@
|
|||||||
chapter: number;
|
chapter: number;
|
||||||
chapterTitle?: string;
|
chapterTitle?: string;
|
||||||
bookTitle?: string;
|
bookTitle?: string;
|
||||||
|
/** Cover image URL for the book (used in MediaSession for lock-screen art). */
|
||||||
|
cover?: string;
|
||||||
/** Next chapter number, or null/undefined if this is the last chapter. */
|
/** Next chapter number, or null/undefined if this is the last chapter. */
|
||||||
nextChapter?: number | null;
|
nextChapter?: number | null;
|
||||||
voice?: string;
|
/** List of available voices from the Kokoro API. */
|
||||||
speed?: number;
|
voices?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -56,11 +68,107 @@
|
|||||||
chapter,
|
chapter,
|
||||||
chapterTitle = '',
|
chapterTitle = '',
|
||||||
bookTitle = '',
|
bookTitle = '',
|
||||||
|
cover = '',
|
||||||
nextChapter = null,
|
nextChapter = null,
|
||||||
voice = 'af_bella',
|
voices = []
|
||||||
speed = 1.0
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
// ── Voice selector state ────────────────────────────────────────────────
|
||||||
|
let showVoicePanel = $state(false);
|
||||||
|
/** Voice whose sample is currently being fetched or playing. */
|
||||||
|
let samplePlayingVoice = $state<string | null>(null);
|
||||||
|
/** Currently active sample <audio> element — one at a time. */
|
||||||
|
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)"
|
||||||
|
*/
|
||||||
|
function voiceLabel(v: string): string {
|
||||||
|
const langMap: Record<string, string> = {
|
||||||
|
af: 'US', am: 'US',
|
||||||
|
bf: 'UK', bm: 'UK',
|
||||||
|
ef: 'ES', em: 'ES',
|
||||||
|
ff: 'FR',
|
||||||
|
hf: 'IN', hm: 'IN',
|
||||||
|
'if': 'IT', im: 'IT',
|
||||||
|
jf: 'JP', jm: 'JP',
|
||||||
|
pf: 'PT', pm: 'PT',
|
||||||
|
zf: 'ZH', zm: 'ZH',
|
||||||
|
};
|
||||||
|
const genderMap: Record<string, string> = {
|
||||||
|
af: 'F', am: 'M',
|
||||||
|
bf: 'F', bm: 'M',
|
||||||
|
ef: 'F', em: 'M',
|
||||||
|
ff: 'F',
|
||||||
|
hf: 'F', hm: 'M',
|
||||||
|
'if': 'F', im: 'M',
|
||||||
|
jf: 'F', jm: 'M',
|
||||||
|
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 displayName = name
|
||||||
|
.replace(/^v0/, '')
|
||||||
|
.replace(/^([a-z])/, (c: string) => c.toUpperCase());
|
||||||
|
const lang = langMap[prefix] ?? prefix.toUpperCase();
|
||||||
|
const gender = genderMap[prefix] ?? '?';
|
||||||
|
return `${displayName} (${lang} ${gender})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop any currently playing sample. */
|
||||||
|
function stopSample() {
|
||||||
|
if (sampleAudio) {
|
||||||
|
sampleAudio.pause();
|
||||||
|
sampleAudio.src = '';
|
||||||
|
sampleAudio = null;
|
||||||
|
}
|
||||||
|
samplePlayingVoice = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play a voice sample from MinIO. */
|
||||||
|
async function playSample(voice: string) {
|
||||||
|
// If this voice is already playing, stop it.
|
||||||
|
if (samplePlayingVoice === voice) {
|
||||||
|
stopSample();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopSample();
|
||||||
|
|
||||||
|
samplePlayingVoice = voice;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voice)}`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
// Sample not generated yet — silently ignore
|
||||||
|
samplePlayingVoice = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`presign failed: ${res.status}`);
|
||||||
|
const data = (await res.json()) as { url: string };
|
||||||
|
|
||||||
|
const audio = new Audio(data.url);
|
||||||
|
sampleAudio = audio;
|
||||||
|
audio.onended = () => {
|
||||||
|
if (samplePlayingVoice === voice) stopSample();
|
||||||
|
};
|
||||||
|
audio.onerror = () => {
|
||||||
|
if (samplePlayingVoice === voice) stopSample();
|
||||||
|
};
|
||||||
|
await audio.play();
|
||||||
|
} catch {
|
||||||
|
samplePlayingVoice = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select a voice and close the panel. */
|
||||||
|
function selectVoice(voice: string) {
|
||||||
|
stopSample();
|
||||||
|
audioStore.voice = voice;
|
||||||
|
showVoicePanel = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep nextChapter in the store so the layout's onended can navigate.
|
// Keep nextChapter in the store so the layout's onended can navigate.
|
||||||
// NOTE: we do NOT clear on unmount here — the store retains the value so
|
// NOTE: we do NOT clear on unmount here — the store retains the value so
|
||||||
// onended (which may fire after {#key} unmounts this component) can still
|
// onended (which may fire after {#key} unmounts this component) can still
|
||||||
@@ -94,6 +202,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close voice panel when user clicks outside (escape key).
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
stopSample();
|
||||||
|
showVoicePanel = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 90% pre-fetch trigger ─────────────────────────────────────────────────
|
// ── 90% pre-fetch trigger ─────────────────────────────────────────────────
|
||||||
// Watch playback progress; when >= 90% of current chapter, pre-generate
|
// Watch playback progress; when >= 90% of current chapter, pre-generate
|
||||||
// the next chapter's audio so it's ready when we navigate.
|
// the next chapter's audio so it's ready when we navigate.
|
||||||
@@ -220,6 +336,9 @@
|
|||||||
if (nextChapter === null || nextChapter === undefined) return;
|
if (nextChapter === null || nextChapter === undefined) return;
|
||||||
if (audioStore.nextStatus !== 'none') return; // already running or done
|
if (audioStore.nextStatus !== 'none') return; // already running or done
|
||||||
|
|
||||||
|
const voice = audioStore.voice;
|
||||||
|
const speed = audioStore.speed;
|
||||||
|
|
||||||
audioStore.nextStatus = 'prefetching';
|
audioStore.nextStatus = 'prefetching';
|
||||||
audioStore.nextChapterPrefetched = nextChapter;
|
audioStore.nextChapterPrefetched = nextChapter;
|
||||||
startNextProgress();
|
startNextProgress();
|
||||||
@@ -257,16 +376,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Media Session ──────────────────────────────────────────────────────────
|
||||||
|
// Sets the OS-level media metadata so the book cover, title, and chapter
|
||||||
|
// appear on the phone lock screen / notification center.
|
||||||
|
|
||||||
|
function setMediaSession() {
|
||||||
|
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
|
||||||
|
|
||||||
|
const artwork: MediaImage[] = cover
|
||||||
|
? [
|
||||||
|
{ src: cover, sizes: '512x512', type: 'image/jpeg' },
|
||||||
|
{ src: cover, sizes: '256x256', type: 'image/jpeg' }
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: chapterTitle || `Chapter ${chapter}`,
|
||||||
|
artist: bookTitle,
|
||||||
|
album: bookTitle,
|
||||||
|
artwork
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Core play flow ─────────────────────────────────────────────────────────
|
// ── Core play flow ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function startPlayback() {
|
async function startPlayback() {
|
||||||
|
const voice = audioStore.voice;
|
||||||
|
const speed = audioStore.speed;
|
||||||
|
|
||||||
// Populate store metadata so layout + mini-bar have track info.
|
// Populate store metadata so layout + mini-bar have track info.
|
||||||
audioStore.slug = slug;
|
audioStore.slug = slug;
|
||||||
audioStore.chapter = chapter;
|
audioStore.chapter = chapter;
|
||||||
audioStore.chapterTitle = chapterTitle;
|
audioStore.chapterTitle = chapterTitle;
|
||||||
audioStore.bookTitle = bookTitle;
|
audioStore.bookTitle = bookTitle;
|
||||||
audioStore.voice = voice;
|
|
||||||
audioStore.speed = speed;
|
// Update OS media session (lock screen / notification center).
|
||||||
|
setMediaSession();
|
||||||
|
|
||||||
audioStore.status = 'loading';
|
audioStore.status = 'loading';
|
||||||
audioStore.errorMsg = '';
|
audioStore.errorMsg = '';
|
||||||
@@ -390,14 +535,118 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeyDown} />
|
||||||
|
|
||||||
<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 gap-2 mb-3">
|
<div class="flex items-center justify-between gap-2 mb-3">
|
||||||
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
<div class="flex items-center gap-2">
|
||||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||||
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
|
</svg>
|
||||||
|
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voice selector button -->
|
||||||
|
{#if voices.length > 0}
|
||||||
|
<button
|
||||||
|
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||||
|
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors {showVoicePanel
|
||||||
|
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||||
|
: 'text-zinc-400 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
|
||||||
|
title="Change voice"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||||
|
<svg class="w-3 h-3 flex-shrink-0 transition-transform {showVoicePanel ? 'rotate-180' : ''}" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 10l5 5 5-5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
|
||||||
|
{#if showVoicePanel && voices.length > 0}
|
||||||
|
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden">
|
||||||
|
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
|
||||||
|
<button
|
||||||
|
onclick={() => { stopSample(); showVoicePanel = false; }}
|
||||||
|
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
aria-label="Close voice selector"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-64 overflow-y-auto">
|
||||||
|
{#each voices as v (v)}
|
||||||
|
<div
|
||||||
|
class="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="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
|
||||||
|
onclick={(e) => { e.stopPropagation(); playSample(v); }}
|
||||||
|
class="p-1 rounded transition-colors flex-shrink-0 {samplePlayingVoice === v
|
||||||
|
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700'}"
|
||||||
|
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
|
||||||
|
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
|
||||||
|
>
|
||||||
|
{#if samplePlayingVoice === v}
|
||||||
|
<!-- Stop icon -->
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 6h12v12H6z"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Play icon -->
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
|
||||||
|
<p class="text-xs text-zinc-500">
|
||||||
|
New voice applies on next "Play narration".
|
||||||
|
{#if voices.length > 0}
|
||||||
|
<a
|
||||||
|
href="/api/audio/voice-samples"
|
||||||
|
class="text-zinc-400 hover:text-amber-400 transition-colors underline"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
||||||
|
}}
|
||||||
|
>Generate missing samples</a>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if audioStore.isCurrentChapter(slug, chapter)}
|
{#if audioStore.isCurrentChapter(slug, chapter)}
|
||||||
<!-- ── This chapter is the active one ── -->
|
<!-- ── This chapter is the active one ── -->
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,35 @@ export async function presignChapter(slug: string, n: number, rewrite = false):
|
|||||||
return rewrite ? rewriteHost(data.url) : data.url;
|
return rewrite ? rewriteHost(data.url) : data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a presigned URL for a voice sample audio file.
|
||||||
|
* URL is valid for ~1 hour. The URL is returned to the browser for direct streaming.
|
||||||
|
* Throws with { status: 404 } when the sample has not been generated yet.
|
||||||
|
*/
|
||||||
|
export async function presignVoiceSample(voice: string): Promise<string> {
|
||||||
|
log.debug('minio', 'presigning voice sample', { voice });
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${SCRAPER_URL}/api/presign/voice-sample/${encodeURIComponent(voice)}`);
|
||||||
|
} catch (e) {
|
||||||
|
log.error('minio', 'presign voice sample network error', { voice, err: String(e) });
|
||||||
|
throw new Error(`presign voice sample ${voice}: network error`);
|
||||||
|
}
|
||||||
|
if (res.status === 404) {
|
||||||
|
const err = new Error(`presign voice sample ${voice}: not found`) as Error & { status: number };
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
log.error('minio', 'presign voice sample failed', { voice, status: res.status, body });
|
||||||
|
throw new Error(`presign voice sample ${voice}: ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { url: string };
|
||||||
|
log.debug('minio', 'presign voice sample ok', { voice });
|
||||||
|
return rewriteHost(data.url);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a presigned URL for an audio file.
|
* Returns a presigned URL for an audio file.
|
||||||
* URL is valid for ~1 hour. The URL is returned to the browser for direct streaming.
|
* URL is valid for ~1 hour. The URL is returned to the browser for direct streaming.
|
||||||
|
|||||||
33
ui/src/routes/api/audio/voice-samples/+server.ts
Normal file
33
ui/src/routes/api/audio/voice-samples/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/audio/voice-samples
|
||||||
|
* Triggers generation of voice sample audio files for all (or specified) voices.
|
||||||
|
* Proxies to the scraper's POST /api/audio/voice-samples endpoint.
|
||||||
|
* Optional body: { voices: string[] } to generate a subset.
|
||||||
|
* Returns: { generated: string[], skipped: string[], failed: string[] }
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
let body: { voices?: string[] } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
// Empty body is fine — generates all voices
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SCRAPER_URL}/api/audio/voice-samples`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return json(data, { status: res.ok ? 200 : res.status });
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: String(e) }, { status: 502 });
|
||||||
|
}
|
||||||
|
};
|
||||||
26
ui/src/routes/api/presign/voice-sample/+server.ts
Normal file
26
ui/src/routes/api/presign/voice-sample/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { presignVoiceSample } from '$lib/server/minio';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/presign/voice-sample?voice=af_bella
|
||||||
|
* Returns a presigned URL for the voice sample audio file.
|
||||||
|
* Returns 404 if the sample has not been generated yet.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const voice = url.searchParams.get('voice');
|
||||||
|
if (!voice) {
|
||||||
|
error(400, 'Missing voice parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const presignedUrl = await presignVoiceSample(voice);
|
||||||
|
return json({ url: presignedUrl });
|
||||||
|
} catch (e) {
|
||||||
|
const status = (e as { status?: number }).status;
|
||||||
|
if (status === 404) {
|
||||||
|
error(404, 'Voice sample not found');
|
||||||
|
}
|
||||||
|
error(502, `Failed to presign voice sample: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
23
ui/src/routes/api/voices/+server.ts
Normal file
23
ui/src/routes/api/voices/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/voices
|
||||||
|
* Proxies the voice list from the scraper → Kokoro.
|
||||||
|
* Returns { voices: string[] }
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SCRAPER_URL}/api/voices`);
|
||||||
|
if (!res.ok) {
|
||||||
|
return json({ voices: [] });
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { voices: string[] };
|
||||||
|
return json({ voices: data.voices ?? [] });
|
||||||
|
} catch {
|
||||||
|
return json({ voices: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,6 +4,9 @@ import type { PageServerLoad } from './$types';
|
|||||||
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
|
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
|
||||||
import { presignChapter } from '$lib/server/minio';
|
import { presignChapter } from '$lib/server/minio';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
@@ -11,14 +14,29 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
|
|
||||||
if (!n || n < 1) error(400, 'Invalid chapter number');
|
if (!n || n < 1) error(400, 'Invalid chapter number');
|
||||||
|
|
||||||
// Fetch book metadata and chapter index in parallel
|
// Fetch book metadata, chapter index, and voice list in parallel
|
||||||
const [book, chapters] = await Promise.all([getBook(slug), listChapterIdx(slug)]);
|
const [book, chapters, voicesRes] = await Promise.all([
|
||||||
|
getBook(slug),
|
||||||
|
listChapterIdx(slug),
|
||||||
|
fetch(`${SCRAPER_URL}/api/voices`).catch(() => null)
|
||||||
|
]);
|
||||||
|
|
||||||
if (!book) error(404, `Book "${slug}" not found`);
|
if (!book) error(404, `Book "${slug}" not found`);
|
||||||
|
|
||||||
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
|
||||||
|
let voices: string[] = [];
|
||||||
|
try {
|
||||||
|
if (voicesRes?.ok) {
|
||||||
|
const data = (await voicesRes.json()) as { voices: string[] };
|
||||||
|
voices = data.voices ?? [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — UI will use store default
|
||||||
|
}
|
||||||
|
|
||||||
// Get presigned URL and fetch chapter markdown server-side
|
// Get presigned URL and fetch chapter markdown server-side
|
||||||
let html = '';
|
let html = '';
|
||||||
try {
|
try {
|
||||||
@@ -36,9 +54,10 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
|||||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book: { slug: book.slug, title: book.title },
|
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
||||||
chapter: chapterIdx,
|
chapter: chapterIdx,
|
||||||
html,
|
html,
|
||||||
|
voices,
|
||||||
prev: prevChapter ? prevChapter.number : null,
|
prev: prevChapter ? prevChapter.number : null,
|
||||||
next: nextChapter ? nextChapter.number : null,
|
next: nextChapter ? nextChapter.number : null,
|
||||||
sessionId: locals.sessionId
|
sessionId: locals.sessionId
|
||||||
|
|||||||
@@ -72,7 +72,9 @@
|
|||||||
chapter={data.chapter.number}
|
chapter={data.chapter.number}
|
||||||
chapterTitle={data.chapter.title || `Chapter ${data.chapter.number}`}
|
chapterTitle={data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||||
bookTitle={data.book.title}
|
bookTitle={data.book.title}
|
||||||
|
cover={data.book.cover}
|
||||||
nextChapter={data.next}
|
nextChapter={data.next}
|
||||||
|
voices={data.voices}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Chapter content -->
|
<!-- Chapter content -->
|
||||||
|
|||||||
Reference in New Issue
Block a user