Build Luna Voice Input with Deepgram

Build a secure React voice input that streams live transcripts with Deepgram.

Introduction

30 Second Summary

Talking is often the quickest way to capture an idea, yet a voice button can go silent when permission or connectivity fails. Without a clear status, you cannot tell whether the microphone is listening.

In this project, you will build Luna’s browser voice input with a visible session lifecycle. Deepgram turns speech into live text through a temporary access token that keeps the permanent key on the server.

What You'll Build

You will watch Luna turn your speech into a final transcript while its status display follows the live microphone session.

By the end of this project, you'll have:

  • A voice lifecycle display that shows whether Luna is handling permission, authentication, connection, listening, stopping, or failure.
  • A live Nova-3 transcript where provisional words visibly settle into stable text.
  • A secure microphone session powered by a temporary browser token, with the permanent Deepgram key kept on the server.
  • Secret Mission: Deliberately break Luna’s microphone, token, and network paths to prove each failure lands in the correct state.

Are there any prerequisites?

You should already be familiar with React and state machines. A working microphone plus internet access are required. You create a free Deepgram account during Step 1.

Before We Start

Before any hands-on work begins, this is your moment to define the focused foundation you are building. You are committing to secure voice input for Luna that can support a future assistant workflow.

Set Up Luna’s Voice Workspace

Luna’s voice path crosses the browser, a local Next.js server, and Deepgram. A problem in any layer can look like a state-machine failure later.

This step confirms your runtime and microphone path. You’ll also create a protected home for the permanent API key before assembling Luna’s local workspace.

In this step, get ready to:
  • Verify Node.js and confirm Linux can see your microphone.
  • Create a Deepgram Member API key and store it outside Git.
  • Assemble the Next.js workspace and serve Luna in the browser.
Verify the runtime and microphone

Next.js 16.3.3 requires Node.js 20.9 or newer. Checking your installed version now prevents package failures from hiding the real voice-input work.

  • Check the installed Node.js version by running this command in your terminal:
node --version

What does this command check?

The --version flag prints the Node.js version used by your current terminal. Luna needs 20.9 or newer to support Next.js 16.3.3.

✔️ I see version 20.9 or higher

Your Node.js runtime is ready for Luna. That removes the first source of setup uncertainty.

ⓧ I see an older version

Your installed release is below the minimum required by Next.js 16.3.3.

  • Use the official Node.js download page to install a supported Linux release.
  • Close the current terminal after the installation finishes.
  • Open a new terminal from your Linux application launcher.
  • Repeat the version command above.
  • Confirm the result is 20.9 or newer.

ⓧ Command not found

Your terminal cannot currently find Node.js.

  • Use the official Node.js download page to install a supported Linux release.
  • Open a new terminal after the installation finishes.
  • Repeat the version command above.
  • Confirm the terminal prints a version of 20.9 or newer.

The browser eventually asks Linux for a live microphone stream. A responsive input meter confirms the hardware path before Luna requests browser permission.

  • Open your Linux application launcher.
  • Search for your system sound settings.
  • Open the input-device section.
  • Select the microphone you plan to use.
  • Speak into the microphone.
  • Confirm the input meter responds to your voice.
  • Return to your Linux application launcher.
  • Open Firefox or Chromium.

Why check the microphone now?

This check proves Linux can receive audio from the intended device. Luna requests browser permission in a later step after you deliberately press its listening button.

Browser microphone capture requires a secure context. Browsers treat localhost as secure for local development.

Create the protected key boundary

Luna’s permanent credential belongs in a server-only environment file. The ignore rule must exist before the real key enters the workspace.

  • Open your Linux file manager from the application launcher.
  • Select your Desktop directory.
  • Create a folder named luna-voice-input.
  • Return to your Linux application launcher.
  • Search for Visual Studio Code.
  • Open Visual Studio Code from the search results.
  • Click File in the top menu.
  • Click Open Folder.
  • Select the luna-voice-input folder on your Desktop.
  • Confirm luna-voice-input appears in the Explorer sidebar.
  • Create .gitignore inside luna-voice-input using the Explorer sidebar.
  • Protect generated files and local environment files by copying this content into .gitignore:
node_modules
.next
.env*
next-env.d.ts

What does this file protect?

  • The node_modules entry excludes installed packages.
  • The .next entry excludes generated development and build output.
  • The .env* entry excludes every local environment file that can contain a credential.
  • The next-env.d.ts entry excludes the generated Next.js type declaration file.
  • Save .gitignore.
  • Confirm .gitignore appears in the Explorer sidebar.

Is the ignore file named incorrectly?

Confirm the filename begins with a full stop. Remove any hidden .txt extension added by your file manager.

Still unsure? Help me verify the .gitignore filename and entries in my Luna project.

  • Create .env.example inside luna-voice-input using the Explorer sidebar.
  • Document Luna’s required environment variable by copying this line into .env.example:
DEEPGRAM_API_KEY=your-api-key-here

Why keep an environment example?

The example records the required variable name without storing a live credential. Someone can prepare Luna from this file without receiving your permanent key.

  • Save .env.example.
  • Confirm .env.example appears in the Explorer sidebar.
  • Create .env.local beside .env.example.
  • Prepare the local credential slot by copying this line into .env.local:
DEEPGRAM_API_KEY=your-api-key-here

How does the secret boundary work?

The permanent key lives under DEEPGRAM_API_KEY in .env.local. The .env* rule keeps that file outside Git.

A later server route reads the permanent key. The browser receives a temporary token instead.

  • Save .env.local.
  • Return to .gitignore.
  • Confirm the file includes the exact .env* rule.

Creating a credential can feel risky because Deepgram reveals the first key secret only once. Your protected .env.local file is ready to receive it without exposing it in project code.

What does the free account include?

Deepgram starts its Pay As You Go plan with Free $200 Credit. No credit card is required to begin.

This project remains free while your transcription usage stays inside that credit. Stop listening sessions when you finish testing them.

  • Use the open browser to visit Deepgram.
  • Choose the account sign-up option.
  • Complete the account prompts.
  • Sign in to the Deepgram Console.
  • Create your first API key.
  • Set the key permissions to Member.
  • Copy the key secret when Deepgram reveals it.
  • Switch back to .env.local in VS Code.
  • Replace your-api-key-here with the copied key.
  • Save .env.local.
  • Close the Deepgram key reveal after the file is saved.

Did you lose the revealed key?

Deepgram shows the key secret only once. Create a replacement Member key if you closed the reveal before copying it.

Keep the replacement inside .env.local. Help me check that my Deepgram key is stored safely without asking me to reveal it.

Assemble and run the Next.js foundation

A manually defined workspace keeps this setup focused on the files Luna uses. Pinned package versions give every later voice-state change the same foundation.

  • Create package.json inside luna-voice-input using the Explorer sidebar.
  • Define Luna’s scripts and pinned packages by copying this code into package.json:
{
  "name": "luna-voice-input",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@deepgram/sdk": "5.8.0",
    "next": "16.3.3",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@types/node": "26.4.0",
    "@types/react": "19.2.18",
    "@types/react-dom": "19.2.5",
    "typescript": "5.9.3"
  }
}

What does this package file define?

  • The dev script starts Luna’s local development server.
  • The build script creates the production build used in the final project check.
  • Next.js 16.3.3 runs with React 19.2.8.
  • The Deepgram JavaScript SDK is pinned to 5.8.0 for the secure token and streaming work ahead.
  • TypeScript is pinned to 5.9.3 for strict type checking.
  • Save package.json.
  • Confirm package.json appears in the Explorer sidebar.
  • Open VS Code’s integrated terminal from the Terminal menu.

The installation may spend a little time downloading packages. Keep the terminal open until the prompt returns.

  • Install the pinned project dependencies by running:
npm install

What does this command create?

The command reads package.json and installs its pinned dependencies. It creates the local node_modules directory that your ignore file already excludes.

You’ll see package installation progress before the terminal prompt returns. That return confirms the installation has finished.

Did the dependency installation fail?

  • Confirm the terminal path ends with luna-voice-input.
  • Confirm the earlier version check showed Node.js 20.9 or newer.
  • Confirm package.json has matching braces.

Need help? Help me troubleshoot npm install without changing Luna’s pinned package versions.

  • Create tsconfig.json inside luna-voice-input using the Explorer sidebar.

The TypeScript configuration is split into two fragments to keep each code block readable. Paste both fragments in order before saving the file.

  • Start tsconfig.json by copying this first fragment:
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,

What does the first fragment configure?

This fragment enables strict type checking and browser libraries. It also configures the JSX transform and bundler-style module resolution used by Next.js.

  • Complete tsconfig.json by pasting this fragment directly below the first:
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts",
    "**/*.mts"
  ],
  "exclude": ["node_modules"]
}

What does the second fragment configure?

