Article to speech
Turn a web article into an MP3 you can listen to, with no speech API key: the article's own paragraphs are taken from the page and spoken offline in a session's shell.
When to use it: You want an MP3 of an article, with no speech API key.
| Uses | extract (the article's paragraphs), fetch (the page's text, to check them), a shell session (espeak-ng through setup, ffmpeg) |
|---|---|
| Needs | shell sessions and a model on the API (no keys of your own) |
| Site | a Wikipedia article by default (CC BY-SA: credit it if you share the audio); the runner uses a stand-in article |
| Output | output/article.mp3, output/article.txt (what was read), output/result.json |
| Languages | Node and Python |
The model is asked for the article's paragraphs word for word (no menus, captions, footnotes or footers), and each paragraph is checked against the page's own text: one the model rewrote is not read out. espeak-ng, installed by the session's setup, speaks the text and ffmpeg encodes it. For more natural voices, swap espeak-ng for Piper (pip install piper-tts and a voice model) in the same shell, or a speech API with your own key.
The code
The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.
/** * Article to speech: turn a web article into an MP3 you can listen to. The article's own paragraphs are taken from the * page (menus, captions and footers left out) and checked word for word against it, then spoken offline in a * session's shell (espeak-ng, installed by the session's setup) and encoded with ffmpeg. No speech API key needed. * * npx tsx node/index.ts (BOXLINE_API_KEY; ARTICLE_URL, MAX_WORDS, VOICE, SPEED; a plan with shell sessions) * * Writes output/article.mp3, output/article.txt and output/result.json. */import { mkdirSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const url = process.env.ARTICLE_URL ?? "https://en.wikipedia.org/wiki/Urban_forest";const maxWords = Number(process.env.MAX_WORDS ?? 250);const voice = process.env.VOICE ?? "en-us";const speed = Number(process.env.SPEED ?? 165); // words per minuteconst bx = new Boxline();const norm = (s: string) => s.toLowerCase().replace(/\[\d+\]/g, "").replace(/[^a-z0-9]+/g, " ").trim();// 1. The article's own paragraphs, word for word, and the page's text to check them against.const [r, page] = await Promise.all([ bx.extract<{ title: string; paragraphs: string[] }>({ url, prompt: `The article on this page: its title, and its paragraphs copied word for word, in order, without menus, captions, footnotes, footers or ads. Stop after about ${maxWords} words, at the end of a paragraph.`, schema: { type: "object", properties: { title: { type: "string" }, paragraphs: { type: "array", items: { type: "string" } } }, required: ["title", "paragraphs"] }, }), bx.fetch(url, { format: "text" }),]);const pageText = norm(page.content);// A paragraph that is not on the page as written (the model rewrote it) is not read out.const paragraphs = r.data.paragraphs.filter((p) => pageText.includes(norm(p).slice(0, 120)));const text = [r.data.title, ...paragraphs].join("\n\n");const words = text.split(/\s+/).filter(Boolean).length;console.log(`"${r.data.title}": ${paragraphs.length} of ${r.data.paragraphs.length} paragraphs match the page word for word; ${words} words`);if (!paragraphs.length) throw new Error("no paragraph could be checked against the page");// 2. Speech, made in the machine.const session = await bx.sessions.create({ browser: false, shell: true, setup: ["sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends espeak-ng >/dev/null"], timeout: 600, idleTimeout: 300, userMetadata: { example: "article-to-speech" } });console.log(`Session: ${session.id}`);let durationSeconds = 0;try { await session.files.write("article.txt", text); const sh = await session.exec( 'espeak-ng -v "$VOICE" -s "$SPEED" -f article.txt -w article.wav && ffmpeg -loglevel error -y -i article.wav -metadata title="$TITLE" -codec:a libmp3lame -qscale:a 5 article.mp3 && ffprobe -v error -show_entries format=duration -of csv=p=0 article.mp3', { env: { VOICE: voice, SPEED: String(speed), TITLE: r.data.title }, timeoutMs: 300_000 }, ); if (sh.exitCode !== 0) throw new Error(`speech failed: ${sh.stderr.trim()}`); durationSeconds = Math.round(Number(sh.stdout.trim()) * 10) / 10; mkdirSync(out, { recursive: true }); writeFileSync(join(out, "article.mp3"), await session.files.read("article.mp3")); writeFileSync(join(out, "article.txt"), text);} finally { await session.stop();}console.log(`article.mp3: ${durationSeconds} s (${voice}, ${speed} words per minute)`);writeFileSync(join(out, "result.json"), JSON.stringify({ url, title: r.data.title, paragraphs, dropped: r.data.paragraphs.length - paragraphs.length, words, voice, speed, durationSeconds, usage: { modelUsd: r.usage.costUsd } }, null, 2));"""Article to speech: turn a web article into an MP3 you can listen to. The article's own paragraphs are taken from thepage (menus, captions and footers left out) and checked word for word against it, then spoken offline in asession's shell (espeak-ng, installed by the session's setup) and encoded with ffmpeg. No speech API key needed. python python/main.py (BOXLINE_API_KEY; ARTICLE_URL, MAX_WORDS, VOICE, SPEED; a plan with shell sessions)Writes output/article.mp3, output/article.txt and output/result.json."""import jsonimport osimport refrom concurrent.futures import ThreadPoolExecutorfrom pathlib import Pathfrom boxline import Boxlineout = Path(os.environ.get("OUTPUT_DIR", "output"))url = os.environ.get("ARTICLE_URL", "https://en.wikipedia.org/wiki/Urban_forest")max_words = int(os.environ.get("MAX_WORDS", "250"))voice = os.environ.get("VOICE", "en-us")speed = int(os.environ.get("SPEED", "165")) # words per minutebx = Boxline()def norm(s: str) -> str: return re.sub(r"[^a-z0-9]+", " ", re.sub(r"\[\d+\]", "", s.lower())).strip()# 1. The article's own paragraphs, word for word, and the page's text to check them against.with ThreadPoolExecutor(2) as pool: got = pool.submit( bx.extract, url=url, prompt=f"The article on this page: its title, and its paragraphs copied word for word, in order, without menus, captions, footnotes, footers or ads. Stop after about {max_words} words, at the end of a paragraph.", schema={"type": "object", "properties": {"title": {"type": "string"}, "paragraphs": {"type": "array", "items": {"type": "string"}}}, "required": ["title", "paragraphs"]}, ) page = pool.submit(bx.fetch, url, format="text").result() r = got.result()page_text = norm(page["content"])# A paragraph that is not on the page as written (the model rewrote it) is not read out.paragraphs = [p for p in r["data"]["paragraphs"] if norm(p)[:120] in page_text]text = "\n\n".join([r["data"]["title"], *paragraphs])words = len(text.split())print(f"\"{r['data']['title']}\": {len(paragraphs)} of {len(r['data']['paragraphs'])} paragraphs match the page word for word; {words} words")if not paragraphs: raise SystemExit("no paragraph could be checked against the page")# 2. Speech, made in the machine.setup = ["sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends espeak-ng >/dev/null"]with bx.sessions.create(browser=False, shell=True, setup=setup, timeout=600, idle_timeout=300, user_metadata={"example": "article-to-speech"}) as session: print(f"Session: {session.id}", flush=True) session.files.write("article.txt", text) sh = session.exec( 'espeak-ng -v "$VOICE" -s "$SPEED" -f article.txt -w article.wav && ffmpeg -loglevel error -y -i article.wav -metadata title="$TITLE" -codec:a libmp3lame -qscale:a 5 article.mp3 && ffprobe -v error -show_entries format=duration -of csv=p=0 article.mp3', env={"VOICE": voice, "SPEED": str(speed), "TITLE": r["data"]["title"]}, timeout_ms=300_000, ) if sh["exitCode"] != 0: raise SystemExit(f"speech failed: {sh['stderr'].strip()}") duration = round(float(sh["stdout"].strip()), 1) out.mkdir(parents=True, exist_ok=True) (out / "article.mp3").write_bytes(session.files.read("article.mp3")) (out / "article.txt").write_text(text)print(f"article.mp3: {duration} s ({voice}, {speed} words per minute)")result = {"url": url, "title": r["data"]["title"], "paragraphs": paragraphs, "dropped": len(r["data"]["paragraphs"]) - len(paragraphs), "words": words, "voice": voice, "speed": speed, "durationSeconds": duration, "usage": {"modelUsd": r["usage"]["costUsd"]}}(out / "result.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))Inputs
Environment variables the example reads:
ARTICLE_URL,MAX_WORDS(default 250),VOICE(defaulten-us),SPEED(words per minute, default 165).
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/article-to-speech. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.
# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts# Python 3.9 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keypip install boxline-sdkpython python/main.pyThen check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.
What its check proves
check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.
article.mp3 is an MP3 whose length fits the number of words and the speed. On the runner's stand-in article: its three paragraphs are read word for word, and the menu and the footer are not.
Source
github.com/Boxline-dev/examples/article-to-speech on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.