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 @@ -33,6 +33,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d
| [`demos/slack-bot-speechify/`](./demos/slack-bot-speechify) | TypeScript (Socket Mode) | | A Slack bot that reads every new message in a channel aloud: on each message it synthesizes the text with the Speechify API and posts the MP3 back as a file. Socket Mode means no public tunnel. |
| [`demos/discord-bot-speechify/`](./demos/discord-bot-speechify) | TypeScript (discord.js) | | A Discord slash-command bot: /speak <text> synthesizes the text with the Speechify API and posts the MP3 into the channel. The command registers automatically on first run. |
| [`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/react-tts-component/`](./demos/react-tts-component) | Next.js | [Open](https://demos.speechify.ai/react-tts-component) | A drop-in <100-line React component that speaks any text. Type, hit play, hear it — the API key stays server-side in a route handler. |
| [`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:END -->
Expand Down
1 change: 1 addition & 0 deletions demos/react-tts-component/.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/react-tts-component/.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
62 changes: 62 additions & 0 deletions demos/react-tts-component/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Voice in a React app

A drop-in React component that adds a Speechify voice to any app. Give it text,
it plays that text as speech. The Speechify API key never reaches the browser —
synthesis goes through a one-route server proxy.

Pairs with the Speechify post *Adding a voice to a React app with the Speechify
SDK*. It complements (doesn't repeat)
[Building an AI Voice Cloning Web App with Next.js and Speechify](https://speechify.ai/blog/building-an-ai-voice-cloning-web-app-with-nextjs-and-speechify)
— read that one for the full cloning app.

## What you get

- **[`components/SpeechifyVoice.tsx`](./components/SpeechifyVoice.tsx)** — the
whole point. Under 100 lines. Props: `text`, optional `voiceId`, `endpoint`,
`label`, and a `getToken` hook for abuse-gated deployments. Copy it into your
own app.
- **[`app/api/speak/route.ts`](./app/api/speak/route.ts)** — a Next.js route
handler that calls `client.audio.speech(...)` server-side and returns base64
MP3, so `SPEECHIFY_API_KEY` stays on the server.
- A small page (`app/page.tsx`) that wires the component to a textarea and a
voice picker.

## Run it yourself

```bash
cp .env.example .env # paste your Speechify API key
pnpm install
pnpm dev # http://localhost:8767/react-tts-component
```

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

## Use the component in your app

```tsx
import { SpeechifyVoice } from "./components/SpeechifyVoice";

<SpeechifyVoice text="Hello from Speechify." voiceId="geffen_32" />;
```

The component POSTs `{ text, voiceId }` to `endpoint` (default `/api/speak`),
expects `{ audio }` (base64 MP3) back, and plays it. Point `endpoint` at your
own proxy route in any framework — the component doesn't care what's behind it.

## Where the code came from

Built on the [`@speechify/api`](https://www.npmjs.com/package/@speechify/api)
TTS client — one `client.audio.speech({ input, voice_id, audio_format, model })`
call. Model `simba-3.2`, MP3 output. Browse voices at
[platform.speechify.ai](https://platform.speechify.ai).

## Abuse protection (hosted)

The hosted build gates `/api/speak` with Cloudflare Turnstile via the shared
[`app/lib/turnstile.ts`](./app/lib/turnstile.ts) helper. It fail-opens when
`TURNSTILE_SECRET_KEY` is unset, so local dev and forks work with zero config.

## Prerequisites

- Node 20+.
- A Speechify API key (the free tier covers this demo).
34 changes: 34 additions & 0 deletions demos/react-tts-component/app/api/speak/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { SpeechifyClient, SpeechifyError } from "@speechify/api";
import { verifyTurnstile } from "../../lib/turnstile";

export const runtime = "nodejs";

const client = new SpeechifyClient({ token: process.env.SPEECHIFY_API_KEY });

export async function POST(req: Request) {
if (!(await verifyTurnstile(req))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const { text, voiceId } = await req.json();

if (typeof text !== "string" || !text.trim()) {
return NextResponse.json({ error: "text is required" }, { status: 400 });
}

try {
const speech = await client.audio.speech({
input: text.slice(0, 2000),
voice_id: typeof voiceId === "string" && voiceId ? voiceId : "geffen_32",
audio_format: "mp3",
model: "simba-3.2",
});
return NextResponse.json({ audio: speech.audio_data });
} catch (err) {
if (err instanceof SpeechifyError) {
return NextResponse.json({ error: err.message }, { status: err.statusCode ?? 500 });
}
throw err;
}
}
190 changes: 190 additions & 0 deletions demos/react-tts-component/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; }
}

/* ---- react-tts-component specifics ---- */
.step h2 {
font-family: var(--font-mono);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-tertiary);
font-weight: 500;
margin: 0 0 0.75rem;
}

.play {
display: flex;
justify-content: center;
}
21 changes: 21 additions & 0 deletions demos/react-tts-component/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: "Voice in a React app with Speechify",
description:
"A drop-in React component that speaks any text with the Speechify API, key held server-side.",
};

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/react-tts-component/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