The Next.js plugin enables framework-specific checks. The @/* alias points to the project directory.

The include list covers Luna’s TypeScript files and generated Next.js types. The exclude list leaves installed packages outside project type checking.

  • Save tsconfig.json.
  • Confirm VS Code shows no JSON syntax marker beside tsconfig.json.
  • Create an app folder inside luna-voice-input using the Explorer sidebar.
  • Create layout.tsx inside the app folder.
  • Define Luna’s root document by copying this code into app/layout.tsx:
import "./globals.css";

export const metadata = {
  title: "Luna Voice Input",
  description: "Secure live transcription with Deepgram",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does this layout do?

  • The metadata object gives the browser page its Luna title and description.
  • The RootLayout component supplies the required HTML document structure.
  • The stylesheet import applies app/globals.css across Luna’s pages.
  • Save app/layout.tsx.
  • Confirm layout.tsx appears inside the app folder.
  • Create page.tsx inside the app folder.
  • Add Luna’s temporary page by copying this code into app/page.tsx:
"use client";

export default function Home() {
  return (
    <main className="shell">
      <section className="card">
        <p className="eyebrow">Luna roadmap: voice input</p>
        <h1>Luna Voice Input</h1>
        <p className="intro">
          A secure, state-driven microphone session powered only by Deepgram.
        </p>
      </section>
    </main>
  );
}

What does this page prove?

The client directive prepares this page for browser interactions in later steps. The temporary Home component gives the development server a visible Luna screen to render now.

  • Save app/page.tsx.
  • Confirm page.tsx appears beside layout.tsx.
  • Create globals.css inside the app folder.
  • Create Luna’s dark page foundation by copying this code into app/globals.css:
:root {
  color-scheme: dark;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  background: #070b18;
  color: #f7f8ff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top, rgba(114, 92, 255, 0.24), transparent 38%),
    #070b18;
}

button {
  font: inherit;
}

.shell {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 32px 20px;
}

What does this foundation style?

The root rules establish Luna’s dark palette and font stack. The shell centers the voice console within the browser window.

  • Save app/globals.css.
  • Confirm VS Code shows no CSS syntax marker beside app/globals.css.
  • Add the card and eyebrow styles below the existing CSS:
.card {
  width: min(760px, 100%);
  padding: 32px;
  border: 1px solid #29304d;
  border-radius: 24px;
  background: rgba(14, 19, 39, 0.94);
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
}

.eyebrow {
  margin: 0 0 8px;
  color: #9f91ff;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

What do these styles add?

The card creates Luna’s bordered console surface. The eyebrow labels this module as part of the Luna roadmap.

  • Save app/globals.css.
  • Switch back to the integrated terminal in VS Code.

Before you start the server, what heading do you expect Luna’s first browser screen to show?

  • Start the local development server by running:
npm run dev

What does this command start?

The command runs the dev script from package.json. Next.js starts a local server that watches your saved files.

The server keeps control of this terminal while it runs. Leave the process active during the remaining style changes.

  • Switch back to Firefox or Chromium.
  • Enter http://localhost:3000 in the address bar.

You’ll see a centered dark card with the heading Luna Voice Input. The development terminal should remain free of compilation errors.

Is the Luna page unavailable?

  • Confirm the development terminal still shows a running process.
  • Confirm the terminal was opened from the luna-voice-input folder.
  • Confirm app/layout.tsx imports ./globals.css.

Still blocked? Help me debug why Luna does not load at localhost.

  • Switch back to app/globals.css in VS Code.
  • Add the heading and state-panel foundation below the existing styles:
h1,
h2,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 10px;
  font-size: clamp(2rem, 6vw, 3.6rem);
}

.intro {
  color: #b8bfd8;
  line-height: 1.6;
}

.statePanel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin: 28px 0 18px;
  padding: 16px 18px;
  border-radius: 14px;
  background: #111831;
}

.stateLabel {
  color: #929bbd;
}

What is this styling preparing?

The heading scales with the browser width. The state-panel rules prepare the container that exposes Luna’s active voice state in the next step.

  • Save app/globals.css.
  • Return to the browser.
  • Confirm the Luna heading is larger.
  • Confirm the introduction uses a muted colour.
  • Switch back to app/globals.css.
  • Add the state-value styles below the existing code:
.stateValue {
  color: #73e6b1;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

.state-error {
  color: #ff8c9e;
}

.stateFlow {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 8px;
  margin: 0 0 24px;
  padding: 0;
  list-style: none;
}

What will these state styles communicate?

The current state uses a monospace face so it reads like a technical value. The lifecycle list becomes a compact grid of state chips.

  • Save app/globals.css.
  • Confirm the development terminal finishes recompiling without a CSS error.
  • Add the base lifecycle-chip styles below the existing code:
.stateFlow li {
  padding: 9px;
  border: 1px solid #303856;
  border-radius: 999px;
  color: #737d9f;
  font-size: 0.75rem;
  text-align: center;
}

.stateFlow li.active {
  border-color: #8a79ff;
  color: #ffffff;
  background: #3b3278;
}

.stateFlow li.complete {
  border-color: #286148;
  color: #8ce8ba;
}

How will lifecycle states look different?

The active chip uses Luna’s purple highlight. Completed chips use green to show progress through the lifecycle.

  • Save app/globals.css.
  • Confirm the development terminal recompiles without a CSS error.
  • Add the transcript-panel styles below the existing code:
.transcriptPanel {
  min-height: 190px;
  margin-bottom: 20px;
  padding: 22px;
  border: 1px solid #29304d;
  border-radius: 18px;
  background: #090e20;
}

.transcriptPanel h2 {
  color: #a7afca;
  font-size: 0.9rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.transcript {
  margin-bottom: 0;
  font-size: 1.25rem;
  line-height: 1.75;
}

What does the transcript panel prepare?

The panel creates a dedicated reading surface for Luna’s live words. The transcript uses a larger line height so spoken phrases remain easy to scan.

  • Save app/globals.css.
  • Confirm the development terminal recompiles without a CSS error.
  • Add the provisional transcript and error styles below the existing code:
.interim,
.placeholder {
  color: #7883aa;
}

.interim {
  font-style: italic;
}

.error {
  padding: 12px 14px;
  border: 1px solid #773748;
  border-radius: 12px;
  color: #ffb0bd;
  background: #33151d;
}

Why distinguish provisional text and errors?

Interim words receive a muted italic style because Deepgram may still revise them. Errors receive a high-contrast panel so a failed voice state remains visible.

  • Save app/globals.css.
  • Confirm the development terminal recompiles without a CSS error.
  • Add the shared microphone-control styles below the existing code:
.startButton,
.stopButton {
  width: 100%;
  padding: 14px 18px;
  border: 0;
  border-radius: 14px;
  color: white;
  font-weight: 800;
  cursor: pointer;
}

.startButton {
  background: #6552e8;
}

.stopButton {
  background: #c6405a;
}

What do these controls prepare?

Both microphone controls share a full-width shape. Different colours separate starting a session from stopping one.

  • Save app/globals.css.
  • Confirm the development terminal recompiles without a CSS error.
  • Finish the disabled and responsive styles by adding this code:
button:disabled {
  cursor: wait;
  opacity: 0.55;
}

@media (max-width: 600px) {
  .card {
    padding: 22px;
  }

  .stateFlow {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

How does the final foundation behave?

Disabled controls signal that Luna is waiting on a voice-state transition. The media query reduces card padding and changes the future state grid on narrow screens.

  • Save app/globals.css.
  • Return to http://localhost:3000 in the browser.
  • Resize the browser below 600px.
  • Confirm the dark card keeps comfortable space around the heading.

Is the Luna page unstyled?

  • Confirm app/layout.tsx imports ./globals.css.
  • Confirm app/page.tsx uses the shell and card class names.
  • Confirm the development terminal shows no CSS syntax error.

Need another pair of eyes? Help me find why the Luna page is missing its dark card styles.

Before the final check, do you expect the browser and terminal to agree that Luna’s workspace is running cleanly?

  • Refresh http://localhost:3000 in the browser.
  • Confirm the page shows Luna Voice Input inside the dark card.
  • Confirm the development terminal shows no compilation error.

That’s the setup complete. Luna now has a running browser surface and a protected server-only home for the permanent Deepgram key.

✔️ Awesome, I've got everything!

Great work. Keep the development server running and make sure every project file is saved in VS Code.

ⓧ I'd like to double check the full code

Compare each file with the complete workspace below. Keep your real Deepgram key in .env.local without pasting it into this reference.

The complete package.json file is:

{
  "name": "luna-voice-input",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@deepgram/sdk": "5.8.0",
    "next": "16.3.3",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  },
  "devDependencies": {
    "@types/node": "26.4.0",
    "@types/react": "19.2.18",
    "@types/react-dom": "19.2.5",
    "typescript": "5.9.3"
  }
}

The complete tsconfig.json file is:

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts",
    "**/*.mts"
  ],
  "exclude": ["node_modules"]
}

The complete .gitignore file is:

node_modules
.next
.env*
next-env.d.ts

The complete .env.example file is:

DEEPGRAM_API_KEY=your-api-key-here

Your local .env.local follows this shape. Your saved file contains the real key in place of the placeholder.

DEEPGRAM_API_KEY=your-api-key-here

The complete app/layout.tsx file is:

import "./globals.css";

export const metadata = {
  title: "Luna Voice Input",
  description: "Secure live transcription with Deepgram",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

The temporary app/page.tsx file is:

"use client";

export default function Home() {
  return (
    <main className="shell">
      <section className="card">
        <p className="eyebrow">Luna roadmap: voice input</p>
        <h1>Luna Voice Input</h1>
        <p className="intro">
          A secure, state-driven microphone session powered only by Deepgram.
        </p>
      </section>
    </main>
  );
}

The complete app/globals.css file is:

:root {
  color-scheme: dark;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  background: #070b18;
  color: #f7f8ff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top, rgba(114, 92, 255, 0.24), transparent 38%),
    #070b18;
}

button {
  font: inherit;
}

.shell {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 32px 20px;
}

.card {
  width: min(760px, 100%);
  padding: 32px;
  border: 1px solid #29304d;
  border-radius: 24px;
  background: rgba(14, 19, 39, 0.94);
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
}

.eyebrow {
  margin: 0 0 8px;
  color: #9f91ff;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

h1,
h2,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 10px;
  font-size: clamp(2rem, 6vw, 3.6rem);
}

.intro {
  color: #b8bfd8;
  line-height: 1.6;
}

.statePanel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin: 28px 0 18px;
  padding: 16px 18px;
  border-radius: 14px;
  background: #111831;
}

.stateLabel {
  color: #929bbd;
}

