Build an AI English Speaking Trainer

Build a voice app for English conversations, mock interviews, and feedback.

Introduction

30 Second Summary

Practising spoken English alone can feel one-sided because nobody answers your next sentence. Interview practice adds pressure when useful feedback arrives after the real conversation.

In this project, you will build a local voice coach that listens through browser speech recognition before replying through speech synthesis. It uses Gemini to continue an everyday conversation or a role-focused mock interview before producing structured feedback.

What You'll Build

You will demo a local voice coach that answers each spoken turn aloud before revealing a visual coaching report.

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

  • Two training modes that let you switch between English Conversation Practice and Interview Preparation.
  • A spoken practice loop that places each recognized sentence in a transcript before playing the trainer's reply aloud.
  • A visual coaching report with corrections, explanations, strengths, next steps, plus scores for grammar, vocabulary, clarity, and relevance.
  • Secret Mission: Hear a corrected sentence before repeating it into the microphone for targeted practice.

Are there any prerequisites?

You need basic HTML plus JavaScript skills.

You also need a Windows computer with internet access, a working microphone, speakers, Node.js 20 or later, Visual Studio Code, Microsoft Edge or Google Chrome, plus an existing Google AI Studio API key.

Before We Start

A speaking score is useful when its limits are clear. This project gives formative feedback from your transcript, while your first choice is whether Conversation Practice or Interview Preparation matters more to you.

Set Up the Secure Project

Your trainer eventually sends spoken answers to the Gemini API. Browser code is visible to anyone who opens the page, so it cannot safely hold your API key.

A small Express server creates the security boundary. You will prepare that server in Visual Studio Code with Node.js version 20 or later.

Why use a server boundary?

The browser sends speaking data to your local server. The server makes Gemini requests using an environment variable that stays outside the browser files.

This separation keeps the credential out of the page source. Every Gemini request remains under server control.

In this step, get ready to:
  • Confirm the required Node.js version and browser support.
  • Store a current Gemini authentication key in a Windows user environment variable.
  • Create the local project and run its Express server.
Confirm the tools and key

The Google GenAI SDK requires Node.js version 20 or later. Checking the installed version now prevents dependency failures during setup.

  • Return to the terminal in VS Code.
  • Check the installed Node.js version by running this command:
node --version

What does this command do?

The command asks Node.js to print its installed version. The first number after v is the major version.

✔️ I see the required version

Continue if the output begins with v20 or a higher major version. Your Node.js runtime can use the pinned Google GenAI SDK.

ⓧ I see an older version

Your current Node.js version is below the project requirement. Install a supported release before creating the project.

  • Visit the official Node.js download page.
  • Download an official Windows installer for a current release.
  • Run the downloaded installer.
  • Complete the installation using its default options.
  • Close VS Code after the installation completes.
  • Reopen VS Code from Windows search.
  • Return to the version check above.

ⓧ Command not found

Windows cannot find a Node.js installation. Install a current release from the official download page.

  • Visit the official Node.js download page.
  • Download an official Windows installer for a current release.
  • Run the downloaded installer.
  • Complete the installation using its default options.
  • Close VS Code after the installation completes.
  • Reopen VS Code from Windows search.
  • Return to the version check above.

Your existing key also needs a quick check in Google AI Studio. Keys labeled Standard are rejected by the Gemini API from September 2026.

  • Open the Google AI Studio API key page in Microsoft Edge or Google Chrome.
  • Find the existing key in the key list.
  • Read its value in the Key Type column.

✔️ The key is current

Continue if the Key Type column does not show Standard. Keep the key ready for the Windows environment-variable field.

ⓧ Key Type shows Standard

A key labeled Standard needs to be replaced with a current authentication key.

  • Click Create API key.
  • Complete the key creation prompt for your Google AI Studio project.
  • Copy the new key when Google AI Studio reveals it.
  • Store the new key in your password manager.

Cannot confirm the key type?

Make sure you are viewing the key list in Google AI Studio. The Key Type column appears alongside each listed key.

If the column still does not appear, help me check whether my Gemini API key is current.

Protect the key and create the project

Windows hides user variables inside an older system dialog, so this part can feel fiddly. The user scope keeps the key available to your account without placing it in the project.

  • Close VS Code before changing the environment variable.
  • Press the Windows key to open search.
  • Type environment variables into Windows search.
  • Open Edit environment variables for your account.

Keep the key out of files

This is the sensitive part because the key belongs only in the Windows user-variable field. Project files can be copied or shared later.

  • Click New under User variables.
  • Enter GEMINI_API_KEY in the Variable name field.
  • Paste the current authentication key into the Variable value field.
  • Click OK to save the user variable.
  • Click OK to close the environment-variable dialog.

Programs inherit environment variables when they start. Reopening VS Code gives its terminal access to the new value.

  • Press the Windows key to open search.
  • Type Visual Studio Code into Windows search.
  • Press Enter to reopen VS Code.
  • Open the File menu in VS Code.
  • Select Open Folder.
  • Select your Desktop in the Windows folder picker.
  • Create a folder named english-speaking-trainer on your Desktop.
  • Select the english-speaking-trainer folder.
  • Create server.js in the VS Code Explorer sidebar.
  • Create package.json in the VS Code Explorer sidebar.
  • Create a public folder in the VS Code Explorer sidebar.
  • Create index.html inside the public folder.
  • Create style.css inside the public folder.
  • Create app.js inside the public folder.

The Explorer now shows server.js and package.json beside the expanded public folder. That folder contains the three browser files.

Start the secure server

The package configuration pins the dependency versions used throughout the project. Its start script gives you one consistent command for launching the server.

  • Select package.json in the VS Code Explorer.
  • Replace its contents with this package configuration:
{"name":"english-speaking-trainer","version":"1.0.0","private":true,"type":"module","scripts":{"start":"node server.js"},"engines":{"node":">=20"},"dependencies":{"@google/genai":"2.23.0","express":"5.2.1"}}

What does this configuration do?

  • The private setting prevents accidental publication to the npm registry.
  • The type setting enables JavaScript module imports.
  • The start script launches server.js.
  • The engines setting records the Node.js requirement.
  • The dependencies pin Express and the Google GenAI SDK.
  • Save package.json by pressing Ctrl+S.

✔️ Awesome, I've got everything!

Great. Confirm that package.json is saved before installing the packages.

ⓧ I'd like to double check the full code

Compare your saved package.json with this complete reference.

{"name":"english-speaking-trainer","version":"1.0.0","private":true,"type":"module","scripts":{"start":"node server.js"},"engines":{"node":">=20"},"dependencies":{"@google/genai":"2.23.0","express":"5.2.1"}}

How to use this reference

Check every key and pinned version against your saved file. The dependency names must match exactly.

  • Open the VS Code terminal from the Terminal menu.
  • Install the two pinned dependencies by running:
npm install express@5.2.1 @google/genai@2.23.0

What does this command do?

The command downloads Express version 5.2.1 and the Google GenAI SDK version 2.23.0. npm records the resolved installation for this project.

The terminal prints an installation summary when the packages are ready. Your Explorer also shows a new node_modules folder.

Package installation failed?

Check that the terminal path ends with english-speaking-trainer. Confirm that your Node.js check showed version 20 or later.

If the failure continues, help me diagnose my npm installation error.

The server now needs the smallest secure foundation. It checks for the environment variable before serving browser files.

  • Select server.js in the VS Code Explorer.
  • Replace its contents with this server code:
import express from "express";
import { GoogleGenAI } from "@google/genai";

const app = express();
const port = 3000;

if (!process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY) {
  console.error("Set GEMINI_API_KEY in Windows Environment Variables, then reopen the terminal.");
  process.exit(1);
}

const ai = new GoogleGenAI({});

app.use(express.json());
app.use(express.static("public"));

app.listen(port, () => {
  console.log(`English trainer running at http://localhost:${port}`);
});

What does this server do?

  • The imports load Express and the Google GenAI client.
  • The environment check stops startup when no supported key variable exists.
  • The GoogleGenAI client reads its credential from the server environment.
  • The JSON middleware prepares the server for request bodies.
  • The static middleware serves files from public.
  • The listener runs the app on port 3000.
  • Save server.js by pressing Ctrl+S.

Before you run this, decide whether the server should print its local address or stop at the key check.

  • Start the Express server by running:
npm start

What does this command do?

npm reads the start script from package.json. That script launches server.js.

You should see English trainer running at http://localhost:3000 in the terminal. This confirms that the reopened terminal inherited the API-key variable.

Server stopped at the key check?

The message about setting GEMINI_API_KEY means the terminal did not inherit the user variable. Close every VS Code window before reopening the project.

If the variable is saved but startup still stops, help me troubleshoot the missing Gemini environment variable.

✔️ Awesome, I've got everything!

Your server is running with the key behind its process boundary. Leave this terminal running.

ⓧ I'd like to double check the full code

Compare your saved server.js with this complete reference.

import express from "express";
import { GoogleGenAI } from "@google/genai";

const app = express();
const port = 3000;

if (!process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY) {
  console.error("Set GEMINI_API_KEY in Windows Environment Variables, then reopen the terminal.");
  process.exit(1);
}

const ai = new GoogleGenAI({});

app.use(express.json());
app.use(express.static("public"));

app.listen(port, () => {
  console.log(`English trainer running at http://localhost:${port}`);
});

How to use this reference

Check the import names and environment-variable names carefully. Confirm that the static directory is public.

A small starter page gives the static server a visible result. The full training interface arrives in the next step.

  • Select public/index.html in the VS Code Explorer.
  • Replace its contents with this starter page:
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>English Speaking Trainer</title>
</head>
<body>
  <main class="app-shell">
    <header>
      <p class="eyebrow">Personal voice coach</p>
      <h1>English Speaking Trainer</h1>
      <p>Practice naturally, then review corrections after the session.</p>
    </header>
  </main>
</body>
</html>

What does this page contain?

The document supplies the starter title and heading served from the public folder. Its viewport setting prepares the page for responsive styling.

  • Save public/index.html by pressing Ctrl+S.

Before you open the local address, predict which heading the static server should return.

  • Press the Windows key to open search.
  • Type Microsoft Edge into Windows search.
  • Press Enter to open Microsoft Edge.
  • Enter http://localhost:3000 in the address bar.
  • Press Enter to load the local page.

You should see English Speaking Trainer above the starter description. You have a working browser-to-server boundary with no API key in the browser files.

Local page did not load?

Confirm that the VS Code terminal still shows the local server address. Check that index.html sits directly inside the public folder.

If the browser still cannot connect, help me troubleshoot my local Express page.

✔️ Awesome, I've got everything!

Your starter page is being served by Express. Keep the server running for the next step.

ⓧ I'd like to double check the full code

