/**
* Jackie's voice, proxied.
*
* `gpt-4o-mini-tts` is the cheapest OpenAI model that carries the `cedar`
* voice — `tts-1` costs less per character but has only the older voices. The
* key stays on this side of the wire: the browser never sees it, and the only
* thing that crosses is the sentence being spoken.
*
* Audio comes back as raw 24 kHz mono PCM rather than mp3, because the client
* schedules it chunk by chunk through Web Audio. That is what makes it feel
* live — playback starts on the first chunk instead of after the whole
* utterance has downloaded — and it sidesteps every container and codec quirk
* along the way.
*
* Not configured is not an error: with no key this answers 503 and the client
* falls back to the browser's own speech synthesis.
*/
export const runtime = "nodejs";
/** Never cached: the response is audio for one specific sentence. */
export const dynamic = "force-dynamic";
const MODEL = process.env.OPENAI_TTS_MODEL ?? "gpt-4o-mini-tts";
const VOICE = process.env.OPENAI_TTS_VOICE ?? "cedar";
/** Jackie's register, handed to the model as delivery direction. */
const INSTRUCTIONS =
"Speak like a knowledgeable colleague walking someone through code: " +
"unhurried, warm, plainly curious. Not a narrator, not an assistant. " +
"Let questions actually sound like questions.";
/** Longer than this is a document being read aloud, not a spoken turn. */
const MAX_CHARS = 1200;
/**
* Is the voice configured? Asked once by the client so an unconfigured setup
* costs one quiet 200 instead of a failed POST — and its console error — on
* every single thing Jackie says.
*/
export function GET(): Response {
return Response.json({ configured: Boolean(process.env.OPENAI_API_KEY) });
}
export async function POST(request: Request): Promise<Response> {
const key = process.env.OPENAI_API_KEY;
if (!key) {
return Response.json(
{ error: "no OPENAI_API_KEY — falling back to the browser voice" },
{ status: 503 },
);
}
let text: unknown;
try {
({ text } = await request.json());
} catch {
return Response.json({ error: "expected a JSON body" }, { status: 400 });
}
if (typeof text !== "string" || text.trim().length === 0) {
return Response.json({ error: "nothing to say" }, { status: 400 });
}
const say = text.trim().slice(0, MAX_CHARS);
const upstream = await fetch("https://api.openai.com/v1/audio/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
voice: VOICE,
input: say,
instructions: INSTRUCTIONS,
response_format: "pcm",
}),
});
if (!upstream.ok || !upstream.body) {
// Surface the real reason — a project without the model enabled fails
// here, and a silent fallback would hide that for good.
const detail = await upstream.text().catch(() => "");
return Response.json(
{ error: `tts upstream ${upstream.status}`, detail: detail.slice(0, 400) },
{ status: 502 },
);
}
return new Response(upstream.body, {
headers: {
// 24 kHz, 16-bit signed, mono — what /v1/audio/speech emits as `pcm`.
"Content-Type": "audio/pcm;rate=24000",
"Cache-Control": "no-store",
},
});
}