.stateValue {
  color: #73e6b1;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

.state-error {
  color: #ff8c9e;
}

.stateFlow {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 8px;
  margin: 0 0 24px;
  padding: 0;
  list-style: none;
}

.stateFlow li {
  padding: 9px;
  border: 1px solid #303856;
  border-radius: 999px;
  color: #737d9f;
  font-size: 0.75rem;
  text-align: center;
}

.stateFlow li.active {
  border-color: #8a79ff;
  color: #ffffff;
  background: #3b3278;
}

.stateFlow li.complete {
  border-color: #286148;
  color: #8ce8ba;
}

.transcriptPanel {
  min-height: 190px;
  margin-bottom: 20px;
  padding: 22px;
  border: 1px solid #29304d;
  border-radius: 18px;
  background: #090e20;
}

.transcriptPanel h2 {
  color: #a7afca;
  font-size: 0.9rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.transcript {
  margin-bottom: 0;
  font-size: 1.25rem;
  line-height: 1.75;
}

.interim,
.placeholder {
  color: #7883aa;
}

.interim {
  font-style: italic;
}

.error {
  padding: 12px 14px;
  border: 1px solid #773748;
  border-radius: 12px;
  color: #ffb0bd;
  background: #33151d;
}

.startButton,
.stopButton {
  width: 100%;
  padding: 14px 18px;
  border: 0;
  border-radius: 14px;
  color: white;
  font-weight: 800;
  cursor: pointer;
}

.startButton {
  background: #6552e8;
}

.stopButton {
  background: #c6405a;
}

button:disabled {
  cursor: wait;
  opacity: 0.55;
}

@media (max-width: 600px) {
  .card {
    padding: 22px;
  }

  .stateFlow {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

Your workspace is running and the permanent key has a protected home. Next up, you’ll turn this static Luna screen into an inspectable voice state machine.

Build the Voice State Machine

Luna currently shows a heading, but it cannot reveal whether voice input is waiting for permission, fetching credentials, connecting, or listening. A voice button without visible lifecycle states makes every delay look the same.

A finite-state machine gives each phase a name. You’ll use React and TypeScript to accept only the events that make sense for Luna’s current phase.

In this step, get ready to:
  • Model Luna’s lifecycle with typed states and events.
  • Implement guarded reducer transitions for every voice phase.
  • Render the state chips and prove that the first transition works.
Model Luna’s voice lifecycle

The state model separates Luna’s current phase from the transcript data collected during a session. The event model then defines every change the machine can receive.

  • In the VS Code Explorer sidebar, select app/page.tsx.
  • Replace the temporary component with this client boundary and state model:
"use client";

import { useReducer } from "react";

type VoiceStatus =
  | "idle"
  | "requesting-permission"
  | "fetching-token"
  | "connecting"
  | "listening"
  | "stopping"
  | "error";

type VoiceState = {
  status: VoiceStatus;
  finalText: string;
  interimText: string;
  error: string | null;
};

What does this code do?

  • The client directive allows this component to use state and respond to browser events.
  • The VoiceStatus union lists every phase Luna can enter.
  • The VoiceState type keeps the active phase beside final text, provisional text, and any readable error.
  • Add the event vocabulary and starting values below VoiceState:
type VoiceAction =
  | { type: "START" }
  | { type: "MIC_GRANTED" }
  | { type: "TOKEN_READY" }
  | { type: "SOCKET_OPEN" }
  | { type: "INTERIM"; text: string }
  | { type: "FINAL"; text: string }
  | { type: "STOP" }
  | { type: "SOCKET_CLOSED" }
  | { type: "FAIL"; error: string };

const initialState: VoiceState = {
  status: "idle",
  finalText: "",
  interimText: "",
  error: null,
};

const orderedStatuses: VoiceStatus[] = [
  "idle",
  "requesting-permission",
  "fetching-token",
  "connecting",
  "listening",
  "stopping",
];

How do the events shape the machine?

  • The VoiceAction union gives each event a distinct shape. Transcript events carry text while failure events carry an error.
  • The initialState object starts Luna in idle with an empty transcript.
  • The orderedStatuses array provides the sequence shown by the lifecycle chips. The error phase sits outside the normal forward path.
  • Save app/page.tsx.

The development server should compile the types without reporting an error. That confirms Luna now has a valid vocabulary for its voice lifecycle.

Seeing a TypeScript error?

  • Check that every VoiceStatus value remains inside double quotes.
  • Confirm that each VoiceAction variant begins with a vertical bar.
  • Make sure initialState includes all four fields required by VoiceState.

Still stuck? Help me find the type error in my Luna voice state model.

Guard every state transition

A reducer receives the current state and one event. Each guard checks whether that event is valid during the current phase before allowing a transition.

The reducer forms one function across the next two snippets. The first snippet opens the switch while the second completes the remaining cases.

  • Add the first half of reducer below orderedStatuses:
function reducer(state: VoiceState, action: VoiceAction): VoiceState {
  switch (action.type) {
    case "START":
      if (state.status !== "idle" && state.status !== "error") return state;
      return { ...initialState, status: "requesting-permission" };
    case "MIC_GRANTED":
      if (state.status !== "requesting-permission") return state;
      return { ...state, status: "fetching-token" };
    case "TOKEN_READY":
      if (state.status !== "fetching-token") return state;
      return { ...state, status: "connecting" };
    case "SOCKET_OPEN":
      if (state.status !== "connecting") return state;
      return { ...state, status: "listening" };
    case "INTERIM":
      if (state.status !== "listening") return state;
      return { ...state, interimText: action.text };

How do these guards work?

  • The START event begins a fresh session from idle or error.
  • The permission, token, and socket events each advance one phase when Luna is ready for that result.
  • An INTERIM event updates provisional text only while Luna is listening.
  • Complete reducer by adding this second half immediately below the previous snippet:
    case "FINAL": {
      if (state.status !== "listening" && state.status !== "stopping") {
        return state;
      }
      const finalText = [state.finalText, action.text]
        .filter(Boolean)
        .join(" ");
      return { ...state, finalText, interimText: "" };
    }
    case "STOP":
      if (state.status !== "listening") return state;
      return { ...state, status: "stopping", interimText: "" };
    case "SOCKET_CLOSED":
      if (state.status === "error") return state;
      return { ...state, status: "idle", interimText: "" };
    case "FAIL":
      return {
        ...state,
        status: "error",
        interimText: "",
        error: action.error,
      };
  }
}

What completes the lifecycle?

  • A FINAL event appends stable text during listening or stopping. It also clears the provisional text.
  • A STOP event moves an active session into its shutdown phase.
  • A closed socket returns Luna to idle unless an error already explains the closure.
  • A FAIL event records a readable message and moves Luna into error.
  • Save app/page.tsx.

The development server should compile again without a TypeScript error. Luna can now reject events that arrive during the wrong phase.

Reducer not compiling?

  • Confirm that the second snippet begins inside the existing switch block.
  • Check that the closing braces at the bottom finish both the switch and the reducer function.
  • Make sure every event name matches its spelling in VoiceAction.

Need another pair of eyes? Help me debug the guarded reducer in app/page.tsx.

Render and test the shell

The state machine becomes useful when you can watch it move. The first interface exposes the current phase before any microphone or network work begins.

  • Add this Home component below reducer:
export default function Home() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const currentIndex = orderedStatuses.indexOf(state.status);

  return (
    <main className="shell">
      <section className="card">
        <p className="eyebrow">Luna roadmap: voice input</p>
        <h1>Luna Voice Input</h1>
        <p className="intro">
          A secure, state-driven microphone session powered only by Deepgram.
        </p>

        <div className="statePanel">
          <span className="stateLabel">Current state</span>
          <strong className={`stateValue state-${state.status}`}>
            {state.status}
          </strong>
        </div>
      </section>
    </main>
  );
}

What does this component show?

  • The useReducer hook connects the guarded reducer to Luna’s current state.
  • The state panel renders the active status value so every transition becomes visible.
  • The existing Luna styles turn the component into the centered voice console.
  • Save app/page.tsx.
  • Return to http://localhost:3000 in the browser.

You should see the Luna card with a current state of idle. The state machine now has its first visible signal.

Still seeing only the old heading?

  • Confirm that you saved app/page.tsx.
  • Check that the development server from the previous step is still running.
  • Review the terminal for a JSX error that prevented the page from updating.

If the card still does not load, help me diagnose why my Next.js page is not rendering the Luna state panel.

  • In app/page.tsx, find the closing tag for statePanel.
  • Add the lifecycle list below that closing tag:
        <ol className="stateFlow" aria-label="Voice state machine">
          {orderedStatuses.map((status, index) => (
            <li
              className={
                index === currentIndex
                  ? "active"
                  : currentIndex > index
                    ? "complete"
                    : ""
              }
              key={status}
            >
              {status}
            </li>
          ))}
        </ol>

How do the lifecycle chips work?

  • The component maps each value in orderedStatuses to one chip.
  • The current index marks one chip as active. Earlier chips receive the completed style.
  • The sequence gives you a visual trace of how far a voice session has progressed.
  • Save app/page.tsx.
  • Refresh the Luna page in your browser.

You should see six lifecycle chips beneath the state panel. The idle chip should use the active style.

Lifecycle chips missing?

  • Confirm that the lifecycle list sits inside the element with the card class.
  • Check that the map uses orderedStatuses with the same capitalization as its declaration.
  • Make sure the lifecycle list closes before the card section closes.

Still missing the chips? Help me fix the stateFlow list in my Luna component.

The final interface needs somewhere to show speech and errors later. Its first button dispatches only START so you can inspect the machine before attaching any browser effects.

  • Add this handler between currentIndex and the component’s return statement:
  async function startListening() {
    if (state.status !== "idle" && state.status !== "error") return;

    dispatch({ type: "START" });
  }

Why guard the handler too?

The handler ignores repeated starts while a session is already moving through its lifecycle. The reducer still provides the authoritative transition guard.

  • Find the closing tag for stateFlow inside the card.
  • Add the transcript panel, error space, and button below it:
        <section className="transcriptPanel" aria-live="polite">
          <h2>Transcript</h2>
          <p className="transcript">
            {state.finalText}
            {state.interimText ? (
              <span className="interim"> {state.interimText}</span>
            ) : null}
            {!state.finalText && !state.interimText ? (
              <span className="placeholder">Your words will appear here.</span>
            ) : null}
          </p>
        </section>

        {state.error ? <p className="error">{state.error}</p> : null}

        <button
          className="startButton"
          disabled={state.status !== "idle"}
          onClick={startListening}
          type="button"
        >
          Start listening
        </button>

What completes the shell?

  • The transcript panel reserves separate positions for stable and provisional text.
  • The error element renders only when the state contains a readable failure message.
  • The button calls startListening and becomes unavailable after Luna leaves idle.
  • Save app/page.tsx.
  • Refresh http://localhost:3000.

You should see the transcript panel and the Start listening button beneath the lifecycle chips.

Before you click the button, which state do you expect to become active?

  • Click Start listening.

The current state changes from idle to requesting-permission. The matching lifecycle chip becomes active.

The machine remains in requesting-permission by design. You have just proved the key lesson: a state transition records intent while browser effects perform the external work.

State stays on idle?

  • Confirm that the button’s onClick property references startListening.
  • Check that the handler dispatches the START event with matching capitalization.
  • Verify that the START reducer case returns requesting-permission.

If the click still has no effect, help me trace the START event through my useReducer state machine.

✔️ Awesome, I've got everything!

Your Luna shell now displays its lifecycle and stops at requesting-permission after the first click. The reducer is enforcing the exact checkpoint this step needs.

ⓧ I'd like to double check the full code

Compare your complete app/page.tsx with this checkpoint version.

"use client";

import { useReducer } from "react";

type VoiceStatus =
  | "idle"
  | "requesting-permission"
  | "fetching-token"
  | "connecting"
  | "listening"
  | "stopping"
  | "error";

type VoiceState = {
  status: VoiceStatus;
  finalText: string;
  interimText: string;
  error: string | null;
};

type VoiceAction =
  | { type: "START" }
  | { type: "MIC_GRANTED" }
  | { type: "TOKEN_READY" }
  | { type: "SOCKET_OPEN" }
  | { type: "INTERIM"; text: string }
  | { type: "FINAL"; text: string }
  | { type: "STOP" }
  | { type: "SOCKET_CLOSED" }
  | { type: "FAIL"; error: string };

const initialState: VoiceState = {
  status: "idle",
  finalText: "",
  interimText: "",
  error: null,
};

const orderedStatuses: VoiceStatus[] = [
  "idle",
  "requesting-permission",
  "fetching-token",
  "connecting",
  "listening",
  "stopping",
];

function reducer(state: VoiceState, action: VoiceAction): VoiceState {
  switch (action.type) {
    case "START":
      if (state.status !== "idle" && state.status !== "error") return state;
      return { ...initialState, status: "requesting-permission" };
    case "MIC_GRANTED":
      if (state.status !== "requesting-permission") return state;
      return { ...state, status: "fetching-token" };
    case "TOKEN_READY":
      if (state.status !== "fetching-token") return state;
      return { ...state, status: "connecting" };
    case "SOCKET_OPEN":
      if (state.status !== "connecting") return state;
      return { ...state, status: "listening" };
    case "INTERIM":
      if (state.status !== "listening") return state;
      return { ...state, interimText: action.text };
    case "FINAL": {
      if (state.status !== "listening" && state.status !== "stopping") {
        return state;
      }
      const finalText = [state.finalText, action.text]
        .filter(Boolean)
        .join(" ");
      return { ...state, finalText, interimText: "" };
    }
    case "STOP":
      if (state.status !== "listening") return state;
      return { ...state, status: "stopping", interimText: "" };
    case "SOCKET_CLOSED":
      if (state.status === "error") return state;
      return { ...state, status: "idle", interimText: "" };
    case "FAIL":
      return {
        ...state,
        status: "error",
        interimText: "",
        error: action.error,
      };
  }
}

export default function Home() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const currentIndex = orderedStatuses.indexOf(state.status);

  async function startListening() {
    if (state.status !== "idle" && state.status !== "error") return;

    dispatch({ type: "START" });
  }

  return (
    <main className="shell">
      <section className="card">
        <p className="eyebrow">Luna roadmap: voice input</p>
        <h1>Luna Voice Input</h1>
        <p className="intro">
          A secure, state-driven microphone session powered only by Deepgram.
        </p>

        <div className="statePanel">
          <span className="stateLabel">Current state</span>
          <strong className={`stateValue state-${state.status}`}>
            {state.status}
          </strong>
        </div>

        <ol className="stateFlow" aria-label="Voice state machine">
          {orderedStatuses.map((status, index) => (
            <li
              className={
                index === currentIndex
                  ? "active"
                  : currentIndex > index
                    ? "complete"
                    : ""
              }
              key={status}
            >
              {status}
            </li>
          ))}
        </ol>

        <section className="transcriptPanel" aria-live="polite">
          <h2>Transcript</h2>
          <p className="transcript">
            {state.finalText}
            {state.interimText ? (
              <span className="interim"> {state.interimText}</span>
            ) : null}
            {!state.finalText && !state.interimText ? (
              <span className="placeholder">Your words will appear here.</span>
            ) : null}
          </p>
        </section>

        {state.error ? <p className="error">{state.error}</p> : null}

        <button
          className="startButton"
          disabled={state.status !== "idle"}
          onClick={startListening}
          type="button"
        >
          Start listening
        </button>
      </section>
    </main>
  );
}

That planned stall proves your state machine can expose missing external work instead of hiding it. Next, you’ll connect the permission request to Luna’s browser-side microphone flow.

Request Microphone Access

Luna’s React state machine now stops at requesting-permission. The reducer can record the next state, but browser hardware access requires an effect triggered by your click.

This step connects Start listening to browser microphone permission. An approved request gives Luna a MediaStream and moves the machine to fetching-token.

In this step, get ready to:
  • Track Luna’s microphone stream and recorder across React renders.
  • Request microphone access after the learner presses Start listening.
  • Confirm that approval and denial reach the correct states.
Track microphone resources

Browser media objects live outside React’s render cycle. Refs give Luna stable places to keep the active stream and recorder until cleanup is required.

  • In app/page.tsx, update the React import to include useRef so it matches:
import { useReducer, useRef } from "react";

Why use refs for media?

The useRef hook preserves a value across renders. Updating a ref does not trigger another render.

That behavior suits browser-owned objects such as MediaStream and MediaRecorder because the reducer already controls the visible interface.

  • Inside Home(), add these refs and the cleanup helper below the existing useReducer line:
  const streamRef = useRef<MediaStream | null>(null);
  const recorderRef = useRef<MediaRecorder | null>(null);

  function releaseMicrophone() {
    streamRef.current?.getTracks().forEach((track) => track.stop());
    streamRef.current = null;
    recorderRef.current = null;
  }

What does this code do?

  • The streamRef ref keeps the live microphone stream available to Luna’s event handlers.
  • The recorderRef ref keeps the recorder associated with that stream.
  • The releaseMicrophone() helper stops every acquired media track. It also clears both references.
  • Save app/page.tsx.
  • Return to the terminal running the development server.

Your Next.js server should recompile the page without reporting a TypeScript error.

Seeing a ref error?

  • Confirm that useRef appears inside the existing React import.
  • Confirm that both refs sit inside Home() after the useReducer line.
  • Check that the ref types use the browser names MediaStream and MediaRecorder exactly.

Still stuck? Help me fix the microphone refs in my React component.

Translate microphone failures

Microphone requests can fail because the learner blocks access or the browser cannot find a matching input. Luna needs to turn either result into a readable state-machine error.

  • Add messageFrom() immediately before Home() in app/page.tsx:
function messageFrom(error: unknown) {
  return error instanceof Error ? error.message : "Unknown voice input error.";
}

What does this helper do?

The browser can reject a media request with an error object. messageFrom() preserves its readable message while providing a fallback for unknown values.

  • Inside Home(), add fail() below releaseMicrophone():
  function fail(error: unknown) {
    releaseMicrophone();
    dispatch({ type: "FAIL", error: messageFrom(error) });
  }

How does failure stay safe?

The fail() helper releases any microphone resources that Luna acquired before the failure. It then dispatches FAIL with a readable message.

That sequence keeps resource cleanup aligned with the visible error state.

  • Save app/page.tsx.
  • Return to the development-server terminal.

You should see the page compile again without an error. Luna still remains at idle because no microphone request has run yet.

Seeing a helper error?

  • Place messageFrom() outside Home() so it remains available to the component.
  • Place fail() inside Home() so it can access dispatch.
  • Confirm that the event name is the uppercase value FAIL from VoiceAction.

Need another pair of eyes? Help me debug Luna’s microphone failure helper.

Request access after the click

The microphone request must begin from the learner’s button click. Luna can now connect that interaction to the browser effect while keeping each state transition explicit.

  • In app/page.tsx, replace the existing startListening() function with this asynchronous version:
  async function startListening() {
    if (state.status !== "idle" && state.status !== "error") return;

    dispatch({ type: "START" });

    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      streamRef.current = stream;
      const recorder = new MediaRecorder(stream);
      recorderRef.current = recorder;
      dispatch({ type: "MIC_GRANTED" });
    } catch (error) {
      fail(error);
    }
  }

What happens after the click?

  • The START event moves Luna to requesting-permission while the browser waits for a decision.
  • The navigator.mediaDevices.getUserMedia({ audio: true }) call requests an audio stream from the selected microphone.
  • An approved stream is stored in streamRef before Luna creates its MediaRecorder.
  • The MIC_GRANTED event advances the successful path to fetching-token.
  • Save app/page.tsx.
  • Return to Luna in the browser from earlier.
  • Reload http://localhost:3000.

Before you press Start listening, which state do you expect Luna to reach after you approve access?

  • Click Start listening.
  • Approve microphone access in the browser prompt.

You should see Luna move through requesting-permission and stop at fetching-token. Your browser’s microphone indicator confirms that the stream is active.

That is the intended checkpoint. Luna now owns microphone input, while secure authentication remains the next boundary.

  • Reload the page to end this temporary microphone session.
  • Confirm that the browser’s microphone indicator turns off.

Before the denial test, where do you expect the machine to land when the browser rejects the request?

  • Open a fresh private browsing window from your browser menu.
  • Go to http://localhost:3000.
  • Click Start listening.
  • Choose the browser option that blocks microphone access.

You should see the current state change to error with a readable permission message. The microphone indicator should remain off.

Did the permission prompt behave differently?

  • Use a fresh private window if the browser remembers an earlier permission choice.
  • Confirm that your Linux sound settings still list the microphone you tested during setup.
  • Check that Luna is loaded from http://localhost:3000 because microphone capture requires a secure context and localhost qualifies.

Still blocked? Help me troubleshoot Luna’s browser microphone permission flow.

✔️ Awesome, I've got everything!

  • Save app/page.tsx.
  • Keep the development server running for the next step.

ⓧ I'd like to double check the full code

The complete app/page.tsx file at this checkpoint is shown below.

"use client";

import { useReducer, useRef } from "react";

type VoiceStatus =
  | "idle"
  | "requesting-permission"
  | "fetching-token"
  | "connecting"
  | "listening"
  | "stopping"
  | "error";

type VoiceState = {
  status: VoiceStatus;
  finalText: string;
  interimText: string;
  error: string | null;
};

type VoiceAction =
  | { type: "START" }
  | { type: "MIC_GRANTED" }
  | { type: "TOKEN_READY" }
  | { type: "SOCKET_OPEN" }
  | { type: "INTERIM"; text: string }
  | { type: "FINAL"; text: string }
  | { type: "STOP" }
  | { type: "SOCKET_CLOSED" }
  | { type: "FAIL"; error: string };

const initialState: VoiceState = {
  status: "idle",
  finalText: "",
  interimText: "",
  error: null,
};

const orderedStatuses: VoiceStatus[] = [
  "idle",
  "requesting-permission",
  "fetching-token",
  "connecting",
  "listening",
  "stopping",
];

function reducer(state: VoiceState, action: VoiceAction): VoiceState {
  switch (action.type) {
    case "START":
      if (state.status !== "idle" && state.status !== "error") return state;
      return { ...initialState, status: "requesting-permission" };
    case "MIC_GRANTED":
      if (state.status !== "requesting-permission") return state;
      return { ...state, status: "fetching-token" };
    case "TOKEN_READY":
      if (state.status !== "fetching-token") return state;
      return { ...state, status: "connecting" };
    case "SOCKET_OPEN":
      if (state.status !== "connecting") return state;
      return { ...state, status: "listening" };
    case "INTERIM":
      if (state.status !== "listening") return state;
      return { ...state, interimText: action.text };
    case "FINAL": {
      if (state.status !== "listening" && state.status !== "stopping") {
        return state;
      }
      const finalText = [state.finalText, action.text]
        .filter(Boolean)
        .join(" ");
      return { ...state, finalText, interimText: "" };
    }
    case "STOP":
      if (state.status !== "listening") return state;
      return { ...state, status: "stopping", interimText: "" };
    case "SOCKET_CLOSED":
      if (state.status === "error") return state;
      return { ...state, status: "idle", interimText: "" };
    case "FAIL":
      return {
        ...state,
        status: "error",
        interimText: "",
        error: action.error,
      };
  }
}

function messageFrom(error: unknown) {
  return error instanceof Error ? error.message : "Unknown voice input error.";
}

export default function Home() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const streamRef = useRef<MediaStream | null>(null);
  const recorderRef = useRef<MediaRecorder | null>(null);

  function releaseMicrophone() {
    streamRef.current?.getTracks().forEach((track) => track.stop());
    streamRef.current = null;
    recorderRef.current = null;
  }

  function fail(error: unknown) {
    releaseMicrophone();
    dispatch({ type: "FAIL", error: messageFrom(error) });
  }

  async function startListening() {
    if (state.status !== "idle" && state.status !== "error") return;

    dispatch({ type: "START" });

    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      streamRef.current = stream;
      const recorder = new MediaRecorder(stream);
      recorderRef.current = recorder;
      dispatch({ type: "MIC_GRANTED" });
    } catch (error) {
      fail(error);
    }
  }

  const canStart = state.status === "idle" || state.status === "error";
  const currentIndex = orderedStatuses.indexOf(state.status);

  return (
    <main className="shell">
      <section className="card">
        <p className="eyebrow">Luna roadmap: voice input</p>
        <h1>Luna Voice Input</h1>
        <p className="intro">
          A secure, state-driven microphone session powered only by Deepgram.
        </p>

        <div className="statePanel">
          <span className="stateLabel">Current state</span>
          <strong className={`stateValue state-${state.status}`}>
            {state.status}
          </strong>
        </div>

        <ol className="stateFlow" aria-label="Voice state machine">
          {orderedStatuses.map((status, index) => (
            <li
              className={
                index === currentIndex
                  ? "active"
                  : currentIndex > index
                    ? "complete"
                    : ""
              }
              key={status}
            >
              {status}
            </li>
          ))}
        </ol>

        <section className="transcriptPanel" aria-live="polite">
          <h2>Transcript</h2>
          <p className="transcript">
            {state.finalText}
            {state.interimText ? (
              <span className="interim"> {state.interimText}</span>
            ) : null}
            {!state.finalText && !state.interimText ? (
              <span className="placeholder">Your words will appear here.</span>
            ) : null}
          </p>
        </section>

        {state.error ? <p className="error">{state.error}</p> : null}

        <button
          className="startButton"
          disabled={!canStart}
          onClick={startListening}
          type="button"
        >
          {state.status === "error" ? "Retry" : "Start listening"}
        </button>
      </section>
    </main>
  );
}

