Skip to content

Instantly share code, notes, and snippets.

@a-hariti
Last active February 8, 2026 13:47
Show Gist options
  • Select an option

  • Save a-hariti/8e80f996ec6aeabbdaa07df901e367e5 to your computer and use it in GitHub Desktop.

Select an option

Save a-hariti/8e80f996ec6aeabbdaa07df901e367e5 to your computer and use it in GitHub Desktop.

sayy - OpenAI TTS CLI

Your OS probably comes with TTS, mac has say, windows has SAPI, and linux has espeak or festival.

But they all suck at uttering compelext technical topics as would be the case of an Agent output.

I built this zero dependency CLI tool to leverage OpenAI's TTS API for high-quality speech synthesis.

Features

  • Supports all OpenAI voices (nova, shimmer, echo, onyx, fable, alloy, ash, sage, coral).
  • Adjustable speech rate.
  • Input from command line, file, or stdin.
  • Save to file or play directly using afplay.

Setup

  1. Ensure you have Node.js installed (v20.12+ for dynamic process.loadEnvFile).
  2. Set your OPENAI_API_KEY in a .env file or your environment.
  3. Make the script executable: chmod +x sayy.

Usage

./sayy "Hello, how are you today?"
./sayy -v alloy -r 1.2 "This is a faster voice."
cat text.txt | ./sayy

Put the it in your PATH for global access (example)

ln -s ./sayy /usr/local/bin/sayy

Verify and torture it with a complex, multi-line briefing:

sayy <<'EOF'
### Deployment Summary for `v2.4.0-rc1`

I have completed the audit of the following artifacts:
- `src/middleware/auth_v3.ts`: Optimized the JWT verification loop.
- `pkg/db/pool.go`: Fixed a connection leak during high-concurrency spikes.
- `deploy/k8s/service.yaml`: Updated resource limits for the `api-gateway` pod.
EOF

Then you can ask you Agents to use somewhere in AGENTS.md:

- When you're done, brief me with the `sayy` command.

Code

sayy

#!/usr/bin/env node
//@ts-check
import { parseArgs } from "node:util";
import { join } from "node:path";
import { unlink } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { existsSync, readFileSync, writeFileSync } from "node:fs";

const MODEL = "gpt-4o-mini-tts";
function printHelp() {
  console.log(`
Usage: sayy [options] [text]

Options:

  -v, --voice <voice>    Specify the voice (nova, shimmer, echo, onyx, fable, alloy, ash, sage or coral)
  -r, --rate <rate>      Speed of the speech (0.25 to 4.0, default: 1.0)
  -f, --file <path>      Read text from the specified file
  -o, --output <path>    Save audio to a file instead of playing it
  -h, --help             Show this help message
`);
}

// 1. Load .env or ../.env
const localEnv = join(import.meta.dirname, ".env");
const parentEnv = join(import.meta.dirname, "../.env");
try {
  if (existsSync(localEnv)) process.loadEnvFile(localEnv);
  else if (existsSync(parentEnv)) process.loadEnvFile(parentEnv);
} catch (e) {
  /* ignore */
}

const OPENAI_API_KEY = process.env.OPENAI_API_KEY;

// 2. Parse Args
const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  options: {
    voice: { type: "string", short: "v", default: "shimmer" },
    rate: { type: "string", short: "r", default: "1.0" },
    file: { type: "string", short: "f" },
    output: { type: "string", short: "o" },
    help: { type: "boolean", short: "h" },
  },
  allowPositionals: true,
});

if (values.help) {
  printHelp();
  process.exit(0);
}

if (!OPENAI_API_KEY) {
  console.error("Error: OPENAI_API_KEY not found in .env");
  process.exit(1);
}

async function main() {
  try {
    let text = positionals.join(" ");

    // Handle file input
    if (values.file) {
      try {
        text = readFileSync(values.file, "utf8");
      } catch (e) {
        console.error(`Error reading file: ${values.file}`);
        process.exit(1);
      }
    } else if (!text && !process.stdin.isTTY) {
      try {
        text = readFileSync(process.stdin.fd, "utf8");
      } catch (e) {
        console.error("Error reading from stdin");
      }
    }

    if (!text?.trim()) {
      printHelp();
      return;
    }

    // 3. Fetch Audio
    const response = await fetch("https://api.openai.com/v1/audio/speech", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${OPENAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: MODEL,
        input: text,
        voice: values.voice,
        speed: parseFloat(values.rate),
        response_format: "mp3",
      }),
    });

    if (!response.ok) {
      const err = await response.text();
      throw new Error(`API Error: ${response.status} ${err}`);
    }

    const buffer = Buffer.from(await response.arrayBuffer());

    // 4. Output or Play
    if (values.output) {
      writeFileSync(values.output, buffer);
    } else {
      const tempFile = join(tmpdir(), `sayy-${Date.now()}.mp3`);
      writeFileSync(tempFile, buffer);
      spawnSync("afplay", [tempFile], { stdio: "inherit" });
      unlink(tempFile).catch(() => {});
    }
  } catch (error) {
    console.error(error);
    process.exit(1);
  }
}

main();

package.json

For type safety (optional)

{
  "name": "say",
  "type": "module",
  "private": true,
  "devDependencies": {
    "@types/node": "latest"
  },
  "peerDependencies": {
    "typescript": "^5"
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment