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 @@ -13,6 +13,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d
| [`demos/ai-sdk-speechify-speech/`](./demos/ai-sdk-speechify-speech) | Next.js + AI SDK | [Open](https://demos.speechify.ai/ai-sdk-speechify-speech) | A custom Speechify speech model for the AI SDK's generateSpeech, wired into a Next.js page. The API key stays server-side. |
| [`demos/multilingual-voiceover/`](./demos/multilingual-voiceover) | Next.js + simba-3.0 | [Open](https://demos.speechify.ai/multilingual-voiceover) | Generate the same line in six languages with a locale-matched simba-3.0 voice. The API key stays server-side. |
| [`demos/captions-speech-marks/`](./demos/captions-speech-marks) | TypeScript (native) | | Synthesizes audio and builds a WebVTT caption file from the speech marks the API returns in the same response. Karaoke-highlight HTML demo included. |
| [`demos/blog-to-podcast/`](./demos/blog-to-podcast) | TypeScript (SDK) | | Point it at any blog post URL, get a podcast-style MP3 out: fetches the article, extracts the text, chunks on sentence boundaries, synthesizes each chunk via the Speechify API, and stitches the parts together with ffmpeg. |
| [`demos/voice-cloning-narration/`](./demos/voice-cloning-narration) | TypeScript (native) | | Clones a voice from a 10-30 sec WAV sample, synthesizes with the new voice, deletes the clone. End-to-end lifecycle. |
| [`demos/audiobook-pipeline/`](./demos/audiobook-pipeline) | Python (SDK) | | Chunks long-form text on sentence boundaries, synthesizes each chunk via the Speechify Python SDK, concatenates the MP3s with ffmpeg. |
| [`demos/livekit-agent-speechify-python/`](./demos/livekit-agent-speechify-python) | Python (LiveKit) | | Real-time voice assistant using LiveKit's official livekit-plugins-speechify package for TTS, with Deepgram STT and an OpenAI LLM in a LiveKit AgentSession. |
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
37 changes: 37 additions & 0 deletions demos/blog-to-podcast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Blog post to podcast

Point it at any blog post URL, get a podcast-style MP3 out. Pairs with the
Speechify post *"Turn this blog post into a podcast episode with the Speechify
API"*.

## What you get

A single-file Node script that turns a web page into an episode:

1. fetches the URL and pulls the readable text out of the `<article>`/`<main>` HTML,
2. chunks it on paragraph/sentence boundaries (each call stays under the API input cap),
3. synthesizes each chunk via the Speechify API,
4. stitches the parts into one MP3 with ffmpeg.

## Run it yourself

```bash
cp .env.example .env # paste your key into .env
npm install

npm start -- https://speechify.ai/blog/some-post -v lily --model simba-english
# ...writes output/<host>-<ts>/part-000.mp3, part-001.mp3, ...

./concat.sh output/<host>-<ts> podcast-episode.mp3
```

Prerequisites: Node 20+, ffmpeg on your PATH, a Speechify API key from
[platform.speechify.ai/api-keys](https://platform.speechify.ai/api-keys).

## Notes

- The extractor is naive on purpose — it strips script/style/nav/footer blocks
and tags from the page. JS-rendered sites will yield nothing; point it at the
raw HTML or a static post instead.
- Long posts are chunked so every synthesis call stays under the input-size
limit; the parts are concatenated losslessly (`-c copy`).
16 changes: 16 additions & 0 deletions demos/blog-to-podcast/concat.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Concatenate the per-chunk MP3s from output/<dir> into one file.
# Usage: ./concat.sh output/<dir> [output.mp3]
set -euo pipefail

src="${1:?usage: ./concat.sh <output-dir> [output.mp3]}"
out="${2:-podcast-episode.mp3}"
list="$(mktemp -t blog-to-podcast.XXXXXX)"
trap 'rm -f "$list"' EXIT

for f in "$src"/part-*.mp3; do
printf "file '%s'\n" "$(cd "$(dirname "$f")" && pwd)/$(basename "$f")" >>"$list"
done

ffmpeg -y -f concat -safe 0 -i "$list" -c copy "$out"
echo "Wrote $out ($(du -h "$out" | cut -f1))"
6 changes: 6 additions & 0 deletions demos/blog-to-podcast/demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"order": 30,
"title": "Blog post to podcast",
"stack": "TypeScript (SDK)",
"blurb": "Point it at any blog post URL, get a podcast-style MP3 out: fetches the article, extracts the text, chunks on sentence boundaries, synthesizes each chunk via the Speechify API, and stitches the parts together with ffmpeg."
}
20 changes: 20 additions & 0 deletions demos/blog-to-podcast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "blog-to-podcast",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Turn a blog post into a podcast episode with the Speechify API",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@speechify/api": "^3.0.1",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
160 changes: 160 additions & 0 deletions demos/blog-to-podcast/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import "dotenv/config";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { SpeechifyClient, SpeechifyError } from "@speechify/api";

const MAX_CHARS = 4_000;

function assertKey(): string {
const token = process.env.SPEECHIFY_API_KEY;
if (!token) throw new Error("Set SPEECHIFY_API_KEY (copy .env.example to .env).");
return token;
}

function stripDeep(node: string, start: string, end: string): string {
let out = node;
let i = out.indexOf(start);
while (i !== -1) {
const j = out.indexOf(end, i + start.length);
if (j === -1) break;
out = out.slice(0, i) + out.slice(j + end.length);
i = out.indexOf(start, i);
}
return out;
}

export function extractArticleText(html: string): string {
const lower = html.toLowerCase();
let top = lower.indexOf("<article");
if (top === -1) top = lower.indexOf("<main");
if (top === -1) top = 0;
let body = top > 0 ? html.slice(top) : html;

for (const [s, e] of [
["<script", "</script>"],
["<style", "</style>"],
["<nav", "</nav>"],
["<aside", "</aside>"],
["<footer", "</footer>"],
["<form", "</form>"],
["<svg", "</svg>"],
]) {
body = stripDeep(body, s, e);
}

const text = body
.replace(/<br\s*\/?>\s*/gi, "\n")
.replace(/<\/(p|h1|h2|h3|h4|h5|h6|li|div|section)>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/\s+\n/g, "\n")
.replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();

return text;
}

export function chunkText(text: string, maxLen: number = MAX_CHARS): string[] {
const chunks: string[] = [];
let buf = "";
const paragraphs = text.split("\n\n").map((p) => p.trim()).filter(Boolean);

for (const para of paragraphs) {
if (buf.length + para.length + 2 <= maxLen) {
buf = buf ? `${buf}\n\n${para}` : para;
continue;
}
if (buf) {
chunks.push(buf);
buf = "";
}
if (para.length > maxLen) {
for (const sent of para.split(/(?<=[.!?])\s+/)) {
if (buf.length + sent.length + 1 <= maxLen) {
buf = buf ? `${buf} ${sent}` : sent;
} else {
if (buf) chunks.push(buf);
buf = sent;
}
}
} else {
buf = para;
}
}
if (buf) chunks.push(buf);
return chunks;
}

async function synthesizeChunks(client: SpeechifyClient, chunks: string[], voiceId: string, model: string, outDir: string): Promise<number> {
fs.mkdirSync(outDir, { recursive: true });
let total = 0;
for (let i = 0; i < chunks.length; i++) {
const resp = await client.audio.speech({
input: chunks[i],
voice_id: voiceId,
audio_format: "mp3",
model: model as "simba-english" | "simba-multilingual" | "simba-3.0" | "simba-3.2",
});
const out = path.join(outDir, `part-${String(i).padStart(3, "0")}.mp3`);
fs.writeFileSync(out, Buffer.from(resp.audio_data, "base64"));
total += resp.billable_characters_count ?? chunks[i].length;
console.log(` wrote ${out} (${resp.billable_characters_count} billable chars)`);
}
return total;
}

async function main() {
const url = process.argv[2];
const voiceId = process.argv.includes("--voice") ? process.argv[process.argv.indexOf("--voice") + 1] : "george";
const model = process.argv.includes("--model") ? process.argv[process.argv.indexOf("--model") + 1] : "simba-english";

if (!url) {
console.error("Usage: npm start -- <blog-post-url> [--voice <id>] [--model <id>]");
process.exit(1);
}
if (!/^https?:\/\//i.test(url)) {
console.error(`Not a URL: ${url}`);
process.exit(1);
}

const client = new SpeechifyClient({ token: assertKey() });

console.log(`Fetching ${url} ...`);
const res = await fetch(url, { headers: { "user-agent": "speechify-blog-to-podcast-demo" } });
if (!res.ok) {
throw new Error(`Fetch failed: ${res.status} ${res.statusText} for ${url}`);
}
const html = await res.text();
const text = extractArticleText(html);
console.log(`Extracted ${text.length.toLocaleString()} characters of article text.`);
if (!text) {
throw new Error("No readable article text found — the page may be JS-rendered; point this at the raw HTML or a static post.");
}

const chunks = chunkText(text);
console.log(`Split into ${chunks.length} chunk(s) (cap ${MAX_CHARS.toLocaleString()} chars).`);

const slug = new URL(url).hostname.replace(/^www\./, "") + "-" + Date.now();
const outDir = path.join("output", slug);
const total = await synthesizeChunks(client, chunks, voiceId, model, outDir);
console.log(`\nSynthesized ${chunks.length} chunks (${total.toLocaleString()} billable chars) to ${outDir}/`);
console.log(`Stitch into one episode: ./concat.sh ${outDir} podcast-episode.mp3`);
}

if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main().catch((err) => {
if (err instanceof SpeechifyError) {
console.error(err.message);
} else {
console.error(err instanceof Error ? err.message : err);
}
process.exit(1);
});
}
14 changes: 14 additions & 0 deletions demos/blog-to-podcast/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
Loading