Luna can now acquire microphone audio and surface permission failures without hiding where the process stopped. Next, you’ll use a temporary Deepgram token to move beyond fetching-token and begin live transcription.

Stream Speech with Deepgram

Luna can now capture microphone audio. Its state pauses at fetching-token because the browser still lacks a safe credential for transcription.

A Next.js server route keeps the permanent Deepgram key on the server. The browser receives a temporary access token before opening a WebSocket connection for live speech.

In this step, get ready to:
  • Create a server-only route that issues temporary access tokens.
  • Connect the browser to Nova-3 with the temporary token.
  • Stream microphone chunks so Luna can display live words.
Create the server token route

The permanent API key authorizes token creation. Reading it inside a Route Handler prevents browser code from receiving the secret.

  • Switch back to VS Code from earlier.
  • Create app/api/deepgram-token/route.ts inside the existing app folder using the file creation control.

You should see route.ts nested under app/api/deepgram-token in your project files.

  • Add the server token route by copying this code into app/api/deepgram-token/route.ts:
import { DeepgramClient } from "@deepgram/sdk";

export const runtime = "nodejs";

export async function POST() {
  const apiKey = process.env.DEEPGRAM_API_KEY;

  if (!apiKey) {
    return Response.json(
      { error: "DEEPGRAM_API_KEY is missing on the server." },
      { status: 500 },
    );
  }

  try {
    const deepgram = new DeepgramClient({ apiKey });
    const token = await deepgram.auth.v1.tokens.grant();

    return Response.json({
      access_token: token.access_token,
      expires_in: token.expires_in,
    });
  } catch {
    return Response.json(
      { error: "Deepgram could not issue a temporary token." },
      { status: 502 },
    );
  }
}

