DEV Community

Waeckerlin Federowicz
Waeckerlin Federowicz

Posted on

Build a Context-Aware Text-to-Speech CLI in Node.js

Text-to-speech APIs are easy to demo but slightly harder to integrate well. A useful CLI should keep credentials outside source control, validate API errors, preserve audio metadata, and write a file that standard players can open.

This tutorial builds a small Node.js 20+ command-line tool around the public context-aware text to speech API from FlowSpeech. It uses only built-in Node modules.

What we will build

The command will:

  • read text from the command line;
  • send one authenticated synthesis request;
  • decode the base64 audio response;
  • wrap raw 16-bit PCM in a WAV container when necessary;
  • save the result to disk;
  • show the remaining quota returned by the API.

Prerequisites

You need Node.js 20 or newer because the example uses the built-in fetch implementation.

Create an API key in your FlowSpeech account under /settings/apikeys/create, then expose it only for the current shell:

export FLOWSPEECH_API_KEY="replace-with-your-own-key"
Enter fullscreen mode Exit fullscreen mode

Do not commit the key to Git or paste it directly into the script.

Create the CLI

Save the following as tts.mjs:

import { writeFile } from "node:fs/promises";

const API_URL = "https://flowspeech.io/api/ai/text-to-speech";
const apiKey = process.env.FLOWSPEECH_API_KEY;
const text = process.argv.slice(2).join(" ").trim();

if (!apiKey) {
  console.error("Missing FLOWSPEECH_API_KEY");
  process.exit(1);
}

if (!text) {
  console.error('Usage: node tts.mjs "Text to synthesize"');
  process.exit(1);
}

function createWavHeader({
  dataLength,
  sampleRate,
  numChannels,
  bitsPerSample,
}) {
  const blockAlign = (numChannels * bitsPerSample) / 8;
  const byteRate = sampleRate * blockAlign;
  const header = Buffer.alloc(44);

  header.write("RIFF", 0);
  header.writeUInt32LE(36 + dataLength, 4);
  header.write("WAVE", 8);
  header.write("fmt ", 12);
  header.writeUInt32LE(16, 16);
  header.writeUInt16LE(1, 20); // PCM
  header.writeUInt16LE(numChannels, 22);
  header.writeUInt32LE(sampleRate, 24);
  header.writeUInt32LE(byteRate, 28);
  header.writeUInt16LE(blockAlign, 32);
  header.writeUInt16LE(bitsPerSample, 34);
  header.write("data", 36);
  header.writeUInt32LE(dataLength, 40);

  return header;
}

function outputForAudio(data) {
  const audio = Buffer.from(data.audioBase64, "base64");
  const mimeType = data.mimeType.toLowerCase();

  if (mimeType.includes("l16")) {
    const header = createWavHeader({
      dataLength: audio.length,
      sampleRate: data.sampleRate,
      numChannels: data.numChannels,
      bitsPerSample: data.bitsPerSample,
    });

    return {
      filename: "speech.wav",
      bytes: Buffer.concat([header, audio]),
    };
  }

  if (mimeType.includes("mpeg") || mimeType.includes("mp3")) {
    return { filename: "speech.mp3", bytes: audio };
  }

  if (mimeType.includes("ogg")) {
    return { filename: "speech.ogg", bytes: audio };
  }

  return { filename: "speech.bin", bytes: audio };
}

async function synthesize() {
  const response = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      text,
      originalText: text,
      speakers: [{ voiceName: "Kore" }],
    }),
    signal: AbortSignal.timeout(90_000),
  });

  const rawBody = await response.text();
  let result;

  try {
    result = JSON.parse(rawBody);
  } catch {
    throw new Error(
      `FlowSpeech returned non-JSON data (HTTP ${response.status})`
    );
  }

  if (!response.ok || result.code !== 0) {
    const message = result.message || "Speech generation failed";
    throw new Error(`${message} (HTTP ${response.status})`);
  }

  if (!result.data?.audioBase64) {
    throw new Error("The response did not include audioBase64");
  }

  const output = outputForAudio(result.data);
  await writeFile(output.filename, output.bytes);

  console.log(`Saved ${output.filename}`);
  console.log(
    `Format: ${result.data.mimeType}, ${result.data.sampleRate} Hz, ` +
      `${result.data.numChannels} channel(s)`
  );

  if (result.data.quota) {
    console.log(`Quota remaining: ${result.data.quota.remaining}`);
  }
}

synthesize().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Run it with:

node tts.mjs "Welcome to the command-line text-to-speech demo."
Enter fullscreen mode Exit fullscreen mode

The script writes speech.wav when the API returns audio/L16. That format is raw signed 16-bit PCM, so the script adds a standard 44-byte WAV header using the sample rate, channel count, and bit depth supplied by the response. For encoded formats such as MP3 or OGG, it writes the decoded bytes directly.

Why keep originalText?

For a simple request, text and originalText can be identical. Keeping both fields makes the request compatible with workflows that later transform or annotate the spoken text while retaining the user's original input.

Use another voice

Voice selection lives in the speakers array. For example:

speakers: [{ voiceName: "Puck" }]
Enter fullscreen mode Exit fullscreen mode

The single-speaker shape is intentionally still an array. That means the same endpoint can represent dialogue by assigning voice names to speaker labels:

{
  text: "Speaker A: Ready to ship?\nSpeaker B: Ready.",
  originalText: "Speaker A: Ready to ship?\nSpeaker B: Ready.",
  speakers: [
    { speaker: "Speaker A", voiceName: "Kore" },
    { speaker: "Speaker B", voiceName: "Puck" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Check quota without generating audio

For dashboards or preflight checks, call the quota endpoint:

const response = await fetch(
  "https://flowspeech.io/api/ai/text-to-speech/quota",
  {
    headers: {
      Authorization: `Bearer ${process.env.FLOWSPEECH_API_KEY}`,
      Accept: "application/json",
    },
  }
);

if (!response.ok) {
  throw new Error(`Quota request failed: ${response.status}`);
}

const result = await response.json();
console.log(result.data.quota);
Enter fullscreen mode Exit fullscreen mode

A successful response includes the limit, used amount, remaining amount, reset time, and whether the request is using guest access.

Production notes

Before placing this code in a service:

  1. Keep the API key on the server. Never expose it in browser JavaScript.
  2. Set request timeouts and handle retries only for failures that are actually retryable.
  3. Limit input length before calling the API.
  4. Use unique filenames or object storage for concurrent jobs.
  5. Log status codes and request timing, but never log credentials or full sensitive input.
  6. Validate the returned MIME type and metadata before writing the file.

The CLI is deliberately small, but it covers the integration details that often get skipped in a quick TTS demo: credential isolation, structured errors, base64 decoding, raw PCM handling, and response metadata.

Top comments (0)