Two reports, one root: the platform's speech engine. "Loud cracking at the end of every sentence" is the browser/OS TTS popping between its internal sentence chunks (an idle cancel makes it worse). Hygiene for that path: never cancel when nothing is pending, normalise the spoken text (control characters, ellipses, dashes), and a 140ms beat between sections so speech never runs into the engine's tail. The real fix is a second voice source that does not use the platform engine: - lib/providers carries TTS profiles (OpenAI gpt-4o-mini-tts, Groq playai-tts, any OpenAI-compatible /audio/speech behind the custom provider), male voices first. - /api/tts proxies them with the same guards and the key still in headers. - the picker gains System / Natural, with the provider's voice list. - lib/podcast-audio plays one clip per section through a hidden <audio> element, measures the clip's real duration and gives each word a slice of it proportional to its spoken length - the highlight follows the audio exactly, and pause/seek/chapters become real audio operations. Clips cache per session, the next section generates while the current plays, and a voice change disposes the element and its blobs. Speed applies live (setRate) without dropping the cache. podcast-test stubs /api/tts with a real silent WAV and pins the path: the voice list, the request body, exactly one playing element, and the highlight riding currentTime. 43 checks. tests/lib.mjs launches Chromium with the autoplay policy relaxed (a real device has the listener's gesture). Version 0.7.6, SW v98.
129 lines
5 KiB
TypeScript
129 lines
5 KiB
TypeScript
import { getServerConfig } from "@/lib/config";
|
|
import { ttsProfile } from "@/lib/providers";
|
|
import { resolveModelTarget } from "@/lib/providers-server";
|
|
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
|
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
|
import { acquireModelGate } from "@/lib/model-guard";
|
|
import { translate } from "@/lib/i18n-dict";
|
|
import { z } from "zod";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
export const maxDuration = 120;
|
|
|
|
const requestSchema = z.object({
|
|
/** One section of the script — a couple of hundred words at most. */
|
|
text: z.string().min(1).max(4000),
|
|
voice: z.string().min(1).max(60).optional(),
|
|
model: z.string().min(1).max(120).optional(),
|
|
});
|
|
|
|
/** About 1 MB of text; audio comes back in the response, not the request. */
|
|
const MAX_BODY_BYTES = 1_048_576;
|
|
|
|
/**
|
|
* Natural podcast voices: the provider's own text-to-speech, proxied so the
|
|
* key stays in the browser (it travels in the header, never lands on disk).
|
|
* OpenAI-compatible `/audio/speech` only — OpenAI, Groq, and whatever local
|
|
* server the custom provider points at.
|
|
*/
|
|
export async function POST(request: Request) {
|
|
const locale = requestLocale(request);
|
|
const crossOrigin = rejectCrossOrigin(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
|
|
if (oversized) return oversized;
|
|
|
|
const config = getServerConfig();
|
|
const limit = rateLimit(`tts:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs);
|
|
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
|
|
|
/* TTS is a provider feature: no provider key, no natural voice. */
|
|
const resolved = resolveModelTarget(config, request, locale);
|
|
if ("error" in resolved) {
|
|
return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
|
|
}
|
|
if (resolved.target.kind !== "provider") {
|
|
return Response.json(
|
|
{ error: translate(locale, "server.ttsNeedsProvider"), kind: "needs_key" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const provider = resolved.target.target;
|
|
const providerId = (request.headers.get("x-freezmo-provider") ?? "").trim().toLowerCase();
|
|
const profile = ttsProfile(providerId);
|
|
if (!profile) {
|
|
return Response.json(
|
|
{ error: translate(locale, "server.ttsUnsupported", { provider: provider.label }), kind: "bad_provider" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
let payload: unknown;
|
|
try {
|
|
payload = await request.json();
|
|
} catch {
|
|
return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
|
|
}
|
|
const parsed = requestSchema.safeParse(payload);
|
|
if (!parsed.success) {
|
|
return Response.json({ error: translate(locale, "server.invalidRequest", { detail: "text" }) }, { status: 400 });
|
|
}
|
|
|
|
const gate = acquireModelGate(request, config, locale);
|
|
if ("response" in gate) return gate.response;
|
|
|
|
const model = parsed.data.model ?? profile.model;
|
|
const voice = parsed.data.voice ?? profile.voices.find((entry) => entry.male)?.id ?? profile.voices[0].id;
|
|
const abort = new AbortController();
|
|
const timer = setTimeout(() => abort.abort(), config.timeoutMs);
|
|
const onRequestAbort = () => abort.abort();
|
|
if (request.signal.aborted) abort.abort();
|
|
else request.signal.addEventListener("abort", onRequestAbort, { once: true });
|
|
|
|
try {
|
|
const upstream = await fetch(`${provider.baseUrl.replace(/\/$/, "")}/audio/speech`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${provider.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
voice,
|
|
input: parsed.data.text,
|
|
response_format: profile.format,
|
|
}),
|
|
signal: abort.signal,
|
|
});
|
|
|
|
if (!upstream.ok || !upstream.body) {
|
|
const detail = await upstream.text().catch(() => "");
|
|
/* Never log the text that was spoken; only the status. */
|
|
console.error("[api/tts]", upstream.status, detail.slice(0, 200));
|
|
const kind = upstream.status === 401 || upstream.status === 403 ? "auth" : upstream.status === 429 ? "usage_limit" : "unknown";
|
|
return Response.json(
|
|
{ error: translate(locale, "server.ttsFailed", { provider: provider.label }), kind },
|
|
{ status: upstream.status === 429 ? 429 : 502 },
|
|
);
|
|
}
|
|
|
|
return new Response(upstream.body, {
|
|
headers: {
|
|
"Content-Type": profile.format === "wav" ? "audio/wav" : "audio/mpeg",
|
|
"Cache-Control": "no-store, no-transform",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const timedOut = error instanceof Error && error.name === "AbortError" && !request.signal.aborted;
|
|
console.error("[api/tts]", (error as Error)?.name ?? error);
|
|
return Response.json(
|
|
{ error: translate(locale, timedOut ? "server.ttsTimeout" : "server.ttsFailed", { provider: provider.label }), kind: "network" },
|
|
{ status: 502 },
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
request.signal.removeEventListener("abort", onRequestAbort);
|
|
gate.gate.release();
|
|
}
|
|
}
|