What does this route do?

  • The nodejs runtime keeps this route on the Node.js server.
  • The route reads DEEPGRAM_API_KEY from the server environment.
  • The auth.v1.tokens.grant() call exchanges server authorization for a temporary token.
  • The JSON response contains access_token plus expires_in. It never contains the permanent key.
  • Save app/api/deepgram-token/route.ts.
  • Confirm the saved file remains visible under app/api/deepgram-token in VS Code.

✔️ Awesome, I've got everything!

Your server route is ready. The client connection below calls it without exposing the permanent key.

ⓧ I'd like to double check the full code

import { DeepgramClient } from "@deepgram/sdk";

export const runtime = "nodejs";

export async function POST() {
  const apiKey = process.env.DEEPGRAM_API_KEY;

  if (!apiKey) {
    return Response.json(
      { error: "DEEPGRAM_API_KEY is missing on the server." },
      { status: 500 },
    );
  }

  try {
    const deepgram = new DeepgramClient({ apiKey });
    const token = await deepgram.auth.v1.tokens.grant();

    return Response.json({
      access_token: token.access_token,
      expires_in: token.expires_in,
    });
  } catch {
    return Response.json(
      { error: "Deepgram could not issue a temporary token." },
      { status: 502 },
    );
  }
}

How to use this reference

Compare this reference with your saved route. The import belongs at the top while both error responses remain inside POST.

Fetch a temporary browser token

The client needs the SDK plus a typed shape for the route response. It also needs one cleanup reference for the session lifecycle used by the completed voice flow.

  • In app/page.tsx, add the SDK import above the existing React import:
import { DeepgramClient } from "@deepgram/sdk";
import { useReducer, useRef } from "react";

Why import the client here?

The browser client authenticates with the temporary access token. The permanent API key remains available only to the server route.

  • Add the token response type below VoiceAction:
type TokenResponse = {
  access_token: string;
  expires_in?: number;
};

What does this type protect?

The type records the response fields the browser may receive. Any later use of the token must come through access_token.

  • Save app/page.tsx.
  • Return to Luna in the browser.
  • Reload the page.

Luna should render normally with no compilation error. That confirms the new import and response type are valid.

  • Find the media references inside Home.
  • Replace that reference group plus releaseMicrophone with this version:
  const streamRef = useRef<MediaStream | null>(null);
  const recorderRef = useRef<MediaRecorder | null>(null);
  const stopSessionRef = useRef<(() => void) | null>(null);

  function releaseMicrophone() {
    streamRef.current?.getTracks().forEach((track) => track.stop());
    streamRef.current = null;
    recorderRef.current = null;
    stopSessionRef.current = null;
  }

Why track the session reference?

