jacquardSnapshot

← snapshot

3341 bytes
/**
 * 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",
    },
  });
}