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:
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 { presignChapter } from '$lib/server/minio';
|
||||
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 }) => {
|
||||
const { slug } = params;
|
||||
@@ -11,14 +14,29 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
|
||||
if (!n || n < 1) error(400, 'Invalid chapter number');
|
||||
|
||||
// Fetch book metadata and chapter index in parallel
|
||||
const [book, chapters] = await Promise.all([getBook(slug), listChapterIdx(slug)]);
|
||||
// Fetch book metadata, chapter index, and voice list in parallel
|
||||
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`);
|
||||
|
||||
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[] = [];
|
||||
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
|
||||
let html = '';
|
||||
try {
|
||||
@@ -36,9 +54,10 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title },
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
||||
chapter: chapterIdx,
|
||||
html,
|
||||
voices,
|
||||
prev: prevChapter ? prevChapter.number : null,
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
sessionId: locals.sessionId
|
||||
|
||||
@@ -72,7 +72,9 @@
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
voices={data.voices}
|
||||
/>
|
||||
|
||||
<!-- Chapter content -->
|
||||
|
||||
Reference in New Issue
Block a user