Etapa 2 de 3 English

Lab 01 · Capítulo 2 — Construa arquivo por arquivo

← Preparação · English · Próximo: execução, testes e deploy →

Este capítulo é deliberadamente operacional. Trabalhe dentro do diretório do laboratório, crie os arquivos na ordem mostrada e execute o checkpoint de cada fatia antes de continuar.

Este arquivo é gerado por npm run docs:generate a partir do código validado. Não edite os blocos de código manualmente; atualize a implementação e regenere a documentação.

Confirme o terminal

Antes do primeiro arquivo, execute pwd. O resultado precisa terminar no diretório abaixo:

labs/lab-01-text-to-speech

Passo 1 — Configure uma base reproduzível

Objetivo da etapa: Agora crie a configuração que faz Next.js, TypeScript, Vitest e Oxlint concordarem. Se você veio do starter, abra cada arquivo e compare; se veio de uma pasta vazia, crie-o.

No terminal do laboratório, garanta que as pastas e os arquivos existam:

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 Crie .env.example

Liste somente nomes de variáveis com valores vazios. A chave real ficará em .env.local.

Abra .env.example, apague qualquer placeholder e coloque exatamente:

.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 Crie .gitignore

Proteja segredos, dependências e artefatos gerados antes do primeiro commit.

Abra .gitignore, apague qualquer placeholder e coloque exatamente:

.gitignore
.env*
!.env.example
.next/
node_modules/
coverage/
playwright-report/
test-results/
.vercel/
*.tsbuildinfo
.DS_Store

1.3 Crie package.json

Defina scripts, versões e dependências reproduzíveis. Não edite package-lock.json manualmente.

Abra package.json, apague qualquer placeholder e coloque exatamente:

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 Crie next.config.mjs

Configure headers, limites e comportamento de produção do Next.js.

Abra next.config.mjs, apague qualquer placeholder e coloque exatamente:

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 Crie tsconfig.json

Ative tipagem estrita e o alias @/ usado pelos imports.

Abra tsconfig.json, apague qualquer placeholder e coloque exatamente:

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 Crie vitest.config.ts

Ensine o Vitest a resolver o mesmo alias e medir os arquivos relevantes.

Abra vitest.config.ts, apague qualquer placeholder e coloque exatamente:

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 Crie scripts/typecheck.mjs

Execute o compilador TypeScript 7 separado do compilador usado pelo Next.js.

Abra scripts/typecheck.mjs, apague qualquer placeholder e coloque exatamente:

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 Crie src/types/assets.d.ts

Declare imports de assets que participam da interface.

Abra src/types/assets.d.ts, apague qualquer placeholder e coloque exatamente:

src/types/assets.d.ts
declare module "*.css";
declare module "@fontsource-variable/jetbrains-mono";
declare module "@fontsource-variable/manrope";

Checkpoint do passo 1

Salve todos os arquivos e execute:

npm run typecheck

Não avance enquanto o comando retornar erro. Leia a primeira mensagem, confira o caminho do arquivo e compare com o checkpoint antes de reinstalar dependências.

Antes de continuar, confirme que: o comando terminou com código zero, o caminho de cada arquivo respeita maiúsculas e minúsculas e você consegue resumir a responsabilidade desta fatia.

Pergunta de compreensão: qual problema o passo “Configure uma base reproduzível” evita antes da próxima fatia?

Conclusão do passo 1: a fatia está validada. O próximo passo parte desse comportamento funcionando; se algo quebrar, volte a este checkpoint.

Passo 2 — Crie o contrato antes da chamada de voz

Objetivo da etapa: A primeira fatia funcional não chama a OpenAI. Ela define exatamente o que o navegador pode pedir e prova as regras com testes rápidos.

No terminal do laboratório, garanta que as pastas e os arquivos existam:

mkdir -p src/lib tests
touch src/lib/constants.ts src/lib/schemas.ts tests/schemas.test.ts

