One-file serverless edge TTS
+
+ Type text, hit play. The audio streams out of a single edge function at{" "}
+ app/api/stream/route.ts — no SDK, key held server-side.
+
+ {status} +
+From 5e95fa01287ba886e6835d4fd086765558511bd8 Mon Sep 17 00:00:00 2001 From: luke-speechify <289678208+luke-speechify@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:43:14 +0100 Subject: [PATCH 1/3] feat: add edge-tts demo One serverless edge function that streams Speechify TTS audio to the browser. Calls the REST audio/stream endpoint with fetch (the SDK is Node-only), pipes the upstream MP3 body straight through, and keeps the API key server-side. Turnstile-gated. Registered as a Vercel Service. --- README.md | 1 + demos/edge-tts/.env.example | 1 + demos/edge-tts/.gitignore | 5 + demos/edge-tts/README.md | 84 +++++++++++++++++ demos/edge-tts/app/api/stream/route.ts | 53 +++++++++++ demos/edge-tts/app/globals.css | 109 ++++++++++++++++++++++ demos/edge-tts/app/layout.tsx | 21 +++++ demos/edge-tts/app/lib/turnstile.ts | 37 ++++++++ demos/edge-tts/app/page.tsx | 124 +++++++++++++++++++++++++ demos/edge-tts/demo.json | 6 ++ demos/edge-tts/next.config.ts | 15 +++ demos/edge-tts/package.json | 26 ++++++ demos/edge-tts/tsconfig.json | 41 ++++++++ pnpm-lock.yaml | 28 ++++++ pnpm-workspace.yaml | 1 + site/public/index.html | 4 +- vercel.json | 8 ++ 17 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 demos/edge-tts/.env.example create mode 100644 demos/edge-tts/.gitignore create mode 100644 demos/edge-tts/README.md create mode 100644 demos/edge-tts/app/api/stream/route.ts create mode 100644 demos/edge-tts/app/globals.css create mode 100644 demos/edge-tts/app/layout.tsx create mode 100644 demos/edge-tts/app/lib/turnstile.ts create mode 100644 demos/edge-tts/app/page.tsx create mode 100644 demos/edge-tts/demo.json create mode 100644 demos/edge-tts/next.config.ts create mode 100644 demos/edge-tts/package.json create mode 100644 demos/edge-tts/tsconfig.json diff --git a/README.md b/README.md index 6434db4..d1e31a9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d | [`demos/voice-agent-showcase/`](./demos/voice-agent-showcase) | Cloudflare Workers | | One page, ten live Voice Agents API demos: calendar booking, policy-bound support, a page copilot, form intake, US outbound calls with a 5-minute cap, a voice gallery, mid-call language handoff, cross-call memory, a grounded knowledge base, and dual-control troubleshooting. | | [`demos/vercel-ai-sdk/`](./demos/vercel-ai-sdk) | TypeScript (Vercel AI SDK) | | Speechify TTS through the Vercel AI SDK's unified `generateSpeech` interface via the official `@speechify/vercel` provider — one-line swap from OpenAI/ElevenLabs, plus word-level speech marks from `providerMetadata`. | | [`demos/puter-txt2speech/`](./demos/puter-txt2speech) | HTML (puter.js) | | Speaks with a Simba 3.2 voice via the Speechify provider in Puter's puter.ai.txt2speech() — one static page, your key configured once on the Puter instance. | +| [`demos/edge-tts/`](./demos/edge-tts) | Next.js | [Open](https://demos.speechify.ai/edge-tts) | A single serverless edge function streams Speechify TTS audio to the browser — no SDK, key held server-side. Ideal for widgets and light integrations. | ## Get an API key diff --git a/demos/edge-tts/.env.example b/demos/edge-tts/.env.example new file mode 100644 index 0000000..534cec4 --- /dev/null +++ b/demos/edge-tts/.env.example @@ -0,0 +1 @@ +SPEECHIFY_API_KEY=your_api_key_here diff --git a/demos/edge-tts/.gitignore b/demos/edge-tts/.gitignore new file mode 100644 index 0000000..95d1bcb --- /dev/null +++ b/demos/edge-tts/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.next/ +.env +next-env.d.ts +*.tsbuildinfo diff --git a/demos/edge-tts/README.md b/demos/edge-tts/README.md new file mode 100644 index 0000000..df3fd93 --- /dev/null +++ b/demos/edge-tts/README.md @@ -0,0 +1,84 @@ +# One-file serverless edge TTS (Next.js) + +A [Next.js](https://nextjs.org) demo whose whole backend is a **single serverless edge function** that streams Speechify text-to-speech audio straight to the browser. No SDK, no buffering, key held server-side. This is the shape you want for a TTS widget, a light integration, or a copy-paste starting point. + +Pairs with the upcoming speechify.ai post "One-file serverless TTS on an edge function". + +## What you get + +- A minimal page: textarea + **Play** button. It POSTs your text to the edge route and plays the streamed audio. +- One edge route, `app/api/stream/route.ts`, that is the entire backend. It runs on the edge runtime and pipes the upstream MP3 body straight through. + +## The one file + +The `@speechify/api` SDK is Node-only, so the edge route calls the REST API directly with `fetch` and streams the response body back unchanged: + +```ts +import { verifyTurnstile } from "../../lib/turnstile"; + +export const runtime = "edge"; + +const SPEECHIFY_STREAM_URL = "https://api.speechify.ai/v1/audio/stream"; + +export async function POST(req: Request) { + if (!(await verifyTurnstile(req))) { + return new Response("Forbidden", { status: 403 }); + } + + const { input } = (await req.json().catch(() => ({}))) as { input?: unknown }; + if (typeof input !== "string" || input.trim() === "") { + return new Response("`input` text is required", { status: 400 }); + } + + const upstream = await fetch(SPEECHIFY_STREAM_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.SPEECHIFY_API_KEY}`, + "content-type": "application/json", + Accept: "audio/mpeg", + }, + body: JSON.stringify({ + input, + voice_id: "geffen_32", + model: "simba-3.2", + audio_format: "mp3", + }), + }); + + if (!upstream.ok || !upstream.body) { + const detail = await upstream.text().catch(() => ""); + return new Response(detail || "Speechify request failed", { + status: upstream.status || 502, + }); + } + + return new Response(upstream.body, { + headers: { "content-type": "audio/mpeg", "cache-control": "no-store" }, + }); +} +``` + +That is the whole backend. + +## Run it yourself + +```bash +cp .env.example .env # then paste your SPEECHIFY_API_KEY +pnpm install +pnpm dev # http://localhost:8768 +``` + +Open `http://localhost:8768`, type some text, and click **Play**. + +## How the key stays server-side + +`SPEECHIFY_API_KEY` is only ever read inside the edge function via `process.env`, which the browser cannot see. The client talks to the same-origin `/api/stream` route and receives audio bytes — never the key. + +## Why edge + +Edge functions start fast, run close to the user, and stream by default. Piping the upstream body straight to the client means the browser can start playing before synthesis finishes, with almost no server code in between. + +## Prerequisites + +- Node 20 or newer +- A `SPEECHIFY_API_KEY` from [platform.speechify.ai/api-keys](https://platform.speechify.ai/api-keys) diff --git a/demos/edge-tts/app/api/stream/route.ts b/demos/edge-tts/app/api/stream/route.ts new file mode 100644 index 0000000..faa1f1b --- /dev/null +++ b/demos/edge-tts/app/api/stream/route.ts @@ -0,0 +1,53 @@ +import { verifyTurnstile } from "../../lib/turnstile"; + +// The whole demo is this one file: a serverless EDGE function that streams +// Speechify TTS audio straight back to the browser. No SDK (it is Node-only), +// no buffering — the upstream MP3 body is piped through as it arrives, so the +// client can start playing before synthesis finishes. +export const runtime = "edge"; + +const SPEECHIFY_STREAM_URL = "https://api.speechify.ai/v1/audio/stream"; + +export async function POST(req: Request) { + // Abuse gate. Fails open locally when TURNSTILE_SECRET_KEY is unset. + if (!(await verifyTurnstile(req))) { + return new Response("Forbidden", { status: 403 }); + } + + const { input } = (await req.json().catch(() => ({}))) as { + input?: unknown; + }; + if (typeof input !== "string" || input.trim() === "") { + return new Response("`input` text is required", { status: 400 }); + } + + const upstream = await fetch(SPEECHIFY_STREAM_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.SPEECHIFY_API_KEY}`, + "content-type": "application/json", + Accept: "audio/mpeg", + }, + body: JSON.stringify({ + input, + voice_id: "geffen_32", + model: "simba-3.2", + audio_format: "mp3", + }), + }); + + if (!upstream.ok || !upstream.body) { + const detail = await upstream.text().catch(() => ""); + return new Response(detail || "Speechify request failed", { + status: upstream.status || 502, + }); + } + + // Pipe the streamed audio straight to the client. + return new Response(upstream.body, { + headers: { + "content-type": "audio/mpeg", + "cache-control": "no-store", + }, + }); +} diff --git a/demos/edge-tts/app/globals.css b/demos/edge-tts/app/globals.css new file mode 100644 index 0000000..f02d988 --- /dev/null +++ b/demos/edge-tts/app/globals.css @@ -0,0 +1,109 @@ +:root { + color-scheme: light dark; + --fg: #111; + --bg: #fff; + --muted: #666; + --border: #ddd; + --surface: #f6f6f6; +} + +@media (prefers-color-scheme: dark) { + :root { + --fg: #f2f2f2; + --bg: #0c0c0c; + --muted: #999; + --border: #2a2a2a; + --surface: #161616; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 2rem 1rem; + background: var(--bg); + color: var(--fg); + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; + line-height: 1.5; +} + +main { + max-width: 34rem; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +h1 { + font-size: 1.6rem; + font-weight: 500; + margin: 0; +} + +.lede { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.step { + border: 1px solid var(--border); + border-radius: 0.6rem; + padding: 1.1rem; + background: var(--surface); +} + +label { + display: block; + font-size: 0.85rem; + margin: 0 0 0.2rem; +} + +textarea, +button { + font: inherit; + color: inherit; + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid var(--border); + border-radius: 0.4rem; + background: var(--bg); +} + +button { + cursor: pointer; + background: var(--fg); + color: var(--bg); + border: 0; + font-weight: 500; + margin-top: 0.9rem; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.status { + font-size: 0.85rem; + color: var(--muted); + min-height: 1.2rem; +} + +.status[data-tone="error"] { + color: #c0392b; +} + +audio { + width: 100%; + margin-top: 0.9rem; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; +} diff --git a/demos/edge-tts/app/layout.tsx b/demos/edge-tts/app/layout.tsx new file mode 100644 index 0000000..faf1fce --- /dev/null +++ b/demos/edge-tts/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import Script from "next/script"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "One-file serverless edge TTS", + description: + "A single serverless edge function that streams Speechify text-to-speech audio to the browser.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + +
+ + {children} + + + ); +} diff --git a/demos/edge-tts/app/lib/turnstile.ts b/demos/edge-tts/app/lib/turnstile.ts new file mode 100644 index 0000000..6fee926 --- /dev/null +++ b/demos/edge-tts/app/lib/turnstile.ts @@ -0,0 +1,37 @@ +// Verifies a Turnstile token against Cloudflare siteverify. Returns true iff +// the caller is allowed to proceed. +// +// Fail-open contract: when TURNSTILE_SECRET_KEY isn't set (local dev, fork +// deploys, anywhere the operator hasn't configured Turnstile) OR when the +// siteverify request itself errors, returns true. The alternative is +// breaking the demo whenever Turnstile isn't configured — a worse experience +// than leaving the abuse gate briefly open. Real prod hardening would flip +// this to fail-closed; this is a reference demo. +const SITEVERIFY_URL = + "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +export async function verifyTurnstile(req: Request): Promise
+ Type text, hit play. The audio streams out of a single edge function at{" "}
+ app/api/stream/route.ts — no SDK, key held server-side.
+
+ {status} +
+