Compare your saved public/index.html with this complete starter reference.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>English Speaking Trainer</title>
</head>
<body>
  <main class="app-shell">
    <header>
      <p class="eyebrow">Personal voice coach</p>
      <h1>English Speaking Trainer</h1>
      <p>Practice naturally, then review corrections after the session.</p>
    </header>
  </main>
</body>
</html>

How to use this reference

Check the document structure and starter text against your saved file. Confirm that every closing tag is present.

Your secure local foundation is ready. Next, you will turn the starter page into a two-mode trainer that speaks through the browser.

Build the Voice Interface

Your Express server already serves a safe local page. The trainer now needs an interface that makes each practice mode clear.

You will build that two-mode interface first. You will then use browser speech synthesis to prove the trainer can speak before adding AI conversations.

In this step, get ready to:
  • Build the two-mode trainer interface.
  • Style the trainer for desktop and mobile screens.
  • Switch modes and hear a local welcome message.
Build the trainer page

The page separates mode selection from the active conversation. A hidden report panel also reserves space for the coaching feedback you will add later.

  • Select public/index.html in the VS Code Explorer sidebar.
  • Replace the starter markup with the page shell by pasting this code:
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>English Speaking Trainer</title>
  <link rel="stylesheet" href="style.css">
  <script src="app.js" defer></script>
</head>
<body>
  <main class="app-shell">
    <header>
      <p class="eyebrow">Personal voice coach</p>
      <h1>English Speaking Trainer</h1>
      <p>Practice naturally, then review corrections after the session.</p>
    </header>
  </main>
</body>
</html>

What does this markup do?

  • The viewport setting lets the layout adapt to narrow screens.
  • The stylesheet link loads the visual design from style.css.
  • The deferred script loads app.js after the page structure is ready.
  • The app-shell element keeps the trainer inside one centered layout.
  • Save public/index.html.
  • Refresh the trainer page in Edge or Chrome.

You should see the English Speaking Trainer heading with its short introduction.

Missing the trainer heading?

Check that the file is saved inside the existing public folder. Confirm that the server terminal still shows the local address.

Ask for help with a missing page heading.

Each mode card stores its choice in a data-mode attribute. JavaScript will read that value when the learner changes modes.

  • Find the closing </main> tag in public/index.html.
  • Insert the mode selector immediately above that tag by pasting this code:
    <section class="panel" aria-labelledby="mode-heading">
      <h2 id="mode-heading">Choose a mode</h2>
      <div class="mode-grid">
        <button class="mode-card active" data-mode="conversation" aria-pressed="true">
          <span>English Conversation</span>
          <small>Friendly everyday speaking practice</small>
        </button>
        <button class="mode-card" data-mode="interview" aria-pressed="false">
          <span>Interview Preparation</span>
          <small>Role-focused mock interview questions</small>
        </button>
      </div>

      <label id="role-group" class="role-group" for="role" hidden>
        Target role
        <input id="role" type="text" maxlength="100" placeholder="For example, frontend developer">
      </label>

      <div class="session-controls">
        <button id="start-session">Start Session</button>
        <button id="end-session" class="secondary" disabled>End Session</button>
      </div>
      <p id="status" class="status" aria-live="polite">Choose a mode and start when ready.</p>
    </section>

How are the modes structured?

  • The first mode card starts with the active class.
  • The aria-pressed values communicate the selected state to assistive technology.
  • The role-group field starts hidden because only interview practice needs a target role.
  • The status paragraph announces session changes through its live region.
  • Save public/index.html.
  • Refresh the trainer page in Edge or Chrome.

You should see two mode buttons above the session controls. The interview role field should remain hidden.

Mode controls missing?

Make sure the new section sits inside <main class="app-shell">. Check that each opening tag has a matching closing tag.

Ask for help with checking the mode selector markup.

The conversation panel holds visible messages from both speakers. The report panel starts hidden because a report only exists after a completed session.

  • Find the closing </main> tag again.
  • Insert the conversation area immediately above that tag by pasting this code:
    <section class="panel conversation-panel" aria-labelledby="conversation-heading">
      <div class="panel-heading">
        <h2 id="conversation-heading">Conversation</h2>
        <button id="microphone" class="microphone" disabled>Speak</button>
      </div>
      <div id="conversation" class="conversation" aria-live="polite">
        <p class="empty-state">Your conversation will appear here.</p>
      </div>
    </section>

    <section id="report" class="panel report" aria-live="polite" hidden></section>

What completes the interface?

  • The disabled microphone button becomes available when a session starts.
  • The conversation live region announces new transcript messages.
  • The empty state gives the unused transcript area a clear purpose.
  • The hidden report section provides the destination for future scores.
  • Save public/index.html.
  • Refresh the trainer page in Edge or Chrome.

You should see the Conversation panel with a disabled Speak button. You should also see the empty transcript message.

Conversation panel in the wrong place?

Confirm that both new sections appear before the closing </main> tag. Move any section that appears below </body> back inside the app shell.

Ask for help with repairing the page structure.

✔️ Awesome, I've got everything!

Your HTML now contains every interface element needed for the voice trainer. Save the file before moving to the visual design.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>English Speaking Trainer</title>
  <link rel="stylesheet" href="style.css">
  <script src="app.js" defer></script>
</head>
<body>
  <main class="app-shell">
    <header>
      <p class="eyebrow">Personal voice coach</p>
      <h1>English Speaking Trainer</h1>
      <p>Practice naturally, then review corrections after the session.</p>
    </header>

    <section class="panel" aria-labelledby="mode-heading">
      <h2 id="mode-heading">Choose a mode</h2>
      <div class="mode-grid">
        <button class="mode-card active" data-mode="conversation" aria-pressed="true">
          <span>English Conversation</span>
          <small>Friendly everyday speaking practice</small>
        </button>
        <button class="mode-card" data-mode="interview" aria-pressed="false">
          <span>Interview Preparation</span>
          <small>Role-focused mock interview questions</small>
        </button>
      </div>

      <label id="role-group" class="role-group" for="role" hidden>
        Target role
        <input id="role" type="text" maxlength="100" placeholder="For example, frontend developer">
      </label>

      <div class="session-controls">
        <button id="start-session">Start Session</button>
        <button id="end-session" class="secondary" disabled>End Session</button>
      </div>
      <p id="status" class="status" aria-live="polite">Choose a mode and start when ready.</p>
    </section>

    <section class="panel conversation-panel" aria-labelledby="conversation-heading">
      <div class="panel-heading">
        <h2 id="conversation-heading">Conversation</h2>
        <button id="microphone" class="microphone" disabled>Speak</button>
      </div>
      <div id="conversation" class="conversation" aria-live="polite">
        <p class="empty-state">Your conversation will appear here.</p>
      </div>
    </section>

    <section id="report" class="panel report" aria-live="polite" hidden></section>
  </main>
</body>
</html>
Style the responsive interface

The stylesheet turns the raw controls into a clear practice workspace. Each visual state helps the learner see the selected mode or active control quickly.

  • Select public/style.css in the VS Code Explorer sidebar.
  • Replace the starter styles with the global rules by pasting this code:
:root {
  font-family: Inter, system-ui, sans-serif;
  color: #172033;
  background: #eef3ff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: linear-gradient(145deg, #eef3ff, #f8fbff 55%, #edfdf6);
}

button,
input {
  font: inherit;
}

button {
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

What do the global styles control?

  • The root rules set the shared typeface and base colors.
  • The universal box model keeps element dimensions predictable.
  • The page background creates a soft frame around the trainer.
  • Disabled buttons show that an action is currently unavailable.
  • Save public/style.css.
  • Refresh the trainer page in Edge or Chrome.

You should see a pale gradient behind the page. Disabled controls should appear faded.

Styles not loading?

Check that the file is named style.css inside the public folder. Confirm that index.html links to the same filename.

Ask for help with a stylesheet that is not loading.

  • Append the layout rules to public/style.css by pasting this code:
[hidden] {
  display: none !important;
}

.app-shell {
  width: min(920px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0 64px;
}

header {
  text-align: center;
  margin-bottom: 24px;
}

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

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

How does the page layout work?

  • The hidden selector keeps conditional content out of the layout.
  • The app shell limits the page width while preserving space near the screen edges.
  • The heading size scales with the available viewport width.
  • Save public/style.css.
  • Refresh the trainer page in Edge or Chrome.

You should see a centered layout with a larger responsive title.

Layout still touches the edges?

Check the closing brace on the .app-shell rule. Confirm that the HTML uses the matching app-shell class.

Ask for help with the centered app layout.

  • Append the heading and panel styles by pasting this code:
.eyebrow {
  margin-bottom: 8px;
  color: #315bd6;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.panel {
  margin-top: 18px;
  padding: 24px;
  border: 1px solid #d9e2f5;
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: 0 14px 40px rgba(44, 67, 118, 0.1);
}

.mode-grid,
.score-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
}

Why use panels and grids?

  • Panels group controls into distinct stages of the practice experience.
  • The shared grid creates two balanced columns for mode cards or scores.
  • The shadow separates each panel from the page background.
  • Save public/style.css.
  • Refresh the trainer page in Edge or Chrome.

You should see the controls inside white rounded panels. The two mode cards should share one row.

Panels still look like plain text?

Confirm that each relevant HTML section has the panel class. Look for a missing brace above the .panel rule.

Ask for help with panel styles that are not applying.

  • Append the mode card styles by pasting this code:
.mode-card {
  padding: 18px;
  border: 2px solid #d8e0f2;
  border-radius: 14px;
  color: #172033;
  background: #ffffff;
  text-align: left;
}

.mode-card span {
  display: block;
  margin-bottom: 6px;
  font-weight: 800;
}

.mode-card small {
  color: #60708e;
}

.mode-card.active {
  border-color: #315bd6;
  background: #eef3ff;
}

How does the selected mode stand out?

  • Each card uses a strong label above a quieter description.
  • The active class changes the border and background of the selected card.
  • The visual state gives JavaScript one class to move between cards.
  • Save public/style.css.
  • Refresh the trainer page in Edge or Chrome.

You should see the English Conversation card highlighted in blue.

Selected card not highlighted?

Check that the first button contains both mode-card and active in its class attribute. Confirm that the selector includes the dot before both class names.

Ask for help with the active mode style.

  • Append the input and control layout styles by pasting this code:
.role-group {
  display: grid;
  gap: 8px;
  margin-top: 16px;
  font-weight: 700;
}

input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #bdc9df;
  border-radius: 10px;
}

.session-controls,
.panel-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.session-controls {
  justify-content: flex-start;
  margin-top: 18px;
}

What do these controls prepare?

  • The role group stacks its label above the target-role input.
  • The full-width input gives longer role names enough room.
  • The flex layouts align related controls on one row.
  • Save public/style.css.
  • Refresh the trainer page in Edge or Chrome.

You should see the session buttons aligned on one row. The role input should remain hidden.

