diff --git a/README.md b/README.md index 6434db4..6c0145f 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/demos/blog-to-podcast/.env.example b/demos/blog-to-podcast/.env.example new file mode 100644 index 0000000..bcbc9c8 --- /dev/null +++ b/demos/blog-to-podcast/.env.example @@ -0,0 +1 @@ +SPEECHIFY_API_KEY=your_api_key_here \ No newline at end of file diff --git a/demos/blog-to-podcast/README.md b/demos/blog-to-podcast/README.md new file mode 100644 index 0000000..d015d14 --- /dev/null +++ b/demos/blog-to-podcast/README.md @@ -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 `
`/`
` 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/-/part-000.mp3, part-001.mp3, ... + +./concat.sh output/- 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`). \ No newline at end of file diff --git a/demos/blog-to-podcast/concat.sh b/demos/blog-to-podcast/concat.sh new file mode 100644 index 0000000..9cb64ce --- /dev/null +++ b/demos/blog-to-podcast/concat.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Concatenate the per-chunk MP3s from output/ into one file. +# Usage: ./concat.sh output/ [output.mp3] +set -euo pipefail + +src="${1:?usage: ./concat.sh [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))" \ No newline at end of file diff --git a/demos/blog-to-podcast/demo.json b/demos/blog-to-podcast/demo.json new file mode 100644 index 0000000..2c4281c --- /dev/null +++ b/demos/blog-to-podcast/demo.json @@ -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." +} \ No newline at end of file diff --git a/demos/blog-to-podcast/package.json b/demos/blog-to-podcast/package.json new file mode 100644 index 0000000..b578b0c --- /dev/null +++ b/demos/blog-to-podcast/package.json @@ -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" + } +} \ No newline at end of file diff --git a/demos/blog-to-podcast/src/index.ts b/demos/blog-to-podcast/src/index.ts new file mode 100644 index 0000000..d7d5257 --- /dev/null +++ b/demos/blog-to-podcast/src/index.ts @@ -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(" 0 ? html.slice(top) : html; + + for (const [s, e] of [ + [""], + [""], + [""], + [""], + [""], + [""], + [""], + ]) { + body = stripDeep(body, s, e); + } + + const text = body + .replace(/\s*/gi, "\n") + .replace(/<\/(p|h1|h2|h3|h4|h5|h6|li|div|section)>/gi, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/"/gi, '"') + .replace(/'|'/gi, "'") + .replace(/</gi, "<") + .replace(/>/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 { + 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 -- [--voice ] [--model ]"); + 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); + }); +} \ No newline at end of file diff --git a/demos/blog-to-podcast/tsconfig.json b/demos/blog-to-podcast/tsconfig.json new file mode 100644 index 0000000..ad10da5 --- /dev/null +++ b/demos/blog-to-podcast/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/site/public/index.html b/site/public/index.html index ae37691..1302c3e 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -276,7 +276,7 @@ - + @@ -368,7 +368,7 @@

FAQ