From 2f5c896ba6d844cc5a32e08dca073ac2d47085db Mon Sep 17 00:00:00 2001 From: luke-speechify <289678208+luke-speechify@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:44:43 +0100 Subject: [PATCH 1/3] feat: add clone-voice-10s demo Zero-shot voice cloning from a ~10 second sample with an explicit consent gate, immediate synthesis with the clone, and auto-delete afterwards. Three Node-runtime routes (clone, speak, delete) hold the Speechify key server-side and are gated with Turnstile. Registered as a Vercel Service under /clone-voice-10s. --- README.md | 1 + demos/clone-voice-10s/.env.example | 1 + demos/clone-voice-10s/.gitignore | 5 + demos/clone-voice-10s/README.md | 45 ++++ demos/clone-voice-10s/app/api/clone/route.ts | 66 +++++ demos/clone-voice-10s/app/api/speak/route.ts | 29 +++ demos/clone-voice-10s/app/api/voice/route.ts | 23 ++ demos/clone-voice-10s/app/globals.css | 142 ++++++++++ demos/clone-voice-10s/app/layout.tsx | 21 ++ demos/clone-voice-10s/app/lib/turnstile.ts | 37 +++ demos/clone-voice-10s/app/page.tsx | 256 +++++++++++++++++++ demos/clone-voice-10s/demo.json | 6 + demos/clone-voice-10s/fixtures/spacewalk.wav | Bin 0 -> 2287520 bytes demos/clone-voice-10s/next.config.ts | 13 + demos/clone-voice-10s/package.json | 26 ++ demos/clone-voice-10s/tsconfig.json | 41 +++ pnpm-lock.yaml | 28 ++ pnpm-workspace.yaml | 1 + site/public/index.html | 4 +- vercel.json | 8 + 20 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 demos/clone-voice-10s/.env.example create mode 100644 demos/clone-voice-10s/.gitignore create mode 100644 demos/clone-voice-10s/README.md create mode 100644 demos/clone-voice-10s/app/api/clone/route.ts create mode 100644 demos/clone-voice-10s/app/api/speak/route.ts create mode 100644 demos/clone-voice-10s/app/api/voice/route.ts create mode 100644 demos/clone-voice-10s/app/globals.css create mode 100644 demos/clone-voice-10s/app/layout.tsx create mode 100644 demos/clone-voice-10s/app/lib/turnstile.ts create mode 100644 demos/clone-voice-10s/app/page.tsx create mode 100644 demos/clone-voice-10s/demo.json create mode 100644 demos/clone-voice-10s/fixtures/spacewalk.wav create mode 100644 demos/clone-voice-10s/next.config.ts create mode 100644 demos/clone-voice-10s/package.json create mode 100644 demos/clone-voice-10s/tsconfig.json diff --git a/README.md b/README.md index b23a0c8..8948c2d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d | [`demos/mastra-agent-speechify/`](./demos/mastra-agent-speechify) | TypeScript (Mastra) | | Text-in, speech-out Mastra Agent using an OpenAI LLM for replies and Speechify's simba-3.2 model for TTS via `@mastra/voice-speechify`. | | [`demos/voice-agent-showcase/`](./demos/voice-agent-showcase) | Cloudflare Workers | | One page, ten live Voice Agents API demos: calendar booking, policy-bound support, a page copilot, form intake, US outbound calls with a 5-minute cap, a voice gallery, mid-call language handoff, cross-call memory, a grounded knowledge base, and dual-control troubleshooting. | | [`demos/vercel-ai-sdk/`](./demos/vercel-ai-sdk) | TypeScript (Vercel AI SDK) | | Speechify TTS through the Vercel AI SDK's unified `generateSpeech` interface via the official `@speechify/vercel` provider — one-line swap from OpenAI/ElevenLabs, plus word-level speech marks from `providerMetadata`. | +| [`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. | ## Get an API key diff --git a/demos/clone-voice-10s/.env.example b/demos/clone-voice-10s/.env.example new file mode 100644 index 0000000..534cec4 --- /dev/null +++ b/demos/clone-voice-10s/.env.example @@ -0,0 +1 @@ +SPEECHIFY_API_KEY=your_api_key_here diff --git a/demos/clone-voice-10s/.gitignore b/demos/clone-voice-10s/.gitignore new file mode 100644 index 0000000..95d1bcb --- /dev/null +++ b/demos/clone-voice-10s/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.next/ +.env +next-env.d.ts +*.tsbuildinfo diff --git a/demos/clone-voice-10s/README.md b/demos/clone-voice-10s/README.md new file mode 100644 index 0000000..ae9ce59 --- /dev/null +++ b/demos/clone-voice-10s/README.md @@ -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 diff --git a/demos/clone-voice-10s/app/api/clone/route.ts b/demos/clone-voice-10s/app/api/clone/route.ts new file mode 100644 index 0000000..69aecf2 --- /dev/null +++ b/demos/clone-voice-10s/app/api/clone/route.ts @@ -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; + } +} diff --git a/demos/clone-voice-10s/app/api/speak/route.ts b/demos/clone-voice-10s/app/api/speak/route.ts new file mode 100644 index 0000000..8655382 --- /dev/null +++ b/demos/clone-voice-10s/app/api/speak/route.ts @@ -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 }); +} diff --git a/demos/clone-voice-10s/app/api/voice/route.ts b/demos/clone-voice-10s/app/api/voice/route.ts new file mode 100644 index 0000000..aec89e8 --- /dev/null +++ b/demos/clone-voice-10s/app/api/voice/route.ts @@ -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 }); +} diff --git a/demos/clone-voice-10s/app/globals.css b/demos/clone-voice-10s/app/globals.css new file mode 100644 index 0000000..82a56d4 --- /dev/null +++ b/demos/clone-voice-10s/app/globals.css @@ -0,0 +1,142 @@ +:root { + color-scheme: light dark; + --fg: #111; + --bg: #fff; + --muted: #666; + --border: #ddd; + --surface: #f6f6f6; +} + +@media (prefers-color-scheme: dark) { + :root { + --fg: #f2f2f2; + --bg: #0c0c0c; + --muted: #999; + --border: #2a2a2a; + --surface: #161616; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 2rem 1rem; + background: var(--bg); + color: var(--fg); + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; + line-height: 1.5; +} + +main { + max-width: 34rem; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +h1 { + font-size: 1.6rem; + font-weight: 500; + margin: 0; +} + +.lead { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.step { + border: 1px solid var(--border); + border-radius: 0.6rem; + padding: 1.1rem; + background: var(--surface); +} + +.step h2 { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); + margin: 0 0 0.75rem; +} + +label { + display: block; + font-size: 0.85rem; + margin: 0.5rem 0 0.2rem; +} + +input, +textarea, +select, +button { + font: inherit; + color: inherit; + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid var(--border); + border-radius: 0.4rem; + background: var(--bg); +} + +label.check { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-top: 0.9rem; + font-size: 0.9rem; +} + +label.check input { + width: auto; + margin-top: 0.15rem; +} + +.hint { + font-size: 0.8rem; + color: var(--muted); + margin: 0.5rem 0 0; +} + +.hint a { + color: inherit; +} + +button { + cursor: pointer; + background: var(--fg); + color: var(--bg); + border: 0; + font-weight: 500; + margin-top: 0.9rem; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.status { + font-size: 0.85rem; + color: var(--muted); + min-height: 1.2rem; +} + +.status[data-tone="error"] { + color: #c0392b; +} + +audio { + width: 100%; + margin-top: 0.5rem; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; +} diff --git a/demos/clone-voice-10s/app/layout.tsx b/demos/clone-voice-10s/app/layout.tsx new file mode 100644 index 0000000..60933ce --- /dev/null +++ b/demos/clone-voice-10s/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: "Clone a voice from 10 seconds with Speechify", + description: + "Zero-shot voice cloning from a ~10 second sample, with an explicit consent gate, then synthesize with the clone and auto-delete it — using the Speechify API.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + @@ -368,7 +368,7 @@

FAQ