Docs
Dashboard
Examples / Chat and researchBeta

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.

Usesextract (the article's paragraphs), fetch (the page's text, to check them), a shell session (espeak-ng through setup, ffmpeg)
Needsshell sessions and a model on the API (no keys of your own)
Sitea Wikipedia article by default (CC BY-SA: credit it if you share the audio); the runner uses a stand-in article
Outputoutput/article.mp3, output/article.txt (what was read), output/result.json
LanguagesNode 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));

Inputs

Environment variables the example reads:

  • ARTICLE_URL, MAX_WORDS (default 250), VOICE (default en-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

Then 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.