Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/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. |
<!-- DEMOS:END -->

## Get an API key
Expand Down
1 change: 1 addition & 0 deletions demos/blog-to-podcast/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SPEECHIFY_API_KEY=your_api_key_here
9 changes: 9 additions & 0 deletions demos/blog-to-podcast/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
.next/
.env
next-env.d.ts
*.tsbuildinfo
test-results/
playwright-report/
/.playwright/
.last-run.json
42 changes: 42 additions & 0 deletions demos/blog-to-podcast/README.md
Original file line number Diff line number Diff line change
@@ -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)
210 changes: 210 additions & 0 deletions demos/blog-to-podcast/app/api/episode/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
Loading