├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── eslint.config.mjs ├── global.d.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── click.wav ├── favicon.ico ├── favicon.png ├── favicon.svg ├── og-image.png ├── pressed.wav ├── screenshot.jpg └── worker-444eae9e2e1bdd6edd8969f319655e70.js ├── src ├── app │ ├── api │ │ ├── generate │ │ │ └── route.ts │ │ └── share │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── tmp.middleware.ts ├── components │ ├── ClientDynamicTTS.tsx │ ├── CodeCopyButton.tsx │ ├── DownloadButton.tsx │ ├── PlayButton.tsx │ ├── ShareButton.tsx │ ├── ShareDialog.tsx │ ├── TTSPage.tsx │ └── ui │ │ ├── Block.tsx │ │ ├── BrowserNotSupported.tsx │ │ ├── Button.module.css │ │ ├── Button.tsx │ │ ├── DevMode.module.css │ │ ├── DevMode.tsx │ │ ├── Footer.module.css │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── Icons.tsx │ │ ├── Switcher.module.css │ │ └── Switcher.tsx ├── hooks │ ├── useAudioClip.tsx │ ├── useBodyScrollable.tsx │ ├── useCopiedDelay.tsx │ └── useServiceWorker.ts └── lib │ ├── codeSnippet.ts │ ├── copyText.ts │ ├── library.ts │ ├── store.ts │ └── types.ts ├── tailwind.config.mjs └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_URL="?sslmode=require" 2 | POSTGRES_PRISMA_URL="?sslmode=require&pgbouncer=true&connect_timeout=15" 3 | POSTGRES_URL_NO_SSL="" 4 | POSTGRES_URL_NON_POOLING="?sslmode=require" 5 | POSTGRES_USER="default" 6 | POSTGRES_HOST="" 7 | POSTGRES_PASSWORD="" 8 | POSTGRES_DATABASE="" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files 34 | .env* 35 | !.env.example 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 OpenAI 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAI.fm 2 | 3 | [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) 4 | ![NextJS](https://img.shields.io/badge/Built_with-NextJS-blue) 5 | ![OpenAI API](https://img.shields.io/badge/Powered_by-OpenAI_API-orange) 6 | 7 | [OpenAI.fm](https://openai.fm) is an interactive demo to showcase the new OpenAI text-to-speech models. 8 | It is built with NextJS and the [Speech API](https://platform.openai.com/docs/api-reference/audio/createSpeech). 9 | 10 | For more information about text-to-speech using the OpenAI API, check out our [documentation](https://platform.openai.com/docs/guides/text-to-speech). 11 | 12 | ![screenshot](./public/screenshot.jpg) 13 | 14 | ## How to run 15 | 16 | 1. **Set up the OpenAI API:** 17 | 18 | - If you're new to the OpenAI API, [sign up for an account](https://platform.openai.com/signup). 19 | - Follow the [Quickstart](https://platform.openai.com/docs/quickstart) to retrieve your API key. 20 | 21 | 2. **Clone the Repository:** 22 | 23 | ```bash 24 | git clone https://github.com/openai/openai-fm.git 25 | ``` 26 | 27 | 3. **Set the OpenAI API key:** 28 | 29 | 2 options: 30 | 31 | - Set the `OPENAI_API_KEY` environment variable [globally in your system](https://platform.openai.com/docs/libraries#create-and-export-an-api-key) 32 | - Set the `OPENAI_API_KEY` environment variable in the project: Create a `.env` file at the root of the project and add the following line (see `.env.example` for reference): 33 | 34 | ```bash 35 | OPENAI_API_KEY= 36 | ``` 37 | 38 | 4. **Install dependencies:** 39 | 40 | Run in the project root: 41 | 42 | ```bash 43 | npm install 44 | ``` 45 | 46 | 5. **(Optional) Connect to a hosted database:** 47 | 48 | If you want to use the sharing feature, you need to connect to a hosted postgres database. 49 | You should set the environment variables in a `.env` file at the root of the project to connect to your database as shown in `.env.example`. 50 | 51 | ```bash 52 | POSTGRES_URL="postgresql://username:password@host:port/database_name" 53 | ``` 54 | 55 | This step is not needed to run the application and only affects the sharing feature. 56 | 57 | 6. **Run the app:** 58 | 59 | ```bash 60 | npm run dev 61 | ``` 62 | 63 | The app will be available at [`http://localhost:3000`](http://localhost:3000). 64 | 65 | > [!NOTE] 66 | > Be aware that if you deploy this app to a public server, you are responsible for any usage it may incur using your OpenAI API key. 67 | 68 | ## Contributors 69 | 70 | ### OpenAI team 71 | 72 | - [Tyler Smith](https://github.com/tylersmith-openai) 73 | - [Karolis Kosas](https://github.com/karoliskosas) 74 | - [Justin Jay Wang](https://github.com/justinjaywang) 75 | - [Bobby Stocker](https://github.com/stocker-openai) 76 | - [Jeff Harris](https://github.com/jeffsharris) 77 | - [Romain Huet](https://github.com/romainhuet) 78 | - [David Weedon](https://github.com/weedon-openai) 79 | - [Iaroslav Tverdokhlib](https://github.com/itv-openai) 80 | - [Adam Walker](https://github.com/awalker-openai) 81 | - [Edwin Arbus](https://x.com/edwinarbus) 82 | - [Katia Gil Guzman](https://github.com/katia-openai) 83 | 84 | ### Contributing 85 | 86 | You are welcome to open issues or submit PRs to improve this app, however, please note that we may not review all suggestions. 87 | 88 | ## License 89 | 90 | This project is licensed under the MIT License. See the LICENSE file for details. 91 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | import * as React from "react"; 3 | const ReactComponent: React.FunctionComponent>; 4 | export default ReactComponent; 5 | } 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const IS_DEV = process.env.NODE_ENV !== "production"; 4 | 5 | const CSP = [ 6 | "default-src 'self';", 7 | "script-src 'self' 'unsafe-eval' 'unsafe-inline' ;", 8 | "connect-src 'self';", 9 | "style-src 'self' 'unsafe-inline';", 10 | "img-src 'self'", 11 | "object-src 'none';", 12 | "frame-ancestors 'none';", 13 | IS_DEV ? null : "upgrade-insecure-requests;", 14 | ] 15 | .filter(Boolean) 16 | .join(" "); 17 | 18 | const nextConfig: NextConfig = { 19 | turbopack: { 20 | rules: { 21 | "*.svg": { 22 | loaders: ["@svgr/webpack"], 23 | as: "*.js", 24 | }, 25 | }, 26 | }, 27 | devIndicators: false, 28 | 29 | // Apply the same SVG transform to the webpack-based production build 30 | webpack(config) { 31 | config.module.rules.push({ 32 | test: /\.svg$/i, 33 | issuer: /\.[jt]sx?$/, 34 | use: ["@svgr/webpack"], 35 | }); 36 | return config; 37 | }, 38 | async headers() { 39 | const headers = [ 40 | { 41 | key: "Content-Security-Policy", 42 | value: CSP, 43 | }, 44 | ]; 45 | 46 | if (!IS_DEV) { 47 | headers.push({ 48 | key: "Strict-Transport-Security", 49 | value: "max-age=31536000; includeSubDomains; preload", 50 | }); 51 | } 52 | 53 | return [{ source: "/(.*)", headers }]; 54 | }, 55 | }; 56 | 57 | export default nextConfig; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openai-fm", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@codemirror/lang-javascript": "^6.2.3", 13 | "@codemirror/lang-python": "^6.1.7", 14 | "@codemirror/legacy-modes": "^6.5.0", 15 | "@radix-ui/react-icons": "^1.3.2", 16 | "@radix-ui/react-switch": "^1.1.3", 17 | "@radix-ui/react-toast": "^1.2.6", 18 | "@uiw/codemirror-theme-duotone": "^4.23.10", 19 | "@uiw/react-codemirror": "^4.23.10", 20 | "@vercel/postgres": "^0.10.0", 21 | "clsx": "^2.1.1", 22 | "immer": "^10.1.1", 23 | "lz-string": "^1.5.0", 24 | "next": "^15.3.0", 25 | "radix-ui": "^1.1.3", 26 | "react": "^19.0.0", 27 | "react-dom": "^19.0.0", 28 | "react-hook-form": "^7.54.2", 29 | "wavesurfer.js": "^7.9.1", 30 | "zustand": "^5.0.3" 31 | }, 32 | "devDependencies": { 33 | "@eslint/eslintrc": "^3", 34 | "@tailwindcss/postcss": "^4", 35 | "@types/node": "^20", 36 | "@types/react": "^19", 37 | "@types/react-dom": "^19", 38 | "eslint": "^9", 39 | "eslint-config-next": "15.2.2", 40 | "tailwindcss": "^4", 41 | "typescript": "^5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/click.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/openai-fm/5ea7fa62ecb2eb8f3035f767f6bfbf56c34aff93/public/click.wav -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/openai-fm/5ea7fa62ecb2eb8f3035f767f6bfbf56c34aff93/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/openai-fm/5ea7fa62ecb2eb8f3035f767f6bfbf56c34aff93/public/favicon.png -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/openai-fm/5ea7fa62ecb2eb8f3035f767f6bfbf56c34aff93/public/og-image.png -------------------------------------------------------------------------------- /public/pressed.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/openai-fm/5ea7fa62ecb2eb8f3035f767f6bfbf56c34aff93/public/pressed.wav -------------------------------------------------------------------------------- /public/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/openai-fm/5ea7fa62ecb2eb8f3035f767f6bfbf56c34aff93/public/screenshot.jpg -------------------------------------------------------------------------------- /public/worker-444eae9e2e1bdd6edd8969f319655e70.js: -------------------------------------------------------------------------------- 1 | self.addEventListener("install", () => { 2 | self.skipWaiting(); 3 | }); 4 | 5 | self.addEventListener("activate", (event) => { 6 | event.waitUntil(self.clients.claim()); 7 | }); 8 | 9 | self.addEventListener("fetch", (event) => { 10 | /** @type {Request} */ 11 | const request = event.request; 12 | if (request.method === "GET" && request.url.includes("/api/generate")) { 13 | const originalUrl = request.url; 14 | const url = new URL(request.url); 15 | 16 | const formData = new FormData(); 17 | formData.append("input", url.searchParams.get("input")); 18 | formData.append("prompt", url.searchParams.get("prompt")); 19 | formData.append("voice", url.searchParams.get("voice")); 20 | formData.append("vibe", url.searchParams.get("vibe")); 21 | 22 | url.search = ""; 23 | 24 | event.respondWith( 25 | fetch(url.toString(), { 26 | method: "POST", 27 | body: formData, 28 | }).then((networkResponse) => { 29 | if ( 30 | !networkResponse || 31 | !networkResponse.ok || 32 | request.headers.get("range") === "bytes=0-1" 33 | ) { 34 | return networkResponse; 35 | } 36 | 37 | const responseClone = networkResponse.clone(); 38 | 39 | self.clients.matchAll({ includeUncontrolled: true }).then((clients) => { 40 | responseClone.blob().then((blob) => { 41 | clients.forEach((client) => { 42 | client.postMessage({ 43 | type: "ADD_TO_CACHE", 44 | url: originalUrl, 45 | blob: blob, 46 | }); 47 | }); 48 | }); 49 | }); 50 | 51 | return networkResponse; 52 | }) 53 | ); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /src/app/api/generate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, userAgent } from "next/server"; 2 | 3 | export const MAX_INPUT_LENGTH = 1000; 4 | export const MAX_PROMPT_LENGTH = 1000; 5 | 6 | // GET handler that proxies requests to the OpenAI TTS API and streams 7 | // the response back to the client. 8 | import { VOICES } from "@/lib/library"; 9 | export async function GET(req: NextRequest) { 10 | const { searchParams } = new URL(req.url); 11 | 12 | const ua = userAgent(req); 13 | const response_format = ua.engine?.name === "Blink" ? "wav" : "mp3"; 14 | 15 | // Get parameters from the query string 16 | let input = searchParams.get("input") || ""; 17 | let prompt = searchParams.get("prompt") || ""; 18 | const voice = searchParams.get("voice") || ""; 19 | const vibe = searchParams.get("vibe") || "audio"; 20 | 21 | // Truncate input and prompt to max 1000 characters 22 | // Frontend handles this, but we'll do it here too 23 | // to avoid extra requests to the server 24 | input = input.slice(0, MAX_INPUT_LENGTH); 25 | prompt = prompt.slice(0, MAX_PROMPT_LENGTH); 26 | 27 | if (!VOICES.includes(voice)) { 28 | return new Response("Invalid voice", { status: 400 }); 29 | } 30 | 31 | try { 32 | const apiResponse = await fetch("https://api.openai.com/v1/audio/speech", { 33 | method: "POST", 34 | headers: { 35 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 36 | "Content-Type": "application/json", 37 | }, 38 | body: JSON.stringify({ 39 | model: "gpt-4o-mini-tts", 40 | input, 41 | response_format, 42 | voice, 43 | // Don't pass if empty 44 | ...(prompt && { instructions: prompt }), 45 | }), 46 | }); 47 | if (!apiResponse.ok) { 48 | return new Response(`An error occurred while generating the audio.`, { 49 | status: apiResponse.status, 50 | }); 51 | } 52 | 53 | const filename = `openai-fm-${voice}-${vibe}.${response_format}`; 54 | 55 | // Stream response back to client. 56 | return new Response(apiResponse.body, { 57 | headers: { 58 | "Content-Type": response_format === "wav" ? "audio/wav" : "audio/mpeg", 59 | "Content-Disposition": `inline; filename="${filename}"`, 60 | "Cache-Control": "no-cache", 61 | }, 62 | }); 63 | } catch (err) { 64 | console.error("Error generating speech:", err); 65 | return new Response("Error generating speech", { 66 | status: 500, 67 | }); 68 | } 69 | } 70 | 71 | export async function POST(req: NextRequest) { 72 | const ua = userAgent(req); 73 | const response_format = ua.engine?.name === "Blink" ? "wav" : "mp3"; 74 | 75 | const formData = await req.formData(); 76 | let input = formData.get("input")?.toString() || ""; 77 | let prompt = formData.get("prompt")?.toString() || ""; 78 | const voice = formData.get("voice")?.toString() || ""; 79 | const vibe = formData.get("vibe") || "audio"; 80 | 81 | // Truncate input and prompt to max 1000 characters 82 | // Frontend handles this, but we'll do it here too 83 | // to avoid extra requests to the server 84 | input = input.slice(0, MAX_INPUT_LENGTH); 85 | prompt = prompt.slice(0, MAX_PROMPT_LENGTH); 86 | 87 | if (!VOICES.includes(voice)) { 88 | return new Response("Invalid voice", { status: 400 }); 89 | } 90 | 91 | try { 92 | const apiResponse = await fetch("https://api.openai.com/v1/audio/speech", { 93 | method: "POST", 94 | headers: { 95 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 96 | "Content-Type": "application/json", 97 | }, 98 | body: JSON.stringify({ 99 | model: "gpt-4o-mini-tts", 100 | input, 101 | response_format, 102 | voice, 103 | // Don't pass if empty 104 | ...(prompt && { instructions: prompt }), 105 | }), 106 | }); 107 | if (!apiResponse.ok) { 108 | return new Response(`An error occurred while generating the audio.`, { 109 | status: apiResponse.status, 110 | }); 111 | } 112 | 113 | const filename = `openai-fm-${voice}-${vibe}.${response_format}`; 114 | 115 | // Stream response back to client. 116 | return new Response(apiResponse.body, { 117 | headers: { 118 | "Content-Type": response_format === "wav" ? "audio/wav" : "audio/mpeg", 119 | "Content-Disposition": `inline; filename="${filename}"`, 120 | "Cache-Control": "no-cache", 121 | }, 122 | }); 123 | } catch (err) { 124 | console.error("Error generating speech:", err); 125 | return new Response("Error generating speech", { 126 | status: 500, 127 | }); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/app/api/share/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | import { sql } from "@vercel/postgres"; 3 | import { MAX_PROMPT_LENGTH, MAX_INPUT_LENGTH } from "../generate/route"; 4 | 5 | // Saves shared links to the database 6 | export async function POST(req: NextRequest) { 7 | try { 8 | const { input, prompt, voice } = await req.json(); 9 | const clippedInput = input.slice(0, MAX_INPUT_LENGTH); 10 | const clippedPrompt = prompt.slice(0, MAX_PROMPT_LENGTH); 11 | const id = crypto.randomUUID(); 12 | await sql`INSERT INTO shares (id, input, prompt, voice) VALUES (${id}, ${ 13 | clippedInput ?? "" 14 | }, ${clippedPrompt ?? ""}, ${voice ?? ""});`; 15 | return Response.json({ id }); 16 | } catch (err) { 17 | console.error("Error storing share params:", err); 18 | return new Response("An error ocurred.", { status: 500 }); 19 | } 20 | } 21 | 22 | export async function GET(req: NextRequest) { 23 | try { 24 | const url = new URL(req.url); 25 | const hash = url.searchParams.get("hash"); 26 | if (!hash) { 27 | return new Response("Not found", { status: 404 }); 28 | } 29 | const { rows } = await sql<{ 30 | input: string; 31 | prompt: string; 32 | voice: string; 33 | }>`SELECT input, prompt, voice FROM shares WHERE id = ${hash};`; 34 | if (rows.length === 0) { 35 | return new Response("Not found", { status: 404 }); 36 | } 37 | return Response.json(rows[0]); 38 | } catch (err) { 39 | console.error("Error retrieving share params:", err); 40 | return new Response("An error ocurred.", { status: 500 }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ececec; 5 | --foreground: #222; 6 | --primary: #ff4a00; 7 | --page-max-width: 1300px; 8 | } 9 | 10 | @theme inline { 11 | --color-background: var(--background); 12 | --color-foreground: var(--foreground); 13 | --color-primary: var(--primary); 14 | --color-screen: #f3f3f3; 15 | --color-black-10: rgba(0, 0, 0, 0.1); 16 | --color-black-50: rgba(0, 0, 0, 0.5); 17 | } 18 | 19 | @theme static { 20 | --radius-xs: 0.125rem; 21 | --radius-sm: 0.25rem; 22 | --radius-md: 0.375rem; 23 | --radius-lg: 0.5rem; 24 | --radius-xl: 0.75rem; 25 | --radius-2xl: 1rem; 26 | --radius-3xl: 1.5rem; 27 | --radius-4xl: 2rem; 28 | 29 | --shadow-textarea: rgba(0, 0, 0, 0.15) 0px 1px 2px 0px inset, 30 | rgba(0, 0, 0, 0.08) 1px -2px 2px 0px inset; 31 | } 32 | 33 | html { 34 | font-size: 14px; 35 | line-height: 130%; 36 | } 37 | 38 | body { 39 | background-color: var(--background); 40 | color: var(--foreground); 41 | 42 | @variant sm { 43 | background-image: linear-gradient( 44 | to bottom right, 45 | #f8f8f8, 46 | var(--background) 20% 47 | ); 48 | background-repeat: no-repeat; 49 | } 50 | } 51 | 52 | svg { 53 | display: block; 54 | flex-shrink: 0; 55 | } 56 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { JetBrains_Mono } from "next/font/google"; 3 | import clsx from "clsx"; 4 | import "./globals.css"; 5 | 6 | const jetBrainsMono = JetBrains_Mono({ 7 | weight: "400", 8 | subsets: [], 9 | preload: true, 10 | }); 11 | 12 | export const metadata: Metadata = { 13 | metadataBase: new URL('https://www.openai.fm'), 14 | title: "OpenAI.fm", 15 | description: "An interactive demo for developers to try the new text-to-speech model in the OpenAI API", 16 | authors: [{ name: "OpenAI" }], 17 | openGraph: { 18 | title: "OpenAI.fm", 19 | description: "An interactive demo for developers to try the new text-to-speech model in the OpenAI API", 20 | images: [ 21 | { 22 | url: "/og-image.png", 23 | width: 1200, 24 | height: 630, 25 | alt: "OpenAI.fm, a text-to-speech demo", 26 | }, 27 | ], 28 | }, 29 | twitter: { 30 | card: "summary_large_image", 31 | }, 32 | }; 33 | 34 | export default function RootLayout({ 35 | children, 36 | }: Readonly<{ 37 | children: React.ReactNode; 38 | }>) { 39 | return ( 40 | 41 | 42 | {children} 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import ClientDynamicTTS from "@/components/ClientDynamicTTS"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/tmp.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | /** 3 | * Middleware that: 4 | * 1. Generates a one‑time nonce for this request, 5 | * 2. Builds a strict Content Security Policy (CSP) using that nonce, 6 | * 3. Attaches the nonce and CSP to both the incoming request and outgoing response, 7 | * 4. Applies only to “real” page navigations (skips API, static files, prefetches). 8 | */ 9 | export function middleware(request: NextRequest) { 10 | // 1. Create a unique, Base64‑encoded value for this request 11 | const nonce = btoa(crypto.randomUUID()); 12 | 13 | // 2. Build a strict Content Security Policy (CSP) using that nonce 14 | const cspHeader = ` 15 | default-src 'self'; 16 | script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; 17 | style-src 'self' 'nonce-${nonce}'; 18 | img-src 'self' blob: data:; 19 | font-src 'self'; 20 | object-src 'none'; 21 | base-uri 'self'; 22 | form-action 'self'; 23 | frame-ancestors 'none'; 24 | upgrade-insecure-requests; 25 | `; 26 | // 3. Replace newline characters and spaces so it can be used in the header 27 | const contentSecurityPolicyHeaderValue = cspHeader 28 | .replace(/\s{2,}/g, " ") 29 | .trim(); 30 | 31 | // 4. Attach the nonce and CSP to the incoming request 32 | const requestHeaders = new Headers(request.headers); 33 | requestHeaders.set("x-nonce", nonce); 34 | requestHeaders.set( 35 | "Content-Security-Policy", 36 | contentSecurityPolicyHeaderValue 37 | ); 38 | 39 | // 5. Create a new response object with the updated headers 40 | const response = NextResponse.next({ 41 | request: { 42 | headers: requestHeaders, 43 | }, 44 | }); 45 | 46 | // 6. Attach the CSP to the outgoing response 47 | response.headers.set( 48 | "Content-Security-Policy", 49 | contentSecurityPolicyHeaderValue 50 | ); 51 | 52 | return response; 53 | } 54 | 55 | export const config = { 56 | matcher: [ 57 | /* 58 | * Match all request paths except for the ones starting with: 59 | * - api (API routes) 60 | * - _next/static (static files) 61 | * - _next/image (image optimization files) 62 | * - favicon.ico (favicon file) 63 | */ 64 | { 65 | source: "/((?!api|_next/static|_next/image|favicon.ico).*)", 66 | missing: [ 67 | { type: "header", key: "next-router-prefetch" }, 68 | { type: "header", key: "purpose", value: "prefetch" }, 69 | ], 70 | }, 71 | ], 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/ClientDynamicTTS.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import dynamic from "next/dynamic"; 3 | 4 | const DynamicComponentWithNoSSR = dynamic( 5 | () => import("@/components/TTSPage"), 6 | { ssr: false } 7 | ); 8 | 9 | export default function ClientDynamicTTS() { 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/CodeCopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { Copy, Check } from "./ui/Icons"; 2 | import { Button } from "./ui/Button"; 3 | import { copyText } from "../lib/copyText"; 4 | import { appStore } from "@/lib/store"; 5 | import { getCodeSnippet } from "@/lib/codeSnippet"; 6 | import { useCopiedDelay } from "@/hooks/useCopiedDelay"; 7 | 8 | export const CodeCopyButton = () => { 9 | const { copied, trigger } = useCopiedDelay(); 10 | 11 | const handleCopy = () => { 12 | if (copied) { 13 | return; 14 | } 15 | 16 | const { input, prompt, voice, codeView } = appStore.getState(); 17 | const codeValue = getCodeSnippet(codeView, { input, prompt, voice }); 18 | copyText(codeValue); 19 | }; 20 | 21 | return ( 22 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/DownloadButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Download } from "./ui/Icons"; 3 | import { Button } from "./ui/Button"; 4 | import { appStore } from "@/lib/store"; 5 | 6 | const PlayingWaveform = ({ 7 | audioLoaded, 8 | amplitudeLevels, 9 | }: { 10 | audioLoaded: boolean; 11 | amplitudeLevels: number[]; 12 | }) => ( 13 |
14 | {amplitudeLevels.map((level, idx) => { 15 | const height = `${Math.min(Math.max(level * 30, 0.2), 1.9) * 100}%`; 16 | return ( 17 |
28 | ); 29 | })} 30 |
31 | ); 32 | 33 | const IS_CHROME = 34 | // @ts-expect-error - it's a safe reach 35 | navigator.userAgentData?.brands?.some( 36 | (b: { brand: string }) => b.brand === "Google Chrome" 37 | ) === true; 38 | 39 | export default function DownloadButton() { 40 | const latestAudioUrl = appStore.useState((s) => s.latestAudioUrl); 41 | const [dataUrl, setDataUrl] = useState(null); 42 | const [loading, setLoading] = useState(false); 43 | 44 | useEffect(() => { 45 | if (!latestAudioUrl) return; 46 | 47 | let objectUrl = ""; 48 | const handler = (e: MessageEvent) => { 49 | if (e.data.type === "ADD_TO_CACHE" && e.data.url === latestAudioUrl) { 50 | objectUrl = URL.createObjectURL(e.data.blob); 51 | setDataUrl(objectUrl); 52 | } 53 | }; 54 | navigator.serviceWorker.addEventListener("message", handler); 55 | 56 | return () => { 57 | setDataUrl(null); 58 | URL.revokeObjectURL(objectUrl); 59 | navigator.serviceWorker.removeEventListener("message", handler); 60 | }; 61 | }, [latestAudioUrl]); 62 | 63 | useEffect(() => { 64 | if ("serviceWorker" in navigator) { 65 | navigator.serviceWorker 66 | // update file name when updating the service worker to avoid cache issues 67 | .register("/worker-444eae9e2e1bdd6edd8969f319655e70.js") 68 | .catch((err) => console.error("SW registration failed", err)); 69 | } 70 | }, []); 71 | 72 | const handleDownload = async () => { 73 | const { 74 | selectedEntry, 75 | input, 76 | prompt, 77 | voice, 78 | latestAudioUrl: storeUrl, 79 | } = appStore.getState(); 80 | 81 | const vibe = 82 | selectedEntry?.name.toLowerCase().replace(/ /g, "-") ?? "audio"; 83 | 84 | const filename = `openai-fm-${voice}-${vibe}.${IS_CHROME ? "wav" : "mp3"}`; 85 | 86 | if (!storeUrl) { 87 | setLoading(true); 88 | const form = new FormData(); 89 | form.append("input", input); 90 | form.append("prompt", prompt); 91 | form.append("voice", voice); 92 | form.append("generation", crypto.randomUUID()); 93 | form.append("vibe", vibe); 94 | 95 | const res = await fetch("/api/generate", { method: "POST", body: form }); 96 | const blob = await res.blob(); 97 | const link = document.createElement("a"); 98 | link.href = URL.createObjectURL(blob); 99 | link.download = filename; 100 | document.body.appendChild(link); 101 | link.click(); 102 | document.body.removeChild(link); 103 | setLoading(false); 104 | return; 105 | } 106 | 107 | appStore.setState({ latestAudioUrl: null }); 108 | 109 | if (!dataUrl) { 110 | setLoading(true); 111 | const handler = (e: MessageEvent) => { 112 | if (e.data.type === "ADD_TO_CACHE" && e.data.url === storeUrl) { 113 | navigator.serviceWorker.removeEventListener("message", handler); 114 | const link = document.createElement("a"); 115 | link.href = URL.createObjectURL(e.data.blob); 116 | link.download = filename; 117 | document.body.appendChild(link); 118 | link.click(); 119 | document.body.removeChild(link); 120 | setLoading(false); 121 | } 122 | }; 123 | navigator.serviceWorker.addEventListener("message", handler); 124 | return; 125 | } 126 | 127 | const link = document.createElement("a"); 128 | link.href = dataUrl; 129 | link.download = filename; 130 | document.body.appendChild(link); 131 | link.click(); 132 | document.body.removeChild(link); 133 | }; 134 | 135 | return ( 136 | 147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /src/components/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import { Play } from "./ui/Icons"; 3 | import { Button } from "./ui/Button"; 4 | import { appStore } from "@/lib/store"; 5 | import s from "./ui/Footer.module.css"; 6 | 7 | const IS_SAFARI = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); 8 | const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 9 | 10 | const PlayingWaveform = ({ 11 | audioLoaded, 12 | amplitudeLevels, 13 | }: { 14 | audioLoaded: boolean; 15 | amplitudeLevels: number[]; 16 | }) => ( 17 |
18 | {amplitudeLevels.map((level, idx) => { 19 | const height = `${Math.min(Math.max(level * 30, 0.2), 1.9) * 100}%`; 20 | return ( 21 |
32 | ); 33 | })} 34 |
35 | ); 36 | 37 | export default function PlayButton() { 38 | const [audioLoading, setAudioLoading] = useState(false); 39 | const [audioLoaded, setAudioLoaded] = useState(false); 40 | const [isPlaying, setIsPlaying] = useState(false); 41 | 42 | const audioRef = useRef(null); 43 | const audioContextRef = useRef(null); 44 | const analyserRef = useRef(null); 45 | const [amplitudeLevels, setAmplitudeLevels] = useState( 46 | new Array(5).fill(0) 47 | ); 48 | const amplitudeIntervalRef = useRef(null); 49 | const useStaticAnimation = IS_SAFARI || IS_IOS; 50 | 51 | const generateRandomAmplitudes = () => 52 | Array(5) 53 | .fill(0) 54 | .map(() => Math.random() * 0.06); 55 | 56 | const handleSubmit = async () => { 57 | const { input, prompt, voice } = appStore.getState(); 58 | 59 | if (audioLoading) return; 60 | 61 | // toggle off if already playing 62 | if (audioRef.current) { 63 | if (isPlaying) { 64 | audioRef.current.pause(); 65 | setIsPlaying(false); 66 | setAudioLoaded(false); 67 | audioRef.current = null; 68 | if (amplitudeIntervalRef.current) { 69 | clearInterval(amplitudeIntervalRef.current); 70 | amplitudeIntervalRef.current = null; 71 | } 72 | } 73 | return; 74 | } 75 | 76 | setAudioLoading(true); 77 | appStore.setState({ latestAudioUrl: null }); 78 | 79 | try { 80 | const url = new URL("/api/generate", window.location.origin); 81 | url.searchParams.append("input", input); 82 | url.searchParams.append("prompt", prompt); 83 | url.searchParams.append("voice", voice); 84 | url.searchParams.append("generation", crypto.randomUUID()); 85 | const audioUrl = url.toString(); 86 | appStore.setState({ latestAudioUrl: audioUrl }); 87 | 88 | // reset any old sampler 89 | if (amplitudeIntervalRef.current !== null) { 90 | clearInterval(amplitudeIntervalRef.current); 91 | amplitudeIntervalRef.current = null; 92 | } 93 | 94 | const audio = new Audio(); 95 | audio.preload = "none"; 96 | audioRef.current = audio; 97 | 98 | // for non‑iOS/Safari, hook up WebAudio analyzer 99 | if (!useStaticAnimation) { 100 | if (!audioContextRef.current) { 101 | audioContextRef.current = new AudioContext(); 102 | } 103 | const ctx = audioContextRef.current; 104 | const source = ctx.createMediaElementSource(audio); 105 | const analyser = ctx.createAnalyser(); 106 | analyser.fftSize = 256; 107 | source.connect(analyser); 108 | analyser.connect(ctx.destination); 109 | analyserRef.current = analyser; 110 | } 111 | 112 | const sample = () => { 113 | if (useStaticAnimation) { 114 | setAmplitudeLevels(generateRandomAmplitudes()); 115 | return; 116 | } 117 | if (!analyserRef.current) return; 118 | const data = new Uint8Array(analyserRef.current.fftSize); 119 | analyserRef.current.getByteTimeDomainData(data); 120 | const avg = 121 | data.reduce((sum, v) => sum + Math.abs(v - 128), 0) / 122 | analyserRef.current.fftSize; 123 | const amp = avg / 128; 124 | setAmplitudeLevels((prev) => [...prev.slice(1), amp]); 125 | }; 126 | 127 | audio.onerror = () => { 128 | setAudioLoading(false); 129 | setAudioLoaded(false); 130 | setIsPlaying(false); 131 | alert("Error generating audio"); 132 | }; 133 | 134 | audio.onplay = () => { 135 | amplitudeIntervalRef.current = window.setInterval(sample, 100); 136 | setIsPlaying(true); 137 | setAudioLoaded(true); 138 | setAudioLoading(false); 139 | }; 140 | 141 | const clearSampling = () => { 142 | audioRef.current = null; 143 | if (amplitudeIntervalRef.current !== null) { 144 | clearInterval(amplitudeIntervalRef.current); 145 | amplitudeIntervalRef.current = null; 146 | } 147 | setIsPlaying(false); 148 | }; 149 | 150 | audio.onpause = clearSampling; 151 | audio.onended = clearSampling; 152 | audio.autoplay = true; 153 | audio.src = audioUrl; 154 | } catch (err) { 155 | console.error("Error generating speech:", err); 156 | setAudioLoading(false); 157 | setAudioLoaded(false); 158 | setIsPlaying(false); 159 | } 160 | }; 161 | 162 | return ( 163 | 186 | ); 187 | } 188 | -------------------------------------------------------------------------------- /src/components/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useCopiedDelay } from "@/hooks/useCopiedDelay"; 3 | import { copyText } from "../lib/copyText"; 4 | import { appStore } from "@/lib/store"; 5 | import { Share } from "./ui/Icons"; 6 | import { Button } from "./ui/Button"; 7 | import ShareDialog from "./ShareDialog"; 8 | 9 | export const ShareButton = () => { 10 | const { copied, trigger } = useCopiedDelay(); 11 | const [open, setOpen] = useState(false); 12 | const [shareUrl, setShareUrl] = useState(null); 13 | const handleShare = async () => { 14 | const { input, prompt, voice } = appStore.getState(); 15 | 16 | try { 17 | const res = await fetch("/api/share", { 18 | method: "POST", 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | body: JSON.stringify({ input, prompt, voice }), 23 | }); 24 | if (!res.ok) { 25 | alert("Error sharing. Please try again."); 26 | return; 27 | } 28 | const data = await res.json(); 29 | const hash = data.id; 30 | const shareUrl = `${window.location.origin}${window.location.pathname}#${hash}`; 31 | // Copy share URL to clipboard to share with others. 32 | await copyText(shareUrl); 33 | setShareUrl(shareUrl); 34 | setOpen(true); 35 | } catch (err) { 36 | console.error("Error creating share link:", err); 37 | alert("Error creating share link. Please try again."); 38 | } 39 | }; 40 | 41 | return ( 42 | <> 43 | 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/ShareDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Dialog } from "radix-ui"; 3 | import { Cross2Icon } from "@radix-ui/react-icons"; 4 | import { copyText } from "../lib/copyText"; 5 | 6 | const DialogDemo = ({ 7 | shareUrl, 8 | open, 9 | onOpenChange, 10 | }: { 11 | shareUrl: string | null; 12 | open: boolean; 13 | onOpenChange: (open: boolean) => void; 14 | }) => ( 15 | 16 | 17 | 18 | 19 | 20 | Share Link 21 | 22 | 23 | Copy the link below to share with others. 24 | 25 |
26 | 31 |
32 |
33 | 34 | 44 | 45 |
46 | 47 | 53 | 54 |
55 |
56 |
57 | ); 58 | 59 | export default DialogDemo; 60 | -------------------------------------------------------------------------------- /src/components/TTSPage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import { 4 | getRandomLibrarySet, 5 | getRandomVoice, 6 | LIBRARY, 7 | VOICES, 8 | } from "../lib/library"; 9 | import { Block } from "./ui/Block"; 10 | import { Footer } from "./ui/Footer"; 11 | 12 | import { Header } from "./ui/Header"; 13 | import { DevMode } from "./ui/DevMode"; 14 | 15 | import { Regenerate, Shuffle, Star } from "./ui/Icons"; 16 | import { useBodyScrollable } from "@/hooks/useBodyScrollable"; 17 | import { Button, ButtonLED } from "./ui/Button"; 18 | import { appStore } from "@/lib/store"; 19 | import BrowserNotSupported from "./ui/BrowserNotSupported"; 20 | 21 | const EXPRESSIVE_VOICES = ["ash", "ballad", "coral", "sage", "verse"]; 22 | 23 | export default function TtsPage() { 24 | const [devMode, setDevMode] = useState(false); 25 | const isScrollable = useBodyScrollable(); 26 | 27 | return ( 28 |
32 |
33 | {devMode ? : } 34 |
35 |
36 | ); 37 | } 38 | 39 | const Board = () => { 40 | const voice = appStore.useState((state) => state.voice); 41 | const input = appStore.useState((state) => state.input); 42 | const inputDirty = appStore.useState((state) => state.inputDirty); 43 | const prompt = appStore.useState((state) => state.prompt); 44 | const selectedEntry = appStore.useState((state) => state.selectedEntry); 45 | const librarySet = appStore.useState((state) => state.librarySet); 46 | const browserNotSupported = appStore.useState( 47 | () => !("serviceWorker" in navigator) 48 | ); 49 | 50 | const handleRefreshLibrarySet = () => { 51 | const nextSet = getRandomLibrarySet(); 52 | 53 | appStore.setState((draft) => { 54 | draft.librarySet = nextSet; 55 | 56 | // When the user has changes, don't update the script. 57 | if (!draft.inputDirty) { 58 | draft.input = nextSet[0].input; 59 | } 60 | 61 | draft.prompt = nextSet[0].prompt; 62 | draft.selectedEntry = nextSet[0]; 63 | draft.latestAudioUrl = null; 64 | }); 65 | }; 66 | 67 | const handlePresetSelect = (name: string) => { 68 | const entry = LIBRARY[name]; 69 | 70 | appStore.setState((draft) => { 71 | // When the user has changes, don't update the script. 72 | if (!inputDirty) { 73 | draft.input = entry.input; 74 | } 75 | 76 | draft.prompt = entry.prompt; 77 | draft.selectedEntry = entry; 78 | draft.latestAudioUrl = null; 79 | }); 80 | }; 81 | 82 | return ( 83 |
84 | {browserNotSupported && ( 85 | {}} 88 | /> 89 | )} 90 |
91 | 92 |
93 | {VOICES.map((newVoice) => ( 94 |
98 | 123 |
124 | ))} 125 |
126 | 141 |
142 |
143 |
144 |
145 |
146 | 147 |
148 |
149 | {librarySet.map((entry) => ( 150 | 163 | ))} 164 | 173 |
174 |