Session controls stacked unexpectedly?

Confirm that the buttons sit inside an element with the session-controls class. Check the shared selector for a missing comma.

Ask for help with the session control layout.

  • Append the button and status styles by pasting this code:
button {
  padding: 11px 16px;
  border: 0;
  border-radius: 999px;
  color: #ffffff;
  background: #315bd6;
  font-weight: 800;
}

button.secondary {
  color: #315bd6;
  background: #e8edfb;
}

.status {
  margin: 14px 0 0;
  color: #53627e;
}

.panel-heading h2 {
  margin-bottom: 0;
}

.microphone {
  min-width: 110px;
  background: #0d8a66;
}

How do button styles show purpose?

  • Primary buttons use a solid blue background.
  • The secondary button uses a softer treatment.
  • The microphone button uses green to distinguish the speaking action.
  • The status text uses a quieter color below the session controls.
  • Save public/style.css.
  • Refresh the trainer page in Edge or Chrome.

You should see rounded blue session buttons. The disabled Speak button should have a green base color.

Buttons still use browser defaults?

Look for an incomplete rule immediately above the button styles. Confirm that the HTML uses a real <button> element for each control.

Ask for help with button styles that are not applying.

  • Append the transcript and trainer-message styles by pasting this code:
.conversation {
  display: grid;
  gap: 12px;
  min-height: 180px;
  max-height: 420px;
  margin-top: 18px;
  padding: 16px;
  overflow-y: auto;
  border-radius: 14px;
  background: #f6f8fc;
}

.message {
  width: fit-content;
  max-width: 78%;
  margin: 0;
  padding: 11px 14px;
  border-radius: 14px;
  line-height: 1.5;
}

.message.trainer {
  justify-self: start;
  background: #ffffff;
  border: 1px solid #d9e2f5;
}

How will the transcript read?

  • The conversation area has enough height to remain visible before any messages arrive.
  • Long sessions scroll inside the panel.
  • Trainer messages align to the left with a light border.
  • Save public/style.css.
  • Refresh the trainer page in Edge or Chrome.

You should see the empty transcript inside a light inset area.

Transcript area has no background?

Check that the transcript element uses the conversation class. Confirm that the .conversation rule closes before the message styles begin.

Ask for help with the transcript panel styling.

  • Append the learner-message and score styles by pasting this code:
.message.learner {
  justify-self: end;
  color: #ffffff;
  background: #315bd6;
}

.empty-state {
  align-self: center;
  color: #7b879e;
  text-align: center;
}

.overall-score {
  font-size: 2.6rem;
  font-weight: 900;
  color: #315bd6;
}

.score-card,
.correction {
  padding: 14px;
  border-radius: 12px;
  background: #f3f6fc;
}

.score-card strong {
  display: block;
  margin-top: 4px;
  font-size: 1.5rem;
}

What future states are ready?

  • Learner messages align to the right with a blue background.
  • The empty state sits in the center of the transcript area.
  • Score and correction cards already have a reusable visual treatment.
  • Save public/style.css.
  • Refresh the trainer page in Edge or Chrome.

You should see the empty transcript message centered inside its panel.

Empty message not centered?

Confirm that the paragraph uses the empty-state class. Check that the conversation container still uses a grid layout.

Ask for help with centering the transcript empty state.

  • Append the final report and mobile styles by pasting this code:
.report h3 {
  margin: 24px 0 10px;
}

.correction {
  margin-bottom: 10px;
}

.correction p:last-child {
  margin-bottom: 0;
}

@media (max-width: 640px) {
  .mode-grid,
  .score-grid {
    grid-template-columns: 1fr;
  }

  .panel {
    padding: 18px;
  }

  .message {
    max-width: 92%;
  }
}

How does the mobile layout adapt?

  • The report spacing keeps future feedback sections distinct.
  • Screens up to 640 pixels wide switch the grids to one column.
  • Narrow screens use less panel padding.
  • Message bubbles can use more of the available width on mobile.
  • Save public/style.css.
  • Narrow the Edge or Chrome window until the page is close to phone width.

You should see the mode cards move into a single column. The panels should keep space between their content and borders.

Mode cards stay in two columns?

Check that the media query includes its final closing brace. Make the browser window narrower than 640 pixels before checking the layout.

Ask for help with the mobile media query.

✔️ Awesome, I've got everything!

Your trainer now has a responsive visual system for modes, controls, messages, scores, and corrections.

ⓧ I'd like to double check the full code

:root {
  font-family: Inter, system-ui, sans-serif;
  color: #172033;
  background: #eef3ff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: linear-gradient(145deg, #eef3ff, #f8fbff 55%, #edfdf6);
}

button,
input {
  font: inherit;
}

button {
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}

[hidden] {
  display: none !important;
}

.app-shell {
  width: min(920px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0 64px;
}

header {
  text-align: center;
  margin-bottom: 24px;
}

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

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

.eyebrow {
  margin-bottom: 8px;
  color: #315bd6;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.panel {
  margin-top: 18px;
  padding: 24px;
  border: 1px solid #d9e2f5;
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: 0 14px 40px rgba(44, 67, 118, 0.1);
}

.mode-grid,
.score-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
}

.mode-card {
  padding: 18px;
  border: 2px solid #d8e0f2;
  border-radius: 14px;
  color: #172033;
  background: #ffffff;
  text-align: left;
}

.mode-card span {
  display: block;
  margin-bottom: 6px;
  font-weight: 800;
}

.mode-card small {
  color: #60708e;
}

.mode-card.active {
  border-color: #315bd6;
  background: #eef3ff;
}

.role-group {
  display: grid;
  gap: 8px;
  margin-top: 16px;
  font-weight: 700;
}

input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid #bdc9df;
  border-radius: 10px;
}

.session-controls,
.panel-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.session-controls {
  justify-content: flex-start;
  margin-top: 18px;
}

button {
  padding: 11px 16px;
  border: 0;
  border-radius: 999px;
  color: #ffffff;
  background: #315bd6;
  font-weight: 800;
}

button.secondary {
  color: #315bd6;
  background: #e8edfb;
}

.status {
  margin: 14px 0 0;
  color: #53627e;
}

.panel-heading h2 {
  margin-bottom: 0;
}

.microphone {
  min-width: 110px;
  background: #0d8a66;
}

.conversation {
  display: grid;
  gap: 12px;
  min-height: 180px;
  max-height: 420px;
  margin-top: 18px;
  padding: 16px;
  overflow-y: auto;
  border-radius: 14px;
  background: #f6f8fc;
}

.message {
  width: fit-content;
  max-width: 78%;
  margin: 0;
  padding: 11px 14px;
  border-radius: 14px;
  line-height: 1.5;
}

.message.trainer {
  justify-self: start;
  background: #ffffff;
  border: 1px solid #d9e2f5;
}

.message.learner {
  justify-self: end;
  color: #ffffff;
  background: #315bd6;
}

.empty-state {
  align-self: center;
  color: #7b879e;
  text-align: center;
}

.overall-score {
  font-size: 2.6rem;
  font-weight: 900;
  color: #315bd6;
}

.score-card,
.correction {
  padding: 14px;
  border-radius: 12px;
  background: #f3f6fc;
}

.score-card strong {
  display: block;
  margin-top: 4px;
  font-size: 1.5rem;
}

.report h3 {
  margin: 24px 0 10px;
}

.correction {
  margin-bottom: 10px;
}

.correction p:last-child {
  margin-bottom: 0;
}

@media (max-width: 640px) {
  .mode-grid,
  .score-grid {
    grid-template-columns: 1fr;
  }

  .panel {
    padding: 18px;
  }

  .message {
    max-width: 92%;
  }
}
Add mode switching and speech

The interface now needs a small local session state. Selecting Interview Preparation reveals the role field while selecting English Conversation hides it.

  • Select public/app.js in the VS Code Explorer sidebar.
  • Replace the starter JavaScript with the mode-selection logic by pasting this code:
const modeButtons = [...document.querySelectorAll(".mode-card")];
const roleGroup = document.querySelector("#role-group");
const roleInput = document.querySelector("#role");
const startButton = document.querySelector("#start-session");
const endButton = document.querySelector("#end-session");
const microphoneButton = document.querySelector("#microphone");
const conversation = document.querySelector("#conversation");
const reportPanel = document.querySelector("#report");
const statusText = document.querySelector("#status");

let mode = "conversation";
let active = false;
let busy = false;
let interactionId = "";
let history = [];

modeButtons.forEach((button) => {
  button.addEventListener("click", () => {
    if (active || busy) return;

    mode = button.dataset.mode;
    modeButtons.forEach((item) => {
      const selected = item === button;
      item.classList.toggle("active", selected);
      item.setAttribute("aria-pressed", String(selected));
    });
    roleGroup.hidden = mode !== "interview";
  });
});

What does the mode logic track?

  • The element references connect JavaScript to the controls already in the page.
  • The mode value starts with conversation practice.
  • The session variables reserve state for later conversation turns.
  • Each click moves the active style and updates aria-pressed.
  • Interview mode reveals the target-role field.
  • Save public/app.js.
  • Refresh the trainer page in Edge or Chrome.
  • Click Interview Preparation.

You should see the interview card become active. You should also see the Target role field.

  • Click English Conversation.

The role field should disappear. The English Conversation card should regain its active style.

Role field stays hidden?

Check that the interview button uses data-mode="interview". Confirm that the label uses the exact role-group ID.

Ask for help with the interview mode switch.

A SpeechSynthesisUtterance stores the text and speaking rate for one spoken message. The browser queues that message through window.speechSynthesis.speak().

  • Append the local session behavior to public/app.js by pasting this code:
startButton.addEventListener("click", startSession);

function startSession() {
  active = true;
  busy = false;
  interactionId = "";
  history = [];
  conversation.replaceChildren();
  reportPanel.hidden = true;
  const welcome = mode === "interview"
    ? "Welcome to Interview Preparation."
    : "Welcome to English Conversation.";
  addMessage("trainer", welcome);
  speak(welcome);
  statusText.textContent = "Your turn. Click Speak when ready.";
  startButton.disabled = true;
  endButton.disabled = false;
  microphoneButton.disabled = false;
}

function addMessage(speaker, text) {
  const message = document.createElement("p");
  message.className = `message ${speaker}`;
  message.textContent = text;
  conversation.appendChild(message);
}

function speak(text) {
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.rate = 0.95;
  window.speechSynthesis.speak(utterance);
}

How does the local session start?

  • Starting a session resets the stored interaction ID and conversation history.
  • The selected mode chooses the welcome sentence.
  • The welcome appears as a trainer message through textContent.
  • The speech rate of 0.95 keeps the welcome slightly slower than the default voice.
  • The controls update to show that a session is active.

Before you start the session, which welcome do you expect the selected mode to produce?

  • Save public/app.js.
  • Refresh the trainer page in Edge or Chrome.
  • Select either practice mode.
  • Click Start Session.

