SpeechifyAI · Edge TTS
+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} +
+diff --git a/README.md b/README.md index 1019530..0817ce2 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d | [`demos/docs-read-aloud/`](./demos/docs-read-aloud) | TypeScript (zero-dep server) | | A documentation-style page with a Listen button that reads the article aloud. The button POSTs the text to a tiny server route, which synthesizes it with the Speechify API (key stays server-side) and returns the MP3 for the browser to play. Framework-agnostic. | | [`demos/ivr-ssml/`](./demos/ivr-ssml) | Next.js | [Open](https://demos.speechify.ai/ivr-ssml) | A phone-system playground for getting names, account numbers, and product terms right with SSML. Hear plain vs SSML side by side; the API key stays server-side. | | [`demos/webpage-audiobook/`](./demos/webpage-audiobook) | Next.js | [Open](https://demos.speechify.ai/webpage-audiobook) | Paste a URL, get narrated audio. The server fetches the article, extracts the text, chunks it on sentence boundaries, and synthesizes each part with the Speechify TTS API. | +| [`demos/edge-tts/`](./demos/edge-tts) | Next.js | [Open](https://demos.speechify.ai/edge-tts) | A single serverless 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..e838375 --- /dev/null +++ b/demos/edge-tts/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.next/ +.env +next-env.d.ts +*.tsbuildinfo +test-results/ +playwright-report/ +/.playwright/ +.last-run.json diff --git a/demos/edge-tts/README.md b/demos/edge-tts/README.md new file mode 100644 index 0000000..1c719aa --- /dev/null +++ b/demos/edge-tts/README.md @@ -0,0 +1,88 @@ +# One-file serverless streaming TTS (Next.js) + +A [Next.js](https://nextjs.org) demo whose whole backend is a **single serverless 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". + +> **Runtime note.** This is hosted under `demos.speechify.ai`, which is one Vercel project composed of many [Services](https://vercel.com/docs/services), and Services don't support the Edge runtime. So it ships as a **Node serverless function** — which streams the response body just the same. The exact same one file runs on the Edge runtime in a standalone project: flip `runtime` to `"edge"`. + +## What you get + +- A minimal page: textarea + **Play** button. It POSTs your text to the route and plays the streamed audio. +- One route, `app/api/stream/route.ts`, that is the entire backend. It pipes the upstream MP3 body straight through as it arrives. + +## The one file + +The `@speechify/api` SDK is Node-only, so the route calls the REST API directly with `fetch` and streams the response body back unchanged: + +```ts +import { verifyTurnstile } from "../../lib/turnstile"; + +// "nodejs" here because Vercel Services don't support Edge. Same file runs on +// the Edge runtime in a standalone project — just set this to "edge". +export const runtime = "nodejs"; + +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 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 streaming + +Piping the upstream body straight to the client means the browser can start playing before synthesis finishes, with almost no server code in between. On the Edge runtime (standalone project) you also get fast cold starts and execution close to the user; under Services it runs on Node, and the streaming behaviour is identical. + +## 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..4aea530 --- /dev/null +++ b/demos/edge-tts/app/api/stream/route.ts @@ -0,0 +1,59 @@ +import { verifyTurnstile } from "../../lib/turnstile"; + +// The whole demo is this one file: a serverless function that streams Speechify +// TTS audio straight back to the browser. No SDK, no buffering — the upstream +// MP3 body is piped through as it arrives, so the client can start playing +// before synthesis finishes. +// +// Runtime note: this deploys under demos.speechify.ai, which is one Vercel +// project composed of many Services, and Services don't support the Edge +// runtime. So it runs as a Node serverless function — which also streams the +// response body. The exact same one file runs on `export const runtime = "edge"` +// in a standalone project; flip the line below if you deploy it on its own. +export const runtime = "nodejs"; + +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..c4e9ebe --- /dev/null +++ b/demos/edge-tts/app/globals.css @@ -0,0 +1,190 @@ +/* Speechify brand base — mirrors demos.speechify.ai/site (speechify.ai/brand). + * ABC Diatype is licensed and NOT committed; it is loaded cross-origin from + * speechify.ai/fonts (served with Access-Control-Allow-Origin: *). Monochrome + * palette, thin display type, pill ink buttons, sentence-case voice. + * Paste this block at the TOP of the demo's app/globals.css, then make the + * demo-specific rules below it reference these tokens (no hardcoded colours). */ + +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Thin.woff2") format("woff2"); font-weight: 100; font-style: normal; font-display: swap; } +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Light.woff2") format("woff2"); font-weight: 300; font-style: normal; font-display: swap; } +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Regular.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Medium.woff2") format("woff2"); font-weight: 500; font-style: normal; font-display: swap; } +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Bold.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; } + +:root { + --font-sans: "ABCDiatype", ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Code", monospace; + + --surface-page: #ffffff; + --surface-card: #ffffff; + --surface-subtle: #f5f5f5; + --surface-raised: #fafafa; + --text-primary: #0a0a0a; + --text-secondary: #525252; + --text-tertiary: #666666; + --border-subtle: #e5e5e5; + --border-strong: #d1d1d4; + --action: #0a0a0a; + --action-hover: #2a2a2e; + --action-foreground: #fafafa; + --focus-ring: rgba(10, 10, 10, 0.22); + --success: #00c270; + --danger: #b42318; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 9999px; +} + +/* Interactive apps: keep a monochrome dark mapping so night viewers aren't + * blinded. Still monochrome, still on-brand (inverted ink/paper). */ +@media (prefers-color-scheme: dark) { + :root { + --surface-page: #0a0a0a; + --surface-card: #101010; + --surface-subtle: #161616; + --surface-raised: #141414; + --text-primary: #fafafa; + --text-secondary: #b3b3b3; + --text-tertiary: #8a8a8a; + --border-subtle: #262626; + --border-strong: #3a3a3a; + --action: #fafafa; + --action-hover: #e5e5e5; + --action-foreground: #0a0a0a; + --focus-ring: rgba(250, 250, 250, 0.28); + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 4rem 1.5rem; + background: var(--surface-page); + color: var(--text-primary); + font-family: var(--font-sans); + font-weight: 400; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +main { + max-width: 44rem; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +/* Thin display headings, tight tracking, sentence case (author copy in sentence case). */ +h1 { + font-weight: 100; + font-size: clamp(2.25rem, 6vw, 3.5rem); + line-height: 1.02; + letter-spacing: -0.03em; + margin: 0 0 0.5rem; +} +h2 { font-weight: 300; letter-spacing: -0.01em; margin: 0 0 0.5rem; } + +.eyebrow { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-tertiary); + text-transform: none; + margin: 0 0 0.75rem; +} + +.lead { + max-width: 62ch; + color: var(--text-secondary); + font-size: 1.0625rem; + line-height: 1.55; + margin: 0; +} + +a { color: var(--text-primary); text-underline-offset: 2px; } + +code, kbd, samp { font-family: var(--font-mono); } +code { + background: var(--surface-subtle); + border: 1px solid var(--border-subtle); + padding: 0.08em 0.38em; + border-radius: 5px; + font-size: 0.88em; +} + +.card, .step { + background: var(--surface-subtle); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1.25rem; +} + +label { display: block; font-size: 0.9rem; color: var(--text-secondary); margin: 0 0 0.5rem; } + +input[type="text"], input[type="email"], textarea, select { + width: 100%; + font: inherit; + color: var(--text-primary); + background: var(--surface-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 0.65rem 0.8rem; +} +textarea { resize: vertical; } + +input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +/* Pill buttons — medium weight only, never 600. */ +.btn, button.btn { + font: inherit; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border-radius: var(--radius-pill); + padding: 0.65rem 1.5rem; + border: 1px solid transparent; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} +.btn-primary { background: var(--action); color: var(--action-foreground); } +.btn-primary:hover:not(:disabled) { background: var(--action-hover); } +.btn-outline { background: transparent; color: var(--text-primary); border-color: var(--border-strong); } +.btn-outline:hover:not(:disabled) { background: var(--action); color: var(--action-foreground); border-color: var(--action); } +.btn:disabled { opacity: 0.55; cursor: default; } + +footer { + color: var(--text-tertiary); + font-size: 0.9rem; + border-top: 1px solid var(--border-subtle); + padding-top: 1.25rem; +} +footer a { color: inherit; } + +@media (prefers-reduced-motion: reduce) { + * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } +} + +/* ---- edge-tts demo-specific rules (brand tokens only) ---- */ + +.status { + font-size: 0.9rem; + color: var(--text-secondary); + min-height: 1.2rem; +} +.status[data-tone="error"] { + color: var(--danger); +} + +audio { + width: 100%; + margin-top: 0.9rem; +} 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): PromiseSpeechifyAI · 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} +
+