The reference gives Luna one place to hold its eventual stream shutdown function. Clearing it with the media references prevents stale session controls from surviving cleanup.

  • Save app/page.tsx.
  • Reload Luna in the browser.
  • Press Start listening.

The status should still reach fetching-token. Your microphone flow still works after the cleanup update.

  • Find the successful microphone section inside startListening.
  • Replace the block from the media request through MIC_GRANTED with this token request:
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      streamRef.current = stream;
      dispatch({ type: "MIC_GRANTED" });

      const response = await fetch("/api/deepgram-token", { method: "POST" });
      const tokenData = (await response.json()) as TokenResponse & {
        error?: string;
      };

      if (!response.ok || !tokenData.access_token) {
        throw new Error(tokenData.error ?? "Could not get a Deepgram token.");
      }

      dispatch({ type: "TOKEN_READY" });

How does the token request work?

  • The browser calls the same-origin /api/deepgram-token route after microphone permission succeeds.
  • An unsuccessful response becomes an error that the existing catch block sends through fail.
  • A successful response dispatches TOKEN_READY. The reducer advances Luna to connecting.

Before you click the button, do you think Luna will remain at fetching-token or advance to the next state?

  • Save app/page.tsx.
  • Reload Luna in the browser.
  • Press Start listening.

You should see connecting. That state proves the browser obtained a temporary token from your server route.

Does Luna enter an error state?

  • Check that .env.local still contains the permanent key under DEEPGRAM_API_KEY.
  • Check that the route file is saved at app/api/deepgram-token/route.ts.
  • Restart the development server if .env.local changed after the server started.

Still blocked? Help me debug Luna's temporary token request.

Open the live transcription stream

The temporary token can now authenticate a direct listening connection. Luna starts recording only after that connection reports it is open.

  • Insert the browser client setup immediately below the TOKEN_READY dispatch:
      const deepgram = new DeepgramClient({
        accessToken: tokenData.access_token,
      });
      const connection = await deepgram.listen.v1.connect({
        model: "nova-3",
        language: "en",
        punctuate: "true",
        interim_results: "true",
      });
      const recorder = new MediaRecorder(stream);
      recorderRef.current = recorder;

What does this connection request?

  • The client uses accessToken so the permanent key never enters this file.
  • The nova-3 model transcribes English audio.
  • The punctuation setting produces punctuated text.
  • The interim setting allows provisional words to arrive before final results.
  • Add the audio sender plus connection startup below recorderRef.current = recorder;:
      recorder.addEventListener("dataavailable", (event) => {
        if (event.data.size > 0) {
          connection.socket.send(event.data);
        }
      });

      connection.connect();
      await connection.waitForOpen();
      recorder.start(250);
      dispatch({ type: "SOCKET_OPEN" });

How does audio reach the stream?

  • The recorder emits an audio blob every 250 milliseconds.
  • The size check prevents empty blobs from reaching the socket.
  • The client waits for the connection to open before recording begins.
  • The SOCKET_OPEN event moves Luna into listening.
  • Save app/page.tsx.
  • Reload Luna in the browser.
  • Press Start listening.

You should see the status reach listening. Luna now has an open audio stream.

  • Reload Luna once more to end this short connection check.

Your browser should return Luna to idle. The microphone indicator should turn off.

  • Insert the transcript message handler below recorderRef.current = recorder;:
      connection.on("message", (data) => {
        if (data.type !== "Results") return;

        const transcript = data.channel.alternatives[0]?.transcript.trim();
        if (!transcript) return;

        dispatch({
          type: data.is_final ? "FINAL" : "INTERIM",
          text: transcript,
        });
      });

How do results reach React state?

  • The handler ignores messages that are not Results payloads.
  • The transcript comes from the first alternative in the result.
  • Final results dispatch FINAL. Provisional results dispatch INTERIM.
  • Add the connection lifecycle handlers below the message handler:
      connection.on("error", (error) => {
        fail(error);
      });

      connection.on("close", () => {
        releaseMicrophone();
        dispatch({ type: "SOCKET_CLOSED" });
      });

How are connection failures contained?

A socket error flows through fail so Luna releases the microphone before entering error.

A closed connection releases local media references. The reducer then returns the state machine to idle unless an error already owns the state.

✔️ Awesome, I've got everything!

Your client now requests a temporary token. It opens a listening connection before sending microphone chunks.

ⓧ I'd like to double check the full code

"use client";

import { DeepgramClient } from "@deepgram/sdk";
import { useReducer, useRef } from "react";

type VoiceStatus =
  | "idle"
  | "requesting-permission"
  | "fetching-token"
  | "connecting"
  | "listening"
  | "stopping"
  | "error";

type VoiceState = {
  status: VoiceStatus;
  finalText: string;
  interimText: string;
  error: string | null;
};

type VoiceAction =
  | { type: "START" }
  | { type: "MIC_GRANTED" }
  | { type: "TOKEN_READY" }
  | { type: "SOCKET_OPEN" }
  | { type: "INTERIM"; text: string }
  | { type: "FINAL"; text: string }
  | { type: "STOP" }
  | { type: "SOCKET_CLOSED" }
  | { type: "FAIL"; error: string };

type TokenResponse = {
  access_token: string;
  expires_in?: number;
};

const initialState: VoiceState = {
  status: "idle",
  finalText: "",
  interimText: "",
  error: null,
};

const orderedStatuses: VoiceStatus[] = [
  "idle",
  "requesting-permission",
  "fetching-token",
  "connecting",
  "listening",
  "stopping",
];

function reducer(state: VoiceState, action: VoiceAction): VoiceState {
  switch (action.type) {
    case "START":
      if (state.status !== "idle" && state.status !== "error") return state;
      return { ...initialState, status: "requesting-permission" };
    case "MIC_GRANTED":
      if (state.status !== "requesting-permission") return state;
      return { ...state, status: "fetching-token" };
    case "TOKEN_READY":
      if (state.status !== "fetching-token") return state;
      return { ...state, status: "connecting" };
    case "SOCKET_OPEN":
      if (state.status !== "connecting") return state;
      return { ...state, status: "listening" };
    case "INTERIM":
      if (state.status !== "listening") return state;
      return { ...state, interimText: action.text };
    case "FINAL": {
      if (state.status !== "listening" && state.status !== "stopping") {
        return state;
      }
      const finalText = [state.finalText, action.text]
        .filter(Boolean)
        .join(" ");
      return { ...state, finalText, interimText: "" };
    }
    case "STOP":
      if (state.status !== "listening") return state;
      return { ...state, status: "stopping", interimText: "" };
    case "SOCKET_CLOSED":
      if (state.status === "error") return state;
      return { ...state, status: "idle", interimText: "" };
    case "FAIL":
      return {
        ...state,
        status: "error",
        interimText: "",
        error: action.error,
      };
  }
}

function messageFrom(error: unknown) {
  return error instanceof Error ? error.message : "Unknown voice input error.";
}

export default function Home() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const streamRef = useRef<MediaStream | null>(null);
  const recorderRef = useRef<MediaRecorder | null>(null);
  const stopSessionRef = useRef<(() => void) | null>(null);

  function releaseMicrophone() {
    streamRef.current?.getTracks().forEach((track) => track.stop());
    streamRef.current = null;
    recorderRef.current = null;
    stopSessionRef.current = null;
  }

  function fail(error: unknown) {
    releaseMicrophone();
    dispatch({ type: "FAIL", error: messageFrom(error) });
  }

  async function startListening() {
    if (state.status !== "idle" && state.status !== "error") return;

    dispatch({ type: "START" });

    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      streamRef.current = stream;
      dispatch({ type: "MIC_GRANTED" });

      const response = await fetch("/api/deepgram-token", { method: "POST" });
      const tokenData = (await response.json()) as TokenResponse & {
        error?: string;
      };

      if (!response.ok || !tokenData.access_token) {
        throw new Error(tokenData.error ?? "Could not get a Deepgram token.");
      }

      dispatch({ type: "TOKEN_READY" });

      const deepgram = new DeepgramClient({
        accessToken: tokenData.access_token,
      });
      const connection = await deepgram.listen.v1.connect({
        model: "nova-3",
        language: "en",
        punctuate: "true",
        interim_results: "true",
      });
      const recorder = new MediaRecorder(stream);
      recorderRef.current = recorder;

      connection.on("message", (data) => {
        if (data.type !== "Results") return;

        const transcript = data.channel.alternatives[0]?.transcript.trim();
        if (!transcript) return;

        dispatch({
          type: data.is_final ? "FINAL" : "INTERIM",
          text: transcript,
        });
      });

      connection.on("error", (error) => {
        fail(error);
      });

      connection.on("close", () => {
        releaseMicrophone();
        dispatch({ type: "SOCKET_CLOSED" });
      });

      recorder.addEventListener("dataavailable", (event) => {
        if (event.data.size > 0) {
          connection.socket.send(event.data);
        }
      });

      connection.connect();
      await connection.waitForOpen();
      recorder.start(250);
      dispatch({ type: "SOCKET_OPEN" });
    } catch (error) {
      fail(error);
    }
  }

  const canStart = state.status === "idle" || state.status === "error";
  const currentIndex = orderedStatuses.indexOf(state.status);

  return (
    <main className="shell">
      <section className="card">
        <p className="eyebrow">Luna roadmap: voice input</p>
        <h1>Luna Voice Input</h1>
        <p className="intro">
          A secure, state-driven microphone session powered only by Deepgram.
        </p>

        <div className="statePanel">
          <span className="stateLabel">Current state</span>
          <strong className={`stateValue state-${state.status}`}>
            {state.status}
          </strong>
        </div>

        <ol className="stateFlow" aria-label="Voice state machine">
          {orderedStatuses.map((status, index) => (
            <li
              className={
                index === currentIndex
                  ? "active"
                  : currentIndex > index
                    ? "complete"
                    : ""
              }
              key={status}
            >
              {status}
            </li>
          ))}
        </ol>

        <section className="transcriptPanel" aria-live="polite">
          <h2>Transcript</h2>
          <p className="transcript">
            {state.finalText}
            {state.interimText ? (
              <span className="interim"> {state.interimText}</span>
            ) : null}
            {!state.finalText && !state.interimText ? (
              <span className="placeholder">Your words will appear here.</span>
            ) : null}
          </p>
        </section>

        {state.error ? <p className="error">{state.error}</p> : null}

        <button
          className="startButton"
          disabled={!canStart}
          onClick={startListening}
          type="button"
        >
          {state.status === "error" ? "Retry" : "Start listening"}
        </button>
      </section>
    </main>
  );
}