You should see a trainer bubble naming the selected experience. You should hear the same welcome through your computer speakers.

That is the core voice loop working: the interface selects a mode, displays a message, and sends that message to the browser voice.

No welcome audio?

Check that the browser tab is not muted. Confirm that your computer output volume is audible.

Click Start Session only once after each page refresh. Some browsers delay speech until a direct user action triggers it.

Ask for help with browser speech synthesis.

✔️ Awesome, I've got everything!

Your interface switches modes and speaks the selected welcome. The browser voice experience is ready for microphone input.

ⓧ I'd like to double check the full code

const modeButtons = [...document.querySelectorAll(".mode-card")];
const roleGroup = document.querySelector("#role-group");
const roleInput = document.querySelector("#role");
const startButton = document.querySelector("#start-session");
const endButton = document.querySelector("#end-session");
const microphoneButton = document.querySelector("#microphone");
const conversation = document.querySelector("#conversation");
const reportPanel = document.querySelector("#report");
const statusText = document.querySelector("#status");

let mode = "conversation";
let active = false;
let busy = false;
let interactionId = "";
let history = [];

modeButtons.forEach((button) => {
  button.addEventListener("click", () => {
    if (active || busy) return;

    mode = button.dataset.mode;
    modeButtons.forEach((item) => {
      const selected = item === button;
      item.classList.toggle("active", selected);
      item.setAttribute("aria-pressed", String(selected));
    });
    roleGroup.hidden = mode !== "interview";
  });
});

startButton.addEventListener("click", startSession);

function startSession() {
  active = true;
  busy = false;
  interactionId = "";
  history = [];
  conversation.replaceChildren();
  reportPanel.hidden = true;
  const welcome = mode === "interview"
    ? "Welcome to Interview Preparation."
    : "Welcome to English Conversation.";
  addMessage("trainer", welcome);
  speak(welcome);
  statusText.textContent = "Your turn. Click Speak when ready.";
  startButton.disabled = true;
  endButton.disabled = false;
  microphoneButton.disabled = false;
}

function addMessage(speaker, text) {
  const message = document.createElement("p");
  message.className = `message ${speaker}`;
  message.textContent = text;
  conversation.appendChild(message);
}

function speak(text) {
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.rate = 0.95;
  window.speechSynthesis.speak(utterance);
}

You have the trainer's visible and audible experience working. Next, you will connect the Speak button to microphone recognition so spoken English appears in the transcript.

Capture Spoken Answers

Your trainer can already greet you aloud. A spoken practice session still needs a way to capture the learner's side of the conversation.

Browser speech recognition turns one microphone recording into text. You will configure one final English result per click so each answer becomes a clear transcript turn.

Why listen one turn at a time?

Single-turn recognition gives each click a clear start and finish. This keeps the microphone state predictable while you build the first listening loop.

In this step, get ready to:
  • Configure browser speech recognition for one English result per microphone turn.
  • Display each recognized answer as a learner message in the transcript.
Configure single-turn recognition

Browsers expose speech recognition through standard and prefixed constructors. The fallback checks both constructors before creating the shared recognition object.

  • Return to Visual Studio Code.
  • Select public/app.js from the file list.
  • Find the line that declares history.
  • Add the recognition setup directly below that line:
const SpeechRecognition =
  window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = SpeechRecognition ? new SpeechRecognition() : null;

if (recognition) {
  recognition.continuous = false;
  recognition.lang = "en-US";
  recognition.interimResults = false;
  recognition.maxAlternatives = 1;

  recognition.onstart = () => {
    statusText.textContent = "Listening. Speak one answer, then pause.";
  };

  recognition.onerror = (event) => {
    statusText.textContent = `Speech recognition error: ${event.error}. Try again.`;
  };
} else {
  statusText.textContent = "Speech recognition is unavailable in this browser. Open the app in Microsoft Edge or Google Chrome.";
}

microphoneButton.addEventListener("click", () => {
  if (!recognition || !active || busy) return;

  try {
    recognition.start();
  } catch {
    statusText.textContent = "The microphone is already listening.";
  }
});

How does the recognition setup work?

  • The constructor fallback supports browsers that expose either speech-recognition name.
  • The four settings request one final result in US English.
  • The start event confirms that the browser is listening.
  • The error event converts a recognition problem into readable status text.
  • The click handler starts listening only during an active session.
  • Save public/app.js.
  • Return to Microsoft Edge or Google Chrome.
  • Refresh http://localhost:3000.
  • Click Start Session.
  • Click Speak.
  • Grant microphone access if the browser asks.

The status text changes to Listening. Speak one answer, then pause. This confirms that the Speak button can start a microphone turn.

Microphone does not start?

Confirm that you started a session before clicking Speak. Check that the browser has permission to use your microphone.

If the status says recognition is unavailable, return to the app in Microsoft Edge or Google Chrome. Help me diagnose why browser speech recognition is not starting.

Display the recognized answer

A recognition result contains the final transcript from the microphone turn. The app needs to validate that text before placing it in the conversation history.

  • Find the updateControls() function in public/app.js.
  • Find the line that controls whether microphoneButton is disabled.
  • Replace that line with this recognition-aware check:
microphoneButton.disabled = !active || busy || !recognition;

Why disable the microphone?

The Speak button now requires an active session and an available recognition object. This prevents a click from starting an unsupported or inactive microphone turn.

  • Return to the recognition setup near the top of public/app.js.
  • Find the recognition.onerror handler.
  • Add the result handler immediately above it:
recognition.onresult = async (event) => {
  const transcript = event.results[0][0].transcript.trim();
  if (!active || !transcript) return;

  addMessage("learner", transcript);
  history.push({ role: "user", text: transcript });
  statusText.textContent = "Your turn. Click Speak when ready.";
};

What does the result handler do?

  • The transcript line reads the browser's final recognized text.
  • The guard ignores empty results and results captured outside an active session.
  • The addMessage() call displays the answer safely through textContent.
  • The history entry records the answer with the user role.
  • The final status confirms that the microphone turn has ended.
  • Save public/app.js.

Before you test, what do you think will appear after the browser stops listening? Hold that prediction for the next check.

  • Refresh http://localhost:3000.
  • Click Start Session.
  • Click Speak.
  • Grant microphone access if the browser asks.
  • Say I enjoy learning new skills into your microphone.
  • Pause so recognition can finish the turn.

You have the listening loop working. You will see the recognized words in a blue learner bubble.

No intelligent trainer reply appears after the learner bubble. That gap is intentional because this version can hear the learner but cannot coach them yet.

Recognized words do not appear?

Speak after the listening status appears. Pause after one sentence so the browser can finalize the result.

Check that the result handler sits inside the if (recognition) block. Help me debug why my recognized transcript is not appearing as a learner message.

✔️ Awesome, I've got everything!

Your saved file now listens for one spoken answer and adds the final transcript to the conversation.

ⓧ I'd like to double check the full code

const modeButtons = [...document.querySelectorAll(".mode-card")];
const roleGroup = document.querySelector("#role-group");
const roleInput = document.querySelector("#role");
const startButton = document.querySelector("#start-session");
const endButton = document.querySelector("#end-session");
const microphoneButton = document.querySelector("#microphone");
const conversation = document.querySelector("#conversation");
const reportPanel = document.querySelector("#report");
const statusText = document.querySelector("#status");

let mode = "conversation";
let active = false;
let busy = false;
let interactionId = "";
let history = [];

const SpeechRecognition =
  window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = SpeechRecognition ? new SpeechRecognition() : null;

if (recognition) {
  recognition.continuous = false;
  recognition.lang = "en-US";
  recognition.interimResults = false;
  recognition.maxAlternatives = 1;

  recognition.onstart = () => {
    statusText.textContent = "Listening. Speak one answer, then pause.";
  };

  recognition.onresult = async (event) => {
    const transcript = event.results[0][0].transcript.trim();
    if (!active || !transcript) return;

    addMessage("learner", transcript);
    history.push({ role: "user", text: transcript });
    statusText.textContent = "Your turn. Click Speak when ready.";
  };

  recognition.onerror = (event) => {
    statusText.textContent = `Speech recognition error: ${event.error}. Try again.`;
  };
} else {
  statusText.textContent = "Speech recognition is unavailable in this browser. Open the app in Microsoft Edge or Google Chrome.";
}

microphoneButton.addEventListener("click", () => {
  if (!recognition || !active || busy) return;

  try {
    recognition.start();
  } catch {
    statusText.textContent = "The microphone is already listening.";
  }
});

modeButtons.forEach((button) => {
  button.addEventListener("click", () => {
    if (active || busy) return;

    mode = button.dataset.mode;
    modeButtons.forEach((item) => {
      const selected = item === button;
      item.classList.toggle("active", selected);
      item.setAttribute("aria-pressed", String(selected));
    });
    roleGroup.hidden = mode !== "interview";
  });
});

startButton.addEventListener("click", startSession);
endButton.addEventListener("click", () => {
  active = false;
  statusText.textContent = "Session ended. Start a new session when ready.";
  updateControls();
});

function startSession() {
  active = true;
  interactionId = "";
  history = [];
  conversation.replaceChildren();
  reportPanel.replaceChildren();
  reportPanel.hidden = true;

  const welcome = mode === "interview"
    ? `Welcome to Interview Preparation for ${roleInput.value.trim() || "a general professional role"}. Click Speak when you are ready.`
    : "Welcome to Conversation Practice. Click Speak and tell me about your day.";

  addMessage("trainer", welcome);
  speak(welcome);
  statusText.textContent = "Your turn. Click Speak when ready.";
  updateControls();
}

function updateControls() {
  startButton.disabled = active || busy;
  endButton.disabled = !active || busy;
  microphoneButton.disabled = !active || busy || !recognition;
  roleInput.disabled = active || busy;
  modeButtons.forEach((button) => {
    button.disabled = active || busy;
  });
}

function addMessage(speaker, text) {
  conversation.querySelector(".empty-state")?.remove();
  const message = document.createElement("p");
  message.className = `message ${speaker}`;
  message.textContent = text;
  conversation.appendChild(message);
  conversation.scrollTop = conversation.scrollHeight;
}

function speak(text) {
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.rate = 0.95;
  window.speechSynthesis.speak(utterance);
}

Your trainer can now speak and capture one answer at a time. Next, you will turn those transcript turns into a responsive coaching conversation.

Connect Gemini Conversations

Your trainer can already hear one spoken answer. It still stops after displaying the learner bubble.

Now your Express server will send each turn to Gemini while the API key stays outside browser code. An interaction ID will carry the conversation context into the next request.

How does the conversation stay connected?

The Interactions API returns an interaction ID with each reply. Your browser sends that ID with the next spoken turn so Gemini can continue the same conversation.

