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 (
+
+
+
+ {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..ad396ed
--- /dev/null
+++ b/demos/clone-voice-10s/app/page.tsx
@@ -0,0 +1,256 @@
+"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 (
+
+
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.
+