diff --git a/README.md b/README.md
index 1019530..bb4c063 100644
--- a/README.md
+++ b/README.md
@@ -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. |
## 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..e838375
--- /dev/null
+++ b/demos/clone-voice-10s/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.next/
+.env
+next-env.d.ts
+*.tsbuildinfo
+test-results/
+playwright-report/
+/.playwright/
+.last-run.json
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..fe56041
--- /dev/null
+++ b/demos/clone-voice-10s/app/globals.css
@@ -0,0 +1,245 @@
+/* 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; }
+}
+
+/* ── clone-voice-10s specifics ─────────────────────────────────────────────
+ * Consent-gated clone → speak → delete flow. Everything below references the
+ * brand tokens above; no hardcoded palette. */
+
+.step h2 {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ text-transform: none;
+ color: var(--text-tertiary);
+ margin: 0 0 0.9rem;
+}
+
+input[type="file"] {
+ width: 100%;
+ font: inherit;
+ color: var(--text-secondary);
+ background: var(--surface-card);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 0.55rem 0.7rem;
+}
+input[type="file"]::file-selector-button {
+ font: inherit;
+ font-weight: 500;
+ cursor: pointer;
+ color: var(--text-primary);
+ background: var(--surface-raised);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-pill);
+ padding: 0.3rem 0.9rem;
+ margin-right: 0.75rem;
+}
+
+label.check {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ margin-top: 0.9rem;
+ color: var(--text-primary);
+}
+label.check input {
+ width: auto;
+ margin-top: 0.2rem;
+}
+
+.hint {
+ font-size: 0.85rem;
+ color: var(--text-tertiary);
+ margin: 0.6rem 0 0;
+}
+.hint a { color: var(--text-secondary); }
+
+.step .btn {
+ margin-top: 1rem;
+ width: 100%;
+}
+
+.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.75rem;
+}
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 (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/demos/clone-voice-10s/app/lib/turnstile.ts b/demos/clone-voice-10s/app/lib/turnstile.ts
new file mode 100644
index 0000000..6fee926
--- /dev/null
+++ b/demos/clone-voice-10s/app/lib/turnstile.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/demos/clone-voice-10s/app/page.tsx b/demos/clone-voice-10s/app/page.tsx
new file mode 100644
index 0000000..cc36217
--- /dev/null
+++ b/demos/clone-voice-10s/app/page.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const API_BASE = "/clone-voice-10s";
+
+type TurnstileHandle = {
+ enabled: boolean;
+ getToken: (opts?: { timeout?: number }) => Promise;
+ reset: () => void;
+};
+
+declare global {
+ interface Window {
+ SpeechifyTurnstile?: {
+ render: (
+ target: string | HTMLElement,
+ options?: unknown,
+ ) => Promise;
+ };
+ }
+}
+
+export default function Home() {
+ const [file, setFile] = useState(null);
+ const [gender, setGender] = useState<"male" | "female">("male");
+ const [fullName, setFullName] = useState("");
+ const [email, setEmail] = useState("");
+ const [consent, setConsent] = useState(false);
+ const [voiceId, setVoiceId] = useState(null);
+ const [deleted, setDeleted] = useState(false);
+ const [text, setText] = useState(
+ "This voice was cloned from a ten second sample, then removed straight after.",
+ );
+ const [audioUrl, setAudioUrl] = useState(null);
+ const [status, setStatus] = useState("");
+ const [tone, setTone] = useState<"info" | "error">("info");
+ const [busy, setBusy] = useState(false);
+ const [turnstile, setTurnstile] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ async function init() {
+ while (!window.SpeechifyTurnstile && !cancelled) {
+ await new Promise((r) => setTimeout(r, 30));
+ }
+ if (cancelled) return;
+ const t = await window.SpeechifyTurnstile!.render("#turnstile-container");
+ if (!cancelled) setTurnstile(t);
+ }
+ void init();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ function say(message: string, t: "info" | "error" = "info") {
+ setStatus(message);
+ setTone(t);
+ }
+
+ async function turnstileHeaders(
+ base: Record = {},
+ ): Promise> {
+ if (!turnstile) return base;
+ const token = await turnstile.getToken();
+ if (!token) return base;
+ return { ...base, "x-turnstile-token": token };
+ }
+
+ // The whole point of the demo: clone → speak → delete, in one click, so the
+ // clone never lingers in the workspace.
+ async function run() {
+ if (!file) {
+ say("Pick a ~10 second WAV sample first.", "error");
+ return;
+ }
+ if (!consent) {
+ say("Tick the consent box before cloning.", "error");
+ return;
+ }
+ if (!fullName.trim() || !email.trim()) {
+ say("Add the consenting person's full name and email.", "error");
+ return;
+ }
+
+ setBusy(true);
+ setAudioUrl(null);
+ setDeleted(false);
+ setVoiceId(null);
+
+ // 1. Clone
+ say("Cloning the voice from your sample…");
+ const body = new FormData();
+ body.append("sample", file);
+ body.append("fullName", fullName.trim());
+ body.append("email", email.trim());
+ body.append("gender", gender);
+ body.append("consent", "true");
+
+ const cloneRes = await fetch(`${API_BASE}/api/clone`, {
+ method: "POST",
+ headers: await turnstileHeaders(),
+ body,
+ });
+ turnstile?.reset();
+ if (!cloneRes.ok) {
+ const { error } = await cloneRes
+ .json()
+ .catch(() => ({ error: cloneRes.statusText }));
+ say(error ?? "Clone failed.", "error");
+ setBusy(false);
+ return;
+ }
+ const { voiceId: id } = await cloneRes.json();
+ setVoiceId(id);
+
+ // 2. Speak with the clone
+ say(`Cloned (${id}). Synthesizing with the clone…`);
+ const speakRes = await fetch(`${API_BASE}/api/speak`, {
+ method: "POST",
+ headers: await turnstileHeaders({ "Content-Type": "application/json" }),
+ body: JSON.stringify({ text, voiceId: id }),
+ });
+ turnstile?.reset();
+
+ let synthOk = false;
+ if (speakRes.ok) {
+ const { audio } = await speakRes.json();
+ const blob = await (
+ await fetch(`data:audio/mpeg;base64,${audio}`)
+ ).blob();
+ setAudioUrl(URL.createObjectURL(blob));
+ synthOk = true;
+ }
+
+ // 3. Auto-delete the clone so the demo doesn't litter the workspace
+ say("Deleting the cloned voice…");
+ const delRes = await fetch(`${API_BASE}/api/voice?id=${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ headers: await turnstileHeaders(),
+ });
+ turnstile?.reset();
+ if (delRes.ok) setDeleted(true);
+
+ setBusy(false);
+ if (!synthOk) {
+ say("Cloned and deleted, but synthesis failed. Try again.", "error");
+ return;
+ }
+ say(
+ delRes.ok
+ ? "Done. Press play — the clone has been deleted from your workspace."
+ : "Synthesized. Press play. (Delete failed — remove the clone manually.)",
+ delRes.ok ? "info" : "error",
+ );
+ }
+
+ return (
+
+
SpeechifyAI · Voice cloning
+
Clone a voice from 10 seconds.
+
+ Zero-shot cloning from a short sample, used immediately to synthesize —
+ then the clone is deleted straight after. Cloning only runs once you
+ confirm consent.
+
+
+
+
Step 1 — sample
+
+ setFile(e.target.files?.[0] ?? null)}
+ />
+
+ A 10 to 30 second WAV of one speaker works best. Aim for ~10 seconds of
+ clean, single-speaker audio. No sample? Use{" "}
+ fixtures/spacewalk.wav from this repo.
+