The browser stores only the conversation state it needs. Your protected key remains inside the Node.js server process.

In this step, get ready to:
  • Add server routes that start and continue a Gemini conversation.
  • Connect the browser session to the new server routes.
  • Verify that both training modes produce spoken follow-up questions.
Build the server conversation routes

The server needs one route for the opening message. A second route continues the same interaction after each learner answer.

  • In server.js, find the line that defines port.
  • Add the stable model name directly below that line by copying:
const model = "gemini-3.8-flash";

Why pin the model?

The model constant gives every conversation request the same stable gemini-3.8-flash model identifier.

  • In server.js, find app.use(express.static("public"));.
  • Add the mode, role, and opening-prompt helpers below that line by copying:
function normalizeMode(value) {
  return value === "interview" ? "interview" : "conversation";
}

function normalizeRole(value) {
  return String(value || "General professional role").trim().slice(0, 100);
}

function buildStartPrompt(mode, role) {
  if (mode === "interview") {
    return `You are a professional interviewer conducting a spoken mock interview for the target role: ${role}. Welcome the learner briefly, then ask one realistic interview question. Ask only one question at a time. Keep every reply under 55 words. Do not correct the learner during the interview because feedback happens after the session.`;
  }

  return "You are a supportive English conversation partner. Begin a friendly everyday conversation with one short question. On later turns, respond naturally and ask one follow-up question. Keep every reply under 45 words. Do not correct the learner during the conversation because feedback happens after the session.";
}

What do these helpers control?

  • normalizeMode() limits the mode to conversation or interview.
  • normalizeRole() trims the target role to a safe length.
  • buildStartPrompt() gives each mode a focused coaching style.
  • Save server.js.
  • Switch back to the VS Code terminal from earlier.
  • Stop the running server by pressing Ctrl+C.
  • Restart the server by running:
npm start

What should I see?

You should see English trainer running at http://localhost:3000. This confirms that the new helpers contain valid JavaScript.

🙋‍♀️ Server stopped during startup?

Check the new helper block for missing braces or quotation marks. Confirm that buildStartPrompt() ends before the server listener.

Help me fix the new server helper functions.

  • Add the safe error helper below buildStartPrompt() by copying:
function sendServerError(res, error) {
  console.error(error);
  res.status(500).json({
    error: "The trainer could not reach Gemini. Check the API key and the server terminal."
  });
}

What does the error helper protect?

sendServerError() logs the detailed failure on the server. The browser receives a safe message without receiving credentials or internal error details.

  • Add the opening conversation route below sendServerError() by copying:
app.post("/api/start", async (req, res) => {
  try {
    const mode = normalizeMode(req.body?.mode);
    const role = normalizeRole(req.body?.role);
    const interaction = await ai.interactions.create({
      model,
      input: buildStartPrompt(mode, role)
    });

    res.json({
      reply: interaction.output_text.trim(),
      interactionId: interaction.id
    });
  } catch (error) {
    sendServerError(res, error);
  }
});

How does a session start?

  • POST /api/start reads the selected mode and target role.
  • ai.interactions.create() sends the matching opening prompt to Gemini.
  • interaction.id gives the browser the state needed for the next turn.
  • Save server.js.
  • Switch back to the VS Code terminal.
  • Stop the running server by pressing Ctrl+C.
  • Restart the server by running:
npm start

What should I see now?

You should see the local server address again. The new opening route is now ready to receive browser requests.

🙋‍♀️ Server cannot load the new route?

Confirm that the route appears before app.listen(). Check that the server terminal still has access to GEMINI_API_KEY or GOOGLE_API_KEY.

Help me debug the Gemini start route.

  • Add the follow-up route below POST /api/start by copying:
app.post("/api/turn", async (req, res) => {
  const previousInteractionId = String(req.body?.previousInteractionId || "");
  const userText = String(req.body?.userText || "").trim().slice(0, 2000);

  if (!previousInteractionId || !userText) {
    return res.status(400).json({ error: "A session ID and spoken answer are required." });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `The learner said: ${userText}`,
      previous_interaction_id: previousInteractionId
    });

    res.json({
      reply: interaction.output_text.trim(),
      interactionId: interaction.id
    });
  } catch (error) {
    sendServerError(res, error);
  }
});

How does the next turn work?

POST /api/turn validates the interaction ID and spoken text. It passes the prior ID as previous_interaction_id so Gemini can answer within the existing conversation.

Each response contains a new interaction ID. The browser replaces the older ID after every successful turn.

  • Save server.js.
  • Switch back to the VS Code terminal.
  • Stop the running server by pressing Ctrl+C.
  • Restart the server by running:
npm start

What should I see after the restart?

You should see the local server address with no startup error. Both Gemini conversation routes are now active.

🙋‍♀️ Follow-up route stops the server?

Check the spelling of previousInteractionId in the request body. Confirm that the SDK request uses previous_interaction_id.

Help me repair the stateful turn route.

✔️ Awesome, I've got everything!

  • Save server.js before continuing.

ⓧ I'd like to double check the full code

import express from "express";
import { GoogleGenAI } from "@google/genai";

const app = express();
const port = 3000;
const model = "gemini-3.8-flash";

if (!process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY) {
  console.error("Set GEMINI_API_KEY in Windows Environment Variables, then reopen the terminal.");
  process.exit(1);
}

const ai = new GoogleGenAI({});

app.use(express.json());
app.use(express.static("public"));

function normalizeMode(value) {
  return value === "interview" ? "interview" : "conversation";
}

function normalizeRole(value) {
  return String(value || "General professional role").trim().slice(0, 100);
}

function buildStartPrompt(mode, role) {
  if (mode === "interview") {
    return `You are a professional interviewer conducting a spoken mock interview for the target role: ${role}. Welcome the learner briefly, then ask one realistic interview question. Ask only one question at a time. Keep every reply under 55 words. Do not correct the learner during the interview because feedback happens after the session.`;
  }

  return "You are a supportive English conversation partner. Begin a friendly everyday conversation with one short question. On later turns, respond naturally and ask one follow-up question. Keep every reply under 45 words. Do not correct the learner during the conversation because feedback happens after the session.";
}

function sendServerError(res, error) {
  console.error(error);
  res.status(500).json({
    error: "The trainer could not reach Gemini. Check the API key and the server terminal."
  });
}

app.post("/api/start", async (req, res) => {
  try {
    const mode = normalizeMode(req.body?.mode);
    const role = normalizeRole(req.body?.role);
    const interaction = await ai.interactions.create({
      model,
      input: buildStartPrompt(mode, role)
    });

    res.json({
      reply: interaction.output_text.trim(),
      interactionId: interaction.id
    });
  } catch (error) {
    sendServerError(res, error);
  }
});

app.post("/api/turn", async (req, res) => {
  const previousInteractionId = String(req.body?.previousInteractionId || "");
  const userText = String(req.body?.userText || "").trim().slice(0, 2000);

  if (!previousInteractionId || !userText) {
    return res.status(400).json({ error: "A session ID and spoken answer are required." });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `The learner said: ${userText}`,
      previous_interaction_id: previousInteractionId
    });

    res.json({
      reply: interaction.output_text.trim(),
      interactionId: interaction.id
    });
  } catch (error) {
    sendServerError(res, error);
  }
});

app.listen(port, () => {
  console.log(`English trainer running at http://localhost:${port}`);
});
Connect the browser to Gemini

The browser needs a small request helper before the session functions can call the server. That helper converts data to JSON and turns failed responses into readable errors.

  • In public/app.js, find the speak(text) function.
  • Add the request helper below speak(text) by copying:
async function postJson(url, body) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body)
  });
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || "The request failed.");
  }

  return data;
}

What does the request helper do?

  • fetch() sends the browser request to your local server.
  • JSON.stringify() converts the request body into JSON.
  • response.ok separates successful replies from safe server errors.
  • Save public/app.js.
  • Refresh http://localhost:3000 in Microsoft Edge.

You should see the complete mode selector and conversation panel. A normal page load confirms that the new helper parses successfully.

Page became blank after the helper?

Check the closing braces around the fetch() options. Confirm that postJson() sits outside the speak() function.

Help me fix the browser request helper.

  • Find the state variables near the top of public/app.js.
  • Add the interaction ID state directly above let history = []; by copying:
let interactionId = "";

Why store the interaction ID?

interactionId holds the latest Gemini conversation state. The next spoken turn sends this value back to the server.

  • Find the existing startSession() function.
  • Replace the complete function with:
async function startSession() {
  active = true;
  busy = true;
  interactionId = "";
  history = [];
  conversation.replaceChildren();
  reportPanel.replaceChildren();
  reportPanel.hidden = true;
  statusText.textContent = "Starting your trainer.";
  updateControls();

  try {
    const data = await postJson("/api/start", {
      mode,
      role: roleInput.value.trim()
    });

    interactionId = data.interactionId;
    history.push({ role: "assistant", text: data.reply });
    addMessage("trainer", data.reply);
    speak(data.reply);
    statusText.textContent = "Your turn. Click Speak when ready.";
  } catch (error) {
    active = false;
    statusText.textContent = error.message;
  } finally {
    busy = false;
    updateControls();
  }
}

What happens when a session starts?

  • startSession() clears the previous transcript and conversation state.
  • postJson() sends the selected mode and role to /api/start.
  • data.reply becomes a visible trainer message and a spoken welcome.
  • Save public/app.js.
  • Refresh the trainer page.
  • Click Start Session.

You should see a trainer message from Gemini. You should also hear that message through your computer speakers.

Trainer cannot start the session?

Check the server terminal for the detailed failure. Confirm that the environment key is available in the terminal session that started the server.

Help me debug the browser-to-server request.

  • Find the complete recognition.onresult handler in public/app.js.
  • Replace that handler with:
recognition.onresult = async (event) => {
    const transcript = event.results[0][0].transcript.trim();
    if (!active || !transcript) return;

    addMessage("learner", transcript);
    history.push({ role: "user", text: transcript });
    busy = true;
    updateControls();
    statusText.textContent = "The trainer is thinking.";

    try {
      const data = await postJson("/api/turn", {
        previousInteractionId: interactionId,
        userText: transcript
      });

      interactionId = data.interactionId;
      history.push({ role: "assistant", text: data.reply });
      addMessage("trainer", data.reply);
      speak(data.reply);
      statusText.textContent = "Your turn. Click Speak when ready.";
    } catch (error) {
      statusText.textContent = error.message;
    } finally {
      busy = false;
      updateControls();
    }
  };

How does a spoken turn reach Gemini?

The handler displays the learner transcript before sending it to /api/turn. The request includes the latest interaction ID so the reply can build on earlier turns.

The returned ID replaces the previous value. The trainer reply is stored in history, displayed through textContent, and passed to speech synthesis.

  • Save public/app.js.
  • Refresh the trainer page.

