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"
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;
});
Run it with:
node tts.mjs "Welcome to the command-line text-to-speech demo."
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" }]
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" }
]
}
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);
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:
- Keep the API key on the server. Never expose it in browser JavaScript.
- Set request timeouts and handle retries only for failures that are actually retryable.
- Limit input length before calling the API.
- Use unique filenames or object storage for concurrent jobs.
- Log status codes and request timing, but never log credentials or full sensitive input.
- 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)