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/clone-voice-10s/`](./demos/clone-voice-10s) | Next.js | [Open](https://demos.speechify.ai/clone-voice-10s) | Clone a voice from a ~10 second sample with explicit consent, synthesize with the clone, then auto-delete it. The API key stays server-side. |
<!-- DEMOS:END -->

## Get an API key
Expand Down
1 change: 1 addition & 0 deletions demos/clone-voice-10s/.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/clone-voice-10s/.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
45 changes: 45 additions & 0 deletions demos/clone-voice-10s/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Clone a voice from 10 seconds (Next.js)

A small [Next.js](https://nextjs.org) app that clones a voice from a ~10 second sample with the Speechify API, synthesizes with the clone, then deletes it — all in one click, with consent as a first-class step. The API key stays server-side in route handlers and never reaches the browser.

Pairs with the blog post "Clone a voice from 10 seconds and ship it today".

## What you get

- A one-page flow: drop a short sample, confirm consent, then **clone → speak → delete** in a single action. The clone never lingers in your workspace.
- Guidance on the sample: a 10 to 30 second WAV of one speaker works best (aim for ~10 seconds of clean, single-speaker audio).
- A real consent gate: a required checkbox ("I have the speaker's consent to clone this voice") plus the consenting person's full name and email. Cloning is blocked — client and server side — until consent is confirmed.
- Three server routes under `app/api/`, each holding the Speechify key server-side:
- `POST /api/clone` — multipart upload plus consent, calls `client.voices.create` with `consent: JSON.stringify({ fullName, email })`, returns the new `voice_id`. Returns `402` with a friendly message if cloning isn't on your plan.
- `POST /api/speak` — synthesizes text with the `voice_id` via `client.audio.speech` (`simba-english`, safe for clones).
- `DELETE /api/voice?id=…` — removes the cloned voice with `client.voices.delete`.
- `fixtures/spacewalk.wav` — a public-domain NASA sample so you can run the whole flow without recording anything.

## Voice cloning consent and safety

Cloning a voice needs the speaker's consent. Speechify verifies consent when you clone — see the announcement, [Voice cloning now verifies consent](https://speechify.ai/blog/voice-cloning-verified-consent), and the [Voice Cloning Consent and Safety](https://speechify.ai/voice-cloning/consent-and-safety) page. This demo makes that explicit in the UI: it records the consenting person's name and email and won't call the clone API until you confirm you have consent.

## Run it yourself

```bash
cp .env.example .env # then paste your SPEECHIFY_API_KEY
pnpm install
pnpm dev # http://localhost:8772
```

Open `http://localhost:8772`, pick `fixtures/spacewalk.wav` (or your own ~10 second clip), fill in the consent name and email, tick the consent box, then click **Clone, speak, then delete**. You'll get audio back in the cloned voice, and the clone is removed straight after.

Voice cloning is gated by your Speechify plan. If it isn't included, `POST /api/clone` returns `402` and the UI shows a plan message instead of a `voice_id`.

## How the key stays server-side

Every Speechify call happens inside an `app/api/*` route handler, which only ever runs on the server. The browser talks to those same-origin routes; it never sees `SPEECHIFY_API_KEY`. `next.config.ts` marks `@speechify/api` as a server-external package so the SDK is never bundled into client JS. Each route also verifies a Cloudflare Turnstile token before doing any work.

## Where the code came from

The clone lifecycle mirrors the TypeScript SDK recipe in the [Speechify Cookbook](https://github.com/SpeechifyInc/speechify-api-cookbook/tree/main/recipes/audio/typescript/sdk/voice-cloning). This folder wraps that lifecycle in a Next.js UI with the key held server-side and an explicit consent + auto-delete flow, which is how you'd ship it responsibly in a real app.

## Prerequisites

- Node 20 or newer
- A `SPEECHIFY_API_KEY` from [platform.speechify.ai/api-keys](https://platform.speechify.ai/api-keys), on a plan that includes voice cloning
66 changes: 66 additions & 0 deletions demos/clone-voice-10s/app/api/clone/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 form = await req.formData();
const sample = form.get("sample");
const fullName = form.get("fullName");
const email = form.get("email");
const gender = form.get("gender");
const consent = form.get("consent");

if (
!(sample instanceof File) ||
typeof fullName !== "string" ||
!fullName.trim() ||
typeof email !== "string" ||
!email.trim()
) {
return NextResponse.json(
{ error: "sample (file), fullName and email are all required" },
{ status: 400 },
);
}

// Consent is a first-class gate: no clone without it. The checkbox in the UI
// sends consent=true; we refuse to call the API otherwise.
if (consent !== "true") {
return NextResponse.json(
{ error: "You must confirm you have the speaker's consent to clone this voice." },
{ status: 400 },
);
}

const voiceGender = gender === "female" ? "female" : "male";

try {
const voice = await client.voices.create({
name: `clone-10s-${Date.now()}`,
gender: voiceGender,
sample,
// The consenting person's identity is recorded with the clone.
consent: JSON.stringify({ fullName: fullName.trim(), email: email.trim() }),
});
return NextResponse.json({ voiceId: voice.id, displayName: voice.display_name });
} catch (err) {
if (err instanceof SpeechifyError && err.statusCode === 402) {
return NextResponse.json(
{
error:
"Voice cloning isn't included in your current Speechify plan. Everything else in this demo still shows the flow.",
},
{ status: 402 },
);
}
throw err;
}
}
29 changes: 29 additions & 0 deletions demos/clone-voice-10s/app/api/speak/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { SpeechifyClient } 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" || typeof voiceId !== "string") {
return NextResponse.json({ error: "text and voiceId are required" }, { status: 400 });
}

// simba-english is the safe model for cloned voices.
const speech = await client.audio.speech({
input: text,
voice_id: voiceId,
audio_format: "mp3",
model: "simba-english",
});

return NextResponse.json({ audio: speech.audio_data });
}
23 changes: 23 additions & 0 deletions demos/clone-voice-10s/app/api/voice/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { SpeechifyClient } 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 DELETE(req: Request) {
if (!(await verifyTurnstile(req))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const { searchParams } = new URL(req.url);
const voiceId = searchParams.get("id");

if (!voiceId) {
return NextResponse.json({ error: "id query param is required" }, { status: 400 });
}

await client.voices.delete({ voice_id: voiceId });
return NextResponse.json({ deleted: voiceId });
}
Loading