✔️ Awesome, I've got everything!

  • Save public/app.js before testing both modes.

ⓧ I'd like to double check the full code

const modeButtons = [...document.querySelectorAll(".mode-card")];
const roleGroup = document.querySelector("#role-group");
const roleInput = document.querySelector("#role");
const startButton = document.querySelector("#start-session");
const endButton = document.querySelector("#end-session");
const microphoneButton = document.querySelector("#microphone");
const conversation = document.querySelector("#conversation");
const reportPanel = document.querySelector("#report");
const statusText = document.querySelector("#status");

const SpeechRecognition =
  window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = SpeechRecognition ? new SpeechRecognition() : null;

let mode = "conversation";
let active = false;
let busy = false;
let interactionId = "";
let history = [];

if (recognition) {
  recognition.continuous = false;
  recognition.lang = "en-US";
  recognition.interimResults = false;
  recognition.maxAlternatives = 1;

  recognition.onstart = () => {
    statusText.textContent = "Listening. Speak one answer, then pause.";
  };

  recognition.onresult = async (event) => {
    const transcript = event.results[0][0].transcript.trim();
    if (!active || !transcript) return;

    addMessage("learner", transcript);
    history.push({ role: "user", text: transcript });
    busy = true;
    updateControls();
    statusText.textContent = "The trainer is thinking.";

    try {
      const data = await postJson("/api/turn", {
        previousInteractionId: interactionId,
        userText: transcript
      });

      interactionId = data.interactionId;
      history.push({ role: "assistant", text: data.reply });
      addMessage("trainer", data.reply);
      speak(data.reply);
      statusText.textContent = "Your turn. Click Speak when ready.";
    } catch (error) {
      statusText.textContent = error.message;
    } finally {
      busy = false;
      updateControls();
    }
  };

  recognition.onerror = (event) => {
    statusText.textContent = `Speech recognition error: ${event.error}. Try again.`;
    busy = false;
    updateControls();
  };
} else {
  statusText.textContent = "Speech recognition is unavailable in this browser. Open the app in Microsoft Edge or Google Chrome.";
}

modeButtons.forEach((button) => {
  button.addEventListener("click", () => {
    if (active || busy) return;

    mode = button.dataset.mode;
    modeButtons.forEach((item) => {
      const selected = item === button;
      item.classList.toggle("active", selected);
      item.setAttribute("aria-pressed", String(selected));
    });
    roleGroup.hidden = mode !== "interview";
  });
});

startButton.addEventListener("click", startSession);
microphoneButton.addEventListener("click", () => {
  if (!recognition || !active || busy) return;

  try {
    recognition.start();
  } catch {
    statusText.textContent = "The microphone is already listening.";
  }
});

async function startSession() {
  active = true;
  busy = true;
  interactionId = "";
  history = [];
  conversation.replaceChildren();
  reportPanel.replaceChildren();
  reportPanel.hidden = true;
  statusText.textContent = "Starting your trainer.";
  updateControls();

  try {
    const data = await postJson("/api/start", {
      mode,
      role: roleInput.value.trim()
    });

    interactionId = data.interactionId;
    history.push({ role: "assistant", text: data.reply });
    addMessage("trainer", data.reply);
    speak(data.reply);
    statusText.textContent = "Your turn. Click Speak when ready.";
  } catch (error) {
    active = false;
    statusText.textContent = error.message;
  } finally {
    busy = false;
    updateControls();
  }
}

function updateControls() {
  startButton.disabled = active || busy;
  endButton.disabled = !active || busy;
  microphoneButton.disabled = !active || busy || !recognition;
  roleInput.disabled = active || busy;
  modeButtons.forEach((button) => {
    button.disabled = active || busy;
  });
}

function addMessage(speaker, text) {
  conversation.querySelector(".empty-state")?.remove();
  const message = document.createElement("p");
  message.className = `message ${speaker}`;
  message.textContent = text;
  conversation.appendChild(message);
  conversation.scrollTop = conversation.scrollHeight;
}

function speak(text) {
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.rate = 0.95;
  window.speechSynthesis.speak(utterance);
}

async function postJson(url, body) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body)
  });
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || "The request failed.");
  }

  return data;
}
Test both conversation modes

Before you test, how should a friendly conversation follow-up differ from a role-focused interview question?

  • Switch back to the VS Code terminal.
  • Stop the running server by pressing Ctrl+C.
  • Start the completed conversation server by running:
npm start

What should I see?

You should see English trainer running at http://localhost:3000. Keep this terminal running while you test the browser.

  • Return to the trainer page in Microsoft Edge.
  • Refresh the page.
  • Select English Conversation.
  • Click Start Session.
  • Click Speak.
  • Say I am practicing English because I want to speak more confidently..
  • Click Speak after the trainer replies.
  • Say I usually practice by talking with my friends..

You should see both learner bubbles and two trainer replies. The second reply should connect naturally to the earlier conversation.

What did this prove?

The second request used the latest interaction ID. Gemini could continue the topic without your browser resending the complete transcript.

  • Refresh the trainer page.
  • Select Interview Preparation.
  • Enter frontend developer in the Target role field.
  • Click Start Session.
  • Click Speak.
  • Say I enjoy building accessible interfaces and solving user problems..

You should hear a role-focused interview question. Your answer should appear as a learner bubble before Gemini adds and speaks its next question.

Trainer does not answer a spoken turn?

Check whether the learner bubble appeared first. If it did, inspect the server terminal because recognition worked and the server request failed.

Help me trace the missing Gemini reply.

Your trainer can now listen, remember the conversation, and answer aloud in two distinct modes. Next, you will turn the completed transcript into corrections, strengths, next steps, and scores.

Generate Coaching Reports

Your trainer can now hold a stateful conversation in either practice mode. Each learner answer appears in the transcript beside Gemini's spoken follow-up.

A conversation alone does not show the learner how to improve. This step turns the completed transcript into corrections, practical next steps, and consistent scores.

The report provides formative feedback based on recognized text. It does not measure accent, phonemes, or certified pronunciation ability.

In this step, get ready to:
  • Define a structured coaching report with scores, corrections, strengths, and next steps.
  • Add a secure server route that evaluates a sanitized session transcript.
  • Render every report value safely in the browser and test both training modes.
Define the coaching report structure

A JSON Schema describes the exact shape Gemini must return. The contract keeps the four category scores consistent while allowing a short list of useful corrections.

  • In server.js, find the line containing app.use(express.static("public"));.
  • Add the schema immediately below that line by pasting these three connected excerpts in order:
const evaluationSchema = {
  type: "object",
  additionalProperties: false,
  properties: {
    overallScore: {
      type: "integer",
      minimum: 0,
      maximum: 100,
      description: "Overall formative score from 0 to 100."
    },
    summary: {
      type: "string",
      description: "A concise summary of the learner's performance."
    },
    categoryScores: {
      type: "object",
      additionalProperties: false,
      properties: {
        grammar: { type: "integer", minimum: 0, maximum: 100 },
        vocabulary: { type: "integer", minimum: 0, maximum: 100 },
        clarity: { type: "integer", minimum: 0, maximum: 100 },
        relevance: { type: "integer", minimum: 0, maximum: 100 }
      },
      required: ["grammar", "vocabulary", "clarity", "relevance"]
    },

What does this section define?

  • The overall score uses an integer from 0 to 100.
  • The summary gives the learner a concise overview.
  • The category object requires grammar, vocabulary, clarity, and relevance scores.
  • Continue directly below the previous excerpt with the correction and recommendation fields:
    corrections: {
      type: "array",
      maxItems: 5,
      items: {
        type: "object",
        additionalProperties: false,
        properties: {
          original: { type: "string" },
          corrected: { type: "string" },
          explanation: { type: "string" }
        },
        required: ["original", "corrected", "explanation"]
      }
    },
    strengths: {
      type: "array",
      items: { type: "string" },
      maxItems: 3
    },
    nextSteps: {
      type: "array",
      items: { type: "string" },
      maxItems: 3
    }

How are recommendations limited?

  • Each correction includes the original sentence, its corrected form, and an explanation.
  • The report can return up to five corrections.
  • Strengths and next steps are capped at three items each.
  • Complete the schema directly below the previous excerpt with its required fields:
  },
  required: [
    "overallScore",
    "summary",
    "categoryScores",
    "corrections",
    "strengths",
    "nextSteps"
  ]
};

Why require every section?

The required list gives the browser a predictable report structure. Every completed report contains a summary, scores, corrections, strengths, and next steps.

  • Save server.js.
  • Return to the VS Code terminal from earlier.
  • Stop the running server by pressing Ctrl+C.
  • Restart the server by running:
npm start

You should see English trainer running at http://localhost:3000. This confirms that the completed schema has valid JavaScript syntax.

Server does not restart?

Compare the three schema excerpts with their order above. Check each closing brace after categoryScores, corrections, and required.

Share the terminal message through help me find the syntax problem in my evaluationSchema.

Evaluate a sanitized transcript

The browser sends the session history to the server when the learner finishes. The server trims each message and limits the report to the latest 20 entries before including the transcript in a Gemini prompt.

  • In server.js, find the closing brace of normalizeRole().
  • Add the transcript-cleaning helper immediately below it by pasting:
function cleanHistory(value) {
  if (!Array.isArray(value)) return [];

  return value
    .slice(-20)
    .map((item) => ({
      role: item?.role === "assistant" ? "assistant" : "user",
      text: String(item?.text || "").trim().slice(0, 2000)
    }))
    .filter((item) => item.text);
}

What does this helper protect?

  • The array check converts an unexpected history value into an empty list.
  • The slice keeps only the latest 20 transcript entries.
  • Each message is normalized to an allowed role and a limited text value.
  • Find the closing brace of buildStartPrompt().
  • Add the evaluation prompt helper immediately below it by pasting:
function buildEvaluationPrompt(mode, role, history) {
  const transcript = history
    .map((item, index) => `${index + 1}. ${item.role === "assistant" ? "Trainer" : "Learner"}: ${item.text}`)
    .join("\n");

  const relevanceMeaning = mode === "interview"
    ? `Relevance measures how directly and convincingly the learner answered questions for the ${role} role.`
    : "Relevance measures conversational coherence and how well the learner responded to the trainer.";

  return `Evaluate only the learner's English in this ${mode} session. This is formative transcript-based coaching, not a certified pronunciation assessment. Score grammar, vocabulary, clarity, relevance, and overall performance from 0 to 100. ${relevanceMeaning} Provide up to five useful corrections. If there are no important corrections, return an empty corrections array. Keep strengths and next steps practical and concise.\n\nTranscript:\n${transcript}`;
}

How does the rubric change by mode?

Interview Preparation treats relevance as the quality of the learner's answer for the target role. English Conversation treats relevance as conversational coherence with the trainer.

