Lab 01 · Chapter 2 — Build file by file
← Preparation · Português · Next: run, test, and deploy →
This chapter is deliberately operational. Work inside the lab directory, create files in the shown order, and run each slice checkpoint before continuing.
This file is generated by
npm run docs:generatefrom validated source. Do not edit code blocks manually; update the implementation and regenerate the documentation.
Confirm the terminal
Before the first file, run pwd. The result must end in:
labs/lab-01-text-to-speech
Passo 1 — Configure a reproducible base
Step objective: Now create the configuration that makes Next.js, TypeScript, Vitest, and Oxlint agree. If you started from the scaffold, open and compare each file; from an empty directory, create it.
In the lab terminal, make sure the directories and files exist:
mkdir -p scripts src/types
touch .env.example .gitignore package.json next.config.mjs tsconfig.json vitest.config.ts scripts/typecheck.mjs src/types/assets.d.ts
1.1 Create .env.example
List variable names with empty values only. The real key belongs in .env.local.
Open .env.example, remove any placeholder, and add exactly:
.env.example
# Server-only. Never prefix this variable with NEXT_PUBLIC_.
OPENAI_API_KEY=
# Required in production; optional only for local development and tests.
# The UI keeps this shared bearer token in memory, never browser storage.
PLAYGROUND_ACCESS_TOKEN=
# Required in production. Example: https://voice.example.com
APP_ORIGIN=
# Required in production for a quota shared by every serverless instance.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Required outside Vercel. Name a client-IP header that your trusted proxy
# overwrites (for example x-forwarded-for). Vercel defaults to
# x-vercel-forwarded-for, which Vercel overwrites at its edge.
CLIENT_IP_HEADER=
1.2 Create .gitignore
Protect secrets, dependencies, and generated artifacts before the first commit.
Open .gitignore, remove any placeholder, and add exactly:
.gitignore
.env*
!.env.example
.next/
node_modules/
coverage/
playwright-report/
test-results/
.vercel/
*.tsbuildinfo
.DS_Store
1.3 Create package.json
Define reproducible scripts, versions, and dependencies. Never edit package-lock.json manually.
Open package.json, remove any placeholder, and add exactly:
package.json
{
"name": "openai-voice-lab-01-text-to-speech",
"version": "1.0.0",
"private": true,
"description": "A production-minded OpenAI text-to-speech workshop built with Next.js.",
"license": "MIT",
"author": "Glaucia Lemos",
"repository": {
"type": "git",
"url": "https://github.com/glaucia86/openai-voice-playground.git"
},
"bugs": {
"url": "https://github.com/glaucia86/openai-voice-playground/issues"
},
"homepage": "https://github.com/glaucia86/openai-voice-playground/tree/main/labs/lab-01-text-to-speech#readme",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "oxlint --deny-warnings src tests",
"typecheck": "node scripts/typecheck.mjs --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/manrope": "^5.2.8",
"@upstash/ratelimit": "2.0.8",
"@upstash/redis": "1.38.0",
"lucide-react": "^1.25.0",
"next": "15.5.20",
"openai": "^6.48.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^4.1.10",
"oxlint": "^1.74.0",
"typescript": "5.8.2",
"typescript7": "npm:typescript@7.0.2",
"vitest": "^4.1.10"
},
"overrides": {
"postcss": "8.5.20"
}
}
1.4 Create next.config.mjs
Configure Next.js headers, limits, and production behavior.
Open next.config.mjs, remove any placeholder, and add exactly:
next.config.mjs
import { fileURLToPath } from "node:url";
const isProduction = process.env.NODE_ENV === "production";
const sourceDirectory = fileURLToPath(new URL("./src", import.meta.url));
const securityHeaders = [
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Permissions-Policy", value: "camera=(), geolocation=(), microphone=()" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
...(isProduction
? [{ key: "Strict-Transport-Security", value: "max-age=63072000" }]
: []),
];
/** @type {import("next").NextConfig} */
const nextConfig = {
outputFileTracingRoot: fileURLToPath(new URL(".", import.meta.url)),
poweredByHeader: false,
reactStrictMode: true,
// Next 15's embedded checker cannot load TypeScript 7. `npm run typecheck`
// remains a required, separate gate before every build in `npm run check` and CI.
typescript: {
ignoreBuildErrors: true,
},
// Oxlint runs as a separate required gate. Next 15's embedded lint path also
// attempts to replace TypeScript 7 with 5.8, so the duplicate pass is skipped.
eslint: {
ignoreDuringBuilds: true,
},
webpack(config) {
// Next 15 does not read TS 7's path mapping correctly during bundling.
config.resolve.alias["@"] = sourceDirectory;
return config;
},
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default nextConfig;
1.5 Create tsconfig.json
Enable strict typing and the @/ alias used by imports.
Open tsconfig.json, remove any placeholder, and add exactly:
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
1.6 Create vitest.config.ts
Teach Vitest to resolve the same alias and measure the relevant files.
Open vitest.config.ts, remove any placeholder, and add exactly:
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": new URL("./src", import.meta.url).pathname,
},
},
test: {
coverage: {
provider: "v8",
reporter: ["text", "json-summary"],
include: ["src/lib/**/*.ts"],
exclude: ["src/lib/openai.ts"],
thresholds: {
lines: 80,
functions: 80,
statements: 80,
branches: 75,
},
},
},
});
1.7 Create scripts/typecheck.mjs
Run the TypeScript 7 compiler separately from the compiler used by Next.js.
Open scripts/typecheck.mjs, remove any placeholder, and add exactly:
scripts/typecheck.mjs
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
const compilerPath = join(
process.cwd(),
"node_modules",
"typescript7",
"bin",
"tsc",
);
if (!existsSync(compilerPath)) {
console.error(
"TypeScript 7 não foi encontrado. Execute `npm install` antes do typecheck.",
);
process.exit(1);
}
const result = spawnSync(process.execPath, [compilerPath, ...process.argv.slice(2)], {
stdio: "inherit",
});
if (result.error) {
console.error(`Não foi possível executar o TypeScript 7: ${result.error.message}`);
process.exit(1);
}
process.exit(result.status ?? 1);
1.8 Create src/types/assets.d.ts
Declare asset imports used by the interface.
Open src/types/assets.d.ts, remove any placeholder, and add exactly:
src/types/assets.d.ts
declare module "*.css";
declare module "@fontsource-variable/jetbrains-mono";
declare module "@fontsource-variable/manrope";
Step 1 checkpoint
Save every file and run:
npm run typecheck
Do not continue while the command fails. Read the first message, verify the file path, and compare with the checkpoint before reinstalling dependencies.
Before continuing, confirm that: the command exited with code zero, every path matches filename casing, and you can summarize this slice’s responsibility.
Comprehension prompt: what problem does “Configure a reproducible base” prevent before the next slice?
Step 1 conclusion: this slice is validated. The next step assumes this behavior works; return to this checkpoint if something breaks.
Passo 2 — Create the contract before the voice call
Step objective: The first functional slice does not call OpenAI. It defines exactly what the browser may request and proves the rules with fast tests.
In the lab terminal, make sure the directories and files exist:
mkdir -p src/lib tests
touch src/lib/constants.ts src/lib/schemas.ts tests/schemas.test.ts
2.1 Create src/lib/constants.ts
Centralize allowlists and limits the client may not choose freely.
Open src/lib/constants.ts, remove any placeholder, and add exactly:
src/lib/constants.ts
export const VOICES = [
{ id: "alloy", label: "Alloy", character: "Balanced and versatile" },
{ id: "ash", label: "Ash", character: "Clear and conversational" },
{ id: "ballad", label: "Ballad", character: "Warm and expressive" },
{ id: "coral", label: "Coral", character: "Bright and engaging" },
{ id: "echo", label: "Echo", character: "Calm and measured" },
{ id: "fable", label: "Fable", character: "Narrative and textured" },
{ id: "nova", label: "Nova", character: "Energetic and polished" },
{ id: "onyx", label: "Onyx", character: "Grounded and resonant" },
{ id: "sage", label: "Sage", character: "Composed and thoughtful" },
{ id: "shimmer", label: "Shimmer", character: "Light and articulate" },
{ id: "verse", label: "Verse", character: "Natural and dynamic" },
{ id: "marin", label: "Marin", character: "Natural, high-quality voice" },
{ id: "cedar", label: "Cedar", character: "Natural, high-quality voice" },
] as const;
export const VOICE_IDS = VOICES.map((voice) => voice.id) as [
(typeof VOICES)[number]["id"],
...(typeof VOICES)[number]["id"][],
];
export const AUDIO_FORMATS = ["mp3", "wav", "opus"] as const;
export const MAX_SPEECH_CHARACTERS = 4_096;
export const MAX_INSTRUCTIONS_CHARACTERS = 4_096;
export type VoiceId = (typeof VOICE_IDS)[number];
export type AudioFormat = (typeof AUDIO_FORMATS)[number];
2.2 Create src/lib/schemas.ts
Turn untrusted input into a strict, typed contract.
Open src/lib/schemas.ts, remove any placeholder, and add exactly:
src/lib/schemas.ts
import { z } from "zod";
import {
AUDIO_FORMATS,
MAX_INSTRUCTIONS_CHARACTERS,
MAX_SPEECH_CHARACTERS,
VOICE_IDS,
} from "@/lib/constants";
export const speechRequestSchema = z
.object({
text: z.string().trim().min(1).max(MAX_SPEECH_CHARACTERS),
voice: z.enum(VOICE_IDS),
format: z.enum(AUDIO_FORMATS).default("mp3"),
instructions: z
.string()
.trim()
.max(MAX_INSTRUCTIONS_CHARACTERS)
.optional()
.default(""),
speed: z.coerce.number().min(0.25).max(4).default(1),
})
.strict();
export type SpeechRequest = z.infer<typeof speechRequestSchema>;
2.3 Create tests/schemas.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/schemas.test.ts, remove any placeholder, and add exactly:
tests/schemas.test.ts
import { describe, expect, it } from "vitest";
import { speechRequestSchema } from "../src/lib/schemas";
describe("speechRequestSchema", () => {
it("applies safe defaults to a minimal request", () => {
const result = speechRequestSchema.parse({ text: "Hello", voice: "marin" });
expect(result).toEqual({
text: "Hello",
voice: "marin",
format: "mp3",
instructions: "",
speed: 1,
});
});
it("trims text and accepts supported controls", () => {
const result = speechRequestSchema.parse({
text: " A clear explanation. ",
voice: "cedar",
format: "wav",
instructions: " Speak calmly. ",
speed: "1.25",
});
expect(result.text).toBe("A clear explanation.");
expect(result.instructions).toBe("Speak calmly.");
expect(result.speed).toBe(1.25);
});
it("rejects unsupported voices and extra fields", () => {
expect(() => speechRequestSchema.parse({ text: "Hello", voice: "celebrity" })).toThrow();
expect(() =>
speechRequestSchema.parse({ text: "Hello", voice: "marin", apiKey: "never" }),
).toThrow();
});
it("enforces the upstream text and speed limits", () => {
expect(() => speechRequestSchema.parse({ text: "x".repeat(4_097), voice: "marin" })).toThrow();
expect(() => speechRequestSchema.parse({ text: "Hello", voice: "marin", speed: 4.1 })).toThrow();
});
});
Step 2 checkpoint
Save every file and run:
npm test -- schemas.test.ts
Do not continue while the command fails. Read the first message, verify the file path, and compare with the checkpoint before reinstalling dependencies.
Before continuing, confirm that: the command exited with code zero, every path matches filename casing, and you can summarize this slice’s responsibility.
Comprehension prompt: what problem does “Create the contract before the voice call” prevent before the next slice?
Step 2 conclusion: this slice is validated. The next step assumes this behavior works; return to this checkpoint if something breaks.
Passo 3 — Build the secure backend and streaming path
Step objective: This slice adds sanitized errors, limits, content-free observability, the OpenAI client, health route, and the route that forwards audio. Create the files in the shown order.
In the lab terminal, make sure the directories and files exist:
mkdir -p src/lib src/app/api/health src/app/api/speech src tests
touch src/lib/errors.ts src/lib/observability.ts src/lib/openai.ts src/lib/rate-limit.ts src/lib/request-body.ts src/lib/security-config.ts src/lib/request-guard.ts src/app/api/health/route.ts src/app/api/speech/route.ts src/middleware.ts tests/errors.test.ts tests/observability.test.ts tests/rate-limit.test.ts tests/request-body.test.ts tests/request-guard.test.ts tests/security-config.test.ts tests/middleware.test.ts
3.1 Create src/lib/errors.ts
Normalize failures into stable responses without forwarding raw provider details.
Open src/lib/errors.ts, remove any placeholder, and add exactly:
src/lib/errors.ts
import OpenAI from "openai";
import { ZodError } from "zod";
export class AppError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly headers: Record<string, string> = {},
) {
super(message);
this.name = "AppError";
}
}
type ErrorBody = {
error: {
code: string;
message: string;
requestId: string;
};
};
export function normalizeError(error: unknown): AppError {
if (error instanceof AppError) return error;
if (error instanceof ZodError) {
return new AppError(
400,
"invalid_request",
error.issues[0]?.message ?? "The request is invalid.",
);
}
if (error instanceof SyntaxError) {
return new AppError(400, "invalid_json", "The request body is not valid JSON.");
}
if (error instanceof OpenAI.APIError) {
if (error.status === 429) {
return new AppError(
429,
"upstream_rate_limit",
"The voice service is busy. Please try again shortly.",
);
}
if (error.status === 401 || error.status === 403) {
return new AppError(
503,
"upstream_authentication_error",
"The voice service is temporarily unavailable.",
);
}
return new AppError(
502,
"upstream_error",
"OpenAI could not complete the request. Please try again.",
);
}
if (error instanceof Error && error.name === "AbortError") {
return new AppError(499, "request_cancelled", "The request was cancelled.");
}
return new AppError(500, "internal_error", "Something went wrong. Please try again.");
}
export function errorResponse(
error: unknown,
requestId: string,
headers?: HeadersInit,
): Response {
const normalized = normalizeError(error);
const responseHeaders = new Headers(headers);
for (const [name, value] of Object.entries(normalized.headers)) {
responseHeaders.set(name, value);
}
responseHeaders.set("Cache-Control", "no-store");
responseHeaders.set("X-Request-Id", requestId);
const body: ErrorBody = {
error: {
code: normalized.code,
message: normalized.message,
requestId,
},
};
return Response.json(body, {
status: normalized.status,
headers: responseHeaders,
});
}
3.2 Create src/lib/observability.ts
Record operational metadata only; text, audio, and credentials stay out of logs.
Open src/lib/observability.ts, remove any placeholder, and add exactly:
src/lib/observability.ts
type LogLevel = "info" | "error";
type VoiceLog = {
event: string;
requestId: string;
route: "speech";
durationMs: number;
status: number;
model?: string;
inputSize?: number;
outputSize?: number;
};
export function logVoiceRequest(level: LogLevel, log: VoiceLog): void {
const serialized = JSON.stringify({
timestamp: new Date().toISOString(),
...log,
});
if (level === "error") {
console.error(serialized);
} else {
console.info(serialized);
}
}
3.3 Create src/lib/openai.ts
Create the OpenAI client lazily and on the server only.
Open src/lib/openai.ts, remove any placeholder, and add exactly:
src/lib/openai.ts
import OpenAI from "openai";
import { AppError } from "@/lib/errors";
const globalForOpenAI = globalThis as unknown as { openai?: OpenAI };
export function getOpenAIClient(): OpenAI {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new AppError(
503,
"configuration_error",
"The voice service is not configured.",
);
}
globalForOpenAI.openai ??= new OpenAI({
apiKey,
maxRetries: 2,
timeout: 45_000,
});
return globalForOpenAI.openai;
}
3.4 Create src/lib/rate-limit.ts
Implement local development quota and distributed production quota.
Open src/lib/rate-limit.ts, remove any placeholder, and add exactly:
src/lib/rate-limit.ts
import { Ratelimit, type Duration } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
type RateLimitEntry = {
count: number;
resetAt: number;
};
export type RateLimitResult = {
allowed: boolean;
limit: number;
remaining: number;
resetAt: number;
};
type RateLimitOptions = {
limit: number;
windowMs: number;
now?: number;
};
const globalForRateLimit = globalThis as unknown as {
voicePlaygroundRateLimits?: Map<string, RateLimitEntry>;
voicePlaygroundDistributedLimiters?: Map<string, Ratelimit>;
};
const entries =
globalForRateLimit.voicePlaygroundRateLimits ?? new Map<string, RateLimitEntry>();
const distributedLimiters =
globalForRateLimit.voicePlaygroundDistributedLimiters ?? new Map<string, Ratelimit>();
globalForRateLimit.voicePlaygroundRateLimits = entries;
globalForRateLimit.voicePlaygroundDistributedLimiters = distributedLimiters;
export async function checkRateLimit(
key: string,
options: RateLimitOptions,
): Promise<RateLimitResult> {
const redisUrl = process.env.UPSTASH_REDIS_REST_URL?.trim();
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN?.trim();
if (redisUrl && redisToken) {
const limiter = getDistributedLimiter(redisUrl, redisToken, options);
const result = await limiter.limit(key);
return {
allowed: result.success,
limit: result.limit,
remaining: result.remaining,
resetAt: result.reset,
};
}
return checkLocalRateLimit(key, options);
}
export function rateLimitHeaders(result: RateLimitResult): Record<string, string> {
return {
"RateLimit-Limit": String(result.limit),
"RateLimit-Remaining": String(result.remaining),
"RateLimit-Reset": String(Math.ceil(result.resetAt / 1_000)),
...(!result.allowed
? { "Retry-After": String(Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1_000))) }
: {}),
};
}
export function resetRateLimitsForTests(): void {
entries.clear();
distributedLimiters.clear();
}
function checkLocalRateLimit(
key: string,
{ limit, windowMs, now = Date.now() }: RateLimitOptions,
): RateLimitResult {
const current = entries.get(key);
if (!current || current.resetAt <= now) {
const resetAt = now + windowMs;
entries.set(key, { count: 1, resetAt });
cleanExpiredEntries(now);
return { allowed: true, limit, remaining: limit - 1, resetAt };
}
if (current.count >= limit) {
return { allowed: false, limit, remaining: 0, resetAt: current.resetAt };
}
current.count += 1;
return {
allowed: true,
limit,
remaining: Math.max(0, limit - current.count),
resetAt: current.resetAt,
};
}
function getDistributedLimiter(
url: string,
token: string,
{ limit, windowMs }: RateLimitOptions,
): Ratelimit {
const cacheKey = `${limit}:${windowMs}`;
const existing = distributedLimiters.get(cacheKey);
if (existing) return existing;
const limiter = new Ratelimit({
redis: new Redis({ url, token }),
limiter: Ratelimit.fixedWindow(limit, `${windowMs} ms` as Duration),
analytics: false,
prefix: "openai-voice-playground:lab-01",
});
distributedLimiters.set(cacheKey, limiter);
return limiter;
}
function cleanExpiredEntries(now: number): void {
if (entries.size < 1_000) return;
for (const [key, entry] of entries) {
if (entry.resetAt <= now) entries.delete(key);
}
}
3.5 Create src/lib/request-body.ts
Bound the bytes actually read before parsing JSON.
Open src/lib/request-body.ts, remove any placeholder, and add exactly:
src/lib/request-body.ts
import { AppError } from "@/lib/errors";
export async function readJsonBody(
request: Request,
maxBytes: number,
tooLargeMessage: string,
): Promise<unknown> {
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.startsWith("application/json")) {
throw new AppError(
415,
"unsupported_media_type",
"The request Content-Type must be application/json.",
);
}
const advertisedLength = readContentLength(request);
if (advertisedLength !== undefined && advertisedLength > maxBytes) {
throw new AppError(413, "request_too_large", tooLargeMessage);
}
if (!request.body) {
throw new AppError(400, "invalid_json", "The request body is not valid JSON.");
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new AppError(413, "request_too_large", tooLargeMessage);
}
chunks.push(value);
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(body);
return JSON.parse(text) as unknown;
} catch {
throw new AppError(400, "invalid_json", "The request body is not valid JSON.");
}
}
function readContentLength(request: Request): number | undefined {
const value = request.headers.get("content-length");
if (!value || !/^\d+$/.test(value)) return undefined;
return Number(value);
}
3.6 Create src/lib/security-config.ts
Make production fail closed when mandatory safeguards are absent.
Open src/lib/security-config.ts, remove any placeholder, and add exactly:
src/lib/security-config.ts
export type SecurityConfiguration = {
isProduction: boolean;
ready: boolean;
missingVariables: string[];
clientIpHeader: string;
distributedRateLimit: boolean;
requiresAccessToken: boolean;
};
type RuntimeEnvironment = Record<string, string | undefined>;
const HEADER_NAME_PATTERN = /^[a-z0-9-]+$/;
const FORBIDDEN_IDENTITY_HEADERS = new Set([
"authorization",
"cookie",
"proxy-authorization",
]);
export function getSecurityConfiguration(
environment: RuntimeEnvironment = process.env,
): SecurityConfiguration {
const isProduction = environment.NODE_ENV === "production";
const accessToken = environment.PLAYGROUND_ACCESS_TOKEN?.trim();
const redisUrl = environment.UPSTASH_REDIS_REST_URL?.trim();
const redisToken = environment.UPSTASH_REDIS_REST_TOKEN?.trim();
const configuredHeader = environment.CLIENT_IP_HEADER?.trim().toLowerCase();
const clientIpHeader = configuredHeader || (environment.VERCEL ? "x-vercel-forwarded-for" : "");
const validClientIpHeader = Boolean(
clientIpHeader &&
HEADER_NAME_PATTERN.test(clientIpHeader) &&
!FORBIDDEN_IDENTITY_HEADERS.has(clientIpHeader),
);
const missingVariables: string[] = [];
if (isProduction) {
if (!accessToken) missingVariables.push("PLAYGROUND_ACCESS_TOKEN");
if (!environment.APP_ORIGIN?.trim()) missingVariables.push("APP_ORIGIN");
if (!redisUrl) missingVariables.push("UPSTASH_REDIS_REST_URL");
if (!redisToken) missingVariables.push("UPSTASH_REDIS_REST_TOKEN");
if (!validClientIpHeader) missingVariables.push("CLIENT_IP_HEADER");
}
return {
isProduction,
ready: missingVariables.length === 0,
missingVariables,
clientIpHeader: validClientIpHeader ? clientIpHeader : "x-forwarded-for",
distributedRateLimit: Boolean(redisUrl && redisToken),
requiresAccessToken: Boolean(accessToken) || isProduction,
};
}
3.7 Create src/lib/request-guard.ts
Apply origin, access, and quota checks before any billable operation.
Open src/lib/request-guard.ts, remove any placeholder, and add exactly:
src/lib/request-guard.ts
import { createHash, timingSafeEqual } from "node:crypto";
import { AppError } from "@/lib/errors";
import {
checkRateLimit,
rateLimitHeaders,
type RateLimitResult,
} from "@/lib/rate-limit";
import { getSecurityConfiguration } from "@/lib/security-config";
const AUTH_ATTEMPTS_PER_MINUTE = 30;
const REQUESTS_PER_MINUTE = 10;
const WINDOW_MS = 60_000;
export async function guardApiRequest(
request: Request,
scope: string,
): Promise<RateLimitResult> {
const security = getSecurityConfiguration();
if (!security.ready) {
throw new AppError(
503,
"security_configuration_incomplete",
"The service security configuration is incomplete.",
);
}
assertSameOrigin(request);
const clientKey = hashIdentifier(getClientAddress(request, security.clientIpHeader));
const authenticationLimit = await applyRateLimit(
`authentication:${clientKey}`,
AUTH_ATTEMPTS_PER_MINUTE,
);
assertAccessToken(request, rateLimitHeaders(authenticationLimit));
return applyRateLimit(`${scope}:${clientKey}`, REQUESTS_PER_MINUTE);
}
export { rateLimitHeaders };
async function applyRateLimit(key: string, limit: number): Promise<RateLimitResult> {
let result: RateLimitResult;
try {
result = await checkRateLimit(key, { limit, windowMs: WINDOW_MS });
} catch {
throw new AppError(
503,
"rate_limiter_unavailable",
"The service cannot safely verify request quota right now. Please try again.",
);
}
if (!result.allowed) {
throw new AppError(
429,
"rate_limit_exceeded",
"Too many requests. Please wait a moment and try again.",
rateLimitHeaders(result),
);
}
return result;
}
function assertSameOrigin(request: Request): void {
const fetchSite = request.headers.get("sec-fetch-site");
if (fetchSite === "cross-site") {
throw new AppError(403, "cross_origin_request", "Cross-origin requests are blocked.");
}
const origin = request.headers.get("origin");
if (!origin) return;
const expectedOrigin = process.env.APP_ORIGIN || new URL(request.url).origin;
try {
if (new URL(origin).origin !== new URL(expectedOrigin).origin) {
throw new AppError(
403,
"cross_origin_request",
"Cross-origin requests are blocked.",
);
}
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(403, "invalid_origin", "The request origin is invalid.");
}
}
function assertAccessToken(request: Request, headers: Record<string, string>): void {
const expected = process.env.PLAYGROUND_ACCESS_TOKEN?.trim();
if (!expected) return;
const authorization = request.headers.get("authorization");
const received = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: "";
if (!safeEqual(received, expected)) {
throw new AppError(
401,
"unauthorized",
"A valid playground access token is required.",
headers,
);
}
}
function safeEqual(received: string, expected: string): boolean {
const receivedDigest = createHash("sha256").update(received).digest();
const expectedDigest = createHash("sha256").update(expected).digest();
return timingSafeEqual(receivedDigest, expectedDigest);
}
function getClientAddress(request: Request, headerName: string): string {
const address = request.headers.get(headerName)?.split(",")[0]?.trim();
return address?.slice(0, 256) || "unidentified-client";
}
function hashIdentifier(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 32);
}
3.8 Create src/app/api/health/route.ts
Expose non-sensitive diagnostics only to prove configuration.
Open src/app/api/health/route.ts, remove any placeholder, and add exactly:
src/app/api/health/route.ts
import { MAX_SPEECH_CHARACTERS } from "@/lib/constants";
import { getSecurityConfiguration } from "@/lib/security-config";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET(): Response {
const security = getSecurityConfiguration();
const configurationIssues = [
...(!process.env.OPENAI_API_KEY ? ["OPENAI_API_KEY"] : []),
...security.missingVariables,
];
return Response.json(
{
ok: true,
service: "openai-voice-lab-01-text-to-speech",
configured: configurationIssues.length === 0,
configurationIssues,
requiresAccessToken: security.requiresAccessToken,
distributedRateLimit: security.distributedRateLimit,
capabilities: {
speechModel: "gpt-4o-mini-tts",
streamedSpeech: true,
},
limits: {
speechCharacters: MAX_SPEECH_CHARACTERS,
requestsPerMinute: 10,
},
},
{
headers: {
"Cache-Control": "no-store",
},
},
);
}
3.9 Create src/app/api/speech/route.ts
Validate, call the Speech API, and forward the stream without buffering all audio on the server.
Open src/app/api/speech/route.ts, remove any placeholder, and add exactly:
src/app/api/speech/route.ts
import { randomUUID } from "node:crypto";
import { AppError, errorResponse, normalizeError } from "@/lib/errors";
import { logVoiceRequest } from "@/lib/observability";
import { getOpenAIClient } from "@/lib/openai";
import { readJsonBody } from "@/lib/request-body";
import { guardApiRequest, rateLimitHeaders } from "@/lib/request-guard";
import { speechRequestSchema } from "@/lib/schemas";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
const MAX_JSON_BYTES = 16 * 1024;
const CONTENT_TYPES = {
mp3: "audio/mpeg",
wav: "audio/wav",
opus: "audio/ogg; codecs=opus",
} as const;
export async function POST(request: Request): Promise<Response> {
const requestId = randomUUID();
const startedAt = performance.now();
let responseHeaders: Record<string, string> = {};
try {
const rateLimit = await guardApiRequest(request, "speech");
responseHeaders = rateLimitHeaders(rateLimit);
const payload = speechRequestSchema.parse(
await readJsonBody(request, MAX_JSON_BYTES, "The speech request is too large."),
);
const openai = getOpenAIClient();
const speech = await openai.audio.speech.create(
{
model: "gpt-4o-mini-tts",
input: payload.text,
voice: payload.voice,
...(payload.instructions ? { instructions: payload.instructions } : {}),
response_format: payload.format,
speed: payload.speed,
stream_format: "audio",
},
{ signal: request.signal },
);
if (!speech.body) {
throw new AppError(502, "empty_audio_stream", "OpenAI returned an empty audio stream.");
}
const upstreamHeadersMs = Math.round(performance.now() - startedAt);
logVoiceRequest("info", {
event: "speech.stream_started",
requestId,
route: "speech",
durationMs: upstreamHeadersMs,
status: 200,
model: "gpt-4o-mini-tts",
inputSize: payload.text.length,
});
return new Response(speech.body, {
status: 200,
headers: {
...responseHeaders,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="voice-${requestId}.${payload.format}"`,
"Content-Type": CONTENT_TYPES[payload.format],
"Server-Timing": `openai;dur=${upstreamHeadersMs}`,
"X-Model": "gpt-4o-mini-tts",
"X-Request-Id": requestId,
},
});
} catch (error) {
const normalized = normalizeError(error);
logVoiceRequest("error", {
event: "speech.failed",
requestId,
route: "speech",
durationMs: Math.round(performance.now() - startedAt),
status: normalized.status,
});
return errorResponse(normalized, requestId, responseHeaders);
}
}
3.10 Create src/middleware.ts
Add security headers and a per-response nonce without exposing secrets.
Open src/middleware.ts, remove any placeholder, and add exactly:
src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const nonce = btoa(crypto.randomUUID());
const isDevelopment = process.env.NODE_ENV !== "production";
const contentSecurityPolicy = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDevelopment ? " 'unsafe-eval'" : ""}`,
`style-src 'self' 'nonce-${nonce}'${isDevelopment ? " 'unsafe-inline'" : ""}`,
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"media-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(!isDevelopment ? ["upgrade-insecure-requests"] : []),
].join("; ");
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", contentSecurityPolicy);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", contentSecurityPolicy);
return response;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
3.11 Create tests/errors.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/errors.test.ts, remove any placeholder, and add exactly:
tests/errors.test.ts
import OpenAI from "openai";
import { describe, expect, it } from "vitest";
import {
AppError,
errorResponse,
normalizeError,
} from "../src/lib/errors";
import { speechRequestSchema } from "../src/lib/schemas";
describe("normalizeError", () => {
it("preserves an application error", () => {
const error = new AppError(418, "teapot", "Short and stout");
expect(normalizeError(error)).toBe(error);
});
it("turns a Zod error into a safe bad request", () => {
const parsed = speechRequestSchema.safeParse({ text: "", voice: "marin" });
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(normalizeError(parsed.error)).toMatchObject({
status: 400,
code: "invalid_request",
});
});
it("maps malformed JSON", () => {
expect(normalizeError(new SyntaxError("private parser detail"))).toMatchObject({
status: 400,
code: "invalid_json",
message: "The request body is not valid JSON.",
});
});
it.each([
[429, 429, "upstream_rate_limit"],
[401, 503, "upstream_authentication_error"],
[403, 503, "upstream_authentication_error"],
[500, 502, "upstream_error"],
])("maps OpenAI status %i without exposing the provider message", (upstream, status, code) => {
const providerError = new OpenAI.APIError(
upstream,
{ error: { message: "sensitive provider detail" } },
"sensitive provider detail",
new Headers(),
);
expect(normalizeError(providerError)).toMatchObject({ status, code });
expect(normalizeError(providerError).message).not.toContain("sensitive");
});
it("maps a cancelled request", () => {
const error = new Error("cancelled");
error.name = "AbortError";
expect(normalizeError(error)).toMatchObject({ status: 499, code: "request_cancelled" });
});
it("uses a generic fallback for unknown failures", () => {
expect(normalizeError({ unexpected: true })).toMatchObject({
status: 500,
code: "internal_error",
});
});
});
describe("errorResponse", () => {
it("returns the stable no-store envelope and custom headers", async () => {
const response = errorResponse(
new AppError(400, "invalid", "Invalid input"),
"request-123",
{ "RateLimit-Remaining": "9" },
);
expect(response.status).toBe(400);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("x-request-id")).toBe("request-123");
expect(response.headers.get("ratelimit-remaining")).toBe("9");
await expect(response.json()).resolves.toEqual({
error: { code: "invalid", message: "Invalid input", requestId: "request-123" },
});
});
});
3.12 Create tests/observability.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/observability.test.ts, remove any placeholder, and add exactly:
tests/observability.test.ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { logVoiceRequest } from "../src/lib/observability";
afterEach(() => vi.restoreAllMocks());
describe("logVoiceRequest", () => {
const event = {
event: "speech.stream_started",
requestId: "request-1",
route: "speech" as const,
durationMs: 42,
status: 200,
model: "gpt-4o-mini-tts",
inputSize: 24,
};
it("writes structured informational metadata", () => {
const info = vi.spyOn(console, "info").mockImplementation(() => undefined);
logVoiceRequest("info", event);
expect(info).toHaveBeenCalledOnce();
const parsed = JSON.parse(String(info.mock.calls[0]?.[0])) as Record<string, unknown>;
expect(parsed).toMatchObject(event);
expect(parsed.timestamp).toEqual(expect.any(String));
});
it("writes failures to stderr", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
logVoiceRequest("error", { ...event, event: "speech.failed", status: 500 });
expect(error).toHaveBeenCalledOnce();
});
});
3.13 Create tests/rate-limit.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/rate-limit.test.ts, remove any placeholder, and add exactly:
tests/rate-limit.test.ts
import { afterEach, describe, expect, it } from "vitest";
import {
checkRateLimit,
rateLimitHeaders,
resetRateLimitsForTests,
} from "../src/lib/rate-limit";
afterEach(() => {
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
resetRateLimitsForTests();
});
describe("checkRateLimit", () => {
it("allows requests inside the fixed window", async () => {
const first = await checkRateLimit("speech:client", { limit: 2, windowMs: 1_000, now: 100 });
const second = await checkRateLimit("speech:client", { limit: 2, windowMs: 1_000, now: 200 });
expect(first).toMatchObject({ allowed: true, remaining: 1, resetAt: 1_100 });
expect(second).toMatchObject({ allowed: true, remaining: 0, resetAt: 1_100 });
});
it("blocks a request after the limit", async () => {
await checkRateLimit("speech:client", { limit: 1, windowMs: 1_000, now: 100 });
const blocked = await checkRateLimit("speech:client", { limit: 1, windowMs: 1_000, now: 200 });
expect(blocked).toEqual({ allowed: false, limit: 1, remaining: 0, resetAt: 1_100 });
expect(rateLimitHeaders(blocked)).toMatchObject({ "Retry-After": "1" });
});
it("opens a fresh window after reset", async () => {
await checkRateLimit("speech:client", { limit: 1, windowMs: 1_000, now: 100 });
const nextWindow = await checkRateLimit("speech:client", {
limit: 1,
windowMs: 1_000,
now: 1_101,
});
expect(nextWindow).toMatchObject({ allowed: true, remaining: 0, resetAt: 2_101 });
});
it("serializes standard rate-limit headers", () => {
expect(
rateLimitHeaders({ allowed: true, limit: 10, remaining: 9, resetAt: 12_345 }),
).toEqual({
"RateLimit-Limit": "10",
"RateLimit-Remaining": "9",
"RateLimit-Reset": "13",
});
});
it("periodically removes expired keys", async () => {
for (let index = 0; index < 1_000; index += 1) {
await checkRateLimit(`expired:${index}`, { limit: 1, windowMs: 1, now: 0 });
}
const result = await checkRateLimit("fresh", { limit: 1, windowMs: 1_000, now: 2 });
expect(result.allowed).toBe(true);
});
});
3.14 Create tests/request-body.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/request-body.test.ts, remove any placeholder, and add exactly:
tests/request-body.test.ts
import { describe, expect, it } from "vitest";
import { readJsonBody } from "../src/lib/request-body";
describe("readJsonBody", () => {
it("parses a bounded JSON request", async () => {
const request = new Request("https://voice.example.com/api/speech", {
method: "POST",
headers: { "content-type": "application/json; charset=utf-8" },
body: JSON.stringify({ text: "hello" }),
});
await expect(readJsonBody(request, 1_024, "Too large")).resolves.toEqual({ text: "hello" });
});
it("rejects an unsupported content type", async () => {
const request = new Request("https://voice.example.com/api/speech", {
method: "POST",
body: "hello",
});
await expect(readJsonBody(request, 1_024, "Too large")).rejects.toMatchObject({
status: 415,
code: "unsupported_media_type",
});
});
it("rejects an advertised body that is too large", async () => {
const request = new Request("https://voice.example.com/api/speech", {
method: "POST",
headers: { "content-type": "application/json", "content-length": "2048" },
body: "{}",
});
await expect(readJsonBody(request, 10, "Too large")).rejects.toMatchObject({
status: 413,
code: "request_too_large",
});
});
it("counts streamed bytes even without Content-Length", async () => {
const request = new Request("https://voice.example.com/api/speech", {
method: "POST",
headers: { "content-type": "application/json" },
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"text":"'));
controller.enqueue(new TextEncoder().encode('payload"}'));
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
await expect(readJsonBody(request, 12, "Too large")).rejects.toMatchObject({ status: 413 });
});
it("maps malformed or empty JSON to a safe error", async () => {
const malformed = new Request("https://voice.example.com/api/speech", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not-json",
});
const empty = new Request("https://voice.example.com/api/speech", {
method: "POST",
headers: { "content-type": "application/json" },
});
await expect(readJsonBody(malformed, 1_024, "Too large")).rejects.toMatchObject({
status: 400,
code: "invalid_json",
});
await expect(readJsonBody(empty, 1_024, "Too large")).rejects.toMatchObject({
status: 400,
code: "invalid_json",
});
});
});
3.15 Create tests/request-guard.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/request-guard.test.ts, remove any placeholder, and add exactly:
tests/request-guard.test.ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetRateLimitsForTests } from "../src/lib/rate-limit";
import { guardApiRequest } from "../src/lib/request-guard";
afterEach(() => {
vi.unstubAllEnvs();
delete process.env.PLAYGROUND_ACCESS_TOKEN;
delete process.env.APP_ORIGIN;
delete process.env.CLIENT_IP_HEADER;
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
resetRateLimitsForTests();
});
describe("guardApiRequest", () => {
it("accepts a same-origin request and returns a quota", async () => {
const request = new Request("https://voice.example.com/api/speech", {
headers: { origin: "https://voice.example.com", "x-forwarded-for": "203.0.113.10" },
});
await expect(guardApiRequest(request, "speech")).resolves.toMatchObject({
allowed: true,
remaining: 9,
});
});
it("fails closed when production safeguards are missing", async () => {
vi.stubEnv("NODE_ENV", "production");
await expect(
guardApiRequest(new Request("https://voice.example.com/api/speech"), "speech"),
).rejects.toMatchObject({
status: 503,
code: "security_configuration_incomplete",
});
});
it("blocks explicit cross-site browser requests", async () => {
const request = new Request("https://voice.example.com/api/speech", {
headers: { origin: "https://attacker.example", "sec-fetch-site": "cross-site" },
});
await expect(guardApiRequest(request, "speech")).rejects.toMatchObject({
status: 403,
code: "cross_origin_request",
});
});
it("requires the configured deployment access token", async () => {
process.env.PLAYGROUND_ACCESS_TOKEN = "a-long-shared-token";
const unauthorized = new Request("https://voice.example.com/api/speech");
await expect(guardApiRequest(unauthorized, "speech")).rejects.toMatchObject({
status: 401,
code: "unauthorized",
});
const authorized = new Request("https://voice.example.com/api/speech", {
headers: { authorization: "Bearer a-long-shared-token" },
});
await expect(guardApiRequest(authorized, "speech")).resolves.toMatchObject({ allowed: true });
});
it("rate-limits invalid token attempts before authentication", async () => {
process.env.PLAYGROUND_ACCESS_TOKEN = "a-long-shared-token";
const request = () =>
new Request("https://voice.example.com/api/speech", {
headers: { authorization: "Bearer wrong", "x-forwarded-for": "203.0.113.20" },
});
for (let index = 0; index < 30; index += 1) {
await expect(guardApiRequest(request(), "speech")).rejects.toMatchObject({ status: 401 });
}
await expect(guardApiRequest(request(), "speech")).rejects.toMatchObject({
status: 429,
code: "rate_limit_exceeded",
headers: expect.objectContaining({ "Retry-After": expect.any(String) }),
});
});
it("honors a configured canonical origin", async () => {
process.env.APP_ORIGIN = "https://voice.example.com";
const request = new Request("https://preview.example.com/api/speech", {
headers: { origin: "https://preview.example.com" },
});
await expect(guardApiRequest(request, "speech")).rejects.toMatchObject({ status: 403 });
});
it("rejects an invalid origin header", async () => {
const request = new Request("https://voice.example.com/api/speech", {
headers: { origin: "not a URL" },
});
await expect(guardApiRequest(request, "speech")).rejects.toMatchObject({
status: 403,
code: "invalid_origin",
});
});
it("enforces the local operation quota", async () => {
for (let index = 0; index < 10; index += 1) {
await guardApiRequest(new Request("https://voice.example.com/api/speech"), "speech");
}
await expect(
guardApiRequest(new Request("https://voice.example.com/api/speech"), "speech"),
).rejects.toMatchObject({ status: 429, code: "rate_limit_exceeded" });
});
});
3.16 Create tests/security-config.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/security-config.test.ts, remove any placeholder, and add exactly:
tests/security-config.test.ts
import { describe, expect, it } from "vitest";
import { getSecurityConfiguration } from "../src/lib/security-config";
describe("getSecurityConfiguration", () => {
it("keeps the workshop usable without infrastructure in development", () => {
expect(getSecurityConfiguration({ NODE_ENV: "development" })).toMatchObject({
ready: true,
requiresAccessToken: false,
distributedRateLimit: false,
clientIpHeader: "x-forwarded-for",
});
});
it("fails closed when production safeguards are absent", () => {
expect(getSecurityConfiguration({ NODE_ENV: "production" })).toMatchObject({
ready: false,
requiresAccessToken: true,
missingVariables: [
"PLAYGROUND_ACCESS_TOKEN",
"APP_ORIGIN",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN",
"CLIENT_IP_HEADER",
],
});
});
it("accepts a complete Vercel production configuration", () => {
expect(
getSecurityConfiguration({
NODE_ENV: "production",
VERCEL: "1",
PLAYGROUND_ACCESS_TOKEN: "secret",
APP_ORIGIN: "https://voice.example.com",
UPSTASH_REDIS_REST_URL: "https://redis.example.com",
UPSTASH_REDIS_REST_TOKEN: "redis-secret",
}),
).toMatchObject({
ready: true,
distributedRateLimit: true,
clientIpHeader: "x-vercel-forwarded-for",
});
});
it("rejects a sensitive header as the client identity source", () => {
const configuration = getSecurityConfiguration({
NODE_ENV: "production",
PLAYGROUND_ACCESS_TOKEN: "secret",
APP_ORIGIN: "https://voice.example.com",
UPSTASH_REDIS_REST_URL: "https://redis.example.com",
UPSTASH_REDIS_REST_TOKEN: "redis-secret",
CLIENT_IP_HEADER: "authorization",
});
expect(configuration.ready).toBe(false);
expect(configuration.missingVariables).toContain("CLIENT_IP_HEADER");
});
});
3.17 Create tests/middleware.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/middleware.test.ts, remove any placeholder, and add exactly:
tests/middleware.test.ts
import { NextRequest } from "next/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { middleware } from "../src/middleware";
afterEach(() => vi.unstubAllEnvs());
describe("security middleware", () => {
it("creates a strict nonce-based production CSP", () => {
vi.stubEnv("NODE_ENV", "production");
const response = middleware(new NextRequest("https://voice.example.com/"));
const policy = response.headers.get("content-security-policy") ?? "";
expect(policy).toMatch(/script-src 'self' 'nonce-[^']+' 'strict-dynamic'/);
expect(policy).not.toContain("'unsafe-inline'");
expect(policy).toContain("upgrade-insecure-requests");
});
});
Step 3 checkpoint
Save every file and run:
npm run typecheck && npm test
Do not continue while the command fails. Read the first message, verify the file path, and compare with the checkpoint before reinstalling dependencies.
Before continuing, confirm that: the command exited with code zero, every path matches filename casing, and you can summarize this slice’s responsibility.
Comprehension prompt: what problem does “Build the secure backend and streaming path” prevent before the next slice?
Step 3 conclusion: this slice is validated. The next step assumes this behavior works; return to this checkpoint if something breaks.
Passo 4 — Create the interface, player, and download flow
Step objective: The browser now gains explicit states, cancellation, playback, download, and synthetic-voice disclosure. Replace the starter page with the final files below.
In the lab terminal, make sure the directories and files exist:
mkdir -p src/lib src/components src/app tests
touch src/lib/client-api.ts src/components/status-message.tsx src/components/waveform.tsx src/components/speech-studio.tsx src/components/voice-playground.tsx src/app/layout.tsx src/app/page.tsx src/app/globals.css src/app/manifest.ts src/app/icon.svg tests/client-api.test.ts
4.1 Create src/lib/client-api.ts
Convert API errors into safe, reusable browser messages.
Open src/lib/client-api.ts, remove any placeholder, and add exactly:
src/lib/client-api.ts
export type ClientApiError = {
message: string;
code?: string;
requestId?: string;
};
export function authorizationHeaders(accessToken: string): HeadersInit {
return accessToken.trim()
? { Authorization: `Bearer ${accessToken.trim()}` }
: {};
}
export async function readApiError(response: Response): Promise<ClientApiError> {
try {
const body = (await response.json()) as {
error?: { message?: string; code?: string; requestId?: string };
};
return {
message: body.error?.message || "The request could not be completed.",
...(body.error?.code ? { code: body.error.code } : {}),
...(body.error?.requestId ? { requestId: body.error.requestId } : {}),
};
} catch {
return { message: "The request could not be completed." };
}
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
4.2 Create src/components/status-message.tsx
Present progress and errors in an accessible live region.
Open src/components/status-message.tsx, remove any placeholder, and add exactly:
src/components/status-message.tsx
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
type StatusMessageProps = {
tone: "error" | "success" | "info";
children: React.ReactNode;
requestId?: string | undefined;
};
const ICONS = {
error: AlertCircle,
success: CheckCircle2,
info: Info,
};
export function StatusMessage({ tone, children, requestId }: StatusMessageProps) {
const Icon = ICONS[tone];
return (
<div className={`status-message status-message--${tone}`} role={tone === "error" ? "alert" : "status"}>
<Icon aria-hidden="true" size={18} strokeWidth={1.8} />
<div>
<p>{children}</p>
{requestId ? <small>Request ID: {requestId}</small> : null}
</div>
</div>
);
}
4.3 Create src/components/waveform.tsx
Create visual feedback that respects reduced-motion preferences.
Open src/components/waveform.tsx, remove any placeholder, and add exactly:
src/components/waveform.tsx
import type { CSSProperties } from "react";
const BAR_HEIGHTS = [34, 58, 42, 76, 48, 88, 64, 38, 72, 54, 92, 60, 44, 80, 50, 68];
type WaveformProps = {
active?: boolean;
compact?: boolean;
label?: string;
};
export function Waveform({
active = false,
compact = false,
label = "Audio waveform",
}: WaveformProps) {
return (
<div
className={`waveform${active ? " waveform--active" : ""}${compact ? " waveform--compact" : ""}`}
role="img"
aria-label={label}
>
{BAR_HEIGHTS.map((height, index) => (
<span
className="waveform__bar"
key={`${height}-${index}`}
style={
{
"--bar-height": `${height}%`,
"--bar-delay": `${index * -70}ms`,
} as CSSProperties
}
/>
))}
</div>
);
}
4.4 Create src/components/speech-studio.tsx
Implement the form, request, cancellation, playback, download, and audio cleanup.
Open src/components/speech-studio.tsx, remove any placeholder, and add exactly:
src/components/speech-studio.tsx
"use client";
import {
CircleStop,
Download,
Gauge,
LoaderCircle,
Play,
Radio,
Sparkles,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { StatusMessage } from "@/components/status-message";
import { Waveform } from "@/components/waveform";
import {
AUDIO_FORMATS,
MAX_INSTRUCTIONS_CHARACTERS,
MAX_SPEECH_CHARACTERS,
VOICES,
type AudioFormat,
type VoiceId,
} from "@/lib/constants";
import {
authorizationHeaders,
formatBytes,
readApiError,
type ClientApiError,
} from "@/lib/client-api";
type SpeechStudioProps = {
accessToken: string;
disabled: boolean;
};
const DEFAULT_TEXT =
"Software de qualidade não nasce apenas do código. Ele nasce das decisões que conseguimos explicar, testar e evoluir em equipe.";
export function SpeechStudio({ accessToken, disabled }: SpeechStudioProps) {
const [text, setText] = useState(DEFAULT_TEXT);
const [instructions, setInstructions] = useState(
"Fale em português do Brasil, com um tom confiante, acolhedor e didático.",
);
const [voice, setVoice] = useState<VoiceId>("marin");
const [format, setFormat] = useState<AudioFormat>("mp3");
const [speed, setSpeed] = useState(1);
const [isGenerating, setIsGenerating] = useState(false);
const [receivedBytes, setReceivedBytes] = useState(0);
const [audioUrl, setAudioUrl] = useState<string>();
const [error, setError] = useState<ClientApiError>();
const [requestId, setRequestId] = useState<string>();
const abortControllerRef = useRef<AbortController | undefined>(undefined);
const currentObjectUrlRef = useRef<string | undefined>(undefined);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
if (currentObjectUrlRef.current) URL.revokeObjectURL(currentObjectUrlRef.current);
};
}, []);
async function generateSpeech() {
if (!text.trim() || isGenerating || disabled) return;
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsGenerating(true);
setReceivedBytes(0);
setError(undefined);
setRequestId(undefined);
try {
const response = await fetch("/api/speech", {
method: "POST",
headers: {
"Content-Type": "application/json",
...authorizationHeaders(accessToken),
},
body: JSON.stringify({ text, instructions, voice, format, speed }),
signal: controller.signal,
});
if (!response.ok) throw await readApiError(response);
if (!response.body) throw { message: "The browser did not receive an audio stream." };
setRequestId(response.headers.get("x-request-id") || undefined);
const reader = response.body.getReader();
const chunks: ArrayBuffer[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const copy = new Uint8Array(value.byteLength);
copy.set(value);
chunks.push(copy.buffer);
totalBytes += value.byteLength;
setReceivedBytes(totalBytes);
}
const blob = new Blob(chunks, {
type: response.headers.get("content-type") || "audio/mpeg",
});
const nextUrl = URL.createObjectURL(blob);
if (currentObjectUrlRef.current) URL.revokeObjectURL(currentObjectUrlRef.current);
currentObjectUrlRef.current = nextUrl;
setAudioUrl(nextUrl);
} catch (caught) {
if (controller.signal.aborted) return;
const apiError = caught as ClientApiError;
setError({
message: apiError.message || "The audio could not be generated.",
...(apiError.code ? { code: apiError.code } : {}),
...(apiError.requestId ? { requestId: apiError.requestId } : {}),
});
} finally {
if (abortControllerRef.current === controller) {
setIsGenerating(false);
abortControllerRef.current = undefined;
}
}
}
function cancelGeneration() {
abortControllerRef.current?.abort();
setIsGenerating(false);
setReceivedBytes(0);
}
const selectedVoice = VOICES.find((option) => option.id === voice) ?? VOICES[0];
return (
<div className="studio-layout">
<section className="studio-main" aria-labelledby="speech-title">
<div className="section-heading">
<div>
<span className="eyebrow"><Sparkles size={14} /> Speech generation</span>
<h2 id="speech-title">Give your words a voice</h2>
</div>
<span className="model-chip"><span /> gpt-4o-mini-tts</span>
</div>
<div className="field-group">
<div className="field-label-row">
<label htmlFor="speech-text">Text to speak</label>
<span className={text.length > MAX_SPEECH_CHARACTERS * 0.9 ? "counter counter--warning" : "counter"}>
{text.length.toLocaleString()} / {MAX_SPEECH_CHARACTERS.toLocaleString()}
</span>
</div>
<textarea
id="speech-text"
className="text-area text-area--hero"
value={text}
onChange={(event) => setText(event.target.value)}
maxLength={MAX_SPEECH_CHARACTERS}
rows={7}
placeholder="Write something worth hearing…"
disabled={disabled || isGenerating}
/>
</div>
<div className="field-group">
<div className="field-label-row">
<label htmlFor="voice-instructions">Voice direction</label>
<span className="field-hint">Optional prompt</span>
</div>
<textarea
id="voice-instructions"
className="text-area"
value={instructions}
onChange={(event) => setInstructions(event.target.value)}
maxLength={MAX_INSTRUCTIONS_CHARACTERS}
rows={3}
placeholder="Describe tone, rhythm, accent, emotion, or delivery…"
disabled={disabled || isGenerating}
/>
<p className="helper-text">Describe delivery, not identity. Avoid asking the model to impersonate a real person.</p>
</div>
{error ? (
<StatusMessage tone="error" requestId={error.requestId}>{error.message}</StatusMessage>
) : null}
<div className="action-row">
<button
className="button button--primary"
type="button"
onClick={generateSpeech}
disabled={disabled || isGenerating || !text.trim()}
>
{isGenerating ? <LoaderCircle className="spin" aria-hidden="true" /> : <Play aria-hidden="true" />}
{isGenerating ? "Streaming audio…" : "Generate voice"}
</button>
{isGenerating ? (
<button className="button button--ghost" type="button" onClick={cancelGeneration}>
<CircleStop aria-hidden="true" /> Cancel
</button>
) : null}
<span className="stream-status" aria-live="polite">
{isGenerating && receivedBytes > 0 ? `${formatBytes(receivedBytes)} received` : "Audio streams through your server"}
</span>
</div>
</section>
<aside className="studio-sidebar" aria-label="Speech controls and output">
<div className="control-card">
<div className="control-card__heading"><Radio size={17} /><span>Voice controls</span></div>
<label className="select-label" htmlFor="voice-select">Voice</label>
<div className="select-wrap">
<select
id="voice-select"
value={voice}
onChange={(event) => setVoice(event.target.value as VoiceId)}
disabled={disabled || isGenerating}
>
{VOICES.map((option) => <option value={option.id} key={option.id}>{option.label}</option>)}
</select>
</div>
<p className="selection-note"><strong>{selectedVoice.label}</strong> · {selectedVoice.character}</p>
<div className="control-grid">
<div>
<label className="select-label" htmlFor="format-select">Format</label>
<div className="select-wrap">
<select
id="format-select"
value={format}
onChange={(event) => setFormat(event.target.value as AudioFormat)}
disabled={disabled || isGenerating}
>
{AUDIO_FORMATS.map((option) => <option value={option} key={option}>{option.toUpperCase()}</option>)}
</select>
</div>
</div>
<div>
<label className="select-label" htmlFor="speed-select">Speed</label>
<div className="select-wrap select-wrap--icon">
<Gauge size={15} aria-hidden="true" />
<select
id="speed-select"
value={speed}
onChange={(event) => setSpeed(Number(event.target.value))}
disabled={disabled || isGenerating}
>
<option value={0.75}>0.75×</option>
<option value={1}>1.0×</option>
<option value={1.25}>1.25×</option>
<option value={1.5}>1.5×</option>
</select>
</div>
</div>
</div>
</div>
<div className={`output-card${audioUrl ? " output-card--ready" : ""}`}>
<div className="output-card__topline">
<span>Output</span>
<span className="ai-disclosure"><span /> AI-generated voice</span>
</div>
<Waveform active={isGenerating} label={isGenerating ? "Audio is streaming" : "Generated audio waveform"} />
{audioUrl ? (
<div className="audio-result">
<audio controls src={audioUrl} preload="metadata">Your browser does not support audio playback.</audio>
<a className="button button--secondary button--full" href={audioUrl} download={`openai-voice-${voice}.${format}`}>
<Download aria-hidden="true" /> Download {format.toUpperCase()}
</a>
{requestId ? <small className="request-meta">Request {requestId.slice(0, 8)}…</small> : null}
</div>
) : (
<p className="empty-state">Your generated voice will appear here. Nothing is stored by this app.</p>
)}
</div>
</aside>
</div>
);
}
4.5 Create src/components/voice-playground.tsx
Compose the main presentation and voice experience.
Open src/components/voice-playground.tsx, remove any placeholder, and add exactly:
src/components/voice-playground.tsx
"use client";
import {
BookOpen,
KeyRound,
LoaderCircle,
LockKeyhole,
Server,
ShieldCheck,
Volume2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { SpeechStudio } from "@/components/speech-studio";
import { StatusMessage } from "@/components/status-message";
type HealthState = {
configured: boolean;
configurationIssues: string[];
distributedRateLimit: boolean;
requiresAccessToken: boolean;
};
export function VoicePlayground() {
const [health, setHealth] = useState<HealthState>();
const [healthError, setHealthError] = useState(false);
const [accessToken, setAccessToken] = useState("");
useEffect(() => {
const controller = new AbortController();
void fetch("/api/health", { signal: controller.signal, cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("Health check failed");
return (await response.json()) as HealthState;
})
.then(setHealth)
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === "AbortError")) setHealthError(true);
});
return () => controller.abort();
}, []);
const isLoading = !health && !healthError;
const needsToken = Boolean(health?.requiresAccessToken);
const isDisabled =
isLoading || healthError || !health?.configured || (needsToken && !accessToken.trim());
return (
<section className="playground-shell" id="playground" aria-labelledby="playground-heading">
<div className="playground-intro">
<div>
<span className="section-kicker">Interactive lab</span>
<h2 id="playground-heading">Turn text into expressive speech</h2>
<p>Control voice, delivery, speed, and format while every request crosses a typed, validated server boundary.</p>
</div>
<div className="architecture-strip" aria-label="Request architecture">
<span><Volume2 aria-hidden="true" /> Browser</span>
<i aria-hidden="true">→</i>
<span><Server aria-hidden="true" /> Next route</span>
<i aria-hidden="true">→</i>
<span><ShieldCheck aria-hidden="true" /> OpenAI</span>
</div>
</div>
{isLoading ? (
<div className="configuration-banner configuration-banner--loading" role="status">
<LoaderCircle className="spin" aria-hidden="true" /> Checking server configuration…
</div>
) : null}
{healthError ? (
<StatusMessage tone="error">The app could not verify its server configuration. Refresh the page or inspect the health route.</StatusMessage>
) : null}
{health && !health.configured ? (
<StatusMessage tone="error">Server configuration is incomplete. Add these environment variables: {health.configurationIssues.join(", ")}.</StatusMessage>
) : null}
{health?.configured ? (
<div className="configuration-banner">
<span><LockKeyhole aria-hidden="true" /> Server key isolated</span>
<span><ShieldCheck aria-hidden="true" /> Inputs validated</span>
<span><ShieldCheck aria-hidden="true" /> {health.distributedRateLimit ? "Distributed quota" : "Local development quota"}</span>
<span><Volume2 aria-hidden="true" /> Voice disclosed as AI</span>
</div>
) : null}
{needsToken ? (
<div className="access-gate">
<div className="access-gate__icon"><KeyRound aria-hidden="true" /></div>
<div>
<strong>This deployment is access-protected</strong>
<p>Enter the shared playground token. It stays in memory and is never written to browser storage.</p>
</div>
<label>
<span className="visually-hidden">Playground access token</span>
<input
type="password"
value={accessToken}
onChange={(event) => setAccessToken(event.target.value)}
placeholder="Access token"
autoComplete="off"
/>
</label>
</div>
) : null}
<div className="playground-card">
<div className="tab-list">
<div className="tab tab--active">
<Volume2 aria-hidden="true" />
<span><strong>Text to speech</strong><small>Make text sound alive</small></span>
</div>
<a className="tutorial-link" href="https://github.com/glaucia86/openai-voice-playground/blob/main/labs/lab-01-text-to-speech/tutorial/tutorial.md" target="_blank" rel="noreferrer">
<BookOpen aria-hidden="true" /> Follow Lab 01
</a>
</div>
<SpeechStudio accessToken={accessToken} disabled={isDisabled} />
</div>
</section>
);
}
4.6 Create src/app/layout.tsx
Define metadata, fonts, language, and the root page structure.
Open src/app/layout.tsx, remove any placeholder, and add exactly:
src/app/layout.tsx
import "@fontsource-variable/jetbrains-mono";
import "@fontsource-variable/manrope";
import type { Metadata, Viewport } from "next";
import { headers } from "next/headers";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL("https://openai-voice-playground.vercel.app"),
title: {
default: "Lab 01 · OpenAI Text to Speech",
template: "%s · OpenAI Voice Labs",
},
description:
"A production-minded workshop for building OpenAI text-to-speech applications with Next.js.",
applicationName: "OpenAI Voice Labs · Lab 01",
authors: [{ name: "Glaucia Lemos", url: "https://github.com/glaucia86" }],
keywords: ["OpenAI", "voice", "text to speech", "TTS", "Next.js", "TypeScript"],
openGraph: {
title: "Lab 01 · OpenAI Text to Speech",
description: "Build a responsible, production-minded text-to-speech experience with OpenAI.",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Lab 01 · OpenAI Text to Speech",
description: "Build a responsible, production-minded text-to-speech experience with OpenAI.",
},
};
export const viewport: Viewport = {
colorScheme: "dark",
themeColor: "#070b12",
};
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await headers();
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
4.7 Create src/app/page.tsx
Render the main experience through the App Router.
Open src/app/page.tsx, remove any placeholder, and add exactly:
src/app/page.tsx
import {
ArrowDown,
ArrowUpRight,
BookOpenText,
Braces,
Code2,
LockKeyhole,
RadioTower,
ShieldCheck,
Sparkles,
TestTube2,
Waves,
} from "lucide-react";
import { VoicePlayground } from "@/components/voice-playground";
import { Waveform } from "@/components/waveform";
const REPOSITORY_URL = "https://github.com/glaucia86/openai-voice-playground";
const LAB_URL = `${REPOSITORY_URL}/tree/main/labs/lab-01-text-to-speech`;
const GUIDE_URL = `${REPOSITORY_URL}/blob/main/labs/lab-01-text-to-speech/tutorial/tutorial.md`;
export default function HomePage() {
return (
<main>
<header className="site-header">
<a className="brand" href="#top" aria-label="OpenAI Voice Playground home">
<span className="brand__mark" aria-hidden="true"><Waves /></span>
<span className="brand__copy"><strong>Voice Playground</strong><small>OpenAI · educational OSS</small></span>
</a>
<nav className="site-nav" aria-label="Primary navigation">
<a href="#playground">Playground</a>
<a href="#principles">Principles</a>
<a href={GUIDE_URL} target="_blank" rel="noreferrer">Lab 01 guide</a>
</nav>
<a className="github-button" href={LAB_URL} target="_blank" rel="noreferrer">
<Code2 aria-hidden="true" /> <span>View source</span> <ArrowUpRight aria-hidden="true" />
</a>
</header>
<div id="top" className="hero-wrap">
<section className="hero">
<div className="hero__copy">
<div className="hero-badge"><span className="hero-badge__pulse" /> Production patterns, explained</div>
<h1>Turn text into speech people can <em>trust.</em></h1>
<p className="hero__lede">
A hands-on text-to-speech lab designed to teach the server boundaries, streaming, safeguards, and trade-offs behind the demo.
</p>
<div className="hero__actions">
<a className="button button--primary button--large" href="#playground"><Sparkles aria-hidden="true" /> Open the playground</a>
<a className="button button--ghost button--large" href={GUIDE_URL} target="_blank" rel="noreferrer"><BookOpenText aria-hidden="true" /> Follow Lab 01</a>
</div>
<dl className="hero__facts">
<div><dt>13</dt><dd>built-in voices</dd></div>
<div><dt>3</dt><dd>audio formats</dd></div>
<div><dt>0</dt><dd>keys in the browser</dd></div>
</dl>
</div>
<div className="hero-visual" aria-label="Abstract voice pipeline visualization">
<div className="hero-visual__glow" />
<div className="signal-card signal-card--input">
<span className="signal-card__icon"><Braces /></span>
<span><small>Input</small><strong>Typed request</strong></span>
<i>01</i>
</div>
<div className="sound-orb">
<span className="sound-orb__ring sound-orb__ring--one" />
<span className="sound-orb__ring sound-orb__ring--two" />
<span className="sound-orb__core"><Waves /></span>
</div>
<div className="visual-wave"><Waveform active label="Animated generated voice signal" /></div>
<div className="signal-card signal-card--output">
<span className="signal-card__icon signal-card__icon--green"><RadioTower /></span>
<span><small>Output</small><strong>Streamed audio</strong></span>
<i>02</i>
</div>
<div className="visual-note"><LockKeyhole /> OPENAI_API_KEY stays server-side</div>
</div>
</section>
<a className="scroll-cue" href="#playground"><span>Explore the lab</span><ArrowDown aria-hidden="true" /></a>
</div>
<VoicePlayground />
<section className="principles" id="principles" aria-labelledby="principles-title">
<div className="principles__heading">
<span className="section-kicker">Beyond the happy path</span>
<h2 id="principles-title">The demo is small. The engineering lessons are not.</h2>
<p>Each layer exists because a production team eventually pays for shortcuts—in security, cost, usability, or maintainability.</p>
</div>
<div className="principles-grid">
<article className="principle-card principle-card--featured">
<span className="principle-card__number">01</span>
<div className="principle-card__icon"><LockKeyhole /></div>
<h3>A deliberate server boundary</h3>
<p>The browser knows your product contract, never your provider credential. Route Handlers validate and translate the request.</p>
<code>browser → /api/* → OpenAI</code>
</article>
<article className="principle-card">
<span className="principle-card__number">02</span>
<div className="principle-card__icon"><ShieldCheck /></div>
<h3>Guardrails that users can see</h3>
<p>Size limits, allowlists, AI-voice disclosure, same-origin checks, and actionable errors are part of the experience.</p>
</article>
<article className="principle-card">
<span className="principle-card__number">03</span>
<div className="principle-card__icon"><RadioTower /></div>
<h3>Streaming with a reason</h3>
<p>The Route Handler forwards the upstream audio stream instead of buffering the entire file in server memory.</p>
</article>
<article className="principle-card">
<span className="principle-card__number">04</span>
<div className="principle-card__icon"><TestTube2 /></div>
<h3>A repeatable delivery loop</h3>
<p>Strict TypeScript, focused unit tests, CI gates, request IDs, and an AGENTS.md make change safer for humans and Codex.</p>
</article>
</div>
</section>
<section className="learn-cta">
<div>
<span className="section-kicker">Build it, don’t just clone it</span>
<h2>Follow every decision from empty folder to deployment.</h2>
<p>The tutorial documents the incremental slices, validation gates, trade-offs, and the prompts used to collaborate with Codex.</p>
</div>
<a className="button button--primary button--large" href={GUIDE_URL} target="_blank" rel="noreferrer">
<BookOpenText aria-hidden="true" /> Read the Lab 01 workshop
</a>
</section>
<footer className="site-footer">
<div className="brand">
<span className="brand__mark" aria-hidden="true"><Waves /></span>
<span className="brand__copy"><strong>Voice Playground</strong><small>Built to be read, run, and questioned.</small></span>
</div>
<p>Created by <a href="https://github.com/glaucia86" target="_blank" rel="noreferrer">Glaucia Lemos</a> · MIT licensed · Not an official OpenAI product.</p>
<div className="footer-links">
<a href={REPOSITORY_URL} target="_blank" rel="noreferrer">GitHub</a>
<a href="https://developers.openai.com/api/docs/guides/audio" target="_blank" rel="noreferrer">OpenAI audio docs</a>
</div>
</footer>
</main>
);
}
4.8 Create src/app/globals.css
Apply the responsive visual system, visible focus, contrast, and reduced motion.
Open src/app/globals.css, remove any placeholder, and add exactly:
src/app/globals.css
:root {
--bg: #070b12;
--bg-raised: #0a1019;
--surface: #0e1622;
--surface-2: #111c2a;
--surface-3: #162333;
--border: rgba(181, 199, 224, 0.13);
--border-strong: rgba(181, 199, 224, 0.22);
--text: #f4f7fb;
--muted: #94a2b8;
--muted-strong: #bac4d4;
--accent: #8bffcc;
--accent-strong: #5beab0;
--accent-ink: #042318;
--blue: #8da7ff;
--coral: #ff8a72;
--danger: #ff8e96;
--success: #8bffcc;
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--radius-xl: 32px;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 96px; }
body {
margin: 0;
background:
radial-gradient(circle at 72% 4%, rgba(93, 124, 255, 0.10), transparent 27rem),
radial-gradient(circle at 15% 31%, rgba(64, 222, 164, 0.055), transparent 24rem),
var(--bg);
color: var(--text);
font-family: "Manrope Variable", "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.6;
min-width: 320px;
}
body::before {
position: fixed;
inset: 0;
pointer-events: none;
content: "";
opacity: 0.13;
background-image: linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: linear-gradient(to bottom, black, transparent 72%);
}
::selection { color: var(--accent-ink); background: var(--accent); }
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.site-header {
position: sticky;
z-index: 40;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
width: min(100% - 40px, 1240px);
min-height: 76px;
margin: 0 auto;
border-bottom: 1px solid var(--border);
background: rgba(7, 11, 18, 0.72);
backdrop-filter: blur(18px);
}
.brand { display: inline-flex; align-items: center; gap: 12px; }
.brand__mark { display: grid; width: 42px; height: 42px; place-items: center; border: 1px solid rgba(139, 255, 204, 0.28); border-radius: 13px; color: var(--accent); background: linear-gradient(145deg, rgba(139, 255, 204, 0.12), rgba(141, 167, 255, 0.05)); box-shadow: inset 0 0 24px rgba(139, 255, 204, 0.05); }
.brand__mark svg { width: 22px; }
.brand__copy { display: flex; flex-direction: column; line-height: 1.25; }
.brand__copy strong { font-size: 14px; letter-spacing: -0.01em; }
.brand__copy small { margin-top: 3px; color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; }
.site-nav { display: flex; gap: 32px; color: var(--muted-strong); font-size: 13px; font-weight: 650; }
.site-nav a { transition: color 160ms ease; }
.site-nav a:hover { color: var(--text); }
.github-button { display: inline-flex; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: 999px; color: var(--muted-strong); background: rgba(255,255,255,.025); font-size: 12px; font-weight: 700; transition: border-color 160ms ease, color 160ms ease, transform 160ms ease; }
.github-button svg { width: 16px; }
.github-button svg:last-child { width: 13px; color: var(--muted); }
.github-button:hover { transform: translateY(-1px); border-color: rgba(139,255,204,.35); color: var(--text); }
.hero-wrap { position: relative; width: min(100% - 40px, 1240px); margin: 0 auto; padding: 100px 0 130px; }
.hero { display: grid; grid-template-columns: minmax(0, 1.02fr) minmax(420px, .98fr); align-items: center; gap: clamp(48px, 8vw, 110px); min-height: 590px; }
.hero__copy { position: relative; z-index: 2; }
.hero-badge { display: inline-flex; align-items: center; gap: 9px; margin-bottom: 24px; padding: 7px 11px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted-strong); background: rgba(14,22,34,.7); font-family: "JetBrains Mono Variable", monospace; font-size: 10px; letter-spacing: .07em; text-transform: uppercase; }
.hero-badge__pulse { position: relative; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 14px var(--accent); }
.hero-badge__pulse::after { position: absolute; inset: -4px; border: 1px solid var(--accent); border-radius: inherit; content: ""; animation: pulse 2s ease-out infinite; }
.hero h1 { max-width: 730px; margin: 0; font-size: clamp(48px, 6.2vw, 82px); font-weight: 650; letter-spacing: -.065em; line-height: .99; }
.hero h1 em { color: var(--accent); font-family: Georgia, serif; font-weight: 400; }
.hero__lede { max-width: 640px; margin: 28px 0 0; color: var(--muted-strong); font-size: clamp(17px, 2vw, 20px); line-height: 1.65; }
.hero__actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 36px; }
.button { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; gap: 9px; padding: 10px 16px; border: 1px solid transparent; border-radius: 11px; cursor: pointer; font-size: 13px; font-weight: 750; line-height: 1; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, opacity 160ms ease; }
.button svg { width: 17px; height: 17px; stroke-width: 1.9; }
.button:hover:not(:disabled) { transform: translateY(-1px); }
.button:active:not(:disabled) { transform: translateY(0); }
.button:disabled { cursor: not-allowed; opacity: .45; }
.button--large { min-height: 50px; padding: 13px 19px; border-radius: 13px; }
.button--primary { color: var(--accent-ink); background: var(--accent); box-shadow: 0 10px 35px rgba(83, 233, 173, .12); }
.button--primary:hover:not(:disabled) { background: #a0ffd8; }
.button--secondary { border-color: var(--border-strong); background: rgba(255,255,255,.045); }
.button--secondary:hover:not(:disabled) { border-color: rgba(139,255,204,.36); background: rgba(139,255,204,.07); }
.button--ghost { border-color: var(--border); color: var(--muted-strong); background: transparent; }
.button--ghost:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text); background: rgba(255,255,255,.035); }
.button--full { width: 100%; }
.hero__facts { display: flex; gap: 30px; margin: 45px 0 0; }
.hero__facts div { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 7px; }
.hero__facts dt { color: var(--text); font-family: "JetBrains Mono Variable", monospace; font-size: 18px; font-weight: 700; }
.hero__facts dd { margin: 0; color: var(--muted); font-size: 11px; }
.hero-visual { position: relative; min-height: 530px; border: 1px solid var(--border); border-radius: 34px; background: linear-gradient(155deg, rgba(20,32,48,.75), rgba(8,13,21,.78)); box-shadow: var(--shadow), inset 0 1px 0 rgba(255,255,255,.04); overflow: hidden; isolation: isolate; }
.hero-visual::before { position: absolute; inset: 0; content: ""; background-image: radial-gradient(circle, rgba(181,199,224,.23) 1px, transparent 1px); background-size: 28px 28px; mask-image: radial-gradient(circle at center, black, transparent 73%); opacity: .32; }
.hero-visual__glow { position: absolute; z-index: -1; top: 21%; left: 50%; width: 310px; height: 310px; border-radius: 50%; background: radial-gradient(circle, rgba(94,225,175,.22), rgba(92,119,255,.1) 38%, transparent 69%); filter: blur(8px); transform: translateX(-50%); }
.sound-orb { position: absolute; top: 95px; left: 50%; width: 225px; height: 225px; transform: translateX(-50%); }
.sound-orb__ring { position: absolute; border: 1px solid rgba(139,255,204,.22); border-radius: 50%; inset: 0; animation: orbit 12s linear infinite; }
.sound-orb__ring::after { position: absolute; top: 22px; right: 22px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 15px var(--accent); content: ""; }
.sound-orb__ring--two { inset: 24px; border-color: rgba(141,167,255,.25); animation-direction: reverse; animation-duration: 8s; }
.sound-orb__ring--two::after { top: auto; right: auto; bottom: 8px; left: 40px; width: 6px; height: 6px; background: var(--blue); box-shadow: 0 0 14px var(--blue); }
.sound-orb__core { position: absolute; inset: 56px; display: grid; place-items: center; border: 1px solid rgba(139,255,204,.36); border-radius: 50%; color: var(--accent); background: radial-gradient(circle at 35% 30%, rgba(139,255,204,.28), rgba(15,27,38,.92) 58%); box-shadow: 0 0 55px rgba(139,255,204,.14), inset 0 0 30px rgba(139,255,204,.08); }
.sound-orb__core svg { width: 37px; height: 37px; }
.visual-wave { position: absolute; top: 335px; right: 12%; left: 12%; }
.signal-card { position: absolute; z-index: 2; display: flex; min-width: 180px; align-items: center; gap: 10px; padding: 11px 12px; border: 1px solid var(--border-strong); border-radius: 14px; background: rgba(11,18,29,.82); box-shadow: 0 16px 35px rgba(0,0,0,.2); backdrop-filter: blur(10px); }
.signal-card--input { top: 34px; left: 25px; }
.signal-card--output { right: 25px; bottom: 31px; }
.signal-card__icon { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 9px; color: var(--blue); background: rgba(141,167,255,.1); }
.signal-card__icon--green { color: var(--accent); background: rgba(139,255,204,.1); }
.signal-card__icon svg { width: 17px; }
.signal-card span:nth-child(2) { display: flex; flex-direction: column; line-height: 1.3; }
.signal-card small { color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 8px; letter-spacing: .08em; text-transform: uppercase; }
.signal-card strong { margin-top: 3px; font-size: 11px; }
.signal-card i { margin-left: auto; color: rgba(181,199,224,.28); font-family: "JetBrains Mono Variable", monospace; font-size: 9px; font-style: normal; }
.visual-note { position: absolute; bottom: 37px; left: 28px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 8px; letter-spacing: .04em; }
.visual-note svg { width: 12px; color: var(--accent); }
.scroll-cue { position: absolute; bottom: 48px; left: 0; display: inline-flex; align-items: center; gap: 10px; color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
.scroll-cue svg { width: 14px; animation: nudge 1.8s ease-in-out infinite; }
.waveform { display: flex; height: 92px; align-items: center; justify-content: center; gap: 5px; padding: 12px 0; }
.waveform__bar { width: 3px; height: var(--bar-height); min-height: 5px; border-radius: 999px; background: linear-gradient(to top, rgba(141,167,255,.42), var(--accent)); opacity: .58; transform-origin: center; }
.waveform--active .waveform__bar { animation: wave 1.1s ease-in-out var(--bar-delay) infinite alternate; opacity: .95; }
.waveform--compact { width: 105px; height: 35px; gap: 3px; padding: 2px 0; }
.waveform--compact .waveform__bar { width: 2px; }
.playground-shell { width: min(100% - 40px, 1240px); margin: 0 auto; padding: 95px 0 120px; }
.playground-intro { display: flex; align-items: flex-end; justify-content: space-between; gap: 40px; margin-bottom: 35px; }
.playground-intro > div:first-child { max-width: 650px; }
.section-kicker { display: block; margin-bottom: 10px; color: var(--accent); font-family: "JetBrains Mono Variable", monospace; font-size: 10px; font-weight: 650; letter-spacing: .12em; text-transform: uppercase; }
.playground-intro h2, .principles__heading h2, .learn-cta h2 { margin: 0; font-size: clamp(34px, 4.5vw, 54px); font-weight: 620; letter-spacing: -.045em; line-height: 1.08; }
.playground-intro p, .principles__heading p, .learn-cta p { margin: 15px 0 0; color: var(--muted); }
.architecture-strip { display: flex; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--border); border-radius: 13px; background: rgba(14,22,34,.54); }
.architecture-strip span { display: inline-flex; align-items: center; gap: 6px; padding: 6px 8px; color: var(--muted-strong); font-family: "JetBrains Mono Variable", monospace; font-size: 8px; letter-spacing: .04em; text-transform: uppercase; }
.architecture-strip svg { width: 13px; color: var(--accent); }
.architecture-strip i { color: rgba(181,199,224,.3); font-size: 11px; font-style: normal; }
.configuration-banner { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; margin-bottom: 14px; padding: 11px 15px; border: 1px solid rgba(139,255,204,.14); border-radius: 12px; color: var(--muted-strong); background: rgba(139,255,204,.035); font-size: 11px; }
.configuration-banner > span { display: inline-flex; align-items: center; gap: 7px; }
.configuration-banner svg { width: 14px; color: var(--accent); }
.configuration-banner--loading { color: var(--muted); border-color: var(--border); background: rgba(255,255,255,.02); }
.access-gate { display: grid; grid-template-columns: auto 1fr minmax(180px, 280px); align-items: center; gap: 16px; margin-bottom: 14px; padding: 16px; border: 1px solid rgba(141,167,255,.24); border-radius: 15px; background: rgba(141,167,255,.055); }
.access-gate__icon { display: grid; width: 38px; height: 38px; place-items: center; border-radius: 11px; color: var(--blue); background: rgba(141,167,255,.1); }
.access-gate__icon svg { width: 18px; }
.access-gate strong { font-size: 12px; }
.access-gate p { margin: 2px 0 0; color: var(--muted); font-size: 10px; }
.access-gate input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-strong); border-radius: 9px; outline: none; color: var(--text); background: rgba(3,8,14,.38); font-size: 12px; }
.access-gate input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(141,167,255,.1); }
.playground-card { border: 1px solid var(--border-strong); border-radius: 24px; background: rgba(10,16,25,.88); box-shadow: var(--shadow), inset 0 1px 0 rgba(255,255,255,.025); overflow: hidden; }
.tab-list { display: grid; grid-template-columns: 230px 1fr; gap: 5px; padding: 10px; border-bottom: 1px solid var(--border); background: rgba(255,255,255,.012); }
.tab { display: flex; align-items: center; gap: 11px; min-height: 60px; padding: 9px 12px; border: 1px solid transparent; border-radius: 12px; color: var(--muted); background: transparent; text-align: left; }
.tab > svg { width: 20px; }
.tab span { display: flex; flex-direction: column; line-height: 1.25; }
.tab strong { font-size: 11px; }
.tab small { margin-top: 4px; font-size: 9px; }
.tab--active { border-color: var(--border); color: var(--text); background: var(--surface-2); box-shadow: 0 7px 22px rgba(0,0,0,.18); }
.tab--active > svg { color: var(--accent); }
.tutorial-link { align-self: center; justify-self: end; display: inline-flex; align-items: center; gap: 7px; margin-right: 9px; color: var(--muted); font-size: 10px; font-weight: 700; transition: color 160ms ease; }
.tutorial-link svg { width: 14px; }
.tutorial-link:hover { color: var(--accent); }
.studio-layout { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .82fr); }
.studio-main { padding: clamp(24px, 4vw, 46px); border-right: 1px solid var(--border); }
.studio-sidebar { display: flex; flex-direction: column; gap: 14px; padding: 22px; background: rgba(255,255,255,.012); }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 34px; }
.section-heading h2 { margin: 5px 0 0; font-size: clamp(27px, 3vw, 38px); font-weight: 620; letter-spacing: -.04em; line-height: 1.13; }
.eyebrow { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); font-family: "JetBrains Mono Variable", monospace; font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
.model-chip { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 7px; padding: 6px 9px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 8px; }
.model-chip > span { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px rgba(139,255,204,.6); }
.field-group { margin-bottom: 24px; }
.field-label-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.field-label-row label, .select-label { color: var(--muted-strong); font-size: 10px; font-weight: 750; letter-spacing: .02em; }
.counter, .field-hint { color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 8px; }
.counter--warning { color: var(--coral); }
.text-area { width: 100%; resize: vertical; padding: 13px 14px; border: 1px solid var(--border); border-radius: 12px; outline: none; color: var(--text); background: rgba(5,10,17,.5); caret-color: var(--accent); font-size: 12px; line-height: 1.65; transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; }
.text-area--hero { min-height: 160px; padding: 18px; font-size: 16px; line-height: 1.7; }
.text-area--small { min-height: 85px; margin-top: 7px; resize: vertical; font-size: 10px; }
.text-area:hover:not(:disabled) { border-color: var(--border-strong); }
.text-area:focus { border-color: rgba(139,255,204,.45); background: rgba(8,15,23,.75); box-shadow: 0 0 0 3px rgba(139,255,204,.06); }
.text-area::placeholder { color: #59667a; }
.text-area:disabled { cursor: not-allowed; opacity: .55; }
.helper-text { margin: 7px 0 0; color: var(--muted); font-size: 9px; line-height: 1.55; }
.action-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 26px; }
.stream-status { margin-left: auto; color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 8px; }
.control-card, .output-card, .transcript-card { padding: 17px; border: 1px solid var(--border); border-radius: 16px; background: rgba(13,21,32,.72); }
.control-card__heading { display: flex; align-items: center; gap: 8px; margin-bottom: 18px; color: var(--text); font-size: 11px; font-weight: 750; }
.control-card__heading svg { color: var(--accent); }
.select-label { display: block; margin: 13px 0 6px; }
.select-wrap { position: relative; border: 1px solid var(--border); border-radius: 10px; background: rgba(5,10,17,.47); transition: border-color 160ms ease; }
.select-wrap:hover { border-color: var(--border-strong); }
.select-wrap:focus-within { border-color: rgba(139,255,204,.4); box-shadow: 0 0 0 3px rgba(139,255,204,.05); }
.select-wrap select { width: 100%; padding: 9px 29px 9px 10px; border: 0; outline: 0; appearance: none; color: var(--muted-strong); background: transparent; cursor: pointer; font-size: 10px; }
.select-wrap::after { position: absolute; top: 50%; right: 10px; width: 6px; height: 6px; border-right: 1px solid var(--muted); border-bottom: 1px solid var(--muted); pointer-events: none; content: ""; transform: translateY(-70%) rotate(45deg); }
.select-wrap--icon { display: flex; align-items: center; padding-left: 8px; }
.select-wrap--icon > svg { width: 14px; flex: 0 0 auto; color: var(--muted); }
.select-wrap--icon select { padding-left: 5px; }
.selection-note { min-height: 28px; margin: 7px 0 0; color: var(--muted); font-size: 8px; line-height: 1.45; }
.selection-note strong { color: var(--muted-strong); }
.control-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.output-card { flex: 1; min-height: 270px; background: linear-gradient(145deg, rgba(20,33,48,.7), rgba(9,15,24,.84)); overflow: hidden; }
.output-card--ready { border-color: rgba(139,255,204,.2); }
.output-card__topline { display: flex; align-items: center; justify-content: space-between; color: var(--muted-strong); font-size: 9px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.ai-disclosure { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 7px; font-weight: 500; }
.ai-disclosure > span { width: 5px; height: 5px; border-radius: 50%; background: var(--coral); box-shadow: 0 0 8px rgba(255,138,114,.5); }
.empty-state { max-width: 230px; margin: 12px auto 0; color: var(--muted); font-size: 9px; text-align: center; }
.audio-result audio { width: 100%; height: 36px; margin: 8px 0 12px; color-scheme: dark; }
.request-meta { display: block; margin-top: 9px; color: var(--muted); font-family: "JetBrains Mono Variable", monospace; font-size: 7px; text-align: center; }
.status-message { display: flex; align-items: flex-start; gap: 10px; margin: 14px 0; padding: 12px 13px; border: 1px solid var(--border); border-radius: 11px; font-size: 10px; line-height: 1.5; }
.status-message > svg { flex: 0 0 auto; margin-top: 1px; }
.status-message p { margin: 0; }
.status-message small { display: block; margin-top: 4px; font-family: "JetBrains Mono Variable", monospace; font-size: 7px; opacity: .7; }
.status-message--error { border-color: rgba(255,142,150,.22); color: #ffc2c7; background: rgba(255,77,90,.055); }
.status-message--success { border-color: rgba(139,255,204,.2); color: var(--success); background: rgba(139,255,204,.05); }
.status-message--info { border-color: rgba(141,167,255,.2); color: #bac8ff; background: rgba(141,167,255,.05); }
.principles { width: min(100% - 40px, 1240px); margin: 0 auto; padding: 110px 0 130px; border-top: 1px solid var(--border); }
.principles__heading { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(260px, .8fr); align-items: end; gap: 60px; margin-bottom: 46px; }
.principles__heading .section-kicker { grid-column: 1 / -1; margin-bottom: -45px; }
.principles__heading p { max-width: 400px; }
.principles-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.principle-card { position: relative; min-height: 285px; padding: 23px; border: 1px solid var(--border); border-radius: 19px; background: linear-gradient(155deg, rgba(17,28,42,.67), rgba(9,15,23,.72)); overflow: hidden; transition: transform 180ms ease, border-color 180ms ease; }
.principle-card:hover { transform: translateY(-3px); border-color: var(--border-strong); }
.principle-card--featured { background: linear-gradient(155deg, rgba(33,65,59,.67), rgba(9,20,21,.8)); }
.principle-card__number { position: absolute; top: 19px; right: 21px; color: rgba(181,199,224,.3); font-family: "JetBrains Mono Variable", monospace; font-size: 9px; }
.principle-card__icon { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 12px; color: var(--accent); background: rgba(139,255,204,.08); }
.principle-card__icon svg { width: 19px; }
.principle-card h3 { margin: 28px 0 10px; font-size: 16px; font-weight: 650; letter-spacing: -.02em; line-height: 1.3; }
.principle-card p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.7; }
.principle-card code { position: absolute; right: 20px; bottom: 20px; left: 20px; padding: 8px; border: 1px solid rgba(139,255,204,.11); border-radius: 8px; color: var(--accent); background: rgba(3,11,10,.35); font-family: "JetBrains Mono Variable", monospace; font-size: 8px; text-align: center; }
.learn-cta { display: flex; width: min(100% - 40px, 1240px); align-items: center; justify-content: space-between; gap: 50px; margin: 0 auto 120px; padding: clamp(32px, 6vw, 70px); border: 1px solid rgba(139,255,204,.18); border-radius: 28px; background: radial-gradient(circle at 85% 10%, rgba(141,167,255,.12), transparent 19rem), linear-gradient(135deg, rgba(23,49,44,.88), rgba(10,20,26,.91)); box-shadow: var(--shadow); }
.learn-cta > div { max-width: 720px; }
.learn-cta h2 { font-size: clamp(32px, 4vw, 49px); }
.learn-cta .button { flex: 0 0 auto; }
.site-footer { display: grid; width: min(100% - 40px, 1240px); grid-template-columns: auto 1fr auto; align-items: center; gap: 35px; margin: 0 auto; padding: 34px 0 46px; border-top: 1px solid var(--border); }
.site-footer p { margin: 0; color: var(--muted); font-size: 9px; text-align: center; }
.site-footer p a, .footer-links a { color: var(--muted-strong); }
.site-footer p a:hover, .footer-links a:hover { color: var(--accent); }
.footer-links { display: flex; gap: 17px; color: var(--muted); font-size: 9px; }
.spin { animation: spin .8s linear infinite; }
@keyframes pulse { 0% { opacity: .8; transform: scale(.7); } 70%, 100% { opacity: 0; transform: scale(1.8); } }
@keyframes orbit { to { transform: rotate(360deg); } }
@keyframes wave { from { transform: scaleY(.34); } to { transform: scaleY(1); } }
@keyframes nudge { 0%, 100% { transform: translateY(-2px); } 50% { transform: translateY(3px); } }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 1020px) {
.hero { grid-template-columns: 1fr; }
.hero__copy { max-width: 760px; }
.hero-visual { width: min(100%, 620px); min-height: 500px; justify-self: center; }
.scroll-cue { display: none; }
.site-nav { display: none; }
.playground-intro { align-items: start; flex-direction: column; }
.studio-layout { grid-template-columns: 1fr; }
.studio-main { border-right: 0; border-bottom: 1px solid var(--border); }
.studio-sidebar { display: grid; grid-template-columns: 1fr 1fr; }
.principles-grid { grid-template-columns: repeat(2, 1fr); }
.learn-cta { align-items: flex-start; flex-direction: column; }
}
@media (max-width: 720px) {
.site-header, .hero-wrap, .playground-shell, .principles, .learn-cta, .site-footer { width: min(100% - 24px, 1240px); }
.site-header { min-height: 68px; }
.github-button span { display: none; }
.brand__mark { width: 38px; height: 38px; }
.hero-wrap { padding: 70px 0 95px; }
.hero { min-height: auto; gap: 58px; }
.hero h1 { font-size: clamp(43px, 14vw, 66px); }
.hero__lede { font-size: 16px; }
.hero__facts { gap: 15px; }
.hero__facts div { display: flex; align-items: flex-start; flex-direction: column; gap: 1px; }
.hero-visual { min-height: 430px; border-radius: 24px; }
.sound-orb { top: 75px; width: 190px; height: 190px; }
.sound-orb__core { inset: 48px; }
.visual-wave { top: 278px; }
.signal-card { min-width: 150px; }
.signal-card--input { top: 18px; left: 15px; }
.signal-card--output { right: 15px; bottom: 17px; }
.visual-note { display: none; }
.playground-shell { padding: 70px 0 90px; }
.architecture-strip { width: 100%; overflow-x: auto; }
.architecture-strip span { white-space: nowrap; }
.access-gate { grid-template-columns: auto 1fr; }
.access-gate label { grid-column: 1 / -1; }
.playground-card { border-radius: 18px; }
.tab-list { grid-template-columns: 1fr; }
.tutorial-link { display: none; }
.tab { min-height: 56px; }
.tab small { display: none; }
.studio-main { padding: 25px 18px; }
.studio-sidebar { display: flex; padding: 16px; }
.section-heading { align-items: flex-start; flex-direction: column; margin-bottom: 25px; }
.section-heading h2 { font-size: 29px; }
.text-area--hero { min-height: 150px; font-size: 14px; }
.stream-status { width: 100%; margin-left: 0; }
.principles { padding: 85px 0 100px; }
.principles__heading { display: block; }
.principles__heading .section-kicker { margin-bottom: 10px; }
.principles-grid { grid-template-columns: 1fr; }
.principle-card { min-height: 245px; }
.learn-cta { margin-bottom: 80px; padding: 28px 22px; }
.learn-cta .button { width: 100%; }
.site-footer { display: flex; align-items: flex-start; flex-direction: column; gap: 22px; }
.site-footer p { text-align: left; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
4.9 Create src/app/manifest.ts
Describe the app for installation and browser metadata.
Open src/app/manifest.ts, remove any placeholder, and add exactly:
src/app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "OpenAI Voice Labs · Lab 01 Text to Speech",
short_name: "Voice Lab 01",
description: "Learn production-minded OpenAI text-to-speech engineering.",
start_url: "/",
display: "standalone",
background_color: "#070b12",
theme_color: "#070b12",
icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }],
};
}
4.10 Create src/app/icon.svg
Add the vector icon used by the application.
Open src/app/icon.svg, remove any placeholder, and add exactly:
src/app/icon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="16" fill="#0b111b"/>
<path d="M13 32h5m4-11v22m7-30v38m7-31v24m7-18v12m5-6h4" fill="none" stroke="#8bffcc" stroke-width="4" stroke-linecap="round"/>
</svg>
4.11 Create tests/client-api.test.ts
Create the test that proves this responsibility without calling OpenAI.
Open tests/client-api.test.ts, remove any placeholder, and add exactly:
tests/client-api.test.ts
import { describe, expect, it } from "vitest";
import {
authorizationHeaders,
formatBytes,
readApiError,
} from "../src/lib/client-api";
describe("client API helpers", () => {
it("only adds authorization when a token exists", () => {
expect(authorizationHeaders(" ")).toEqual({});
expect(authorizationHeaders(" token ")).toEqual({ Authorization: "Bearer token" });
});
it("formats byte counts for the UI", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(2_048)).toBe("2.0 KB");
expect(formatBytes(2 * 1024 * 1024)).toBe("2.0 MB");
});
it("parses the stable API error envelope", async () => {
const response = Response.json(
{ error: { message: "Try again", code: "busy", requestId: "request-1" } },
{ status: 429 },
);
await expect(readApiError(response)).resolves.toEqual({
message: "Try again",
code: "busy",
requestId: "request-1",
});
});
it("falls back when an upstream body is not JSON", async () => {
const response = new Response("gateway error", { status: 502 });
await expect(readApiError(response)).resolves.toEqual({
message: "The request could not be completed.",
});
});
it("uses a stable message when a JSON error omits optional fields", async () => {
const response = Response.json({ error: {} }, { status: 500 });
await expect(readApiError(response)).resolves.toEqual({
message: "The request could not be completed.",
});
});
});
Step 4 checkpoint
Save every file and run:
npm run check
Do not continue while the command fails. Read the first message, verify the file path, and compare with the checkpoint before reinstalling dependencies.
Before continuing, confirm that: the command exited with code zero, every path matches filename casing, and you can summarize this slice’s responsibility.
Comprehension prompt: what problem does “Create the interface, player, and download flow” prevent before the next slice?
Step 4 conclusion: this slice is validated. The next step assumes this behavior works; return to this checkpoint if something breaks.
Chapter checkpoint
The complete application must now pass the local gate:
npm run check
This command still makes no paid request. The real voice test happens in the next chapter, briefly and explicitly.