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 ( + + + + @@ -368,7 +368,7 @@

FAQ