How to use this reference

Compare the imports and token type first. Continue through startListening to confirm the token request appears before the connection setup.

The message handlers belong before the recorder sends chunks. The connection startup remains at the end of the try block.

Before you start the final check, what do you expect Luna to display after the connection opens and you speak?

  • Save app/page.tsx.
  • Reload Luna in the browser.
  • Press Start listening.
  • Speak a short sentence into your selected microphone.

You should see the status reach listening. Your words should appear in the transcript panel.

That is the live path working. Luna now moves microphone audio through a temporary browser credential without revealing the permanent key.

No transcript text?

  • Check that Luna reaches listening before you speak.
  • Check that the selected microphone detects your voice in the Linux audio settings.
  • Check the development terminal for a route or connection failure.

Need another pair of eyes? Help me debug Luna's live transcript stream.

  • Reload the browser after capturing your checkpoint.

You should see Luna return to idle while the microphone indicator turns off.

Your secure speech stream is live. Next up, you will preserve final text while giving Luna a graceful way to close every session.

Finalize and Clean Up Sessions

Your secure Luna session already reaches listening through a temporary Deepgram token. It can stream microphone audio to Nova-3.

Streaming words remain provisional until Deepgram marks them as final. This step preserves those final words. It also closes the WebSocket before Luna releases every browser resource.

In this step, get ready to:
  • Separate provisional transcript text from final transcript text.
  • Add a Stop listening control that exposes the abrupt-shutdown problem.
  • Close the Deepgram stream gracefully before releasing the microphone.
Separate provisional and final words

A Deepgram result with is_final: false can still change. Luna stores that text in interimText so the interface can show it as a provisional draft.

  • In app/page.tsx, locate the INTERIM branch in reducer().
  • Replace that branch with this reference:
    case "INTERIM":
      if (state.status !== "listening") return state;
      return { ...state, interimText: action.text };

How does provisional text work?

  • The status guard accepts provisional text only during an active listening session.
  • Each new result replaces interimText because Deepgram may revise the current phrase.

A result with is_final: true is stable. Luna appends it to finalText so completed phrases remain visible.

  • Locate the FINAL branch directly below the interim branch.
  • Replace that branch with this reference:
    case "FINAL": {
      if (state.status !== "listening" && state.status !== "stopping") {
        return state;
      }
      const finalText = [state.finalText, action.text]
        .filter(Boolean)
        .join(" ");
      return { ...state, finalText, interimText: "" };
    }

How does final text stay stable?

  • The guard accepts final results while Luna is listening or stopping.
  • The filtered array joins completed phrases with a single space.
  • The returned state clears interimText after each final segment.

Before you test this change, which words do you think will appear provisional before becoming stable?

  • Save app/page.tsx.
  • Return to the running Luna app from earlier.
  • Click Start listening.
  • Say, “Luna drafts my words before saving them.”

You should see the current phrase in muted italic text. The same words should become stable text when Deepgram returns a final result.

  • Reload the browser page to end this checkpoint session.

You should see Luna return to idle. Your browser should stop showing its microphone indicator.

Transcript text not settling?

  • Check that the INTERIM branch writes to interimText.
  • Check that the FINAL branch writes to finalText.
  • Confirm that the final branch clears interimText.

Still stuck? Help me debug why Luna's interim transcript is not becoming final text.

Expose the abrupt-stop problem

Luna needs a stopping transition before it can coordinate shutdown. The first version releases the local microphone through its fallback path.

  • In app/page.tsx, add stopListening() below startListening().
  • Use this function as the final reference:
  function stopListening() {
    if (state.status !== "listening") return;

    dispatch({ type: "STOP" });
    const stopSession = stopSessionRef.current;

    if (stopSession) {
      stopSession();
    } else {
      releaseMicrophone();
      dispatch({ type: "SOCKET_CLOSED" });
    }
  }

What does the stop handler control?

  • The status guard prevents a stop request outside an active session.
  • The STOP event moves Luna into stopping.
  • The fallback releases the microphone when no session shutdown callback is available.
  • Find the existing <button> block near the bottom of app/page.tsx.
  • Replace the entire button block with this reference:
        <button
          className={isListening ? "stopButton" : "startButton"}
          disabled={!canStart && !isListening}
          onClick={isListening ? stopListening : startListening}
          type="button"
        >
          {isListening
            ? "Stop listening"
            : state.status === "error"
              ? "Retry"
              : "Start listening"}
        </button>

How does one button serve three states?

  • An active session uses stopListening with the stop-button style.
  • An error changes the label to Retry while keeping startListening available.
  • Every other startable state displays Start listening.
  • Save app/page.tsx.

Before you stop while a phrase is still provisional, do you think the unfinished words will remain?

  • Return to the Luna app.
  • Click Start listening.
  • Speak until muted interim text appears.
  • Click Stop listening immediately.

You should see the provisional phrase disappear as Luna returns to idle. That shortfall is intentional.

Luna stopped local capture before Deepgram processed its cached audio. The next change gives the remote stream time to return its final result.

Close the stream gracefully

Deepgram’s {"type":"CloseStream"} payload requests final processing before the connection closes. The close handler can then release every local reference.

  • In app/page.tsx, locate releaseMicrophone().
  • Replace the function with this final version:
  function releaseMicrophone() {
    streamRef.current?.getTracks().forEach((track) => track.stop());
    streamRef.current = null;
    recorderRef.current = null;
    stopSessionRef.current = null;
  }

Why clear all three references?

  • Stopping every media track turns off local microphone capture.
  • Clearing the refs prevents a later Retry from reusing a finished stream.
  • Resetting stopSessionRef ensures the next session receives its own shutdown callback.
  • Find the dataavailable listener inside startListening().
  • Add this shutdown code after that listener.
  • Place the code before connection.connect();.
      recorder.addEventListener("stop", () => {
        stream.getTracks().forEach((track) => track.stop());

        try {
          connection.socket.send(JSON.stringify({ type: "CloseStream" }));
        } catch (error) {
          fail(error);
        }
      });

      stopSessionRef.current = () => {
        if (recorder.state === "recording") {
          recorder.stop();
        } else {
          connection.socket.send(JSON.stringify({ type: "CloseStream" }));
        }
      };

How does graceful shutdown work?

  • Stopping the recorder emits its final audio data before the recorder stops.
  • The stop listener ends every local media track.
  • The listener sends {"type":"CloseStream"} so Deepgram processes cached audio.
  • A failed close request passes through fail() to release resources and show a recoverable error.

Before the final browser check, what state sequence do you expect after clicking Stop listening?

  • Save app/page.tsx.
  • Return to the Luna app.
  • Click Start listening.
  • Speak two short phrases.
  • Click Stop listening.

You should see stopping before Luna returns to idle. Your final transcript should remain visible.

You should also see the browser microphone indicator turn off. Luna has now closed both sides of the live session.

Session not returning to idle?

  • Confirm that stopSessionRef.current calls recorder.stop() while the recorder is active.
  • Confirm that the recorder stop listener sends the exact CloseStream message.
  • Check that the existing connection close handler dispatches SOCKET_CLOSED.

Need another pair of eyes? Help me trace why Luna remains in stopping or keeps the microphone active.

✔️ Awesome, I've got everything!

Great. Save app/page.tsx before running the production build.

ⓧ I'd like to double check the full code

Compare your final app/page.tsx with this complete reference.

"use client";

import { DeepgramClient } from "@deepgram/sdk";
import { useReducer, useRef } from "react";

type VoiceStatus =
  | "idle"
  | "requesting-permission"
  | "fetching-token"
  | "connecting"
  | "listening"
  | "stopping"
  | "error";

type VoiceState = {
  status: VoiceStatus;
  finalText: string;
  interimText: string;
  error: string | null;
};

type VoiceAction =
  | { type: "START" }
  | { type: "MIC_GRANTED" }
  | { type: "TOKEN_READY" }
  | { type: "SOCKET_OPEN" }
  | { type: "INTERIM"; text: string }
  | { type: "FINAL"; text: string }
  | { type: "STOP" }
  | { type: "SOCKET_CLOSED" }
  | { type: "FAIL"; error: string };

type TokenResponse = {
  access_token: string;
  expires_in?: number;
};

const initialState: VoiceState = {
  status: "idle",
  finalText: "",
  interimText: "",
  error: null,
};

const orderedStatuses: VoiceStatus[] = [
  "idle",
  "requesting-permission",
  "fetching-token",
  "connecting",
  "listening",
  "stopping",
];

function reducer(state: VoiceState, action: VoiceAction): VoiceState {
  switch (action.type) {
    case "START":
      if (state.status !== "idle" && state.status !== "error") return state;
      return { ...initialState, status: "requesting-permission" };
    case "MIC_GRANTED":
      if (state.status !== "requesting-permission") return state;
      return { ...state, status: "fetching-token" };
    case "TOKEN_READY":
      if (state.status !== "fetching-token") return state;
      return { ...state, status: "connecting" };
    case "SOCKET_OPEN":
      if (state.status !== "connecting") return state;
      return { ...state, status: "listening" };
    case "INTERIM":
      if (state.status !== "listening") return state;
      return { ...state, interimText: action.text };
    case "FINAL": {
      if (state.status !== "listening" && state.status !== "stopping") {
        return state;
      }
      const finalText = [state.finalText, action.text]
        .filter(Boolean)
        .join(" ");
      return { ...state, finalText, interimText: "" };
    }
    case "STOP":
      if (state.status !== "listening") return state;
      return { ...state, status: "stopping", interimText: "" };
    case "SOCKET_CLOSED":
      if (state.status === "error") return state;
      return { ...state, status: "idle", interimText: "" };
    case "FAIL":
      return {
        ...state,
        status: "error",
        interimText: "",
        error: action.error,
      };
  }
}

