Skip to content
Open
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/streaming-tts-karaoke/`](./demos/streaming-tts-karaoke) | Next.js | [Open](https://demos.speechify.ai/streaming-tts-karaoke) | Realtime TTS with word timestamps: each word lights up the instant its audio arrives over the stream, then again as it plays. Key held server-side. |
<!-- DEMOS:END -->

## Get an API key
Expand Down
1 change: 1 addition & 0 deletions demos/streaming-tts-karaoke/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SPEECHIFY_API_KEY=your_api_key_here
11 changes: 11 additions & 0 deletions demos/streaming-tts-karaoke/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules/
.next/
.env
next-env.d.ts
*.tsbuildinfo

# Playwright
test-results/
playwright-report/
/.playwright/
.last-run.json
65 changes: 65 additions & 0 deletions demos/streaming-tts-karaoke/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Streaming TTS karaoke

Realtime text-to-speech with word timestamps. Speechify's streaming endpoint
returns audio **and** per-word timings as it synthesizes, so this demo shows two
highlights at once:

1. **Received** — each word lights up the instant its audio + timestamp arrive
over the stream. This is the wire speed: the marks race ahead of playback.
2. **Playing** — a second highlight follows the actual audio position as it plays
back through the browser.

The Speechify API key never reaches the browser — the page talks to a one-route
server proxy.

Pairs with the Speechify post *Realtime streaming TTS with word highlighting*.

## What you get

- **[`app/api/stream/route.ts`](./app/api/stream/route.ts)** — a Node route that
proxies `POST /v1/audio/stream/with-timestamps` (Server-Sent Events) and pipes
it straight to the browser, key held server-side.
- **[`app/page.tsx`](./app/page.tsx)** — parses the SSE `speech.chunk` events,
maps each word mark to the rendered text by character offset (the "received"
highlight), streams the base64 audio into a `<audio>` element with the
[MediaSource API](https://developer.mozilla.org/docs/Web/API/Media_Source_Extensions_API),
and follows playback with `requestAnimationFrame` (the "playing" highlight).

## How the endpoint works

`POST /v1/audio/stream/with-timestamps` streams SSE events:

- `speech.chunk` — carries a run of base64 `audio`, a batch of `speech_marks`, or
both. Marks are `{ value, start, end, start_time, end_time }` where `start`/`end`
are character offsets into your input and the times are **absolute
milliseconds** from the start of synthesis. Concatenate the audio into one
stream and apply the marks against that single timeline.
- `speech.done` — terminal, with `billable_characters_count` and `audio_duration_ms`.
- `speech.error` — terminal error envelope.

Speech marks come from the streaming-native models: `simba-3.2` (used here) and
`simba-3.0`. The legacy `simba-english` / `simba-multilingual` models return
`400 speech_marks_unsupported` on this route.

## Run it yourself

```bash
cp .env.example .env # paste your Speechify API key
pnpm install
pnpm dev # http://localhost:8775/streaming-tts-karaoke
```

Get an API key at [platform.speechify.ai/api-keys](https://platform.speechify.ai/api-keys).

## Tests

```bash
pnpm e2e # Playwright, drives the real streaming API
```

## Prerequisites

- Node 20+.
- A browser with MediaSource support for `audio/mpeg` (Chrome/Edge). Where it's
unavailable the demo buffers the clip and plays it at the end — the "received"
highlight still streams live.
63 changes: 63 additions & 0 deletions demos/streaming-tts-karaoke/app/api/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { verifyTurnstile } from "../../lib/turnstile";

export const runtime = "nodejs";
// Don't let the platform buffer the SSE — we want events to reach the browser
// the instant Speechify emits them.
export const dynamic = "force-dynamic";

const UPSTREAM = "https://api.speechify.ai/v1/audio/stream/with-timestamps";

// Proxies the Speechify streaming-with-timestamps endpoint. It is Server-Sent
// Events: each `speech.chunk` carries a run of base64 audio and/or word
// `speech_marks` (absolute-ms times + char offsets into the input). We pass the
// stream straight through so the browser sees marks and audio arrive live — the
// API key never leaves the server.
export async function POST(req: Request) {
if (!(await verifyTurnstile(req))) {
return new Response("Forbidden", { status: 403 });
}

const key = process.env.SPEECHIFY_API_KEY;
if (!key) {
return new Response("SPEECHIFY_API_KEY is not set on the server.", { status: 503 });
}

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(UPSTREAM, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"content-type": "application/json",
accept: "audio/mpeg",
// Speech marks are produced by streaming-native models only.
"Speechify-Version": "2026-09-13",
},
body: JSON.stringify({
input: input.slice(0, 3000),
voice_id: "geffen_32",
model: "simba-3.2",
}),
});

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": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
// Codec of the base64 audio carried in each event (mirrors upstream).
"speechify-audio-content-type":
upstream.headers.get("speechify-audio-content-type") ?? "audio/mpeg",
},
});
}
117 changes: 117 additions & 0 deletions demos/streaming-tts-karaoke/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/* Speechify brand base — mirrors demos.speechify.ai/site (speechify.ai/brand).
* ABC Diatype is licensed and NOT committed; it loads cross-origin from
* speechify.ai/fonts. Monochrome palette, thin display type, pill ink buttons. */

@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);
--danger: #b42318;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 9999px;
}

@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);
--danger: #ff6b5e;
}
}

* { 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;
}

main { max-width: 46rem; margin: 0 auto; display: flex; flex-direction: column; gap: 1.5rem; }

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; }

.eyebrow { font-family: var(--font-mono); font-size: 13px; font-weight: 500; color: var(--text-tertiary); margin: 0 0 0.75rem; }

.lead { max-width: 62ch; color: var(--text-secondary); font-size: 1.0625rem; margin: 0; }

a { color: inherit; text-underline-offset: 2px; }

code { font-family: var(--font-mono); background: var(--surface-subtle); border: 1px solid var(--border-subtle); padding: 0.08em 0.38em; border-radius: 5px; font-size: 0.85em; }

.card { 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; }

textarea { 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; resize: vertical; }

textarea:focus-visible, button:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 1px; }

.btn { font: inherit; font-weight: 500; cursor: pointer; display: inline-flex; align-items: 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; }
.btn-primary { background: var(--action); color: var(--action-foreground); }
.btn-primary:hover:not(:disabled) { background: var(--action-hover); }
.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; }

@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.001ms !important; } }

/* ---- demo-specific ---- */

.controls { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }

.legend { display: flex; gap: 0.75rem; font-size: 0.8rem; color: var(--text-tertiary); }
.chip { display: inline-flex; align-items: center; gap: 0.35rem; }
.chip::before { content: ""; width: 0.85rem; height: 0.85rem; border-radius: 3px; display: inline-block; }
.chip.received::before { background: transparent; border: 2px solid var(--text-primary); }
.chip.playing::before { background: var(--action); }

.status { font-size: 0.9rem; color: var(--text-secondary); margin: 0; }
.status.error { color: var(--danger); }

.stage { min-height: 6rem; background: var(--surface-subtle); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 1.5rem; }
.hint { color: var(--text-tertiary); margin: 0; }

.words { margin: 0; font-size: 1.5rem; line-height: 1.8; letter-spacing: -0.01em; }

/* Three states: pending (dim) → received (full ink) → playing (inverted pill). */
.w { color: var(--text-tertiary); transition: color 0.15s ease, background-color 0.15s ease; border-radius: 5px; }
.w.received { color: var(--text-primary); }
.w.playing { background: var(--action); color: var(--action-foreground); padding: 0.05em 0.15em; }

audio { width: 100%; margin-top: 0.25rem; }
21 changes: 21 additions & 0 deletions demos/streaming-tts-karaoke/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: "Streaming TTS karaoke with Speechify",
description:
"Realtime streaming text-to-speech with word timestamps — each word highlights as its audio streams in, then again as it plays.",
};

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Script src="/turnstile.js" strategy="beforeInteractive" />
{children}
</body>
</html>
);
}
37 changes: 37 additions & 0 deletions demos/streaming-tts-karaoke/app/lib/turnstile.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) return true;

const token = req.headers.get("x-turnstile-token");
if (!token) return false;

const form = new URLSearchParams();
form.set("secret", secret);
form.set("response", token);
const remoteip = req.headers
.get("x-forwarded-for")
?.split(",")[0]
?.trim();
if (remoteip) form.set("remoteip", remoteip);

try {
const cf = await fetch(SITEVERIFY_URL, { method: "POST", body: form });
if (!cf.ok) return true;
const result = (await cf.json()) as { success?: boolean };
return Boolean(result?.success);
} catch {
return true;
}
}
Loading