diff --git a/README.md b/README.md index 1019530..43fc0ca 100644 --- a/README.md +++ b/README.md @@ -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 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. | 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..e838375 --- /dev/null +++ b/demos/terminal-tts/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.next/ +.env +next-env.d.ts +*.tsbuildinfo +test-results/ +playwright-report/ +/.playwright/ +.last-run.json 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..cbe2746 --- /dev/null +++ b/demos/terminal-tts/app/globals.css @@ -0,0 +1,284 @@ +/* Speechify brand base — mirrors demos.speechify.ai/site (speechify.ai/brand). + * ABC Diatype is licensed and NOT committed; it is loaded cross-origin from + * speechify.ai/fonts (served with Access-Control-Allow-Origin: *). Monochrome + * palette, thin display type, pill ink buttons, sentence-case voice. + * Paste this block at the TOP of the demo's app/globals.css, then make the + * demo-specific rules below it reference these tokens (no hardcoded colours). */ + +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Thin.woff2") format("woff2"); font-weight: 100; font-style: normal; font-display: swap; } +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Light.woff2") format("woff2"); font-weight: 300; font-style: normal; font-display: swap; } +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Regular.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; } +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Medium.woff2") format("woff2"); font-weight: 500; font-style: normal; font-display: swap; } +@font-face { font-family: "ABCDiatype"; src: url("https://speechify.ai/fonts/ABCDiatype-Bold.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; } + +:root { + --font-sans: "ABCDiatype", ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Code", monospace; + + --surface-page: #ffffff; + --surface-card: #ffffff; + --surface-subtle: #f5f5f5; + --surface-raised: #fafafa; + --text-primary: #0a0a0a; + --text-secondary: #525252; + --text-tertiary: #666666; + --border-subtle: #e5e5e5; + --border-strong: #d1d1d4; + --action: #0a0a0a; + --action-hover: #2a2a2e; + --action-foreground: #fafafa; + --focus-ring: rgba(10, 10, 10, 0.22); + --success: #00c270; + --danger: #b42318; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 9999px; +} + +/* Interactive apps: keep a monochrome dark mapping so night viewers aren't + * blinded. Still monochrome, still on-brand (inverted ink/paper). */ +@media (prefers-color-scheme: dark) { + :root { + --surface-page: #0a0a0a; + --surface-card: #101010; + --surface-subtle: #161616; + --surface-raised: #141414; + --text-primary: #fafafa; + --text-secondary: #b3b3b3; + --text-tertiary: #8a8a8a; + --border-subtle: #262626; + --border-strong: #3a3a3a; + --action: #fafafa; + --action-hover: #e5e5e5; + --action-foreground: #0a0a0a; + --focus-ring: rgba(250, 250, 250, 0.28); + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 4rem 1.5rem; + background: var(--surface-page); + color: var(--text-primary); + font-family: var(--font-sans); + font-weight: 400; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +main { + max-width: 44rem; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +/* Thin display headings, tight tracking, sentence case (author copy in sentence case). */ +h1 { + font-weight: 100; + font-size: clamp(2.25rem, 6vw, 3.5rem); + line-height: 1.02; + letter-spacing: -0.03em; + margin: 0 0 0.5rem; +} +h2 { font-weight: 300; letter-spacing: -0.01em; margin: 0 0 0.5rem; } + +.eyebrow { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-tertiary); + text-transform: none; + margin: 0 0 0.75rem; +} + +.lead { + max-width: 62ch; + color: var(--text-secondary); + font-size: 1.0625rem; + line-height: 1.55; + margin: 0; +} + +a { color: var(--text-primary); text-underline-offset: 2px; } + +code, kbd, samp { font-family: var(--font-mono); } +code { + background: var(--surface-subtle); + border: 1px solid var(--border-subtle); + padding: 0.08em 0.38em; + border-radius: 5px; + font-size: 0.88em; +} + +.card, .step { + background: var(--surface-subtle); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1.25rem; +} + +label { display: block; font-size: 0.9rem; color: var(--text-secondary); margin: 0 0 0.5rem; } + +input[type="text"], input[type="email"], textarea, select { + width: 100%; + font: inherit; + color: var(--text-primary); + background: var(--surface-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 0.65rem 0.8rem; +} +textarea { resize: vertical; } + +input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 1px; +} + +/* Pill buttons — medium weight only, never 600. */ +.btn, button.btn { + font: inherit; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border-radius: var(--radius-pill); + padding: 0.65rem 1.5rem; + border: 1px solid transparent; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} +.btn-primary { background: var(--action); color: var(--action-foreground); } +.btn-primary:hover:not(:disabled) { background: var(--action-hover); } +.btn-outline { background: transparent; color: var(--text-primary); border-color: var(--border-strong); } +.btn-outline:hover:not(:disabled) { background: var(--action); color: var(--action-foreground); border-color: var(--action); } +.btn:disabled { opacity: 0.55; cursor: default; } + +footer { + color: var(--text-tertiary); + font-size: 0.9rem; + border-top: 1px solid var(--border-subtle); + padding-top: 1.25rem; +} +footer a { color: inherit; } + +@media (prefers-reduced-motion: reduce) { + * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } +} + +/* ------------------------------------------------------------------ */ +/* Demo-specific: terminal pane. */ +/* The terminal is intentionally a dark monospace surface, regardless */ +/* of the page theme. Its colours are the brand's inverted ink/paper */ +/* (the dark-mode token values) plus the brand mono font — dark, but */ +/* on-brand and monochrome. Error/prompt use the brand semantic tokens.*/ +:root { + --term-bg: #0a0a0a; + --term-bar: #161616; + --term-border: #262626; + --term-fg: #b3b3b3; + --term-dim: #8a8a8a; + --term-input: #fafafa; + --term-prompt: var(--success); + --term-ok: var(--success); + --term-err: #f87171; +} + +.eyebrow { margin-bottom: 0.5rem; } + +.terminal { + border: 1px solid var(--term-border); + border-radius: var(--radius-lg); + 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; +} + +/* macOS-style window controls — decorative, kept as literal traffic-light hues. */ +.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: var(--font-mono); +} + +.screen { + padding: 1rem; + height: 26rem; + overflow-y: auto; + font-family: var(--font-mono); + 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); } + +/* The terminal input is a bare inline field; suppress the base input chrome. */ +.cmd:focus-visible { outline: none; } + +.widget { min-height: 1rem; } + +.player { width: 100%; } 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