diff --git a/README.md b/README.md index 1019530..c2e0103 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Demos with a **Live** link run in your browser at [demos.speechify.ai](https://d | [`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/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/multilingual-tts/`](./demos/multilingual-tts) | Next.js | [Open](https://demos.speechify.ai/multilingual-tts) | Pick a language, hear it in a native voice. One request to the Speechify TTS API with a `language` parameter, six languages wired up and 30+ available. | | [`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/multilingual-tts/.env.example b/demos/multilingual-tts/.env.example new file mode 100644 index 0000000..534cec4 --- /dev/null +++ b/demos/multilingual-tts/.env.example @@ -0,0 +1 @@ +SPEECHIFY_API_KEY=your_api_key_here diff --git a/demos/multilingual-tts/.gitignore b/demos/multilingual-tts/.gitignore new file mode 100644 index 0000000..ad644c6 --- /dev/null +++ b/demos/multilingual-tts/.gitignore @@ -0,0 +1,6 @@ +node_modules +.next +.env +.env.local +test-results +playwright-report diff --git a/demos/multilingual-tts/README.md b/demos/multilingual-tts/README.md new file mode 100644 index 0000000..785e4db --- /dev/null +++ b/demos/multilingual-tts/README.md @@ -0,0 +1,53 @@ +# multilingual-tts + +Read text in any of 30+ languages with the Speechify TTS API, one native voice per language. Pick a language, hear it spoken by a voice native to that locale. + +Pairs with the speechify.ai post **"Text-to-speech in 30+ languages with the Speechify API."** + +## What it does + +- A one-page Next.js playground with a language picker and a text box. Choose a language, press **Speak**, and the text is synthesized in a voice native to that locale. +- `POST /api/speak` (Node runtime) calls Speechify's `POST /v1/audio/speech` with `model: "simba-3.0"`, `audio_format: "mp3"`, and the `language` parameter, then returns the audio as base64. +- The whole feature is that one `language` field. `simba-3.0` is the multilingual, streaming-native model. + +## The six wired-up languages + +Each uses a voice native to the locale (from the public catalogue). `simba-3.0` officially supports these; the catalogue carries 30+ more locales. + +| Language | Locale | Voice | +| --- | --- | --- | +| English | `en-US` | `alfonso` | +| German | `de-DE` | `amalia` | +| Spanish | `es-MX` | `aitana` | +| French | `fr-FR` | `adeline` | +| Italian | `it-IT` | `alessia` | +| Portuguese (Brazil) | `pt-BR` | `adriana` | + +To add a language: pick a voice for the locale at [platform.speechify.ai](https://platform.speechify.ai), then add a row to `LANGUAGES` in `app/page.tsx` and to `ALLOWED_LANGUAGES` in `app/api/speak/route.ts`. + +## Notes + +- `simba-3.2` and `simba-english` are English-only. Non-English synthesis uses `simba-3.0` (or `simba-multilingual` on an API version pinned before `2026-09-21`, which sunsets `2026-11-21`). +- The Speechify API key stays server-side. The browser only ever calls this app's own `/api/speak` route. +- Abuse protection is Cloudflare Turnstile, which fails open when `TURNSTILE_SECRET_KEY` is unset (local dev). + +## Run it + +```bash +cp .env.example .env # add your SPEECHIFY_API_KEY +pnpm install +pnpm dev # http://localhost:8781/multilingual-tts +``` + +End-to-end test against the live API (needs `SPEECHIFY_API_KEY`): + +```bash +pnpm e2e +``` + +## Environment + +| Variable | Required | Purpose | +| --- | --- | --- | +| `SPEECHIFY_API_KEY` | yes | Server-side Speechify API key | +| `TURNSTILE_SECRET_KEY` | no | Enables the Turnstile abuse gate; fails open when unset | diff --git a/demos/multilingual-tts/app/api/speak/route.ts b/demos/multilingual-tts/app/api/speak/route.ts new file mode 100644 index 0000000..04e6a2e --- /dev/null +++ b/demos/multilingual-tts/app/api/speak/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { verifyTurnstile } from "../../lib/turnstile"; + +export const runtime = "nodejs"; + +// The whole feature is one field: `language`. We call the REST speech endpoint +// directly (rather than the Node SDK) so the `language` parameter passes +// through verbatim and the demo stays dependency-light. `simba-3.0` is the +// multilingual, streaming-native model; each language uses a voice native to +// that locale (see app/page.tsx). Omit `language` and the model infers it from +// the voice's own locale, but we send it to show the parameter doing the work. +const SPEECH_URL = "https://api.speechify.ai/v1/audio/speech"; + +// Locales this demo wires up, kept in sync with the picker in app/page.tsx. +const ALLOWED_LANGUAGES = new Set([ + "en-US", + "de-DE", + "es-MX", + "fr-FR", + "it-IT", + "pt-BR", +]); + +export async function POST(req: Request) { + if (!(await verifyTurnstile(req))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { text, voiceId, language } = (await req.json().catch(() => ({}))) as { + text?: unknown; + voiceId?: unknown; + language?: unknown; + }; + + if (typeof text !== "string" || !text.trim()) { + return NextResponse.json({ error: "text is required" }, { status: 400 }); + } + if (typeof voiceId !== "string" || !voiceId) { + return NextResponse.json({ error: "voiceId is required" }, { status: 400 }); + } + if (typeof language !== "string" || !ALLOWED_LANGUAGES.has(language)) { + return NextResponse.json( + { error: "unsupported language" }, + { status: 400 }, + ); + } + + const upstream = await fetch(SPEECH_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.SPEECHIFY_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + input: text.slice(0, 2000), + voice_id: voiceId, + model: "simba-3.0", + audio_format: "mp3", + language, + }), + }); + + if (!upstream.ok) { + const detail = await upstream.text().catch(() => ""); + return NextResponse.json( + { error: detail || "Speechify request failed" }, + { status: upstream.status || 502 }, + ); + } + + const data = (await upstream.json()) as { audio_data?: string }; + return NextResponse.json({ audio: data.audio_data }); +} diff --git a/demos/multilingual-tts/app/globals.css b/demos/multilingual-tts/app/globals.css new file mode 100644 index 0000000..a14bba2 --- /dev/null +++ b/demos/multilingual-tts/app/globals.css @@ -0,0 +1,114 @@ +/* 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. */ + +@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; +} + +@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; } + +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; font-size: 1.125rem; } + +.eyebrow { font-family: var(--font-mono); font-size: 13px; font-weight: 500; letter-spacing: 0.02em; color: var(--text-tertiary); 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; } + +.step { display: flex; flex-direction: column; gap: 0.5rem; } + +select, textarea { + width: 100%; + font-family: var(--font-sans); + font-size: 1rem; + color: var(--text-primary); + background: var(--surface-raised); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + padding: 0.75rem 0.875rem; +} +textarea { resize: vertical; line-height: 1.5; } +select:focus, textarea:focus { outline: none; border-color: var(--action); box-shadow: 0 0 0 3px var(--focus-ring); } + +.play { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } + +.btn { + font-family: var(--font-sans); + font-size: 1rem; + font-weight: 500; + border: none; + border-radius: var(--radius-pill); + padding: 0.75rem 1.75rem; + cursor: pointer; +} +.btn-primary { background: var(--action); color: var(--action-foreground); } +.btn-primary:hover:not(:disabled) { background: var(--action-hover); } +.btn:disabled { opacity: 0.55; cursor: default; } + +.err { color: var(--danger); font-size: 0.9375rem; margin: 0; } + +footer { border-top: 1px solid var(--border-subtle); padding-top: 1.25rem; color: var(--text-secondary); font-size: 0.9375rem; } +footer p { margin: 0; } diff --git a/demos/multilingual-tts/app/layout.tsx b/demos/multilingual-tts/app/layout.tsx new file mode 100644 index 0000000..3f48410 --- /dev/null +++ b/demos/multilingual-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: "Text-to-speech in 30+ languages with Speechify", + description: + "Pick a language and hear it in a native voice. One Speechify TTS request with a language parameter; the API key stays server-side.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + @@ -368,7 +368,7 @@

FAQ