# Chunked speech script

The source included a practical JavaScript generator but read a provider key from a local file, used fixed personal voice shortcuts, and concatenated MP3 buffers directly. This version removes those dependencies, splits overlong sentences, retries transient failures, and uses FFmpeg for MP3 assembly. The output folder contains Markdown only, so copy the fenced JavaScript to `generate_speech.js` in your own runtime when you need to run it.

Use Node.js 20+ with FFmpeg on `PATH`. Inject `ELEVENLABS_API_KEY` through a secret manager or runtime environment, never a key file. Example invocation: `node generate_speech.js input.txt output.mp3 <YOUR_VOICE_ID> [model_id] [speed]`. Do not copy the key into command-line arguments or source code.

```javascript
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");

const [inputPath, outputPath, voiceId, modelId = "eleven_multilingual_v2", speedArg = "1"] = process.argv.slice(2);
const apiKey = process.env.ELEVENLABS_API_KEY;
if (!inputPath || !outputPath || !voiceId || !apiKey) {
  throw new Error("Supply input.txt, output.mp3, voice ID, and a managed ELEVENLABS_API_KEY environment variable");
}
const speed = Number(speedArg);
if (!Number.isFinite(speed) || speed < 0.7 || speed > 1.2) {
  throw new Error("Speech speed must be between 0.7 and 1.2");
}
const maxChars = modelId === "eleven_v3" ? 450 : 4500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

function pushLongSentence(chunks, sentence, limit) {
  let rest = sentence.trim();
  while (rest.length > limit) {
    let splitAt = rest.lastIndexOf(" ", limit);
    if (splitAt < Math.floor(limit / 2)) splitAt = limit;
    chunks.push(rest.slice(0, splitAt).trim());
    rest = rest.slice(splitAt).trim();
  }
  if (rest) chunks.push(rest);
}

function splitText(text, limit) {
  const chunks = [];
  for (const paragraph of text.trim().split(/\n\s*\n/u)) {
    let current = "";
    for (const sentence of paragraph.trim().split(/(?<=[.!?])\s+/u)) {
      if (!sentence.trim()) continue;
      if (sentence.length > limit) {
        if (current) chunks.push(current);
        current = "";
        pushLongSentence(chunks, sentence, limit);
        continue;
      }
      const joined = current ? `${current} ${sentence}` : sentence;
      if (joined.length > limit) {
        chunks.push(current);
        current = sentence;
      } else {
        current = joined;
      }
    }
    if (current) chunks.push(current);
  }
  return chunks;
}

async function synthesizeChunk(text, index) {
  const url = new URL(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}`);
  url.searchParams.set("output_format", "mp3_44100_128");
  let lastError;
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch(url, {
        method: "POST",
        headers: { "content-type": "application/json", "xi-api-key": apiKey },
        body: JSON.stringify({
          text,
          model_id: modelId,
          voice_settings: { stability: 0.5, similarity_boost: 0.75, speed }
        }),
        signal: AbortSignal.timeout(60000)
      });
      if (response.ok) return Buffer.from(await response.arrayBuffer());
      const detail = (await response.text()).slice(0, 250);
      if (![408, 429, 500, 502, 503, 504].includes(response.status)) {
        throw new Error(`Permanent speech error ${response.status}: ${detail}`);
      }
      lastError = new Error(`Temporary speech error ${response.status}: ${detail}`);
    } catch (error) {
      if (error.message.startsWith("Permanent speech error")) throw error;
      lastError = error;
    }
    if (attempt < 2) await sleep(1000 * (2 ** attempt));
  }
  throw new Error(`Chunk ${index + 1} failed after retries: ${lastError.message}`);
}

function runFfmpeg(listPath, targetPath) {
  return new Promise((resolve, reject) => {
    const child = spawn("ffmpeg", [
      "-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", targetPath
    ], { stdio: "inherit" });
    child.once("error", reject);
    child.once("close", code => code === 0 ? resolve() : reject(new Error(`FFmpeg exited ${code}`)));
  });
}

async function main() {
  const text = await fs.readFile(inputPath, "utf8");
  const chunks = splitText(text, maxChars);
  if (!chunks.length) throw new Error("Input contains no speech text");
  const targetPath = path.resolve(outputPath);
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
  const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "speech-parts-"));
  try {
    const filenames = [];
    for (const [index, chunk] of chunks.entries()) {
      console.log(`Generating ${index + 1}/${chunks.length} (${chunk.length} chars)`);
      const audio = await synthesizeChunk(chunk, index);
      const filename = `part-${String(index).padStart(5, "0")}.mp3`;
      await fs.writeFile(path.join(scratch, filename), audio);
      filenames.push(filename);
    }
    if (filenames.length === 1) {
      await fs.copyFile(path.join(scratch, filenames[0]), targetPath);
    } else {
      const concatPath = path.join(scratch, "concat.txt");
      await fs.writeFile(concatPath, filenames.map(name => `file '${name}'`).join("\n") + "\n");
      await runFfmpeg(concatPath, targetPath);
    }
    console.log(`Audio ready: ${targetPath}`);
  } finally {
    await fs.rm(scratch, { recursive: true, force: true });
  }
}

main().catch(error => {
  console.error(error.message);
  process.exitCode = 1;
});
```

The concat list and chunk files share a directory, so their relative basenames resolve correctly. A different layout requires absolute paths or correct paths relative to the list file. This script does not implement provider request-ID stitching; for that add the [backend stitching pattern](stitching-pattern.md) when supported. In Sauna, use a connected account or pinned app connection rather than expecting shell environment variables or sending a manual key header. Outside Sauna, a secret manager can inject the runtime environment variable.
