Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d
| [`demos/slack-bot-speechify/`](./demos/slack-bot-speechify) | TypeScript (Socket Mode) | | A Slack bot that reads every new message in a channel aloud: on each message it synthesizes the text with the Speechify API and posts the MP3 back as a file. Socket Mode means no public tunnel. |
| [`demos/discord-bot-speechify/`](./demos/discord-bot-speechify) | TypeScript (discord.js) | | A Discord slash-command bot: /speak <text> synthesizes the text with the Speechify API and posts the MP3 into the channel. The command registers automatically on first run. |
| [`demos/docs-read-aloud/`](./demos/docs-read-aloud) | TypeScript (zero-dep server) | | A documentation-style page with a Listen button that reads the article aloud. The button POSTs the text to a tiny server route, which synthesizes it with the Speechify API (key stays server-side) and returns the MP3 for the browser to play. Framework-agnostic. |
| [`demos/terminal-tts/`](./demos/terminal-tts) | Next.js | [Open](https://demos.speechify.ai/terminal-tts) | A terminal-styled playground: type a `speechify say` command, hit enter, and hear the Speechify API read it back. Ships with a real dependency-free CLI. |
| [`demos/ivr-ssml/`](./demos/ivr-ssml) | Next.js | [Open](https://demos.speechify.ai/ivr-ssml) | A phone-system playground for getting names, account numbers, and product terms right with SSML. Hear plain vs SSML side by side; the API key stays server-side. |
| [`demos/webpage-audiobook/`](./demos/webpage-audiobook) | Next.js | [Open](https://demos.speechify.ai/webpage-audiobook) | Paste a URL, get narrated audio. The server fetches the article, extracts the text, chunks it on sentence boundaries, and synthesizes each part with the Speechify TTS API. |
<!-- DEMOS:END -->
Expand Down
1 change: 1 addition & 0 deletions demos/terminal-tts/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SPEECHIFY_API_KEY=your_api_key_here
9 changes: 9 additions & 0 deletions demos/terminal-tts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
.next/
.env
next-env.d.ts
*.tsbuildinfo
test-results/
playwright-report/
/.playwright/
.last-run.json
66 changes: 66 additions & 0 deletions demos/terminal-tts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# TTS from your terminal (Next.js)

A terminal-styled web playground for Speechify text-to-speech. Type a command like `speechify say "hello world" --voice geffen_32`, press enter, and the page synthesizes the text and plays it back — printing shell-style output as it goes. The API key stays server-side in a route handler and never reaches the browser.

Pairs with the blog post [Text-to-speech from your terminal: a CLI demo with the Speechify API](https://speechify.ai/blog).

The original idea — reading text aloud straight from the command line — can't be hosted as a shared web page, so this demo is a hostable adaptation: a fake terminal in the browser backed by a real Speechify server route. The genuine CLI ships alongside it in `cli/say.mjs` so you can run the exact same thing in your own shell.

## What you get

- A one-page terminal UI. Supported commands:
- `speechify say "<text>" [--voice <id>] [--model <id>]` — synthesize and play.
- `voices` — list the `simba-3.2` voices.
- `help`, `clear` — the usual.
- Up/Down arrows walk command history.
- Client-side parsing of the quoted text and `--voice` / `--model` flags, then a `POST` to `app/api/say/route.ts` with `{ text, voiceId, model }` and an `x-turnstile-token` header.
- `app/api/say/route.ts` — a Node runtime route that holds the Speechify key, calls `client.audio.speech`, and returns base64 MP3.
- `cli/say.mjs` — the real, dependency-free CLI (native `fetch` + `node:fs`), for the terminal you actually use.

## Run it yourself

```bash
cp .env.example .env # then paste your SPEECHIFY_API_KEY
pnpm install
pnpm dev # http://localhost:8769
```

Open `http://localhost:8769`, type `speechify say "hello from my terminal"`, and press enter. Add `--voice harper_32` or `--model simba-english` to change the output.

## Run it as a real CLI

`cli/say.mjs` is a standalone Node script with zero dependencies — no `npm install`, no SDK. It calls the same Speechify speech endpoint the web route uses and writes an MP3.

```bash
export SPEECHIFY_API_KEY=sk_... # your key
node cli/say.mjs "hello world" # writes say.mp3
node cli/say.mjs "hello world" --voice harper_32 --model simba-3.2 --out hi.mp3
```

Pipe the audio straight to a player instead of a file with `--out -`:

```bash
node cli/say.mjs "hello world" --out - | ffplay -autoexit -nodisp - # ffmpeg
node cli/say.mjs "hello world" --out - | mpv - # mpv
```

On macOS, `afplay` can't read a pipe — write a file first, then play it:

```bash
node cli/say.mjs "hello world" && afplay say.mp3
```

Flags: `--voice` (default `geffen_32`), `--model` (default `simba-3.2`), `--out` (default `say.mp3`, or `-` for stdout). Run `node cli/say.mjs --help` for the summary.

## How the key stays server-side

The web playground never sees `SPEECHIFY_API_KEY`. The browser parses your command, then POSTs the text to the same-origin `app/api/say` route, which runs only on the server and holds the key. `next.config.ts` marks `@speechify/api` as a server-external package so the SDK is never bundled into client JS. The `cli/say.mjs` script reads the key from your own environment — it runs on your machine, not in a browser.

## Where the code came from

The web route wraps the Speechify TypeScript SDK's `client.audio.speech` call in a Next.js handler. The CLI calls the equivalent REST endpoint (`POST https://api.speechify.ai/v1/audio/speech`) directly with `fetch`, which is all the SDK does under the hood for a one-shot synthesis.

## Prerequisites

- Node 20 or newer
- A `SPEECHIFY_API_KEY` from [platform.speechify.ai/api-keys](https://platform.speechify.ai/api-keys)
73 changes: 73 additions & 0 deletions demos/terminal-tts/app/api/say/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { SpeechifyClient, SpeechifyError } from "@speechify/api";
import type { Speechify } from "@speechify/api";
import { verifyTurnstile } from "../../lib/turnstile";

export const runtime = "nodejs";

const client = new SpeechifyClient({ token: process.env.SPEECHIFY_API_KEY });

type Model = Speechify.GetSpeechRequest.Model;

const DEFAULT_VOICE = "geffen_32";
const DEFAULT_MODEL: Model = "simba-3.2";
const MAX_CHARS = 2000;
const MODELS = new Set<Model>([
"simba-3.2",
"simba-3.0",
"simba-english",
"simba-multilingual",
]);

export async function POST(req: Request) {
if (!(await verifyTurnstile(req))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const { text, voiceId, model } = await req.json().catch(() => ({}));

if (typeof text !== "string" || text.trim().length === 0) {
return NextResponse.json(
{ error: "text is required" },
{ status: 400 },
);
}
if (text.length > MAX_CHARS) {
return NextResponse.json(
{ error: `text must be ${MAX_CHARS} characters or fewer` },
{ status: 400 },
);
}

const voice_id = typeof voiceId === "string" && voiceId ? voiceId : DEFAULT_VOICE;
const chosenModel: Model =
typeof model === "string" && MODELS.has(model as Model)
? (model as Model)
: DEFAULT_MODEL;

try {
const speech = await client.audio.speech({
input: text,
voice_id,
audio_format: "mp3",
model: chosenModel,
});
return NextResponse.json({
audio: speech.audio_data,
voiceId: voice_id,
model: chosenModel,
billableCharacters: speech.billable_characters_count,
});
} catch (err) {
if (err instanceof SpeechifyError) {
return NextResponse.json(
{ error: err.message || "Speechify request failed" },
{ status: err.statusCode ?? 502 },
);
}
return NextResponse.json(
{ error: "Synthesis failed" },
{ status: 500 },
);
}
}
Loading