From cf894b77e268aae1f844c9f45da6be98f1c47e0b Mon Sep 17 00:00:00 2001
From: luke-speechify <289678208+luke-speechify@users.noreply.github.com>
Date: Fri, 21 Aug 2026 00:46:19 +0100
Subject: [PATCH 1/2] feat: add blog-to-podcast demo
Paste a long-form article and turn it into a podcast episode. A Next.js
route chunks the text on sentence boundaries into ~500-800 char segments,
optionally prepends an episode intro, and synthesizes each segment with the
Speechify TTS API (simba-3.2, mp3), returning base64 mp3 chunks. The client
plays them back-to-back as one continuous episode with a playlist and a
download button. Supports an optional 2-voice host/guest reading.
The SPEECHIFY_API_KEY stays server-side; requests are gated with Turnstile.
Input is capped at 8000 characters. Registered as a Vercel Service.
---
README.md | 1 +
demos/blog-to-podcast/.env.example | 1 +
demos/blog-to-podcast/.gitignore | 5 +
demos/blog-to-podcast/README.md | 42 +++
.../blog-to-podcast/app/api/episode/route.ts | 210 +++++++++++
demos/blog-to-podcast/app/globals.css | 207 ++++++++++
demos/blog-to-podcast/app/layout.tsx | 21 ++
demos/blog-to-podcast/app/lib/turnstile.ts | 37 ++
demos/blog-to-podcast/app/page.tsx | 354 ++++++++++++++++++
demos/blog-to-podcast/demo.json | 6 +
demos/blog-to-podcast/next.config.ts | 13 +
demos/blog-to-podcast/package.json | 26 ++
demos/blog-to-podcast/tsconfig.json | 41 ++
pnpm-lock.yaml | 28 ++
pnpm-workspace.yaml | 1 +
site/public/index.html | 4 +-
vercel.json | 8 +
17 files changed, 1003 insertions(+), 2 deletions(-)
create mode 100644 demos/blog-to-podcast/.env.example
create mode 100644 demos/blog-to-podcast/.gitignore
create mode 100644 demos/blog-to-podcast/README.md
create mode 100644 demos/blog-to-podcast/app/api/episode/route.ts
create mode 100644 demos/blog-to-podcast/app/globals.css
create mode 100644 demos/blog-to-podcast/app/layout.tsx
create mode 100644 demos/blog-to-podcast/app/lib/turnstile.ts
create mode 100644 demos/blog-to-podcast/app/page.tsx
create mode 100644 demos/blog-to-podcast/demo.json
create mode 100644 demos/blog-to-podcast/next.config.ts
create mode 100644 demos/blog-to-podcast/package.json
create mode 100644 demos/blog-to-podcast/tsconfig.json
diff --git a/README.md b/README.md
index 6434db4..10e0435 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d
| [`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/puter-txt2speech/`](./demos/puter-txt2speech) | HTML (puter.js) | | Speaks with a Simba 3.2 voice via the Speechify provider in Puter's puter.ai.txt2speech() — one static page, your key configured once on the Puter instance. |
+| [`demos/blog-to-podcast/`](./demos/blog-to-podcast) | Next.js | [Open](https://demos.speechify.ai/blog-to-podcast) | Paste a long-form article and turn it into a podcast episode — chunked on sentence boundaries and narrated with the Speechify TTS API, key held server-side. |
## Get an API key
diff --git a/demos/blog-to-podcast/.env.example b/demos/blog-to-podcast/.env.example
new file mode 100644
index 0000000..534cec4
--- /dev/null
+++ b/demos/blog-to-podcast/.env.example
@@ -0,0 +1 @@
+SPEECHIFY_API_KEY=your_api_key_here
diff --git a/demos/blog-to-podcast/.gitignore b/demos/blog-to-podcast/.gitignore
new file mode 100644
index 0000000..95d1bcb
--- /dev/null
+++ b/demos/blog-to-podcast/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.next/
+.env
+next-env.d.ts
+*.tsbuildinfo
diff --git a/demos/blog-to-podcast/README.md b/demos/blog-to-podcast/README.md
new file mode 100644
index 0000000..5d61dfb
--- /dev/null
+++ b/demos/blog-to-podcast/README.md
@@ -0,0 +1,42 @@
+# Blog post to podcast episode (Next.js)
+
+A small [Next.js](https://nextjs.org) app that turns a long-form article into a podcast episode. Paste plain text or simple markdown, and the Speechify TTS API narrates it in a host-quality voice. The API key stays server-side in a route handler and never reaches the browser.
+
+Pairs with the blog post [Turn this blog post into a podcast episode with the Speechify API](https://speechify.ai/blog). It complements Speechify's [podcast generation](https://speechify.ai/tts/podcast-generation) page — this is the API-first, "build it yourself" version.
+
+## What you get
+
+- A one-page UI: paste an article, pick a voice, generate the episode, and play it back-to-back as one continuous listen with a segment playlist and progress view.
+- One server route, `POST /api/episode`, that holds the Speechify key server-side:
+ - Chunks the text on **sentence boundaries** into ~500–800 character segments, packing whole paragraphs together where they fit and falling back to sentence splits (`(?<=[.!?])\s+`) only when a paragraph is larger than the cap. The lookbehind keeps punctuation attached so abbreviations like "Mr. Smith" are not torn apart.
+ - Optionally prepends a short "You're listening to…" intro so it opens like an episode.
+ - Synthesizes each chunk with `client.audio.speech` (model `simba-3.2`, `audio_format: "mp3"`) and returns `{ chunks: [{ audio, text }] }` where `audio` is base64 mp3.
+- **Optional 2-voice reading.** Pick a guest voice and the reading alternates host/guest per paragraph — a simple back-and-forth. Leave it on "None" for a single narrator.
+- **Download episode.** Concatenates the mp3 segment blobs into one `episode.mp3`. This is naive Blob concatenation of the mp3 parts — fine for a demo listen; a production pipeline would remux with `ffmpeg -f concat` (see the `audiobook-pipeline` demo).
+
+## Limits
+
+- Input is capped at **8,000 characters** to keep the demo cheap. Longer articles are rejected with a message; the character counter in the UI warns you before you hit it.
+
+## Run it yourself
+
+```bash
+cp .env.example .env # then paste your SPEECHIFY_API_KEY
+pnpm install
+pnpm dev # http://localhost:8773
+```
+
+Open `http://localhost:8773`, paste an article (a sample is pre-filled), pick a host voice, and click **Generate episode**. When it's ready, press **Play episode** — each segment plays into the next automatically — or **Download episode** to save the mp3.
+
+## How the key stays server-side
+
+The Speechify call happens inside the `app/api/episode` route handler, which only ever runs on the server. The browser talks to that same-origin route; 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. Requests are gated with Cloudflare Turnstile — when `TURNSTILE_SECRET_KEY` is unset (local dev), the gate fails open.
+
+## Where the code came from
+
+The sentence-boundary chunker follows the same approach as the [`audiobook-pipeline`](../audiobook-pipeline) demo, adapted to a Next.js route and the TypeScript SDK. Synthesis uses `client.audio.speech` from [`@speechify/api`](https://www.npmjs.com/package/@speechify/api).
+
+## Prerequisites
+
+- Node 20 or newer
+- A `SPEECHIFY_API_KEY` from [platform.speechify.ai/api-keys](https://platform.speechify.ai/api-keys)
diff --git a/demos/blog-to-podcast/app/api/episode/route.ts b/demos/blog-to-podcast/app/api/episode/route.ts
new file mode 100644
index 0000000..5d431f8
--- /dev/null
+++ b/demos/blog-to-podcast/app/api/episode/route.ts
@@ -0,0 +1,210 @@
+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 });
+
+const MODEL = "simba-3.2";
+// Cap total input so the demo stays cheap. Noted in the UI + README.
+const MAX_INPUT_CHARS = 8000;
+// Target chunk size. Sentence-boundary splits keep chunks in this window so
+// each TTS request is small enough to synthesize quickly and stitch cleanly.
+const MAX_CHUNK_CHARS = 800;
+
+// Voices that support simba-3.2.
+const SIMBA_VOICES = new Set([
+ "geffen_32",
+ "harper_32",
+ "dominic_32",
+ "beatrice_32",
+ "wyatt_32",
+ "edmund_32",
+ "hugh_32",
+ "imogen_32",
+]);
+
+// Split AFTER sentence punctuation on whitespace only. The lookbehind keeps the
+// punctuation attached to the sentence (so "Mr. Smith" is not torn apart on the
+// following whitespace — it only breaks after . ! ? that end a sentence).
+function splitSentences(paragraph: string): string[] {
+ return paragraph
+ .split(/(?<=[.!?])\s+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+// Pack a single paragraph's sentences into <= max-char chunks.
+function chunkParagraph(paragraph: string, max: number): string[] {
+ const chunks: string[] = [];
+ let buf = "";
+ for (const sentence of splitSentences(paragraph)) {
+ if (!buf) {
+ buf = sentence;
+ } else if (`${buf} ${sentence}`.length <= max) {
+ buf += ` ${sentence}`;
+ } else {
+ chunks.push(buf);
+ buf = sentence;
+ }
+ }
+ if (buf) chunks.push(buf);
+ return chunks;
+}
+
+type VoicedChunk = { text: string; voice: string };
+
+// Single-voice: pack whole paragraphs together up to the cap, falling back to
+// sentence splits only when a paragraph is bigger than the cap on its own.
+function chunkSingleVoice(text: string, voice: string): VoicedChunk[] {
+ const paragraphs = text
+ .split(/\n\s*\n/)
+ .map((p) => p.trim())
+ .filter(Boolean);
+
+ const out: string[] = [];
+ let buf = "";
+ for (const para of paragraphs) {
+ if (para.length > MAX_CHUNK_CHARS) {
+ if (buf) {
+ out.push(buf);
+ buf = "";
+ }
+ for (const c of chunkParagraph(para, MAX_CHUNK_CHARS)) out.push(c);
+ } else if (!buf) {
+ buf = para;
+ } else if (`${buf}\n\n${para}`.length <= MAX_CHUNK_CHARS) {
+ buf += `\n\n${para}`;
+ } else {
+ out.push(buf);
+ buf = para;
+ }
+ }
+ if (buf) out.push(buf);
+ return out.map((t) => ({ text: t, voice }));
+}
+
+// Two-voice: never merge across paragraphs. Alternate host/guest per paragraph
+// so the reading feels like a back-and-forth between two speakers.
+function chunkTwoVoice(
+ text: string,
+ hostVoice: string,
+ guestVoice: string,
+): VoicedChunk[] {
+ const paragraphs = text
+ .split(/\n\s*\n/)
+ .map((p) => p.trim())
+ .filter(Boolean);
+
+ const out: VoicedChunk[] = [];
+ paragraphs.forEach((para, i) => {
+ const voice = i % 2 === 0 ? hostVoice : guestVoice;
+ for (const c of chunkParagraph(para, MAX_CHUNK_CHARS)) {
+ out.push({ text: c, voice });
+ }
+ });
+ return out;
+}
+
+export async function POST(req: Request) {
+ if (!(await verifyTurnstile(req))) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const {
+ text,
+ hostVoice = "geffen_32",
+ guestVoice,
+ intro = true,
+ } = (body ?? {}) as {
+ text?: unknown;
+ hostVoice?: unknown;
+ guestVoice?: unknown;
+ intro?: unknown;
+ };
+
+ if (typeof text !== "string" || !text.trim()) {
+ return NextResponse.json(
+ { error: "text is required" },
+ { status: 400 },
+ );
+ }
+ if (text.length > MAX_INPUT_CHARS) {
+ return NextResponse.json(
+ {
+ error: `Article is too long. This demo caps input at ${MAX_INPUT_CHARS} characters (got ${text.length}).`,
+ },
+ { status: 400 },
+ );
+ }
+ if (typeof hostVoice !== "string" || !SIMBA_VOICES.has(hostVoice)) {
+ return NextResponse.json(
+ { error: "hostVoice must be a valid simba-3.2 voice" },
+ { status: 400 },
+ );
+ }
+ const guest =
+ typeof guestVoice === "string" && guestVoice ? guestVoice : null;
+ if (guest && !SIMBA_VOICES.has(guest)) {
+ return NextResponse.json(
+ { error: "guestVoice must be a valid simba-3.2 voice" },
+ { status: 400 },
+ );
+ }
+
+ // Build the ordered, voiced chunk list.
+ const voiced: VoicedChunk[] = guest
+ ? chunkTwoVoice(text.trim(), hostVoice, guest)
+ : chunkSingleVoice(text.trim(), hostVoice);
+
+ // Optionally prepend a short intro line, always in the host voice, so it
+ // opens like an episode.
+ if (intro) {
+ voiced.unshift({
+ text: "You're listening to an episode generated with the Speechify API. Here's today's story.",
+ voice: hostVoice,
+ });
+ }
+
+ if (voiced.length === 0) {
+ return NextResponse.json(
+ { error: "Nothing to synthesize" },
+ { status: 400 },
+ );
+ }
+
+ try {
+ // Synthesize each chunk. Kept sequential to preserve order and stay gentle
+ // on rate limits — a real pipeline could bound-concurrency this.
+ const chunks: { audio: string; text: string }[] = [];
+ for (const c of voiced) {
+ const speech = await client.audio.speech({
+ input: c.text,
+ voice_id: c.voice,
+ audio_format: "mp3",
+ model: MODEL,
+ });
+ chunks.push({ audio: speech.audio_data, text: c.text });
+ }
+ return NextResponse.json({ chunks });
+ } catch (err) {
+ if (err instanceof SpeechifyError) {
+ return NextResponse.json(
+ { error: err.message || "Speechify API error" },
+ { status: err.statusCode ?? 502 },
+ );
+ }
+ return NextResponse.json(
+ { error: "Failed to synthesize episode" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/demos/blog-to-podcast/app/globals.css b/demos/blog-to-podcast/app/globals.css
new file mode 100644
index 0000000..5e3102e
--- /dev/null
+++ b/demos/blog-to-podcast/app/globals.css
@@ -0,0 +1,207 @@
+:root {
+ color-scheme: light dark;
+ --fg: #111;
+ --bg: #fff;
+ --muted: #666;
+ --border: #ddd;
+ --surface: #f6f6f6;
+ --accent: #4f46e5;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --fg: #f2f2f2;
+ --bg: #0c0c0c;
+ --muted: #999;
+ --border: #2a2a2a;
+ --surface: #161616;
+ --accent: #8b85f5;
+ }
+}
+
+* {
+ 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: 40rem;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+h1 {
+ font-size: 1.6rem;
+ font-weight: 500;
+ margin: 0;
+}
+
+.lede {
+ 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;
+}
+
+label.check {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.9rem;
+}
+
+label.check input {
+ width: auto;
+}
+
+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);
+}
+
+textarea {
+ resize: vertical;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.85rem;
+}
+
+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;
+}
+
+.count {
+ font-size: 0.78rem;
+ color: var(--muted);
+ margin: 0.4rem 0 0;
+}
+
+.count[data-over="true"] {
+ color: #c0392b;
+}
+
+.player {
+ display: flex;
+ gap: 0.6rem;
+}
+
+.player button {
+ margin-top: 0;
+}
+
+button.playbtn {
+ background: var(--accent);
+ color: #fff;
+}
+
+button.ghost {
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+}
+
+.playlist {
+ list-style: none;
+ margin: 1rem 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.playlist li {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.5rem 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: 0.4rem;
+ background: var(--bg);
+ cursor: pointer;
+ font-size: 0.85rem;
+}
+
+.playlist li[data-active="true"] {
+ border-color: var(--accent);
+ outline: 1px solid var(--accent);
+}
+
+.playlist .idx {
+ flex: 0 0 1.4rem;
+ height: 1.4rem;
+ display: grid;
+ place-items: center;
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.playlist .snippet {
+ color: var(--fg);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.status {
+ font-size: 0.85rem;
+ color: var(--muted);
+ min-height: 1.2rem;
+}
+
+.status[data-tone="error"] {
+ color: #c0392b;
+}
+
+code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.85em;
+}
diff --git a/demos/blog-to-podcast/app/layout.tsx b/demos/blog-to-podcast/app/layout.tsx
new file mode 100644
index 0000000..42b2d6e
--- /dev/null
+++ b/demos/blog-to-podcast/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: "Blog post to podcast episode with Speechify",
+ description:
+ "Paste a long-form article and turn it into a podcast episode with host-quality narration from the Speechify API.",
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/demos/blog-to-podcast/app/lib/turnstile.ts b/demos/blog-to-podcast/app/lib/turnstile.ts
new file mode 100644
index 0000000..6fee926
--- /dev/null
+++ b/demos/blog-to-podcast/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/blog-to-podcast/app/page.tsx b/demos/blog-to-podcast/app/page.tsx
new file mode 100644
index 0000000..aa759ff
--- /dev/null
+++ b/demos/blog-to-podcast/app/page.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+const API_BASE = "/blog-to-podcast";
+const MAX_INPUT_CHARS = 8000;
+
+const VOICES = [
+ "geffen_32",
+ "harper_32",
+ "dominic_32",
+ "beatrice_32",
+ "wyatt_32",
+ "edmund_32",
+ "hugh_32",
+ "imogen_32",
+];
+
+const SAMPLE_ARTICLE = `The best APIs disappear.
+
+You reach for one when you have a job to do, and the good ones let you finish the job without ever thinking about the API again. That is the whole trick. A text-to-speech API is no different: paste some words, get back audio that sounds like a person actually said them.
+
+For years, machine narration announced itself. The flat cadence, the wrong stress on the wrong syllable, the little digital exhale between sentences. You always knew. Modern models close that gap, and the interesting work moves up a level — from "can it talk" to "does it sound like a host".
+
+That is what this demo is about. Take a long article, the kind you would actually read, and turn it into an episode you would actually listen to.`;
+
+type Chunk = { audio: string; text: string };
+
+type TurnstileHandle = {
+ enabled: boolean;
+ getToken: (opts?: { timeout?: number }) => Promise;
+ reset: () => void;
+};
+
+// "waiting": no token yet — gated buttons stay disabled.
+// "ready": token in hand — submits go out with it attached.
+// "open": genuinely ungated client-side (no site key, script blocked,
+// widget error) — buttons enabled, requests go tokenless and the
+// server stays the authority (403s them whenever it's enforcing).
+type TurnstileState = "waiting" | "ready" | "open";
+
+declare global {
+ interface Window {
+ SpeechifyTurnstile?: {
+ render: (
+ target: string | HTMLElement,
+ options?: unknown,
+ ) => Promise;
+ };
+ }
+}
+
+export default function Home() {
+ const [text, setText] = useState(SAMPLE_ARTICLE);
+ const [hostVoice, setHostVoice] = useState("geffen_32");
+ const [guestVoice, setGuestVoice] = useState("none");
+ const [intro, setIntro] = useState(true);
+ const [chunks, setChunks] = useState([]);
+ const [urls, setUrls] = useState([]);
+ const [current, setCurrent] = useState(-1);
+ const [playing, setPlaying] = useState(false);
+ const [status, setStatus] = useState("");
+ const [tone, setTone] = useState<"info" | "error">("info");
+ const [busy, setBusy] = useState(false);
+ const [turnstile, setTurnstile] = useState(null);
+ const [tsState, setTsState] = useState("waiting");
+
+ const audioRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ async function init() {
+ while (!window.SpeechifyTurnstile && !cancelled) {
+ await new Promise((r) => setTimeout(r, 30));
+ }
+ if (cancelled) return;
+ try {
+ const t = await window.SpeechifyTurnstile!.render(
+ "#turnstile-container",
+ {
+ onToken: () => setTsState("ready"),
+ onExpired: () => setTsState("waiting"),
+ onError: () => setTsState("open"),
+ },
+ );
+ if (cancelled) return;
+ setTurnstile(t);
+ if (!t.enabled) setTsState("open");
+ } catch {
+ if (!cancelled) setTsState("open");
+ }
+ }
+ void init();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Revoke object URLs when they are replaced or the page unmounts.
+ useEffect(() => {
+ return () => {
+ urls.forEach((u) => URL.revokeObjectURL(u));
+ };
+ }, [urls]);
+
+ function say(message: string, t: "info" | "error" = "info") {
+ setStatus(message);
+ setTone(t);
+ }
+
+ async function turnstileHeaders(
+ base: Record = {},
+ ): Promise> {
+ if (!turnstile?.enabled || tsState === "open") return base;
+ const token = await turnstile.getToken();
+ if (!token) return base;
+ return { ...base, "x-turnstile-token": token };
+ }
+
+ function consumeToken() {
+ turnstile?.reset();
+ setTsState((s) => (s === "open" ? s : "waiting"));
+ }
+
+ function b64ToBlob(b64: string): Blob {
+ const bytes = atob(b64);
+ const arr = new Uint8Array(bytes.length);
+ for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
+ return new Blob([arr], { type: "audio/mpeg" });
+ }
+
+ async function generate() {
+ if (!text.trim()) {
+ say("Paste an article first.", "error");
+ return;
+ }
+ // Tear down any previous episode.
+ urls.forEach((u) => URL.revokeObjectURL(u));
+ setUrls([]);
+ setChunks([]);
+ setCurrent(-1);
+ setPlaying(false);
+
+ setBusy(true);
+ say("Generating episode… this synthesizes each chunk in turn.");
+ const headers = await turnstileHeaders({ "Content-Type": "application/json" });
+ let res: Response;
+ try {
+ res = await fetch(`${API_BASE}/api/episode`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ text,
+ hostVoice,
+ guestVoice: guestVoice === "none" ? undefined : guestVoice,
+ intro,
+ }),
+ });
+ } catch {
+ consumeToken();
+ setBusy(false);
+ say("Network error.", "error");
+ return;
+ }
+ consumeToken();
+ setBusy(false);
+ if (!res.ok) {
+ const { error } = await res
+ .json()
+ .catch(() => ({ error: res.statusText }));
+ say(error ?? "Generation failed.", "error");
+ return;
+ }
+ const { chunks: got } = (await res.json()) as { chunks: Chunk[] };
+ const newUrls = got.map((c) => URL.createObjectURL(b64ToBlob(c.audio)));
+ setChunks(got);
+ setUrls(newUrls);
+ say(`Episode ready — ${got.length} segment(s). Press play.`);
+ }
+
+ // Seamless queue: when one segment ends, advance to the next.
+ function playFrom(index: number) {
+ if (index < 0 || index >= urls.length) return;
+ setCurrent(index);
+ setPlaying(true);
+ const el = audioRef.current;
+ if (el) {
+ el.src = urls[index];
+ void el.play().catch(() => setPlaying(false));
+ }
+ }
+
+ function onEnded() {
+ if (current + 1 < urls.length) {
+ playFrom(current + 1);
+ } else {
+ setPlaying(false);
+ setCurrent(-1);
+ }
+ }
+
+ function togglePlay() {
+ const el = audioRef.current;
+ if (!el) return;
+ if (playing) {
+ el.pause();
+ setPlaying(false);
+ } else if (current >= 0) {
+ void el.play().catch(() => setPlaying(false));
+ setPlaying(true);
+ } else {
+ playFrom(0);
+ }
+ }
+
+ // Naive concatenation of the mp3 segment blobs into one file. Good enough for
+ // a demo download; noted as naive in the README.
+ function download() {
+ if (urls.length === 0) return;
+ const blob = new Blob(
+ chunks.map((c) => b64ToBlob(c.audio)),
+ { type: "audio/mpeg" },
+ );
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "episode.mp3";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ }
+
+ const over = text.length > MAX_INPUT_CHARS;
+
+ return (
+
+
Turn a blog post into a podcast episode
+
+ Paste a long-form article. Speechify chunks it on sentence boundaries and
+ narrates each chunk with the TTS API, then it plays back-to-back as one
+ continuous episode.
+