# Chunked speech generator

This local example keeps the source script's useful `count`, `part`, and `combine` modes. It removes the hard-coded voice, storage account, destination folder, and private TTS proxy. The final MP3 stays local until the user chooses where to host it. Use only for material and voices you have rights to narrate.

For a normal Node.js runtime, set the ElevenLabs credential and chosen voice through a secure environment. Do not paste actual values into this file. Copy the fenced block into a local `.mjs` file; the public skill itself ships only Markdown.

```js
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";

const MAX_CHARS = 460;
const MODEL = "eleven_v3";
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function chunks(text) {
  const output = [];
  const paragraphs = text.trim().split(/\n\s*\n/).map((p) => p.replace(/\s+/g, " ").trim()).filter(Boolean);
  for (const paragraph of paragraphs) {
    if (paragraph.length <= MAX_CHARS) { output.push(paragraph); continue; }
    const sentences = paragraph.split(/(?<=[.!?])\s+/);
    let current = "";
    const push = () => { if (current) output.push(current); current = ""; };
    for (const sentence of sentences) {
      const pieces = sentence.length <= MAX_CHARS ? [sentence] : sentence.split(/\s+/);
      for (const piece of pieces) {
        if (piece.length > MAX_CHARS) throw new Error("A word exceeds the TTS chunk limit");
        const candidate = current ? `${current} ${piece}` : piece;
        if (candidate.length > MAX_CHARS) { push(); current = piece; }
        else current = candidate;
      }
    }
    push();
  }
  return output;
}

async function speak(text) {
  const key = process.env.ELEVENLABS_API_KEY;
  const voice = process.env.ELEVENLABS_VOICE_ID;
  if (!key || !voice) throw new Error("Configure a TTS credential and consented voice ID");
  for (let attempt = 0; attempt < 6; attempt++) {
    try {
      const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
        method: "POST",
        headers: { "xi-api-key": key, Accept: "audio/mpeg", "Content-Type": "application/json" },
        body: JSON.stringify({ text, model_id: MODEL, voice_settings: { stability: 0.5, similarity_boost: 0.8 } }),
        signal: AbortSignal.timeout(60_000),
      });
      if (res.ok) return Buffer.from(await res.arrayBuffer());
      const detail = (await res.text()).slice(0, 300);
      if (res.status !== 429 && res.status < 500) throw new Error(`${res.status}: ${detail}`);
      console.error(`TTS_RETRY status=${res.status} attempt=${attempt + 1}`);
    } catch (error) {
      if (attempt === 5 || /^\d{3}:/.test(error.message)) throw error;
      console.error(`TTS_RETRY attempt=${attempt + 1} reason=${error.name}`);
    }
    await pause(1500 * (attempt + 1));
  }
  throw new Error("Speech generation did not complete");
}

function run(command, args, cwd) {
  const done = spawnSync(command, args, { cwd, encoding: "utf8" });
  if (done.status !== 0) throw new Error(`${command}: ${done.stderr || done.error}`);
  return done.stdout.trim();
}

const [mode, scriptPath, ...args] = process.argv.slice(2);
if (!mode || !scriptPath) throw new Error("count <script.txt> | part <script.txt> <start> <end> <dir> | combine <script.txt> <dir> <output.mp3>");
const text = fs.readFileSync(scriptPath, "utf8");
const all = chunks(text);

if (mode === "count") {
  const words = text.trim().split(/\s+/).filter(Boolean).length;
  console.log(JSON.stringify({ words, chunks: all.length, estimated_minutes: +(words / 158).toFixed(1) }));
} else if (mode === "part") {
  const [from, to, dir] = args;
  const start = Number(from), end = Number(to);
  if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start || end > all.length || !dir) {
    throw new Error(`Invalid part range. Total chunks: ${all.length}`);
  }
  fs.mkdirSync(dir, { recursive: true });
  let next = start;
  async function worker() {
    while (next < end) {
      const i = next++;
      const file = path.join(dir, `${String(i).padStart(5, "0")}.mp3`);
      if (fs.existsSync(file) && fs.statSync(file).size > 0) { console.log(`TTS_SKIP index=${i}`); continue; }
      console.log(`TTS_START index=${i}`);
      const bytes = await speak(all[i]);
      fs.writeFileSync(file, bytes);
      console.log(`TTS_DONE index=${i} bytes=${bytes.length}`);
    }
  }
  await Promise.all(Array.from({ length: Math.min(3, end - start) }, worker));
} else if (mode === "combine") {
  const [dir, output] = args;
  if (!dir || !output) throw new Error("combine <script.txt> <dir> <output.mp3>");
  const files = all.map((_, i) => path.join(dir, `${String(i).padStart(5, "0")}.mp3`));
  for (const file of files) if (!fs.existsSync(file) || fs.statSync(file).size === 0) throw new Error(`Missing chunk: ${file}`);
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), "audio-combine-"));
  try {
    const list = files.map((file, i) => {
      const name = `${String(i).padStart(5, "0")}.mp3`;
      fs.copyFileSync(file, path.join(temp, name));
      return `file '${name}'`;
    });
    fs.writeFileSync(path.join(temp, "list.txt"), list.join("\n"));
    const destination = path.resolve(output);
    run("ffmpeg", ["-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", "list.txt", "-codec:a", "libmp3lame", "-b:a", "64k", "-ac", "1", destination], temp);
    const seconds = Number(run("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", destination]));
    console.log(JSON.stringify({ output: destination, seconds, minutes: +(seconds / 60).toFixed(1) }));
  } finally {
    fs.rmSync(temp, { recursive: true, force: true });
  }
} else {
  throw new Error(`Unknown mode: ${mode}`);
}
```

Example sequence: run `count` to get `chunks`, run `part` over non-overlapping windows of about 15 chunks using a persistent output directory, then run `combine`. The `part` mode skips already completed chunk files so a retry is cheaper. If a generation was billed but died before a file was saved, check the provider's job history before trying again. Validate both duration and spoken content of the finished file.

In Sauna, `run_script` may have a different filesystem and auth model, so use a durable session or workspace path and pass the ElevenLabs connection instead of reading `process.env` or setting `xi-api-key` yourself. Outside Sauna, the example runs with local Node.js and ffmpeg after you supply your own service credential.
