freezmo/app/api/tts/route.ts
Martin Dvorak 8e1cd0855d Podcast voices: natural provider TTS, and system-voice hygiene
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.
2026-10-16 16:34:46 +00:00

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