Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
<!-- DEMOS:END -->

## Get an API key
Expand Down
1 change: 1 addition & 0 deletions demos/edge-tts/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SPEECHIFY_API_KEY=your_api_key_here
9 changes: 9 additions & 0 deletions demos/edge-tts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
.next/
.env
next-env.d.ts
*.tsbuildinfo
test-results/
playwright-report/
/.playwright/
.last-run.json
88 changes: 88 additions & 0 deletions demos/edge-tts/README.md
Original file line number Diff line number Diff line change
@@ -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)
59 changes: 59 additions & 0 deletions demos/edge-tts/app/api/stream/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
190 changes: 190 additions & 0 deletions demos/edge-tts/app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions demos/edge-tts/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body>
<Script src="/turnstile.js" strategy="beforeInteractive" />
{children}
</body>
</html>
);
}
Loading