The prompt explicitly frames every score as transcript-based coaching. This boundary prevents the report from claiming to assess pronunciation details that the text transcript cannot contain.

  • Save server.js.
  • Return to the VS Code terminal.
  • Stop the running server by pressing Ctrl+C.
  • Confirm that both helpers have valid syntax by restarting the server:
npm start

You should see the local trainer address again. The server can now clean a transcript and turn it into a mode-specific evaluation prompt.

Server reports a helper problem?

Check that cleanHistory() sits after normalizeRole(). Check that buildEvaluationPrompt() sits after buildStartPrompt().

Use help me debug my transcript cleaning and evaluation prompt helpers if the terminal still reports a problem.

The final server route combines the sanitized history, mode-specific prompt, and schema. It also rejects an evaluation request when the session contains no learner answer.

  • In server.js, find the closing }); for the POST /api/turn route.
  • Add the evaluation route immediately below it by pasting:
app.post("/api/evaluate", async (req, res) => {
  const mode = normalizeMode(req.body?.mode);
  const role = normalizeRole(req.body?.role);
  const history = cleanHistory(req.body?.history);

  if (!history.some((item) => item.role === "user")) {
    return res.status(400).json({ error: "Complete at least one spoken turn before evaluation." });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: buildEvaluationPrompt(mode, role, history),
      response_format: {
        type: "text",
        mime_type: "application/json",
        schema: evaluationSchema
      }
    });

    res.json(JSON.parse(interaction.output_text));
  } catch (error) {
    sendServerError(res, error);
  }
});

What does the evaluation route do?

  • The route normalizes the selected mode and interview role.
  • The route rejects a session that contains no learner turn.
  • The Gemini request asks for JSON matching evaluationSchema.
  • The server parses the returned JSON before sending the report to the browser.
  • Save server.js.
  • Return to the VS Code terminal.
  • Stop the running server by pressing Ctrl+C.
  • Load the new evaluation route by restarting the server:
npm start

You should see English trainer running at http://localhost:3000 again. Your server now accepts secure coaching-report requests.

Evaluation route prevents startup?

Confirm that the route appears before app.listen(port, () => {. Check the braces around response_format and the final route closure.

Ask for focused help through help me debug my POST /api/evaluate route.

✔️ Awesome, I've got everything!

Your saved server.js now contains the complete conversation and evaluation server.

ⓧ I'd like to double check the full code

import express from "express";
import { GoogleGenAI } from "@google/genai";

const app = express();
const port = 3000;
const model = "gemini-3.8-flash";

if (!process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY) {
  console.error("Set GEMINI_API_KEY in Windows Environment Variables, then reopen the terminal.");
  process.exit(1);
}

const ai = new GoogleGenAI({});

app.use(express.json());
app.use(express.static("public"));

const evaluationSchema = {
  type: "object",
  additionalProperties: false,
  properties: {
    overallScore: {
      type: "integer",
      minimum: 0,
      maximum: 100,
      description: "Overall formative score from 0 to 100."
    },
    summary: {
      type: "string",
      description: "A concise summary of the learner's performance."
    },
    categoryScores: {
      type: "object",
      additionalProperties: false,
      properties: {
        grammar: { type: "integer", minimum: 0, maximum: 100 },
        vocabulary: { type: "integer", minimum: 0, maximum: 100 },
        clarity: { type: "integer", minimum: 0, maximum: 100 },
        relevance: { type: "integer", minimum: 0, maximum: 100 }
      },
      required: ["grammar", "vocabulary", "clarity", "relevance"]
    },
    corrections: {
      type: "array",
      maxItems: 5,
      items: {
        type: "object",
        additionalProperties: false,
        properties: {
          original: { type: "string" },
          corrected: { type: "string" },
          explanation: { type: "string" }
        },
        required: ["original", "corrected", "explanation"]
      }
    },
    strengths: {
      type: "array",
      items: { type: "string" },
      maxItems: 3
    },
    nextSteps: {
      type: "array",
      items: { type: "string" },
      maxItems: 3
    }
  },
  required: [
    "overallScore",
    "summary",
    "categoryScores",
    "corrections",
    "strengths",
    "nextSteps"
  ]
};

function normalizeMode(value) {
  return value === "interview" ? "interview" : "conversation";
}

function normalizeRole(value) {
  return String(value || "General professional role").trim().slice(0, 100);
}

function cleanHistory(value) {
  if (!Array.isArray(value)) return [];

  return value
    .slice(-20)
    .map((item) => ({
      role: item?.role === "assistant" ? "assistant" : "user",
      text: String(item?.text || "").trim().slice(0, 2000)
    }))
    .filter((item) => item.text);
}

function buildStartPrompt(mode, role) {
  if (mode === "interview") {
    return `You are a professional interviewer conducting a spoken mock interview for the target role: ${role}. Welcome the learner briefly, then ask one realistic interview question. Ask only one question at a time. Keep every reply under 55 words. Do not correct the learner during the interview because feedback happens after the session.`;
  }

  return "You are a supportive English conversation partner. Begin a friendly everyday conversation with one short question. On later turns, respond naturally and ask one follow-up question. Keep every reply under 45 words. Do not correct the learner during the conversation because feedback happens after the session.";
}

function buildEvaluationPrompt(mode, role, history) {
  const transcript = history
    .map((item, index) => `${index + 1}. ${item.role === "assistant" ? "Trainer" : "Learner"}: ${item.text}`)
    .join("\n");

  const relevanceMeaning = mode === "interview"
    ? `Relevance measures how directly and convincingly the learner answered questions for the ${role} role.`
    : "Relevance measures conversational coherence and how well the learner responded to the trainer.";

  return `Evaluate only the learner's English in this ${mode} session. This is formative transcript-based coaching, not a certified pronunciation assessment. Score grammar, vocabulary, clarity, relevance, and overall performance from 0 to 100. ${relevanceMeaning} Provide up to five useful corrections. If there are no important corrections, return an empty corrections array. Keep strengths and next steps practical and concise.\n\nTranscript:\n${transcript}`;
}

function sendServerError(res, error) {
  console.error(error);
  res.status(500).json({
    error: "The trainer could not reach Gemini. Check the API key and the server terminal."
  });
}

app.post("/api/start", async (req, res) => {
  try {
    const mode = normalizeMode(req.body?.mode);
    const role = normalizeRole(req.body?.role);
    const interaction = await ai.interactions.create({
      model,
      input: buildStartPrompt(mode, role)
    });

    res.json({
      reply: interaction.output_text.trim(),
      interactionId: interaction.id
    });
  } catch (error) {
    sendServerError(res, error);
  }
});

app.post("/api/turn", async (req, res) => {
  const previousInteractionId = String(req.body?.previousInteractionId || "");
  const userText = String(req.body?.userText || "").trim().slice(0, 2000);

  if (!previousInteractionId || !userText) {
    return res.status(400).json({ error: "A session ID and spoken answer are required." });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: `The learner said: ${userText}`,
      previous_interaction_id: previousInteractionId
    });

    res.json({
      reply: interaction.output_text.trim(),
      interactionId: interaction.id
    });
  } catch (error) {
    sendServerError(res, error);
  }
});

app.post("/api/evaluate", async (req, res) => {
  const mode = normalizeMode(req.body?.mode);
  const role = normalizeRole(req.body?.role);
  const history = cleanHistory(req.body?.history);

  if (!history.some((item) => item.role === "user")) {
    return res.status(400).json({ error: "Complete at least one spoken turn before evaluation." });
  }

  try {
    const interaction = await ai.interactions.create({
      model,
      input: buildEvaluationPrompt(mode, role, history),
      response_format: {
        type: "text",
        mime_type: "application/json",
        schema: evaluationSchema
      }
    });

    res.json(JSON.parse(interaction.output_text));
  } catch (error) {
    sendServerError(res, error);
  }
});

app.listen(port, () => {
  console.log(`English trainer running at http://localhost:${port}`);
});
Render the coaching report safely

The browser needs to end the active session, request the report, and turn the returned values into visible elements. Every Gemini-provided value enters the page through textContent so model text cannot become executable HTML.

  • In public/app.js, find these event listeners:
