Learn by Speaking: AI Pronunciation Trainer
Build a full-stack pronunciation trainer with React, Express, and the Web Speech API.
Introduction
30 Second Summary
You can read a word perfectly on the page and still mispronounce it out loud. The fastest way to fix that is hearing the correct version, trying it yourself, and getting immediate feedback on how close you got.
In this project, you will build a full-stack pronunciation trainer using React, Express, and TypeScript. The app shows you a word, speaks it aloud, listens to your attempt through the microphone, and scores your pronunciation with color-coded feedback.
What You'll Build
You will speak a word into your laptop's microphone, and your website will instantly flash green with a confidence score because you nailed the pronunciation.
By the end of this project, you'll have:
- A full pronunciation loop where you click Listen to hear exactly how a word should sound, click Speak to say it into your microphone, and see instant color-coded feedback (green for nailed it, yellow for close, red for try again) with a percentage score.
- A set of 20 words served from your own Express API, each with a phonetic guide, difficulty badge, and example sentence you can flip through.
- Real-time voice-to-text conversion using the Web Speech API directly in the browser, with zero external services or API keys required.
- Secret Mission: Add a streak counter and session statistics dashboard that tracks your pronunciation accuracy over time.
Are there any prerequisites?
You should have basic familiarity with HTML, CSS, and JavaScript. You also need Node.js and npm installed on your machine.
The speech recognition feature works best in Chrome or other Chromium-based browsers. Firefox and Safari have limited support for the SpeechRecognition API.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you.
Set Up the Project Structure
Your pronunciation trainer needs two pieces running side by side: a React frontend where users see word cards and talk into their microphone, and an Express backend that serves the word data.
In this step, you will scaffold the React frontend using Vite (a fast build tool for modern web apps), then create a minimal Express server with TypeScript. By the end, both will be running and you will confirm they respond.
In this step, get ready to:
- Scaffold a React + TypeScript frontend using Vite.
- Create an Express + TypeScript backend with a health-check endpoint.
- Run both servers and confirm they respond.
Scaffold the React frontend
Vite generates a complete React + TypeScript starter project in seconds. You will use its react-ts template to create the frontend inside a client folder.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir learn-by-speaking
cd learn-by-speaking
- Scaffold the React frontend by running this command:
npm create vite@latest client -- --template react-ts
You will see output ending with a "Done" message and instructions to cd, install, and run.
What does this command do?
The npm create vite@latest command downloads Vite's project scaffolding tool. The client argument names the folder, and --template react-ts tells Vite to use React with TypeScript. No interactive prompts appear because you specified both the name and template directly.
- Move into the new client folder and install its dependencies by running:
cd client
npm install
You will see npm download packages and print a summary of installed dependencies.
- Start the Vite development server by running:
npm run dev
- Open your browser and visit http://localhost:5173.
You should see the Vite + React starter page with a spinning logo and a counter button.
Don't see the starter page?
- Make sure npm run dev is still running in your terminal. If you see an error, check that your Node.js version is 20.19 or higher by running node --version in a new terminal window.
- If the port is already in use, Vite will pick the next available port and display it in the terminal output. Use that URL instead.
Help me troubleshoot my Vite dev server not starting.
- Stop the development server by pressing Ctrl+C in the terminal.
- Navigate back to the project root by running:
cd ..
Create the Express backend
The backend serves word data through a REST API. You will create a server folder alongside client, set up TypeScript, and wire up a single health-check endpoint to prove the server runs.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the learn-by-speaking folder on your Desktop, then click Open.
You should see client listed in the Explorer sidebar on the left.
- Open VS Code's integrated terminal by pressing Ctrl+` (macOS and Windows).
- Create the server folder structure by running:
mkdir -p server/src
- Confirm the folder was created by running:
ls
You should see both client and server listed in the output.
- In the VS Code Explorer sidebar, right-click the server folder and select New File.
- Name it package.json and paste the following content:
{
"name": "learn-by-speaking-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
What does this file do?
- The "type": "module" field enables ES module imports (import/export syntax) instead of CommonJS require.
- The dev script uses tsx to run TypeScript files directly and watch for changes, so the server auto-restarts as you edit.
- Express is the web framework. The cors package lets the React frontend (running on a different port) make requests to this server.
- Save package.json.
- Install the dependencies by running this in the integrated terminal:
cd server
npm install
You should see npm download packages and display a summary with the number of packages added.
Seeing permission or network errors?
- Check that your terminal is in the server directory by running pwd. It should end in learn-by-speaking/server.
- If npm shows a network error, check your internet connection and try again.
Help me troubleshoot my npm install failing in the server folder.
- In the Explorer sidebar, right-click the server folder and select New File.
- Name it tsconfig.json and paste the following content:
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
What does this configure?
This tells TypeScript to target the latest JavaScript features, use Node.js-style module resolution, and enforce strict type checking. The rootDir points at src/ where your code lives.
- Save tsconfig.json.
- In the Explorer sidebar, expand the server/src folder, right-click src, and select New File.
- Name it index.ts and paste the following code:
import express, { type Request, type Response } from 'express';
import cors from 'cors';
const app = express();
const PORT = 3001;
// Enable cross-origin requests from the React frontend
app.use(cors());
app.use(express.json());
// Health check endpoint to confirm the server is running
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
What does this code do?
- It creates an Express app on port 3001 (separate from Vite's port 5173).
- The cors() middleware allows the React frontend to make requests to this server without browser security blocking them.
- The /api/health endpoint returns a simple JSON response, proving the server is alive and responding.
- Save index.ts.
- Start the backend server by running this in the integrated terminal:
npm run dev
You should see the message Server running at http://localhost:3001 in the terminal.
- Open your browser and visit http://localhost:3001/api/health.
You should see {"status":"ok"} displayed in the browser.
Seeing an error instead of the JSON response?
- If the terminal shows a syntax error, double-check that server/src/index.ts is saved and matches the code above exactly.
- If you see "Cannot find module", confirm the terminal is inside the server directory and that npm install completed successfully.
- If port 3001 is already in use, stop whatever is using it or change PORT to another number like 3002.
Help me troubleshoot my Express server not starting.
✔️ Awesome, I've got everything!
Great. Make sure index.ts is saved and move on to the final check.
ⓧ I'd like to double check the full code
Here is the complete server/src/index.ts file:
import express, { type Request, type Response } from 'express';
import cors from 'cors';
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Run both servers and confirm they respond
You have verified each server individually. Now you will run both at the same time to confirm they coexist without conflict.
- Stop the backend server by pressing Ctrl+C in the integrated terminal.
- Start the backend server again by running:
npm run dev
- Open a second terminal by pressing Ctrl+Shift+` (macOS and Windows).
- In the new terminal, navigate to the client folder and start the frontend by running:
cd client
npm run dev
Before you check the browser, what do you expect to see at each URL? Think about what each server returns.
- Visit http://localhost:5173 in your browser.
- Open a second tab and visit http://localhost:3001/api/health.
You should see the Vite + React starter page at localhost:5173 and {"status":"ok"} at localhost:3001/api/health. Both servers are running in parallel.
One of the servers not responding?
- Check that you have two separate terminals open in VS Code. Each server needs its own terminal.
- If the frontend terminal says the port is in use, you may not have stopped the server from earlier. Press Ctrl+C in any stale terminals and try again.
- Confirm the first terminal shows Server running at http://localhost:3001 and the second shows a Local: URL for Vite.
Help me get both servers running at the same time.
Your project structure is ready. You have a React frontend and an Express backend running in parallel, able to communicate across ports. Next up, you will fill that backend with real word data for pronunciation practice.
Build the Word API
Your backend server is running and responding to health checks. Now it needs real content to serve.
The frontend will eventually display word cards for the learner to practice pronouncing. For that to work, it needs structured word data from the API. By building the API first, you can confirm real data is flowing before touching any UI code.
In this step, get ready to:
- Define a TypeScript interface that describes the shape of a word object.
- Create a data file with 20 English words across easy, medium, and hard difficulty levels.
- Build two API endpoints that return word data as JSON.
Define the Word interface and word data
Before writing any endpoint logic, you need to define what a "word" looks like in your system. A TypeScript interface locks down the shape so both your data file and your route handlers stay consistent.
- In VS Code, create a new file called types.ts inside the server/src/ folder.
- Add the following interface definition:
export interface Word {
id: number;
word: string;
phonetic: string;
difficulty: 'easy' | 'medium' | 'hard';
example: string;
}
What does this code do?
This interface defines the exact shape every word object must follow. Each word has a numeric ID, the word itself, a phonetic pronunciation guide, a difficulty rating constrained to three possible values, and an example sentence showing usage.
The 'easy' | 'medium' | 'hard' syntax is a union type. TypeScript will reject any word object that tries to use a difficulty value outside these three options.
- Save types.ts.
Now you need a data file that uses this interface. The pronunciation trainer needs a curated set of English words with real phonetic guides and example sentences.
- Create a new folder called data inside server/src/.
- Inside that data folder, create a file called words.ts and add this word list:
import type { Word } from '../types.ts';
export const words: Word[] = [
{ id: 1, word: 'hello', phonetic: '/həˈloʊ/', difficulty: 'easy', example: 'Hello, how are you today?' },
{ id: 2, word: 'world', phonetic: '/wɜːrld/', difficulty: 'easy', example: 'She traveled around the world.' },
{ id: 3, word: 'thank', phonetic: '/θæŋk/', difficulty: 'easy', example: 'Thank you for your help.' },
{ id: 4, word: 'please', phonetic: '/pliːz/', difficulty: 'easy', example: 'Please pass the salt.' },
{ id: 5, word: 'water', phonetic: '/ˈwɔːtər/', difficulty: 'easy', example: 'Can I have a glass of water?' },
{ id: 6, word: 'friend', phonetic: '/frɛnd/', difficulty: 'easy', example: 'She is my best friend.' },
{ id: 7, word: 'beautiful', phonetic: '/ˈbjuːtɪfəl/', difficulty: 'medium', example: 'What a beautiful sunset!' },
{ id: 8, word: 'necessary', phonetic: '/ˈnɛsəˌsɛri/', difficulty: 'medium', example: 'Sleep is necessary for health.' },
{ id: 9, word: 'comfortable', phonetic: '/ˈkʌmftərbəl/', difficulty: 'medium', example: 'This chair is very comfortable.' },
{ id: 10, word: 'temperature', phonetic: '/ˈtɛmpərətʃər/', difficulty: 'medium', example: 'The temperature dropped overnight.' },
{ id: 11, word: 'restaurant', phonetic: '/ˈrɛstərɒnt/', difficulty: 'medium', example: 'We ate at a nice restaurant.' },
{ id: 12, word: 'environment', phonetic: '/ɪnˈvaɪrənmənt/', difficulty: 'medium', example: 'Protect the environment.' },
{ id: 13, word: 'February', phonetic: '/ˈfɛbruˌɛri/', difficulty: 'medium', example: 'February is the shortest month.' },
{ id: 14, word: 'pronunciation', phonetic: '/prəˌnʌnsiˈeɪʃən/', difficulty: 'hard', example: 'Practice your pronunciation daily.' },
{ id: 15, word: 'thoroughly', phonetic: '/ˈθɜːrəli/', difficulty: 'hard', example: 'Read the instructions thoroughly.' },
{ id: 16, word: 'entrepreneur', phonetic: '/ˌɒntrəprəˈnɜːr/', difficulty: 'hard', example: 'She became a successful entrepreneur.' },
{ id: 17, word: 'miscellaneous', phonetic: '/ˌmɪsəˈleɪniəs/', difficulty: 'hard', example: 'The box contained miscellaneous items.' },
{ id: 18, word: 'conscience', phonetic: '/ˈkɒnʃəns/', difficulty: 'hard', example: 'Let your conscience guide you.' },
{ id: 19, word: 'rhythm', phonetic: '/ˈrɪðəm/', difficulty: 'hard', example: 'The music has a catchy rhythm.' },
{ id: 20, word: 'queue', phonetic: '/kjuː/', difficulty: 'hard', example: 'Please wait in the queue.' },
];
What does this code do?
This file exports an array of 20 word objects that match the Word interface you just defined. Each word includes a phonetic guide using the International Phonetic Alphabet (IPA) so learners know the correct sounds.
The words are split across three difficulty levels: six easy everyday words, seven medium words that people commonly mispronounce, and seven hard words that trip up even native speakers.
- Save words.ts.
- Confirm both new files exist by running this in the terminal where your server is running:
ls server/src/types.ts server/src/data/words.ts
You should see both file paths printed without errors.
Don't see both files listed?
- Make sure types.ts is directly inside server/src/ and not nested inside the data folder.
- Confirm the data folder is inside server/src/ and words.ts is inside that data folder.
Help me find my missing files in VS Code.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is server/src/types.ts:
export interface Word {
id: number;
word: string;
phonetic: string;
difficulty: 'easy' | 'medium' | 'hard';
example: string;
}
And here is server/src/data/words.ts. Confirm it starts with the import and has 20 word objects in the array:
import type { Word } from '../types.ts';
export const words: Word[] = [
{ id: 1, word: 'hello', phonetic: '/həˈloʊ/', difficulty: 'easy', example: 'Hello, how are you today?' },
{ id: 2, word: 'world', phonetic: '/wɜːrld/', difficulty: 'easy', example: 'She traveled around the world.' },
{ id: 3, word: 'thank', phonetic: '/θæŋk/', difficulty: 'easy', example: 'Thank you for your help.' },
{ id: 4, word: 'please', phonetic: '/pliːz/', difficulty: 'easy', example: 'Please pass the salt.' },
{ id: 5, word: 'water', phonetic: '/ˈwɔːtər/', difficulty: 'easy', example: 'Can I have a glass of water?' },
{ id: 6, word: 'friend', phonetic: '/frɛnd/', difficulty: 'easy', example: 'She is my best friend.' },
{ id: 7, word: 'beautiful', phonetic: '/ˈbjuːtɪfəl/', difficulty: 'medium', example: 'What a beautiful sunset!' },
{ id: 8, word: 'necessary', phonetic: '/ˈnɛsəˌsɛri/', difficulty: 'medium', example: 'Sleep is necessary for health.' },
{ id: 9, word: 'comfortable', phonetic: '/ˈkʌmftərbəl/', difficulty: 'medium', example: 'This chair is very comfortable.' },
{ id: 10, word: 'temperature', phonetic: '/ˈtɛmpərətʃər/', difficulty: 'medium', example: 'The temperature dropped overnight.' },
{ id: 11, word: 'restaurant', phonetic: '/ˈrɛstərɒnt/', difficulty: 'medium', example: 'We ate at a nice restaurant.' },
{ id: 12, word: 'environment', phonetic: '/ɪnˈvaɪrənmənt/', difficulty: 'medium', example: 'Protect the environment.' },
{ id: 13, word: 'February', phonetic: '/ˈfɛbruˌɛri/', difficulty: 'medium', example: 'February is the shortest month.' },
{ id: 14, word: 'pronunciation', phonetic: '/prəˌnʌnsiˈeɪʃən/', difficulty: 'hard', example: 'Practice your pronunciation daily.' },
{ id: 15, word: 'thoroughly', phonetic: '/ˈθɜːrəli/', difficulty: 'hard', example: 'Read the instructions thoroughly.' },
{ id: 16, word: 'entrepreneur', phonetic: '/ˌɒntrəprəˈnɜːr/', difficulty: 'hard', example: 'She became a successful entrepreneur.' },
{ id: 17, word: 'miscellaneous', phonetic: '/ˌmɪsəˈleɪniəs/', difficulty: 'hard', example: 'The box contained miscellaneous items.' },
{ id: 18, word: 'conscience', phonetic: '/ˈkɒnʃəns/', difficulty: 'hard', example: 'Let your conscience guide you.' },
{ id: 19, word: 'rhythm', phonetic: '/ˈrɪðəm/', difficulty: 'hard', example: 'The music has a catchy rhythm.' },
{ id: 20, word: 'queue', phonetic: '/kjuː/', difficulty: 'hard', example: 'Please wait in the queue.' },
];
Create the word endpoints
Your data exists but nothing serves it yet. You need two routes: one that returns all words, and one that filters by difficulty level.
- Open server/src/index.ts.
- Find the two import lines at the top of the file:
import express, { type Request, type Response } from 'express';
import cors from 'cors';
- Add these two new imports directly below them:
import { words } from './data/words.ts';
import type { Word } from './types.ts';
- Now find the /api/health route in the same file:
app.get('/api/health', (_req: Request, res: Response) => {
res.json({ status: 'ok' });
});
- Add the following /api/words route directly below the health route:
// Return all words
app.get('/api/words', (_req: Request, res: Response) => {
res.json(words);
});
What does this code do?
The two new imports bring in your word data and the Word type. The /api/words route returns the entire array of 20 words as JSON whenever a GET request hits that path.
Since tsx watch is running, the server automatically restarts when you save.
- Save index.ts.
- Open your browser and visit http://localhost:3001/api/words.
You should see a JSON array of 20 word objects in the browser, each with id, word, phonetic, difficulty, and example fields.
Seeing an error in the terminal?
- Check that your import path includes the .ts extension: './data/words.ts' and './types.ts'. The NodeNext module resolution requires explicit extensions.
- Make sure words.ts is at server/src/data/words.ts and types.ts is at server/src/types.ts.
Why is my Express server crashing after adding imports?
Now add the second endpoint that lets users filter words by difficulty. This endpoint uses a URL parameter.
- In server/src/index.ts, add this route directly below the /api/words route you just created:
// Return words filtered by difficulty level
app.get('/api/words/:difficulty', (req: Request, res: Response) => {
const { difficulty } = req.params;
const validDifficulties = ['easy', 'medium', 'hard'];
if (!validDifficulties.includes(difficulty)) {
res.status(400).json({ error: 'Invalid difficulty. Use easy, medium, or hard.' });
return;
}
const filtered: Word[] = words.filter((w) => w.difficulty === difficulty);
res.json(filtered);
});
What does this code do?
The :difficulty in the path is a route parameter. When someone visits /api/words/easy, Express captures "easy" into req.params.difficulty.
The handler first validates that the difficulty is one of the three allowed values. If not, it returns a 400 error with a helpful message. Otherwise it filters the full word array and returns only the matching words.
- Save index.ts.
✔️ Awesome, I've got everything!
Great. Confirm your terminal shows Server running at http://localhost:3001 with no errors after saving.
ⓧ I'd like to double check the full code
Your server/src/index.ts should now have these sections in order:
- Four import lines (express, cors, words, Word type).
- App setup with cors() and express.json() middleware.
- The /api/health route returning {status: 'ok'}.
- The /api/words route returning all words.
- The /api/words/:difficulty route with validation and filtering.
- The app.listen call at the bottom.
Scroll up through the step and confirm each code block is present in your file, in this order.
Test both endpoints in the browser
Both endpoints are wired up. Time to confirm they return the right data with the right structure.
Before you test the difficulty filter, what do you think visiting http://localhost:3001/api/words/easy will return? How many words do you expect to see?
- Open your browser and visit http://localhost:3001/api/words/easy.
You should see a JSON array of exactly 6 word objects, all with "difficulty": "easy".
- Now visit http://localhost:3001/api/words/hard to confirm the hard words filter works too.
You should see 7 word objects, all with "difficulty": "hard".
- Finally, test the validation by visiting http://localhost:3001/api/words/impossible.
You should see a 400 error response: {"error":"Invalid difficulty. Use easy, medium, or hard."}.
Getting an empty array or a 404?
- An empty array means the difficulty value in the URL does not match any words. Check that you typed easy, medium, or hard exactly (all lowercase).
- A 404 means the route is not registered. Check that your /api/words/:difficulty route appears AFTER the /api/words route in your file.
- Confirm your terminal still shows the server running without errors.
Why is my Express route returning a 404 or empty array?
Your API is serving structured word data. You have real content flowing from the backend, ready for the frontend to display. Next up, you will build the React word cards that bring this data to life on screen.
Display Word Cards in React
Your API is serving word data, but right now the React frontend is still showing the default Vite starter page. There is no way to see or interact with the words you built.
In this step, you will create a WordCard component that fetches words from your Express API and displays them as styled, navigable cards. By the end, you will flip through word cards showing the word, its phonetic spelling, difficulty badge, and an example sentence.
In this step, get ready to:
- Create a WordCard component that displays word data with a difficulty badge and navigation.
- Fetch words from the Express API and manage state in App.tsx.
- Style the card with color-coded difficulty badges and a clean layout.
Create the Word type and WordCard component
Before you build the card UI, you need a shared TypeScript interface that describes the shape of a word object. This keeps your frontend code type-safe when handling data from the API.
- In VS Code, open the client/src folder in the Explorer sidebar.
- Create a new file called types.ts inside client/src and paste this code:
export interface Word {
id: number;
word: string;
phonetic: string;
difficulty: 'easy' | 'medium' | 'hard';
example: string;
}
What does this code do?
This interface defines the exact shape of each word object your API returns. TypeScript uses it to catch errors at edit time if you accidentally misspell a field name or use the wrong type.
- Save types.ts.
Now create the component itself. The WordCard will receive a word object and navigation callbacks as props, then render the card UI.
- Create a new folder called components inside client/src.
- Inside client/src/components, create a new file called WordCard.tsx and paste this code:
import type { Word } from '../types';
import './WordCard.css';
interface WordCardProps {
word: Word;
onNext: () => void;
onPrevious: () => void;
currentIndex: number;
totalWords: number;
}
export function WordCard({ word, onNext, onPrevious, currentIndex, totalWords }: WordCardProps) {
return (
<div className="word-card">
<div className="word-card-header">
{/* Badge shows the word's difficulty level */}
<span className={`difficulty-badge ${word.difficulty}`}>{word.difficulty}</span>
<span className="word-counter">{currentIndex + 1} / {totalWords}</span>
</div>
{/* Main word display with phonetic guide and example */}
<h1 className="word-text">{word.word}</h1>
<p className="word-phonetic">{word.phonetic}</p>
<p className="word-example">"{word.example}"</p>
<div className="word-card-nav">
<button className="btn btn-nav" onClick={onPrevious} disabled={currentIndex === 0}>Previous</button>
<button className="btn btn-nav" onClick={onNext} disabled={currentIndex === totalWords - 1}>Next</button>
</div>
</div>
);
}
What does this code do?
The WordCardProps interface defines what data the component expects from its parent: the current word, navigation functions, and position info.
The component renders a card with a difficulty badge that uses the word's difficulty as a CSS class name. The Previous and Next buttons are disabled at the start and end of the list to prevent out-of-bounds navigation.
- Save WordCard.tsx.
- Confirm both files exist by checking the Explorer sidebar. You should see types.ts in src/ and WordCard.tsx inside src/components/.
Don't see the components folder?
Make sure you created the folder inside client/src, not inside client or server. The path should be client/src/components/WordCard.tsx.
Help me fix my file structure.
Fetch words from the API and render the card
The WordCard component is ready, but nothing is using it yet. You need to wire up App.tsx to fetch words from your Express API and pass the current word into the card.
- Open client/src/App.tsx in VS Code.
- Select all the existing Vite starter code and delete it.
- Replace it with this code that sets up state and fetches words from your API:
import { useState, useEffect } from 'react';
import type { Word } from './types';
import { WordCard } from './components/WordCard';
import './App.css';
function App() {
// State for word list, current position, loading, and errors
const [words, setWords] = useState<Word[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch all words from the Express API when the component mounts
useEffect(() => {
fetch('http://localhost:3001/api/words')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch words');
return res.json();
})
.then((data: Word[]) => {
setWords(data);
setLoading(false);
})
.catch((err: Error) => {
setError(err.message);
setLoading(false);
});
}, []);
What does this code do?
The useEffect hook runs once when the component mounts (the empty [] dependency array means it fires only on first render). It calls your Express API at http://localhost:3001/api/words and stores the response in state.
The loading and error states let you show appropriate messages while the fetch is in progress or if something goes wrong.
- Directly below the useEffect closing (after the }, []); line), paste the remaining component code:
// Navigate forward and backward through the word list
const handleNext = () => setCurrentIndex((prev) => Math.min(prev + 1, words.length - 1));
const handlePrevious = () => setCurrentIndex((prev) => Math.max(prev - 1, 0));
// Show loading, error, or empty states before rendering the card
if (loading) return <div className="app-loading">Loading words...</div>;
if (error) return <div className="app-error">Error: {error}</div>;
if (words.length === 0) return <div className="app-empty">No words found.</div>;
return (
<div className="app">
<header className="app-header">
<h1>Learn by Speaking</h1>
<p>Practice your pronunciation with instant AI feedback</p>
</header>
<main>
<WordCard word={words[currentIndex]} onNext={handleNext} onPrevious={handlePrevious} currentIndex={currentIndex} totalWords={words.length} />
</main>
</div>
);
}
export default App;
What does this code do?
The handleNext and handlePrevious functions update currentIndex while clamping it within bounds so you cannot navigate past the first or last word.
The early return statements handle loading, error, and empty states. If everything loaded successfully, the component renders the header and passes the current word plus handlers into the WordCard.
- Save App.tsx.
- Switch to your browser and refresh http://localhost:5173.
You should see the word "hello" displayed on the page along with its phonetic guide and an example sentence. The text will be unstyled for now, but the data is live from your API.
Seeing a blank page or error?
Check that your Express backend is still running in its terminal (you should see Server running at http://localhost:3001 in the output). If it stopped, restart it with npm run dev from the server folder.
If you see a TypeScript error in the terminal where Vite runs, double-check that your types.ts file is saved in client/src/types.ts and that the import path in WordCard.tsx uses '../types' (with two dots).
Help me debug my blank page.
Style the card with color-coded difficulty badges
The card works but looks plain. You will add CSS that gives it a card layout, color-coded difficulty badges (green for easy, yellow for medium, red for hard), and clean typography.
- Create a new file called WordCard.css inside client/src/components and paste the card structure and badge styles:
.word-card {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
text-align: center;
}
.word-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.difficulty-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
/* Color-coded badges for each difficulty level */
.difficulty-badge.easy { background: #dcfce7; color: #166534; }
.difficulty-badge.medium { background: #fef9c3; color: #854d0e; }
.difficulty-badge.hard { background: #fee2e2; color: #991b1b; }
What does this code do?
The .word-card class creates a centered white card with rounded corners and a subtle shadow. The .difficulty-badge uses the word's difficulty value as an extra class name, so .difficulty-badge.easy gets a green background, .medium gets yellow, and .hard gets red.
- In the same WordCard.css file, paste the text and navigation styles below the badge rules:
/* Word content typography */
.word-counter { font-size: 0.875rem; color: #6b7280; }
.word-text { font-size: 2.5rem; font-weight: 700; margin: 0.5rem 0; color: #1f2937; }
.word-phonetic { font-size: 1.25rem; color: #6b7280; font-style: italic; margin: 0.25rem 0; }
.word-example { font-size: 0.95rem; color: #4b5563; margin: 1rem 0; }
/* Button base and navigation layout */
.btn { padding: 0.75rem 1.5rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-nav { background: #f3f4f6; color: #374151; }
.word-card-nav { display: flex; justify-content: space-between; margin-top: 1.5rem; }
What does this code do?
These rules size the word text large and bold, style the phonetic guide in italic gray, and set up the navigation buttons with a neutral background. Disabled buttons get 50% opacity so you can see when you have reached the start or end of the word list.
- Save WordCard.css and refresh your browser.
You should see a white card centered on the page with the word "hello" in large text, its phonetic guide below, a green "easy" badge in the top-left corner, and Previous/Next buttons at the bottom.
Now add the app-level layout styles. The current page background is still the Vite default.
- Open client/src/App.css, delete all the existing Vite starter styles, and replace with:
/* App layout and loading states */
.app { min-height: 100vh; background: #f8fafc; padding: 2rem 1rem; }
.app-header { text-align: center; margin-bottom: 1rem; }
.app-header h1 { font-size: 2rem; font-weight: 800; color: #1f2937; margin: 0; }
.app-header p { color: #6b7280; margin: 0.5rem 0 0; }
.app-loading, .app-error, .app-empty { text-align: center; padding: 4rem; font-size: 1.125rem; color: #6b7280; }
.app-error { color: #ef4444; }
What does this code do?
The .app class sets a light gray full-height background. The loading and error state classes center their messages in the viewport so you see clear feedback while data loads.
- Save App.css.
Before you check the final result, what do you expect to see when you refresh the page?
- Refresh your browser at http://localhost:5173.
You should see a light gray page with a "Learn by Speaking" heading, and below it a white card displaying "hello", its phonetic guide /həˈloʊ/, a green "easy" badge, the example sentence, and a word counter showing "1 / 20". Click Next a few times to cycle through words and watch the difficulty badge change color.
Card not styled or still showing Vite default page?
Make sure you deleted all the original content from App.css before pasting the new styles. Leftover Vite styles can override your layout.
Also check that the import import './WordCard.css' appears at the top of WordCard.tsx. Without that import, Vite does not bundle the CSS file.
Help me fix my word card styles.
✔️ Awesome, I've got everything!
Great job. Make sure all five files are saved: types.ts, WordCard.tsx, WordCard.css, App.tsx, and App.css.
ⓧ I'd like to double check the full code
Here is client/src/types.ts:
export interface Word {
id: number;
word: string;
phonetic: string;
difficulty: 'easy' | 'medium' | 'hard';
example: string;
}
Here is client/src/components/WordCard.tsx:
import type { Word } from '../types';
import './WordCard.css';
interface WordCardProps {
word: Word;
onNext: () => void;
onPrevious: () => void;
currentIndex: number;
totalWords: number;
}
export function WordCard({ word, onNext, onPrevious, currentIndex, totalWords }: WordCardProps) {
return (
<div className="word-card">
<div className="word-card-header">
<span className={`difficulty-badge ${word.difficulty}`}>{word.difficulty}</span>
<span className="word-counter">{currentIndex + 1} / {totalWords}</span>
</div>
<h1 className="word-text">{word.word}</h1>
<p className="word-phonetic">{word.phonetic}</p>
<p className="word-example">"{word.example}"</p>
<div className="word-card-nav">
<button className="btn btn-nav" onClick={onPrevious} disabled={currentIndex === 0}>Previous</button>
<button className="btn btn-nav" onClick={onNext} disabled={currentIndex === totalWords - 1}>Next</button>
</div>
</div>
);
}
For client/src/App.tsx, confirm these sections are present in order: imports (4 lines), state declarations (4 variables), useEffect with fetch, handleNext and handlePrevious handlers, three early-return guards (loading, error, empty), and the JSX return with the header and WordCard.
For client/src/components/WordCard.css, confirm you have: .word-card container styles, .word-card-header flex layout, .difficulty-badge base styles with .easy/.medium/.hard color variants, word text styles, and .btn/.word-card-nav button and layout rules.
Here is client/src/App.css:
.app { min-height: 100vh; background: #f8fafc; padding: 2rem 1rem; }
.app-header { text-align: center; margin-bottom: 1rem; }
.app-header h1 { font-size: 2rem; font-weight: 800; color: #1f2937; margin: 0; }
.app-header p { color: #6b7280; margin: 0.5rem 0 0; }
.app-loading, .app-error, .app-empty { text-align: center; padding: 4rem; font-size: 1.125rem; color: #6b7280; }
.app-error { color: #ef4444; }
Your word cards are looking great. Next up, you will make the app truly interactive by adding speech recognition so you can actually practice saying these words out loud.
Add Speech Recognition
You can see word cards and flip through them, but you cannot practice saying any of them yet. This step makes the app interactive by letting you speak into your microphone and see what the browser heard.
You will use the Web Speech API to capture your voice, convert it to text, and compare it against the target word. The feedback at this stage is intentionally basic: just "correct" or "try again."
In this step, get ready to:
- Create a custom React hook that wraps the Web Speech API for microphone input.
- Add a Speak button to the word card that captures your voice and displays what the browser heard.
- Compare the transcript to the target word and show a correct/incorrect badge.
Create the useSpeechRecognition hook
The Web Speech API provides a SpeechRecognition interface that listens to microphone input and converts it to text. You will wrap it in a custom React hook so any component can use speech recognition with simple state values.
What is the Web Speech API?
The Web Speech API has two parts: SpeechRecognition (listening to you) and SpeechSynthesis (speaking to you). In Chrome, SpeechRecognition sends your audio to a server-based engine for processing, so it requires an internet connection.
Firefox and Safari have limited or no support for SpeechRecognition. For this project, use Chrome or a Chromium-based browser (Edge, Brave, Arc).
- Inside your client/src folder, create a new folder called hooks.
- Create a new file useSpeechRecognition.ts inside the hooks folder, then add the following code:
import { useState, useCallback, useRef } from 'react';
interface SpeechRecognitionResult {
transcript: string;
confidence: number;
}
interface UseSpeechRecognitionReturn {
isListening: boolean;
result: SpeechRecognitionResult | null;
error: string | null;
startListening: () => void;
stopListening: () => void;
}
export function useSpeechRecognition(): UseSpeechRecognitionReturn {
const [isListening, setIsListening] = useState(false);
const [result, setResult] = useState<SpeechRecognitionResult | null>(null);
const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
What does this code do?
- SpeechRecognitionResult defines the shape of what the browser gives back: a transcript (what it heard) and a confidence value (how sure it is).
- UseSpeechRecognitionReturn defines the hook's public API: state values and control functions that components can use.
- recognitionRef stores a reference to the active recognition instance so you can stop it later without losing it between renders.
- Below the state declarations, add the startListening and stopListening callbacks, then close the function with the return statement:
const startListening = useCallback(() => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
setError('Speech recognition is not supported in this browser. Try Chrome.');
return;
}
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.lang = 'en-US';
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onstart = () => { setIsListening(true); setError(null); setResult(null); };
recognition.onresult = (event: SpeechRecognitionEvent) => {
const speechResult = event.results[0][0];
setResult({ transcript: speechResult.transcript.toLowerCase().trim(), confidence: speechResult.confidence });
setIsListening(false);
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
setError(`Speech recognition error: ${event.error}`);
setIsListening(false);
};
recognition.onend = () => { setIsListening(false); };
recognitionRef.current = recognition;
recognition.start();
}, []);
const stopListening = useCallback(() => {
if (recognitionRef.current) recognitionRef.current.stop();
}, []);
return { isListening, result, error, startListening, stopListening };
}
What does this code do?
- window.SpeechRecognition || window.webkitSpeechRecognition checks both the standard and Chrome-prefixed version of the API. Chrome still requires the webkit prefix.
- continuous: false means the recognizer stops after one phrase. You want single-word input, not a running dictation.
- onresult fires when the browser has a transcript. It lowercases and trims the result so your comparison later is clean.
- onerror catches permission denials, network failures, and timeouts so the UI can display a helpful message.
- Save client/src/hooks/useSpeechRecognition.ts.
Seeing a TypeScript error about SpeechRecognition?
TypeScript may not recognize the SpeechRecognition type in your editor. Vite's tsconfig.json includes the DOM library by default, so the code will compile and run correctly even if your editor shows a squiggly line.
If the error persists, confirm your client/tsconfig.json has "lib": ["DOM", "ES2020"] or similar. Help me fix my TypeScript SpeechRecognition type error
Add the Speak button to WordCard
Now you will wire the hook into your WordCard component so you can click a button, speak into your microphone, and see what the browser heard.
- Open client/src/components/WordCard.tsx and replace its entire contents with the following code.
First, the imports, interface, hook setup, and handler logic:
import { useState } from 'react';
import type { Word } from '../types';
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
import './WordCard.css';
interface WordCardProps {
word: Word;
onNext: () => void;
onPrevious: () => void;
currentIndex: number;
totalWords: number;
}
export function WordCard({ word, onNext, onPrevious, currentIndex, totalWords }: WordCardProps) {
const { isListening, result, error, startListening } = useSpeechRecognition();
const [attempts, setAttempts] = useState(0);
const [bestScore, setBestScore] = useState(0);
// Simple case-insensitive comparison
const isCorrect = result ? result.transcript === word.word.toLowerCase() : null;
const handleSpeak = () => {
startListening();
setAttempts((prev) => prev + 1);
};
if (isCorrect && bestScore < 100) setBestScore(100);
What does this code do?
- useSpeechRecognition() gives the component access to isListening, result, error, and startListening from the hook you just created.
- isCorrect does a simple case-insensitive string comparison between the transcript and the target word.
- handleSpeak starts listening and increments the attempt counter each time the learner tries.
- Directly below the code you just added, paste the JSX return statement:
return (
<div className="word-card">
<div className="word-card-header">
<span className={`difficulty-badge ${word.difficulty}`}>{word.difficulty}</span>
<span className="word-counter">{currentIndex + 1} / {totalWords}</span>
</div>
<h1 className="word-text">{word.word}</h1>
<p className="word-phonetic">{word.phonetic}</p>
<p className="word-example">"{word.example}"</p>
<div className="word-card-actions">
<button className="btn btn-speak" onClick={handleSpeak} disabled={isListening}>
{isListening ? 'Listening...' : 'Speak'}
</button>
</div>
{error && <p className="error-message">{error}</p>}
{result && (
<div className="feedback">
<p>You said: "{result.transcript}"</p>
<p>{isCorrect ? '✅ Correct!' : '❌ Try again'}</p>
</div>
)}
<div className="word-card-stats">
<span>Attempts: {attempts}</span>
<span>Best: {bestScore}%</span>
</div>
<div className="word-card-nav">
<button className="btn btn-nav" onClick={onPrevious} disabled={currentIndex === 0}>Previous</button>
<button className="btn btn-nav" onClick={onNext} disabled={currentIndex === totalWords - 1}>Next</button>
</div>
</div>
);
}
What does this JSX do?
- The word-card-actions div holds the Speak button. It shows "Listening..." while the microphone is active.
- The feedback div only renders when a result exists. It shows the transcript and a simple correct/incorrect badge.
- word-card-stats tracks how many times you tried the word and your best score so far.
- Save client/src/components/WordCard.tsx.
Update the styles and test the microphone
The new button and feedback elements need CSS classes to match your card design.
- Open client/src/components/WordCard.css and add these rules below your existing styles:
.word-card-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin: 1.5rem 0;
}
.btn-speak {
background: #8b5cf6;
color: white;
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin: 0.5rem 0;
}
.feedback {
margin: 1.5rem 0;
padding: 1rem;
border-radius: 12px;
background: #fafafa;
}
.word-card-stats {
display: flex;
justify-content: center;
gap: 2rem;
margin: 1rem 0;
font-size: 0.875rem;
color: #6b7280;
}
What do these styles do?
- btn-speak gives the Speak button a purple background so it stands out from the gray navigation buttons.
- feedback creates a soft gray box where the transcript and result badge appear after you speak.
- word-card-stats shows the attempt counter and best score in a subtle row below the feedback.
- Save client/src/components/WordCard.css.
Chrome will ask for microphone permission
The first time you click Speak, Chrome displays a permission popup asking to use your microphone. Click Allow. Chrome treats localhost as a secure context, so this works without HTTPS.
If you accidentally click Block, click the lock icon in the address bar and reset microphone permissions for the site.
Before you test, what do you think will happen if you say the word correctly? What about if you say something slightly different?
- Switch to your browser at http://localhost:5173.
- Click the purple Speak button on the word card.
- Allow microphone access when prompted.
- Say the word displayed on the card clearly.
You should see the button text change to "Listening..." while the microphone is active, then your transcript appears below the card with a correct or incorrect badge.
Not seeing any transcript?
- Confirm you are using Chrome (or Edge/Brave). Firefox and Safari do not support SpeechRecognition reliably.
- Check that you allowed microphone access. Look for a camera/microphone icon in your address bar.
- Open the browser console (F12) and look for error messages. The hook surfaces errors in the UI, but the console may show additional detail.
Help me debug why speech recognition is not working in my browser
Try saying the word slightly wrong on purpose. You will notice the app only tells you "Correct" or "Try again" with no detail about how close you were. If you say "weather" and the recognizer hears "whether," you get the same flat "Try again" as if you said something completely different.
That binary feedback is the limitation you will fix in the next step with a proper similarity score.
✔️ Awesome, I've got everything!
Make sure you have saved useSpeechRecognition.ts, WordCard.tsx, and WordCard.css.
ⓧ I'd like to double check the full code
Your client/src/hooks/useSpeechRecognition.ts should contain both chunks combined: the imports and interfaces at the top, then the startListening/stopListening callbacks and return at the bottom.
Your WordCard.tsx should contain the two chunks combined: the imports and hook logic first, then the JSX return.
Your WordCard.css should have all the original styles from the previous step, plus the five new rules: .word-card-actions, .btn-speak, .error-message, .feedback, and .word-card-stats.
Scroll up and confirm each chunk is present in your files, in order.
Your word card now listens and responds. In the next step, you will replace the blunt correct/incorrect check with a similarity scoring algorithm that shows exactly how close you were.
Enhance Feedback with Confidence Scoring
In the last step, you got speech recognition working. You can speak a word and see whether the browser heard it correctly.
The problem is that the feedback is binary. Say "weather" and the recognizer hears "whether" and you get a flat "incorrect." There is no sense of how close you were or whether trying again would help. In this step, you will replace that pass/fail check with a percentage-based similarity score that shows exactly how close your pronunciation was.
In this step, get ready to:
- Build a similarity scoring function using Levenshtein distance.
- Replace the binary correct/incorrect badge with color-coded percentage feedback.
- Track your best score per word so you can see improvement.
Implement the similarity scoring function
Instead of checking if two strings are identical, you need a function that measures how different they are. The Levenshtein distance algorithm counts the minimum number of single-character edits (insertions, deletions, substitutions) needed to turn one string into another.
- Create a new folder and file at client/src/utils/similarity.ts by right-clicking the src folder in the VS Code Explorer sidebar, selecting New Folder, naming it utils, then right-clicking utils and selecting New File to create similarity.ts.
- Add the following code to similarity.ts:
export function calculateSimilarity(a: string, b: string): number {
const s1 = a.toLowerCase().trim();
const s2 = b.toLowerCase().trim();
if (s1 === s2) return 100;
if (s1.length === 0 || s2.length === 0) return 0;
// Build a matrix to track edit distances
const matrix: number[][] = [];
for (let i = 0; i <= s1.length; i++) { matrix[i] = [i]; }
for (let j = 0; j <= s2.length; j++) { matrix[0][j] = j; }
// Fill in the matrix with minimum edit costs
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost
);
}
}
// Normalize distance to a 0-100 percentage
const distance = matrix[s1.length][s2.length];
const maxLength = Math.max(s1.length, s2.length);
return Math.round((1 - distance / maxLength) * 100);
}
What does this code do?
The Levenshtein distance algorithm builds a matrix that tracks the cheapest way to transform one string into another, character by character.
At the end, the raw distance (number of edits) is divided by the longer string's length and flipped to a percentage. "hello" vs "helo" has a distance of 1, giving a score of 80%. A perfect match returns 100%.
Now add two helper functions below calculateSimilarity in the same file. These convert the numeric score into user-friendly colors and labels:
export function getFeedbackColor(score: number): string {
if (score >= 90) return '#22c55e';
if (score >= 60) return '#eab308';
return '#ef4444';
}
export function getFeedbackLabel(score: number): string {
if (score >= 90) return 'Excellent!';
if (score >= 60) return 'Close! Try again.';
return 'Keep practicing!';
}
What do the thresholds mean?
- 90% or above (green): the pronunciation was essentially correct.
- 60-89% (yellow): close enough that a small adjustment will fix it.
- Below 60% (red): the attempt was far off and needs another try.
- Save similarity.ts.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
export function calculateSimilarity(a: string, b: string): number {
const s1 = a.toLowerCase().trim();
const s2 = b.toLowerCase().trim();
if (s1 === s2) return 100;
if (s1.length === 0 || s2.length === 0) return 0;
const matrix: number[][] = [];
for (let i = 0; i <= s1.length; i++) { matrix[i] = [i]; }
for (let j = 0; j <= s2.length; j++) { matrix[0][j] = j; }
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost
);
}
}
const distance = matrix[s1.length][s2.length];
const maxLength = Math.max(s1.length, s2.length);
return Math.round((1 - distance / maxLength) * 100);
}
export function getFeedbackColor(score: number): string {
if (score >= 90) return '#22c55e';
if (score >= 60) return '#eab308';
return '#ef4444';
}
export function getFeedbackLabel(score: number): string {
if (score >= 90) return 'Excellent!';
if (score >= 60) return 'Close! Try again.';
return 'Keep practicing!';
}
Wire similarity scoring into the word card
Now you will replace the binary correct/incorrect check in WordCard.tsx with your new similarity scoring. This touches the imports, the comparison logic, and the feedback JSX.
- In client/src/components/WordCard.tsx, find this import line near the top:
import './WordCard.css';
- Add this new import directly above it:
import { calculateSimilarity, getFeedbackColor, getFeedbackLabel } from '../utils/similarity';
- Now find the comparison logic inside the component. Look for these lines:
const isCorrect = result ? result.transcript === word.word.toLowerCase() : null;
const handleSpeak = () => {
startListening();
setAttempts((prev) => prev + 1);
};
if (isCorrect && bestScore < 100) setBestScore(100);
- Replace that entire block with the following:
// Calculate similarity percentage instead of binary match
const score = result ? calculateSimilarity(result.transcript, word.word) : null;
const handleSpeak = () => { startListening(); setAttempts((prev) => prev + 1); };
// Track the best score across attempts
if (score !== null && score > bestScore) setBestScore(score);
What changed here?
Instead of a boolean isCorrect that is either true or false, you now get a numeric score from 0 to 100. The bestScore updates whenever a new attempt beats the previous high, not just when you get a perfect match.
- Next, find the feedback JSX block. Look for:
{result && (
<div className="feedback">
<p>You said: "{result.transcript}"</p>
<p>{isCorrect ? '✅ Correct!' : '❌ Try again'}</p>
</div>
)}
- Replace it with this color-coded feedback block:
{result && score !== null && (
<div className="feedback" style={{ borderColor: getFeedbackColor(score) }}>
<p className="feedback-label" style={{ color: getFeedbackColor(score) }}>
{getFeedbackLabel(score)}
</p>
<p className="feedback-score" style={{ color: getFeedbackColor(score) }}>
{score}%
</p>
<p className="feedback-transcript">
You said: "{result.transcript}"
</p>
</div>
)}
What does this feedback block do?
- The borderColor and text color change dynamically based on the score (green, yellow, or red).
- The label gives an encouraging message matching the score range.
- The percentage gives the learner a concrete target to beat on their next attempt.
- Save WordCard.tsx and refresh your browser.
- Click Speak and say a word. You should see a percentage score with a colored border instead of the old correct/incorrect badge.
Seeing a compilation error?
- Check that the import path is ../utils/similarity (not ./utils/similarity). The utils folder is a sibling of components, not inside it.
- Make sure you removed all references to isCorrect. If any remain, TypeScript will flag an undefined variable.
Still stuck? Help me debug my WordCard.tsx compilation error after adding similarity scoring.
Style the feedback display
The feedback is working, but the text inside needs proper sizing to make the score stand out.
- In client/src/components/WordCard.css, find the .feedback rule:
.feedback { margin: 1.5rem 0; padding: 1rem; border-radius: 12px; background: #fafafa; }
- Replace it with this version that adds a border:
.feedback { margin: 1.5rem 0; padding: 1rem; border-radius: 12px; border: 2px solid; background: #fafafa; }
- Now add these three new rules directly below .feedback to style the label, score, and transcript:
/* Large bold label for the feedback message */
.feedback-label { font-size: 1.25rem; font-weight: 700; margin: 0 0 0.25rem; }
/* Extra-large score percentage to draw attention */
.feedback-score { font-size: 2rem; font-weight: 800; margin: 0.25rem 0; }
/* Subtle transcript text below the score */
.feedback-transcript { font-size: 0.875rem; color: #6b7280; margin: 0.5rem 0 0; }
- Save WordCard.css and refresh your browser.
- Click Speak and say the word. The feedback section now shows a large percentage number, a bold label, and small transcript text, all tinted with the score color.
Border not showing a color?
- The border color is set by the inline style prop in JSX, not by CSS. Make sure your feedback div has style={{ borderColor: getFeedbackColor(score) }} on it.
- Confirm that the CSS .feedback rule includes border: 2px solid. Without a border width and style, the color has nothing to apply to.
Need more help? Why isn't my feedback border showing a color after I added the similarity scoring?
Test the full scoring loop
Everything is wired up. Time to test that the scoring, colors, and attempt tracking all work together.
- Refresh your browser to reset state.
- Navigate to a word like "beautiful" using the Next button.
Before you click Speak, think about this: if you deliberately say "bootiful" instead of "beautiful", do you expect to see green, yellow, or red feedback?
- Click Speak and say the word slightly wrong on purpose.
You should see a yellow or red result with a percentage below 90%, showing both what you said and how close it was. The attempts counter increments to 1.
- Now click Speak again and say the word correctly.
You should see green feedback with a score of 90% or higher. The best score updates to reflect your new high, and attempts shows 2.
Why might a perfect pronunciation still show 85%?
The Web Speech API sometimes transcribes words slightly differently than expected. For example, it might hear "beautiful" as "beautifull" or add a trailing space. The similarity function handles case and whitespace, but minor transcription quirks can shave off a few points.
✔️ Awesome, I've got everything!
Great. Double check you have saved both similarity.ts, WordCard.tsx, and WordCard.css.
ⓧ I'd like to double check the full code
Here is the complete similarity.ts:
export function calculateSimilarity(a: string, b: string): number {
const s1 = a.toLowerCase().trim();
const s2 = b.toLowerCase().trim();
if (s1 === s2) return 100;
if (s1.length === 0 || s2.length === 0) return 0;
const matrix: number[][] = [];
for (let i = 0; i <= s1.length; i++) { matrix[i] = [i]; }
for (let j = 0; j <= s2.length; j++) { matrix[0][j] = j; }
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost
);
}
}
const distance = matrix[s1.length][s2.length];
const maxLength = Math.max(s1.length, s2.length);
return Math.round((1 - distance / maxLength) * 100);
}
export function getFeedbackColor(score: number): string {
if (score >= 90) return '#22c55e';
if (score >= 60) return '#eab308';
return '#ef4444';
}
export function getFeedbackLabel(score: number): string {
if (score >= 90) return 'Excellent!';
if (score >= 60) return 'Close! Try again.';
return 'Keep practicing!';
}
For WordCard.tsx, scroll up and confirm these chunks are in place, in order:
- The import of calculateSimilarity, getFeedbackColor, and getFeedbackLabel from ../utils/similarity.
- The score variable using calculateSimilarity(result.transcript, word.word) instead of a boolean comparison.
- The bestScore update using score !== null && score > bestScore.
- The feedback JSX with inline style props using getFeedbackColor(score) and the three class names: feedback-label, feedback-score, feedback-transcript.
For WordCard.css, confirm the .feedback rule includes border: 2px solid and that the three new classes (.feedback-label, .feedback-score, .feedback-transcript) appear below it.
Your pronunciation trainer now gives real, actionable feedback. Instead of a flat pass or fail, learners see exactly how close they got and are motivated to try again. Next up, you will add a Listen button so users can hear the correct pronunciation before they attempt it.
Add Text-to-Speech Audio Examples
Your app now scores pronunciation attempts with helpful color-coded feedback. But there's a gap in the experience: users have no way to hear the correct pronunciation before they attempt to say the word.
The SpeechSynthesis API provides free text-to-speech directly in the browser. In this step, you'll add a Listen button that speaks the word aloud, completing the full practice loop: hear it, speak it, see your score.
In this step, get ready to:
- Create a useTextToSpeech hook that wraps the SpeechSynthesis API.
- Add a Listen button to the word card with a speaking indicator.
- Test the full pronunciation loop from listening to speaking to feedback.
Create the useTextToSpeech hook
The Web Speech API has two parts: SpeechRecognition (which you already used) and SpeechSynthesis for text-to-speech. You'll wrap SpeechSynthesis in a custom hook just like you did for recognition.
- Create a new file at client/src/hooks/useTextToSpeech.ts and add the following code:
import { useState, useCallback } from 'react';
interface UseTextToSpeechReturn {
isSpeaking: boolean;
speak: (text: string) => void;
}
export function useTextToSpeech(): UseTextToSpeechReturn {
const [isSpeaking, setIsSpeaking] = useState(false);
const speak = useCallback((text: string) => {
if (!window.speechSynthesis) {
return;
}
// Cancel any in-progress speech before starting new
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'en-US';
utterance.rate = 0.8;
utterance.onstart = () => setIsSpeaking(true);
utterance.onend = () => setIsSpeaking(false);
utterance.onerror = () => setIsSpeaking(false);
window.speechSynthesis.speak(utterance);
}, []);
return { isSpeaking, speak };
}
What does this code do?
- speechSynthesis.cancel() stops any speech currently playing so clicking Listen twice doesn't overlap.
- SpeechSynthesisUtterance represents a speech request. Setting rate to 0.8 slows the speech slightly so the pronunciation is clearer.
- The onstart/onend/onerror handlers track speaking state so the UI can show a visual indicator.
- Save useTextToSpeech.ts.
- Check your Vite terminal. You should see no errors (the file is valid TypeScript but nothing imports it yet).
Seeing a TypeScript error?
Make sure the file is inside client/src/hooks/ (the same folder as useSpeechRecognition.ts). Check that the file extension is .ts, not .tsx (this hook has no JSX).
Help me fix a TypeScript error in my useTextToSpeech hook.
Add the Listen button to WordCard
Now you'll wire the hook into the WordCard component and add a blue Listen button next to the existing Speak button.
- In WordCard.tsx, find the import section at the top of the file. Find this line:
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
- Add this import directly below it:
import { useTextToSpeech } from '../hooks/useTextToSpeech';
- Next, find the line inside the component where you call useSpeechRecognition:
const { isListening, result, error, startListening } = useSpeechRecognition();
- Add the text-to-speech hook and a new handler directly below it:
const { isSpeaking, speak } = useTextToSpeech();
const [attempts, setAttempts] = useState(0);
const [bestScore, setBestScore] = useState(0);
const score = result ? calculateSimilarity(result.transcript, word.word) : null;
const handleSpeak = () => { startListening(); setAttempts((prev) => prev + 1); };
const handleListen = () => { speak(word.word); };
What changed here?
You added the useTextToSpeech hook to get isSpeaking state and a speak function. The handleListen handler passes the current word to the speech engine.
- Now find the word-card-actions div in the JSX. It currently has only the Speak button:
<div className="word-card-actions">
<button className="btn btn-speak" onClick={handleSpeak} disabled={isListening}>
{isListening ? 'Listening...' : 'Speak'}
</button>
</div>
- Replace that entire block with this version that adds the Listen button before the Speak button:
<div className="word-card-actions">
<button className="btn btn-listen" onClick={handleListen} disabled={isSpeaking}>
{isSpeaking ? 'Speaking...' : 'Listen'}
</button>
<button className="btn btn-speak" onClick={handleSpeak} disabled={isListening}>
{isListening ? 'Listening...' : 'Speak'}
</button>
</div>
- Save WordCard.tsx.
The Listen button uses a btn-listen class that doesn't exist yet. You need to add it to the CSS.
- In WordCard.css, find the .btn-speak rule:
.btn-speak { background: #8b5cf6; color: white; }
- Add this new rule directly above it:
.btn-listen {
background: #3b82f6;
color: white;
}
- Save WordCard.css and refresh your browser.
- You should see a blue Listen button to the left of the purple Speak button on the word card.
Don't see the Listen button?
Check that your import path is exactly ../hooks/useTextToSpeech (no file extension needed). Also verify you placed the Listen button inside the word-card-actions div, before the Speak button.
Help me figure out why my Listen button isn't appearing.
Test the full pronunciation loop
The entire practice flow is now wired. Before you test it, think about what you expect to happen when you click Listen, then Speak.
- Click the Listen button on the word card.
- You should hear the browser speak the word aloud. The button text changes to "Speaking..." while the audio plays.
- Once the audio finishes, click the Speak button and say the word into your microphone.
- You should see color-coded feedback with your percentage score, just like before.
How do I try again?
Clicking Speak again automatically clears the previous result and starts a new attempt. The speech recognition hook resets its state every time it starts listening, so each click is a fresh try.
Try a harder word. Navigate to a medium or hard difficulty word using the Next button, listen to it first, then attempt to say it.
The full loop works: listen to the correct pronunciation, speak the word, see your score, and try again to improve. Your pronunciation trainer is complete.
Why SpeechSynthesis over audio files?
Pre-recorded audio files would require hosting and storage for every word. The SpeechSynthesis API generates speech on the fly from any text, which means you could add hundreds of words to your API without recording a single audio file.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both useTextToSpeech.ts, WordCard.tsx, and WordCard.css.
ⓧ I'd like to double check the full code
Here is the complete useTextToSpeech.ts file:
import { useState, useCallback } from 'react';
interface UseTextToSpeechReturn {
isSpeaking: boolean;
speak: (text: string) => void;
}
export function useTextToSpeech(): UseTextToSpeechReturn {
const [isSpeaking, setIsSpeaking] = useState(false);
const speak = useCallback((text: string) => {
if (!window.speechSynthesis) {
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'en-US';
utterance.rate = 0.8;
utterance.onstart = () => setIsSpeaking(true);
utterance.onend = () => setIsSpeaking(false);
utterance.onerror = () => setIsSpeaking(false);
window.speechSynthesis.speak(utterance);
}, []);
return { isSpeaking, speak };
}
For WordCard.tsx, scroll up and confirm you have:
- The useTextToSpeech import at the top.
- const { isSpeaking, speak } = useTextToSpeech(); inside the component.
- A handleListen function that calls speak(word.word).
- The Listen button with class btn-listen above the Speak button.
For WordCard.css, confirm you have the .btn-listen rule with background: #3b82f6 and color: white.
Secret mission
Add a Streak Counter and Session Stats
Your pronunciation trainer works, but there is no sense of progress across a session. Can you add a streak counter that rewards consecutive correct answers and a stats dashboard that tracks your performance over time, persisted to localStorage?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The learn-by-speaking project folder on your Desktop (contains client/ and server/ directories).
- Running dev servers (Vite frontend on port 5173 and Express backend on port 3001).
- localStorage session stats data stored under the key learn-by-speaking-stats in your browser.
Keep everything running
No action needed. Choose this if you are still actively building or want to continue practicing pronunciation.
- Your dev servers stay running until you close the terminal windows or press Ctrl+C.
- Your session stats persist in localStorage and will be there next time you open the app.
- Your project files remain on your Desktop, ready for further development.
Pause - I'll come back to this later
Stop the running servers to free up your terminal, but keep all files and data for later.
- Switch to the terminal running your frontend dev server and press Ctrl+C to stop it.
- Switch to the terminal running your backend server and press Ctrl+C to stop it.
- Your project files and localStorage stats will be waiting for you when you return.
Delete - I don't want to use this again
Remove all project files and data completely.
- Stop both running servers by pressing Ctrl+C in each terminal window.
- Delete the project folder by running this command from your Desktop:
macOS
cd ~/Desktop
rm -rf learn-by-speaking
Windows
cd ~\Desktop
rmdir /s /q learn-by-speaking
- Clear your localStorage session stats by opening your browser's developer console and running:
localStorage.removeItem('learn-by-speaking-stats')
- Confirm the project folder is gone by running ls on your Desktop. You should no longer see learn-by-speaking listed.
Nice Work!
Nice Work!
You just built a full-stack pronunciation trainer that literally listens to you speak and responds with instant feedback. That is a serious piece of interactive web development.
You've learned how to:
- Build a full-stack TypeScript application with a React frontend fetching structured data from an Express REST API.
- Integrate the Web Speech API for real-time speech recognition and text-to-speech audio playback directly in the browser.
- Implement intelligent pronunciation scoring using Levenshtein distance to give color-coded, percentage-based feedback instead of a binary pass/fail.
- Secret Mission: Added a streak counter and session statistics dashboard with localStorage persistence to track pronunciation accuracy over time.
Ready to quiz yourself?