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:
Admin
2026-03-04 19:05:08 +05:00
parent 1e7f396b2d
commit 3899a96576
8 changed files with 544 additions and 14 deletions

View 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 });
}
};

View 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}`);
}
};

View 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: [] });
}
};