startButton.addEventListener("click", startSession);
microphoneButton.addEventListener("click", () => {

Why connect the End Session button?

The existing start and microphone listeners control the live conversation. The end listener gives the report request its own deliberate action.

  • Replace those adjacent lines with this version so endButton calls endSession().
startButton.addEventListener("click", startSession);
endButton.addEventListener("click", endSession);
microphoneButton.addEventListener("click", () => {

What changed?

The new middle listener connects the existing End Session control to the report workflow. The surrounding start and microphone listeners stay unchanged.

  • Find the closing brace of startSession().
  • Add the session-ending function immediately below it by pasting:
async function endSession() {
  if (!history.some((item) => item.role === "user")) {
    statusText.textContent = "Speak at least once before ending the session.";
    return;
  }

  busy = true;
  statusText.textContent = "Preparing corrections and scores.";
  updateControls();

  try {
    const report = await postJson("/api/evaluate", {
      mode,
      role: roleInput.value.trim(),
      history
    });

    renderReport(report);
    active = false;
    statusText.textContent = "Session complete. Review your feedback below.";
  } catch (error) {
    statusText.textContent = error.message;
  } finally {
    busy = false;
    updateControls();
  }
}

What happens when a session ends?

  • The function requires at least one learner message before requesting feedback.
  • The request sends the selected mode, target role, and conversation history to the protected server route.
  • A successful response passes the structured report to renderReport() and closes the active session.
  • Find the closing brace of postJson().
  • Add the safe element and list helpers immediately below it by pasting:
function appendText(parent, tag, className, text) {
  const element = document.createElement(tag);
  if (className) element.className = className;
  element.textContent = text;
  parent.appendChild(element);
  return element;
}

function appendList(parent, items) {
  const list = document.createElement("ul");
  items.forEach((item) => appendText(list, "li", "", item));
  parent.appendChild(list);
}

Why create elements this way?

The helpers create each element through the browser's DOM API. Assigning model output through textContent keeps the report readable while treating the returned text as plain content.

  • Continue directly below appendList() with the first connected part of renderReport().
function renderReport(report) {
  reportPanel.replaceChildren();
  reportPanel.hidden = false;

  appendText(reportPanel, "h2", "", "Session Report");
  appendText(reportPanel, "div", "overall-score", `${report.overallScore}/100`);
  appendText(reportPanel, "p", "", report.summary);

  const scoreGrid = document.createElement("div");
  scoreGrid.className = "score-grid";
  const labels = {
    grammar: "Grammar",
    vocabulary: "Vocabulary",
    clarity: "Clarity",
    relevance: "Relevance"
  };

  Object.entries(labels).forEach(([key, label]) => {
    const card = document.createElement("div");
    card.className = "score-card";
    appendText(card, "span", "", label);
    appendText(card, "strong", "", `${report.categoryScores[key]}/100`);
    scoreGrid.appendChild(card);
  });
  reportPanel.appendChild(scoreGrid);

How are the scores displayed?

The report begins with the overall score and summary. A labeled card is then created for each category score returned by the server.

  • Complete renderReport() directly below the previous excerpt by pasting:
  appendText(reportPanel, "h3", "", "Corrections");
  if (report.corrections.length === 0) {
    appendText(reportPanel, "p", "", "No major corrections were needed in this short session.");
  } else {
    report.corrections.forEach((correction) => {
      const card = document.createElement("div");
      card.className = "correction";
      appendText(card, "p", "", `Original: ${correction.original}`);
      appendText(card, "p", "", `Corrected: ${correction.corrected}`);
      appendText(card, "p", "", `Why: ${correction.explanation}`);
      reportPanel.appendChild(card);
    });
  }

  appendText(reportPanel, "h3", "", "Strengths");
  appendList(reportPanel, report.strengths);
  appendText(reportPanel, "h3", "", "Next steps");
  appendList(reportPanel, report.nextSteps);
  reportPanel.scrollIntoView({ behavior: "smooth" });
}

How is the rest of the report built?

Each correction becomes a card containing the learner's original sentence, a corrected version, and an explanation. The final sections turn the strengths and next steps into readable lists.

An empty correction array receives its own plain-text message. The browser then scrolls the completed report into view.

  • Save public/app.js.

✔️ Awesome, I've got everything!

Your saved public/app.js now contains the complete pre-mission trainer and report renderer.

ⓧ I'd like to double check the full code

const modeButtons = [...document.querySelectorAll(".mode-card")];
const roleGroup = document.querySelector("#role-group");
const roleInput = document.querySelector("#role");
const startButton = document.querySelector("#start-session");
const endButton = document.querySelector("#end-session");
const microphoneButton = document.querySelector("#microphone");
const conversation = document.querySelector("#conversation");
const reportPanel = document.querySelector("#report");
const statusText = document.querySelector("#status");

const SpeechRecognition =
  window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = SpeechRecognition ? new SpeechRecognition() : null;

let mode = "conversation";
let active = false;
let busy = false;
let interactionId = "";
let history = [];

if (recognition) {
  recognition.continuous = false;
  recognition.lang = "en-US";
  recognition.interimResults = false;
  recognition.maxAlternatives = 1;

  recognition.onstart = () => {
    statusText.textContent = "Listening. Speak one answer, then pause.";
  };

  recognition.onresult = async (event) => {
    const transcript = event.results[0][0].transcript.trim();
    if (!active || !transcript) return;

    addMessage("learner", transcript);
    history.push({ role: "user", text: transcript });
    busy = true;
    updateControls();
    statusText.textContent = "The trainer is thinking.";

    try {
      const data = await postJson("/api/turn", {
        previousInteractionId: interactionId,
        userText: transcript
      });

      interactionId = data.interactionId;
      history.push({ role: "assistant", text: data.reply });
      addMessage("trainer", data.reply);
      speak(data.reply);
      statusText.textContent = "Your turn. Click Speak when ready.";
    } catch (error) {
      statusText.textContent = error.message;
    } finally {
      busy = false;
      updateControls();
    }
  };

  recognition.onerror = (event) => {
    statusText.textContent = `Speech recognition error: ${event.error}. Try again.`;
    busy = false;
    updateControls();
  };
} else {
  statusText.textContent = "Speech recognition is unavailable in this browser. Open the app in Microsoft Edge or Google Chrome.";
}

modeButtons.forEach((button) => {
  button.addEventListener("click", () => {
    if (active || busy) return;

    mode = button.dataset.mode;
    modeButtons.forEach((item) => {
      const selected = item === button;
      item.classList.toggle("active", selected);
      item.setAttribute("aria-pressed", String(selected));
    });
    roleGroup.hidden = mode !== "interview";
  });
});

startButton.addEventListener("click", startSession);
endButton.addEventListener("click", endSession);
microphoneButton.addEventListener("click", () => {
  if (!recognition || !active || busy) return;

  try {
    recognition.start();
  } catch {
    statusText.textContent = "The microphone is already listening.";
  }
});

async function startSession() {
  active = true;
  busy = true;
  interactionId = "";
  history = [];
  conversation.replaceChildren();
  reportPanel.replaceChildren();
  reportPanel.hidden = true;
  statusText.textContent = "Starting your trainer.";
  updateControls();

  try {
    const data = await postJson("/api/start", {
      mode,
      role: roleInput.value.trim()
    });

    interactionId = data.interactionId;
    history.push({ role: "assistant", text: data.reply });
    addMessage("trainer", data.reply);
    speak(data.reply);
    statusText.textContent = "Your turn. Click Speak when ready.";
  } catch (error) {
    active = false;
    statusText.textContent = error.message;
  } finally {
    busy = false;
    updateControls();
  }
}

async function endSession() {
  if (!history.some((item) => item.role === "user")) {
    statusText.textContent = "Speak at least once before ending the session.";
    return;
  }

  busy = true;
  statusText.textContent = "Preparing corrections and scores.";
  updateControls();

  try {
    const report = await postJson("/api/evaluate", {
      mode,
      role: roleInput.value.trim(),
      history
    });

    renderReport(report);
    active = false;
    statusText.textContent = "Session complete. Review your feedback below.";
  } catch (error) {
    statusText.textContent = error.message;
  } finally {
    busy = false;
    updateControls();
  }
}

function updateControls() {
  startButton.disabled = active || busy;
  endButton.disabled = !active || busy;
  microphoneButton.disabled = !active || busy || !recognition;
  roleInput.disabled = active || busy;
  modeButtons.forEach((button) => {
    button.disabled = active || busy;
  });
}

function addMessage(speaker, text) {
  conversation.querySelector(".empty-state")?.remove();
  const message = document.createElement("p");
  message.className = `message ${speaker}`;
  message.textContent = text;
  conversation.appendChild(message);
  conversation.scrollTop = conversation.scrollHeight;
}

function speak(text) {
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.rate = 0.95;
  window.speechSynthesis.speak(utterance);
}

async function postJson(url, body) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body)
  });
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.error || "The request failed.");
  }

  return data;
}

function appendText(parent, tag, className, text) {
  const element = document.createElement(tag);
  if (className) element.className = className;
  element.textContent = text;
  parent.appendChild(element);
  return element;
}

function appendList(parent, items) {
  const list = document.createElement("ul");
  items.forEach((item) => appendText(list, "li", "", item));
  parent.appendChild(list);
}

function renderReport(report) {
  reportPanel.replaceChildren();
  reportPanel.hidden = false;

  appendText(reportPanel, "h2", "", "Session Report");
  appendText(reportPanel, "div", "overall-score", `${report.overallScore}/100`);
  appendText(reportPanel, "p", "", report.summary);

  const scoreGrid = document.createElement("div");
  scoreGrid.className = "score-grid";
  const labels = {
    grammar: "Grammar",
    vocabulary: "Vocabulary",
    clarity: "Clarity",
    relevance: "Relevance"
  };

  Object.entries(labels).forEach(([key, label]) => {
    const card = document.createElement("div");
    card.className = "score-card";
    appendText(card, "span", "", label);
    appendText(card, "strong", "", `${report.categoryScores[key]}/100`);
    scoreGrid.appendChild(card);
  });
  reportPanel.appendChild(scoreGrid);

  appendText(reportPanel, "h3", "", "Corrections");
  if (report.corrections.length === 0) {
    appendText(reportPanel, "p", "", "No major corrections were needed in this short session.");
  } else {
    report.corrections.forEach((correction) => {
      const card = document.createElement("div");
      card.className = "correction";
      appendText(card, "p", "", `Original: ${correction.original}`);
      appendText(card, "p", "", `Corrected: ${correction.corrected}`);
      appendText(card, "p", "", `Why: ${correction.explanation}`);
      reportPanel.appendChild(card);
    });
  }

  appendText(reportPanel, "h3", "", "Strengths");
  appendList(reportPanel, report.strengths);
  appendText(reportPanel, "h3", "", "Next steps");
  appendList(reportPanel, report.nextSteps);
  reportPanel.scrollIntoView({ behavior: "smooth" });
}

Before you test the completed flow, which report sections do you expect to appear after one spoken answer?

  • Return to Microsoft Edge at http://localhost:3000.
  • Refresh the page.
  • Select English Conversation.
  • Click Start Session.
  • Click Speak.
  • Say one English sentence after the browser begins listening.
  • Wait for the trainer's spoken follow-up.
  • Click End Session.

You should see an overall score, four category scores, a summary, corrections, strengths, and next steps. The page scrolls the completed coaching report into view.

Report does not appear?

Check the status line for a safe server message. Return to the VS Code terminal to confirm that the evaluation request reached the running server.

Use help me trace my coaching report from endSession to POST /api/evaluate for a focused debugging path.

Now predict how the meaning of relevance changes when the trainer evaluates an interview answer for a specific role.

  • Select Interview Preparation.
  • Enter frontend developer in the Target role field.
  • Click Start Session.
  • Click Speak.
  • Answer the interview question after the browser begins listening.
  • Wait for the trainer's role-focused follow-up.
  • Click End Session.

You should see the same complete report structure. Relevance now reflects how directly and convincingly your answer fits the target role.

That closes the coaching loop. Your trainer can hold a spoken session and turn its transcript into actionable feedback without exposing the Gemini key.

Secret mission

Hear and Repeat a Correction

Turn every correction into a listen-and-repeat drill. The trainer reads the corrected sentence aloud before capturing your spoken retry inside the same correction card.

Clean Up Your Resources

🗑 Before you go

Calls to the Gemini API use gemini-3.8-flash. Input plus output are free of charge on the free tier.

A billing-enabled Google AI Studio project follows paid token pricing. Free-tier content may be used to improve Google's products.

  • Review usage in Google AI Studio to confirm whether this project generated paid token activity.
  • Configure billing alerts if billing remains enabled.
  • Keep sensitive interview material out of free-tier sessions.

Nice Work!

Nice Work!

You did it! Your local trainer now carries a spoken session in either English Conversation Practice or Interview Preparation. It also turns the transcript into a visual coaching report.

You've learned how to:

  • Build a responsive, two-mode voice trainer for conversation practice or role-focused interview preparation. Use speech recognition to show each spoken answer as text. Use speech synthesis to play each trainer reply aloud.
  • Protect the Gemini API key with a secure Express server boundary. Continue each Gemini conversation through its interaction ID.
  • Generate structured coaching reports that show corrections alongside rubric scores from 0 to 100. Keep the feedback formative by avoiding certified pronunciation claims.
  • Secret Mission: Add a targeted hear-and-repeat exercise to every correction card. Route the retry transcript back to the selected card for visual comparison.

Ready to quiz yourself?