From 09305627a83bda1718b83af99d9e8ce5028b0a8a Mon Sep 17 00:00:00 2001 From: luke-speechify <289678208+luke-speechify@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:45:24 +0100 Subject: [PATCH 1/2] feat: add terminal-tts demo A terminal-styled web playground for Speechify text-to-speech: type a 'speechify say' command, hit enter, and the browser posts it to a Node route that holds the API key and returns audio. Ships the real, dependency-free cli/say.mjs so the same thing runs in a genuine shell. Registers the demo as a Vercel Service and workspace package. --- README.md | 1 + demos/terminal-tts/.env.example | 1 + demos/terminal-tts/.gitignore | 5 + demos/terminal-tts/README.md | 66 +++++ demos/terminal-tts/app/api/say/route.ts | 73 ++++++ demos/terminal-tts/app/globals.css | 187 ++++++++++++++ demos/terminal-tts/app/layout.tsx | 21 ++ demos/terminal-tts/app/lib/turnstile.ts | 37 +++ demos/terminal-tts/app/page.tsx | 329 ++++++++++++++++++++++++ demos/terminal-tts/cli/say.mjs | 125 +++++++++ demos/terminal-tts/demo.json | 6 + demos/terminal-tts/next.config.ts | 13 + demos/terminal-tts/package.json | 26 ++ demos/terminal-tts/tsconfig.json | 42 +++ pnpm-lock.yaml | 28 ++ pnpm-workspace.yaml | 1 + site/public/index.html | 4 +- vercel.json | 8 + 18 files changed, 971 insertions(+), 2 deletions(-) create mode 100644 demos/terminal-tts/.env.example create mode 100644 demos/terminal-tts/.gitignore create mode 100644 demos/terminal-tts/README.md create mode 100644 demos/terminal-tts/app/api/say/route.ts create mode 100644 demos/terminal-tts/app/globals.css create mode 100644 demos/terminal-tts/app/layout.tsx create mode 100644 demos/terminal-tts/app/lib/turnstile.ts create mode 100644 demos/terminal-tts/app/page.tsx create mode 100755 demos/terminal-tts/cli/say.mjs create mode 100644 demos/terminal-tts/demo.json create mode 100644 demos/terminal-tts/next.config.ts create mode 100644 demos/terminal-tts/package.json create mode 100644 demos/terminal-tts/tsconfig.json diff --git a/README.md b/README.md index 6434db4..0bcadf5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d | [`demos/voice-agent-showcase/`](./demos/voice-agent-showcase) | Cloudflare Workers | | One page, ten live Voice Agents API demos: calendar booking, policy-bound support, a page copilot, form intake, US outbound calls with a 5-minute cap, a voice gallery, mid-call language handoff, cross-call memory, a grounded knowledge base, and dual-control troubleshooting. | | [`demos/vercel-ai-sdk/`](./demos/vercel-ai-sdk) | TypeScript (Vercel AI SDK) | | Speechify TTS through the Vercel AI SDK's unified `generateSpeech` interface via the official `@speechify/vercel` provider — one-line swap from OpenAI/ElevenLabs, plus word-level speech marks from `providerMetadata`. | | [`demos/puter-txt2speech/`](./demos/puter-txt2speech) | HTML (puter.js) | | Speaks with a Simba 3.2 voice via the Speechify provider in Puter's puter.ai.txt2speech() — one static page, your key configured once on the Puter instance. | +| [`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. | ## Get an API key diff --git a/demos/terminal-tts/.env.example b/demos/terminal-tts/.env.example new file mode 100644 index 0000000..534cec4 --- /dev/null +++ b/demos/terminal-tts/.env.example @@ -0,0 +1 @@ +SPEECHIFY_API_KEY=your_api_key_here diff --git a/demos/terminal-tts/.gitignore b/demos/terminal-tts/.gitignore new file mode 100644 index 0000000..95d1bcb --- /dev/null +++ b/demos/terminal-tts/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.next/ +.env +next-env.d.ts +*.tsbuildinfo diff --git a/demos/terminal-tts/README.md b/demos/terminal-tts/README.md new file mode 100644 index 0000000..7c52a0e --- /dev/null +++ b/demos/terminal-tts/README.md @@ -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 "" [--voice ] [--model ]` — 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) diff --git a/demos/terminal-tts/app/api/say/route.ts b/demos/terminal-tts/app/api/say/route.ts new file mode 100644 index 0000000..4ffc841 --- /dev/null +++ b/demos/terminal-tts/app/api/say/route.ts @@ -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([ + "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 }, + ); + } +} diff --git a/demos/terminal-tts/app/globals.css b/demos/terminal-tts/app/globals.css new file mode 100644 index 0000000..b46a921 --- /dev/null +++ b/demos/terminal-tts/app/globals.css @@ -0,0 +1,187 @@ +:root { + color-scheme: light dark; + --fg: #111; + --bg: #fff; + --muted: #666; + --border: #ddd; + --surface: #f6f6f6; + --accent: #2563eb; +} + +@media (prefers-color-scheme: dark) { + :root { + --fg: #f2f2f2; + --bg: #0c0c0c; + --muted: #999; + --border: #2a2a2a; + --surface: #161616; + --accent: #7aa2ff; + } +} + +/* The terminal itself is always dark, regardless of the page theme. */ +:root { + --term-bg: #0b0e14; + --term-fg: #cbd5e1; + --term-dim: #7d8590; + --term-prompt: #4ade80; + --term-ok: #4ade80; + --term-err: #f87171; + --term-input: #e2e8f0; + --term-bar: #161b22; + --term-border: #21262d; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 2rem 1rem; + background: var(--bg); + color: var(--fg); + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; + line-height: 1.5; +} + +main { + max-width: 46rem; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.masthead h1 { + font-size: 1.6rem; + font-weight: 600; + margin: 0 0 0.4rem; +} + +.masthead p { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.terminal { + border: 1px solid var(--term-border); + border-radius: 0.6rem; + overflow: hidden; + background: var(--term-bg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + cursor: text; +} + +.titlebar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.8rem; + background: var(--term-bar); + border-bottom: 1px solid var(--term-border); +} + +.dot { + width: 0.72rem; + height: 0.72rem; + border-radius: 50%; + display: inline-block; +} + +.dot.red { + background: #ff5f56; +} +.dot.yellow { + background: #ffbd2e; +} +.dot.green { + background: #27c93f; +} + +.titletext { + margin-left: 0.5rem; + color: var(--term-dim); + font-size: 0.78rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.screen { + padding: 1rem; + height: 26rem; + overflow-y: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88rem; + line-height: 1.55; + color: var(--term-fg); +} + +.line { + white-space: pre-wrap; + word-break: break-word; +} + +.line.input { + color: var(--term-input); +} +.line.ok { + color: var(--term-ok); +} +.line.error { + color: var(--term-err); +} +.line.output { + color: var(--term-fg); +} + +.prompt-row { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.prompt { + color: var(--term-prompt); + flex: 0 0 auto; + font-family: inherit; +} + +.cmd { + flex: 1 1 auto; + background: transparent; + border: 0; + outline: none; + color: var(--term-input); + font-family: inherit; + font-size: inherit; + padding: 0; + caret-color: var(--term-prompt); +} + +.cmd::placeholder { + color: var(--term-dim); +} + +.widget { + min-height: 1rem; +} + +.player { + width: 100%; +} + +.foot { + color: var(--muted); + font-size: 0.85rem; + border-top: 1px solid var(--border); + padding-top: 1rem; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; + background: var(--surface); + padding: 0.1rem 0.3rem; + border-radius: 0.25rem; +} diff --git a/demos/terminal-tts/app/layout.tsx b/demos/terminal-tts/app/layout.tsx new file mode 100644 index 0000000..b81bd45 --- /dev/null +++ b/demos/terminal-tts/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import Script from "next/script"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "TTS from your terminal", + description: + "A terminal-styled web playground for Speechify text-to-speech. Type a command, hit enter, hear it back.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + @@ -368,7 +368,7 @@

FAQ