2.1 Crie src/lib/constants.ts

Centralize allowlists e limites que não podem ser escolhidos livremente pelo cliente.

Abra src/lib/constants.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/lib/schemas.ts

Transforme a entrada não confiável num contrato estrito e tipado.

Abra src/lib/schemas.ts, apague qualquer placeholder e coloque exatamente:

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 Crie tests/schemas.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/schemas.test.ts, apague qualquer placeholder e coloque exatamente:

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();
  });
});

Checkpoint do passo 2

Salve todos os arquivos e execute:

npm test -- schemas.test.ts

Não avance enquanto o comando retornar erro. Leia a primeira mensagem, confira o caminho do arquivo e compare com o checkpoint antes de reinstalar dependências.

Antes de continuar, confirme que: o comando terminou com código zero, o caminho de cada arquivo respeita maiúsculas e minúsculas e você consegue resumir a responsabilidade desta fatia.

Pergunta de compreensão: qual problema o passo “Crie o contrato antes da chamada de voz” evita antes da próxima fatia?

Conclusão do passo 2: a fatia está validada. O próximo passo parte desse comportamento funcionando; se algo quebrar, volte a este checkpoint.

Passo 3 — Construa o backend seguro e o streaming

Objetivo da etapa: Nesta fatia você cria erros sanitizados, limites, observabilidade sem conteúdo, cliente OpenAI, health check e a rota que encaminha o áudio. Crie os arquivos na ordem apresentada.

No terminal do laboratório, garanta que as pastas e os arquivos existam:

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 Crie src/lib/errors.ts

Normalize falhas numa resposta estável sem devolver detalhes crus do provedor.

Abra src/lib/errors.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/lib/observability.ts

Registre somente metadados operacionais; texto, áudio e credenciais ficam fora dos logs.

Abra src/lib/observability.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/lib/openai.ts

Crie o cliente OpenAI de forma preguiçosa e somente no servidor.

Abra src/lib/openai.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/lib/rate-limit.ts

Implemente quota local no desenvolvimento e distribuída no ambiente de produção.

Abra src/lib/rate-limit.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/lib/request-body.ts

Limite os bytes realmente lidos antes de interpretar JSON.

Abra src/lib/request-body.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/lib/security-config.ts

Faça produção falhar fechada quando proteções obrigatórias estiverem ausentes.

Abra src/lib/security-config.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/lib/request-guard.ts

Aplique origem, acesso e quota antes de qualquer operação faturável.

Abra src/lib/request-guard.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/app/api/health/route.ts

Exponha somente diagnóstico não sensível para provar configuração.

Abra src/app/api/health/route.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/app/api/speech/route.ts

Valide, chame a Speech API e encaminhe o stream sem montar o áudio inteiro no servidor.

Abra src/app/api/speech/route.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/middleware.ts

Adicione headers de segurança e nonce por resposta sem expor segredos.

Abra src/middleware.ts, apague qualquer placeholder e coloque exatamente:

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 Crie tests/errors.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/errors.test.ts, apague qualquer placeholder e coloque exatamente:

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 Crie tests/observability.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/observability.test.ts, apague qualquer placeholder e coloque exatamente:

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 Crie tests/rate-limit.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/rate-limit.test.ts, apague qualquer placeholder e coloque exatamente:

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 Crie tests/request-body.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/request-body.test.ts, apague qualquer placeholder e coloque exatamente:

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 Crie tests/request-guard.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/request-guard.test.ts, apague qualquer placeholder e coloque exatamente:

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 Crie tests/security-config.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/security-config.test.ts, apague qualquer placeholder e coloque exatamente:

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 Crie tests/middleware.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/middleware.test.ts, apague qualquer placeholder e coloque exatamente:

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");
  });
});

Checkpoint do passo 3

Salve todos os arquivos e execute:

npm run typecheck && npm test