function messageFrom(error: unknown) {
  return error instanceof Error ? error.message : "Unknown voice input error.";
}

export default function Home() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const streamRef = useRef<MediaStream | null>(null);
  const recorderRef = useRef<MediaRecorder | null>(null);
  const stopSessionRef = useRef<(() => void) | null>(null);

  function releaseMicrophone() {
    streamRef.current?.getTracks().forEach((track) => track.stop());
    streamRef.current = null;
    recorderRef.current = null;
    stopSessionRef.current = null;
  }

  function fail(error: unknown) {
    releaseMicrophone();
    dispatch({ type: "FAIL", error: messageFrom(error) });
  }

  async function startListening() {
    if (state.status !== "idle" && state.status !== "error") return;

    dispatch({ type: "START" });

    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      streamRef.current = stream;
      dispatch({ type: "MIC_GRANTED" });

      const response = await fetch("/api/deepgram-token", { method: "POST" });
      const tokenData = (await response.json()) as TokenResponse & {
        error?: string;
      };

      if (!response.ok || !tokenData.access_token) {
        throw new Error(tokenData.error ?? "Could not get a Deepgram token.");
      }

      dispatch({ type: "TOKEN_READY" });

      const deepgram = new DeepgramClient({
        accessToken: tokenData.access_token,
      });
      const connection = await deepgram.listen.v1.connect({
        model: "nova-3",
        language: "en",
        punctuate: "true",
        interim_results: "true",
      });
      const recorder = new MediaRecorder(stream);
      recorderRef.current = recorder;

      connection.on("message", (data) => {
        if (data.type !== "Results") return;

        const transcript = data.channel.alternatives[0]?.transcript.trim();
        if (!transcript) return;

        dispatch({
          type: data.is_final ? "FINAL" : "INTERIM",
          text: transcript,
        });
      });

      connection.on("error", (error) => {
        fail(error);
      });

      connection.on("close", () => {
        releaseMicrophone();
        dispatch({ type: "SOCKET_CLOSED" });
      });

      recorder.addEventListener("dataavailable", (event) => {
        if (event.data.size > 0) {
          connection.socket.send(event.data);
        }
      });

      recorder.addEventListener("stop", () => {
        stream.getTracks().forEach((track) => track.stop());

        try {
          connection.socket.send(JSON.stringify({ type: "CloseStream" }));
        } catch (error) {
          fail(error);
        }
      });

      stopSessionRef.current = () => {
        if (recorder.state === "recording") {
          recorder.stop();
        } else {
          connection.socket.send(JSON.stringify({ type: "CloseStream" }));
        }
      };

      connection.connect();
      await connection.waitForOpen();
      recorder.start(250);
      dispatch({ type: "SOCKET_OPEN" });
    } catch (error) {
      fail(error);
    }
  }

  function stopListening() {
    if (state.status !== "listening") return;

    dispatch({ type: "STOP" });
    const stopSession = stopSessionRef.current;

    if (stopSession) {
      stopSession();
    } else {
      releaseMicrophone();
      dispatch({ type: "SOCKET_CLOSED" });
    }
  }

  const isListening = state.status === "listening";
  const canStart = state.status === "idle" || state.status === "error";
  const currentIndex = orderedStatuses.indexOf(state.status);

  return (
    <main className="shell">
      <section className="card">
        <p className="eyebrow">Luna roadmap: voice input</p>
        <h1>Luna Voice Input</h1>
        <p className="intro">
          A secure, state-driven microphone session powered only by Deepgram.
        </p>

        <div className="statePanel">
          <span className="stateLabel">Current state</span>
          <strong className={`stateValue state-${state.status}`}>
            {state.status}
          </strong>
        </div>

        <ol className="stateFlow" aria-label="Voice state machine">
          {orderedStatuses.map((status, index) => (
            <li
              className={
                index === currentIndex
                  ? "active"
                  : currentIndex > index
                    ? "complete"
                    : ""
              }
              key={status}
            >
              {status}
            </li>
          ))}
        </ol>

        <section className="transcriptPanel" aria-live="polite">
          <h2>Transcript</h2>
          <p className="transcript">
            {state.finalText}
            {state.interimText ? (
              <span className="interim"> {state.interimText}</span>
            ) : null}
            {!state.finalText && !state.interimText ? (
              <span className="placeholder">Your words will appear here.</span>
            ) : null}
          </p>
        </section>

        {state.error ? <p className="error">{state.error}</p> : null}

        <button
          className={isListening ? "stopButton" : "startButton"}
          disabled={!canStart && !isListening}
          onClick={isListening ? stopListening : startListening}
          type="button"
        >
          {isListening
            ? "Stop listening"
            : state.status === "error"
              ? "Retry"
              : "Start listening"}
        </button>
      </section>
    </main>
  );
}

How to use this reference

Compare the reference with your file from top to bottom. Keep your permanent key in .env.local because this client file receives only a temporary access token.

  • Return to the terminal running the development server.
  • Stop the development server with Ctrl+C.

Before you run the production check, do you expect TypeScript to accept every reducer event and browser reference?

  • Build the completed Luna application by running:
npm run build

What does the production build prove?

The build checks the final Next.js application with its strict TypeScript configuration. A successful result confirms that the token route and client component compile together.

You should see the production build complete without a TypeScript compilation error. That confirms Luna’s final application code is ready.

Build not completing?

  • Check the first reported file and line because later messages may follow from the same typo.
  • Confirm that every VoiceAction branch uses the exact action names from the union type.
  • Confirm that stopSessionRef stores either a function or null.

Still blocked? Help me diagnose why npm run build fails for my final Luna application.

That is the full session lifecycle working. Luna now preserves final transcript text while closing the remote stream and releasing the local microphone.

Secret mission

Fault-Test Luna’s Voice State Machine

A working voice session proves Luna’s happy path. This challenge deliberately breaks microphone permission, server authentication, and the live network connection to prove that every failure reaches a safe state.

Clean Up Your Resources

Clean Up Your Resources

Your local Next.js app has no hosting cost. Deepgram usage draws from your included $200 credit before pay-as-you-go pricing applies, so choose whether to keep the project available, pause its processes, or delete its resources.

Cost warning

The included $200 credit covers this project. Live speech-to-text usage draws from that balance before Deepgram becomes pay-as-you-go.

  • Stop Luna's listening session whenever you are not testing.
  • Review your account usage before extending the project.
  • Revoke the Member API key if you are finished with Luna.

Resources you used:

  • Local luna-voice-input folder on your Desktop containing the application source files, node_modules, and .next.
  • Local development server serving http://localhost:3000.
  • Browser microphone capture plus the active WebSocket stream used during listening.
  • Permanent Deepgram Member API key stored in .env.local, which remains ignored by Git.

Keep everything running

No cleanup is required. Choose this if you are continuing to build or test Luna.

  • Keep the luna-voice-input folder on your Desktop.
  • Keep the Member API key inside Git-ignored .env.local.
  • Leave the development server available while you are actively testing.
  • Click Stop listening before leaving Luna unattended.

Your project remains ready for another session. Stopping Luna's microphone stream protects the Deepgram credit you have left.

Pause - I'll come back to this later

Pause every running process while keeping the project ready for later.

  • Click Stop listening if Luna is currently listening.
  • Confirm the browser microphone indicator turns off.
  • Press Ctrl+C in the development-server terminal.

You are safely paused. Luna no longer holds the microphone or an active WebSocket.

Your luna-voice-input files remain on your Desktop. The permanent key stays inside .env.local.

Delete - I don't want to use this again

Deleting Luna's folder removes your source code plus its local permanent key. Revoking the Member API key prevents it from minting more temporary tokens.

This cleanup cannot be undone. Your Deepgram account plus its remaining credit stay available.

  • Click Stop listening if Luna is currently listening.
  • Confirm the browser microphone indicator turns off.
  • Press Ctrl+C in the development-server terminal if it is still running.

Luna now has no active microphone stream or local server process. The next action disables its permanent credential.

  • Sign in to the Deepgram Console.
  • Open the Deepgram project that contains the Member API key.
  • Locate the Member API key you created for Luna.
  • Revoke the key through its key-management control.
  • Confirm the key no longer appears as active in the project's key list.

The revoked key can no longer mint temporary access tokens. You can now remove its local copy with the rest of the project.

  • Delete the complete project folder from your Desktop by running this command:
rm -rf ~/Desktop/luna-voice-input

What does this command remove?

This permanently deletes the luna-voice-input folder. Files inside it disappear too, including .env.local, node_modules, .next, plus the application source files.

  • Verify the project folder is gone by running this command:
ls ~/Desktop

What should I see?

You should no longer see luna-voice-input in the Desktop listing. That confirms the local application plus its secret file have been removed.

Cleanup complete. Luna no longer has a local project folder or an active Member API key.

Nice Work!

Nice Work!

You did it! Luna now turns live microphone speech into a visible transcript through a secure Deepgram stream.

You've learned how to:

  • Built a live Luna microphone interface with React plus TypeScript. Made every stage of the voice lifecycle visible through typed states.
  • Protected the permanent Deepgram API key inside .env.local. Issued temporary browser tokens through a server-only route.
  • Streamed browser audio to Deepgram Nova-3 with distinct transcript stages. Added graceful shutdown to preserve final words. Released microphone tracks after each session.
  • Completed the optional Secret Mission by fault-testing revoked microphone permission. Repeated the test with a missing server key. Finished with an offline WebSocket. Proved that each fault reaches error safely. Confirmed that Retry can recover Luna to idle.

Ready to quiz yourself?