Não avance enquanto o comando retornar erro. Leia a primeira mensagem, confira o caminho do arquivo e compare com o checkpoint antes de reinstalar dependências.

Antes de continuar, confirme que: o comando terminou com código zero, o caminho de cada arquivo respeita maiúsculas e minúsculas e você consegue resumir a responsabilidade desta fatia.

Pergunta de compreensão: qual problema o passo “Construa o backend seguro e o streaming” evita antes da próxima fatia?

Conclusão do passo 3: a fatia está validada. O próximo passo parte desse comportamento funcionando; se algo quebrar, volte a este checkpoint.

Passo 4 — Crie a interface, o player e o download

Objetivo da etapa: Agora o navegador ganha estados explícitos, cancelamento, player, download e disclosure de voz sintética. Substitua a página starter pelos arquivos finais abaixo.

No terminal do laboratório, garanta que as pastas e os arquivos existam:

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 Crie src/lib/client-api.ts

Converta erros da API em mensagens seguras e reutilizáveis no navegador.

Abra src/lib/client-api.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/components/status-message.tsx

Apresente progresso e erro numa live region acessível.

Abra src/components/status-message.tsx, apague qualquer placeholder e coloque exatamente:

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 Crie src/components/waveform.tsx

Crie feedback visual respeitando preferências de movimento reduzido.

Abra src/components/waveform.tsx, apague qualquer placeholder e coloque exatamente:

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 Crie src/components/speech-studio.tsx

Implemente formulário, requisição, cancelamento, player, download e cleanup do áudio.

Abra src/components/speech-studio.tsx, apague qualquer placeholder e coloque exatamente:

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 Crie src/components/voice-playground.tsx

Monte a composição principal que conecta apresentação e experiência de voz.

Abra src/components/voice-playground.tsx, apague qualquer placeholder e coloque exatamente:

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 Crie src/app/layout.tsx

Defina metadados, fontes, idioma e estrutura raiz da página.

Abra src/app/layout.tsx, apague qualquer placeholder e coloque exatamente:

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 Crie src/app/page.tsx

Renderize a experiência principal pelo App Router.

Abra src/app/page.tsx, apague qualquer placeholder e coloque exatamente:

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 Crie src/app/globals.css

Aplique o sistema visual responsivo, foco visível, contraste e reduced motion.

Abra src/app/globals.css, apague qualquer placeholder e coloque exatamente:

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 Crie src/app/manifest.ts

Descreva o app para instalação e metadados de navegador.

Abra src/app/manifest.ts, apague qualquer placeholder e coloque exatamente:

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 Crie src/app/icon.svg

Adicione o ícone vetorial usado pela aplicação.

Abra src/app/icon.svg, apague qualquer placeholder e coloque exatamente:

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 Crie tests/client-api.test.ts

Crie o teste que prova esta responsabilidade sem chamar a OpenAI.

Abra tests/client-api.test.ts, apague qualquer placeholder e coloque exatamente:

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.",
    });
  });
});

Checkpoint do passo 4

Salve todos os arquivos e execute:

npm run check

Não avance enquanto o comando retornar erro. Leia a primeira mensagem, confira o caminho do arquivo e compare com o checkpoint antes de reinstalar dependências.

Antes de continuar, confirme que: o comando terminou com código zero, o caminho de cada arquivo respeita maiúsculas e minúsculas e você consegue resumir a responsabilidade desta fatia.

Pergunta de compreensão: qual problema o passo “Crie a interface, o player e o download” evita antes da próxima fatia?

Conclusão do passo 4: a fatia está validada. O próximo passo parte desse comportamento funcionando; se algo quebrar, volte a este checkpoint.

Checkpoint do capítulo

A aplicação completa agora deve passar pelo gate local:

npm run check

Esse comando ainda não faz uma chamada paga. O teste real de voz acontece no próximo capítulo, de forma curta e explícita.

Próximo: execute, teste e publique →

Navegação rápida

Buscar nesta página