Voice-Powered Interview Tool
Build a full-stack app that records voice answers and transcribes them with Whisper.
Introduction
30 Second Summary
Interviews often require long, thoughtful answers. Typing those out can feel slow and unnatural compared to just speaking your mind.
In this project, you will build a voice-powered interview tool that records spoken answers through the browser microphone and transcribes them into text using AI speech recognition running entirely on your machine.
What You'll Build
A full-stack web application that presents interview questions, captures voice responses, and uses OpenAI Whisper to transcribe them locally with high accuracy.
By the end of this project, you'll have:
- A Flask backend with a transcription API endpoint that accepts audio files and returns text.
- A browser-based frontend that records audio via the microphone and displays transcribed answers alongside interview questions.
- A complete interview flow with voice recording, transcription display, and a summary of all questions and answers.
- Secret Mission: Add a "Download Interview" button that exports all questions and answers as a formatted text file.
Are there any prerequisites?
You should have Python (3.9+), ffmpeg, and Visual Studio Code installed on your machine. You also need Chrome as your browser for microphone recording support.
Before We Start
Before We Start...
Set Up Your Python Environment
Before you can record audio and transcribe speech, your project needs two core packages: Flask for serving web pages and handling audio uploads, and OpenAI Whisper for converting speech to text locally on your machine.
In this step, you will create an isolated Python environment, install both packages, and download the AI model that powers transcription.
In this step, get ready to:
- Create and activate a Python virtual environment.
- Install the openai-whisper and Flask packages.
- Download the Whisper base English model for local transcription.
Create and activate a virtual environment
A virtual environment keeps your project's packages separate from other Python projects on your machine. This prevents version conflicts between projects.
- In VS Code, open a terminal by pressing Ctrl+` (the backtick key, top-left of your keyboard).
- Create a virtual environment by running this command:
macOS
python3 -m venv .venv
- Activate the virtual environment by running:
source .venv/bin/activate
Windows
py -3 -m venv .venv
- Activate the virtual environment by running:
.\.venv\Scripts\activate
- Confirm activation by checking that your terminal prompt now starts with (.venv).
What is a virtual environment?
A virtual environment is an isolated folder (.venv) where Python installs packages just for this project.
Without it, installing packages globally could break other projects that depend on different versions of the same library.
Don't see (.venv) in your prompt?
Make sure you ran the activate command (not just the create command). On Windows, check that your terminal is using PowerShell or Command Prompt, not Git Bash.
Help me activate my Python virtual environment.
Install the required packages
You need two packages: openai-whisper (the speech recognition model) and flask (the web server framework).
- Install both packages by running this command:
pip install openai-whisper flask
What does this install?
This command installs Whisper and Flask along with their dependencies. Whisper requires PyTorch (a machine learning framework), which is a large download of approximately 2GB.
The total installation may take several minutes depending on your internet speed. Let it run to completion.
- Verify that Whisper installed correctly by running:
python -c "import whisper; print(whisper.__version__)"
- Verify that Flask installed correctly by running:
python -c "import flask; print(flask.__version__)"
You should see version numbers printed for both commands without any errors.
Seeing an error during install?
If you see a "No matching distribution" error, confirm your virtual environment is activated (you should see (.venv) in your prompt) and that you are running Python 3.9 or newer.
If the install hangs on the PyTorch download, check your internet connection. The download is approximately 2GB.
Help me troubleshoot my pip install for whisper and flask.
Download the Whisper model
Whisper offers several model sizes. The base.en model is optimized for English and provides a good balance of speed, accuracy, and download size (approximately 140MB).
- Download and cache the model by running:
python -c "import whisper; whisper.load_model('base.en')"
What does this command do?
This downloads the base.en model file (approximately 140MB) and caches it in your user directory at ~/.cache/whisper/.
The model only downloads once. Future runs load it from the local cache, so startup will be much faster.
- Wait for the download to complete. You should see the Python prompt return without any errors.
Seeing an ffmpeg error?
Whisper requires ffmpeg to be installed on your system. If you see an error mentioning ffmpeg, confirm it is available by running ffmpeg -version in your terminal.
Help me fix my Whisper ffmpeg error.
Your Python environment is ready with all the tools you need. Next up, you will build the Flask backend that receives audio files and returns transcriptions.
Build the Flask Backend
Your Python environment is set up and ready to go.
Now it's time to build the server that powers your interview tool. Flask is a lightweight Python web framework that handles incoming requests and sends back responses. Your app needs two capabilities: serving an interview web page and processing audio files into text.
In this step, get ready to:
- Create the project folder structure with templates and static directories.
- Write a Flask app that loads the Whisper model and serves a web page.
- Build a /transcribe API endpoint that converts audio to text.
Create the project structure and Flask foundation
Before writing any Python code, you need folders for your web page files. Flask expects HTML templates in a templates folder and CSS/JavaScript in a static folder.
- Create both folders inside your voice-interview project by running these commands:
mkdir templates
mkdir static
- Confirm the folders exist by running:
ls
You should see templates and static listed alongside your .venv folder.
Don't see both folders?
Make sure your terminal is inside the voice-interview folder. Run cd voice-interview if you navigated away.
help me figure out why my folders weren't created
Now you'll create the main Python file that runs your server.
- Create a new file called app.py in the root of your voice-interview folder and add the following code:
import os
import tempfile
from flask import Flask, render_template, request, jsonify
import whisper
# Create the Flask app instance
app = Flask(__name__)
# Load the Whisper model once at startup so every request is fast
model = whisper.load_model("base.en")
# Serve the interview page
@app.route("/")
def index():
return render_template("index.html")
What does this code do?
- The imports pull in file handling (os, tempfile), Flask's web tools, and the Whisper speech recognition library.
- app = Flask(__name__) creates your web application.
- whisper.load_model("base.en") loads the English speech model into memory once, so transcription requests don't wait for model loading each time.
- The / route tells Flask to serve your interview HTML page when someone visits the site.
- Save app.py.
You won't see a visible change yet. The next chunk adds the transcription endpoint that processes audio.
Add the transcription endpoint
The core of your backend is a REST API endpoint that accepts audio file uploads and returns transcribed text as JSON.
- In app.py, add the following code below the index function:
# Accept audio uploads and return transcription text
@app.route("/transcribe", methods=["POST"])
def transcribe():
if "audio" not in request.files:
return jsonify({"error": "No audio file provided"}), 400
audio_file = request.files["audio"]
temp_path = None
try:
# Save uploaded audio to a temp file (Whisper needs a file path)
with tempfile.NamedTemporaryFile(delete=False, suffix=".webm") as temp:
audio_file.save(temp)
temp_path = temp.name
# Run Whisper transcription on the saved audio
result = model.transcribe(temp_path, language="en")
text = result["text"].strip()
return jsonify({"text": text})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
# Always clean up the temp file
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
if __name__ == "__main__":
app.run(debug=True)
What does this code do?
- The /transcribe route only accepts POST requests carrying an audio file.
- It saves the uploaded audio to a temporary .webm file because Whisper's transcribe() method requires a file path, not raw bytes.
- model.transcribe(temp_path, language="en") runs the speech-to-text conversion and returns a dictionary with a text key.
- The finally block deletes the temp file regardless of success or failure, preventing leftover files from piling up.
- if __name__ == "__main__" makes the file runnable with python app.py, starting the server in debug mode so it auto-reloads when you save changes.
- Save app.py.
Your backend is complete. The next substep tests that everything starts up correctly.
✔️ Awesome, I've got everything!
Great. Double check you've saved app.py with both the index route and the transcribe route.
ⓧ I'd like to double check the full code
Here is the complete app.py file for comparison:
import os
import tempfile
from flask import Flask, render_template, request, jsonify
import whisper
app = Flask(__name__)
model = whisper.load_model("base.en")
@app.route("/")
def index():
return render_template("index.html")
@app.route("/transcribe", methods=["POST"])
def transcribe():
if "audio" not in request.files:
return jsonify({"error": "No audio file provided"}), 400
audio_file = request.files["audio"]
temp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".webm") as temp:
audio_file.save(temp)
temp_path = temp.name
result = model.transcribe(temp_path, language="en")
text = result["text"].strip()
return jsonify({"text": text})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
if __name__ == "__main__":
app.run(debug=True)
Test the backend
With the code in place, it's time to confirm your server starts without errors.
- Start the Flask server by running this command:
python app.py
What should I see?
Your terminal will pause for a moment while the Whisper model loads into memory. After a few seconds, you should see output similar to:
* Serving Flask app 'app'
* Debug mode: on
* Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
This means your server is running and listening for requests.
- Confirm you see Running on http://127.0.0.1:5000 in the terminal output.
Seeing an error instead?
- If you see ModuleNotFoundError: No module named 'whisper', your virtual environment may not be activated. Run .\.\.venv\Scripts\activate and try again.
- If you see ModuleNotFoundError: No module named 'flask', run pip install flask inside your activated environment.
- If the terminal shows a SyntaxError, double check your app.py against the full code tab above for missing colons or indentation issues.
help me fix my Flask server startup error
Why load the model at startup?
Loading OpenAI Whisper's model takes a few seconds because it reads a large neural network into memory. By doing this once when the server starts (instead of on every request), each transcription call stays fast.
Your backend is up and running. Next up, you'll build the interview frontend that users will see in the browser.
Create the Interview Frontend
Your Flask backend can now receive audio and return transcriptions. The missing piece is what the user actually sees and interacts with in their browser.
In this step, you will build the interview UI: an HTML page with a question display, a microphone button, and an answer area. You will also add CSS styling to make it look polished.
In this step, get ready to:
- Create an HTML page with the full interview UI structure.
- Style the interface with CSS for a clean, interactive look.
- Create an empty JavaScript file and link all assets.
Create the interview HTML structure
The interview page needs a question display area, a microphone button to trigger recording, a textarea for the transcribed answer, and a hidden summary section that appears once all questions are answered.
- In VS Code's Explorer sidebar, right-click the templates folder and select New File.
- Name the file index.html.
- Paste the following code into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Voice Interview Tool</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Voice Interview</h1>
<div id="interview-section">
<div class="progress" id="progress">Question 1 of 5</div>
<div class="question-card">
<p class="question-label">Question:</p>
<p class="question-text" id="question-text"></p>
</div>
<div class="answer-section">
<div class="mic-row">
<button id="mic-btn" class="mic-btn" title="Click to record">
<span class="mic-icon">🎤</span>
</button>
<span id="rec-status" class="rec-status"></span>
</div>
<textarea id="answer-input" placeholder="Your answer will appear here after recording, or type manually..."></textarea>
</div>
<button id="next-btn" class="next-btn" disabled>Next Question</button>
</div>
<div id="summary-section" class="hidden">
<h2>Interview Summary</h2>
<div id="summary-list"></div>
<div class="summary-actions">
<button id="restart-btn" class="restart-btn">Start Over</button>
</div>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>
What does this HTML do?
The page is split into two main sections that swap visibility as the interview progresses.
- interview-section holds the active interview: a progress indicator, the current question card, a mic button for recording, a textarea for the answer, and a Next Question button.
- summary-section starts hidden (using the hidden class) and will display all questions with answers once the interview is complete.
- The CSS and JS files are linked at the top and bottom respectively, so Flask serves them from the static folder automatically.
- Save index.html.
- You won't see a styled page yet because the CSS and JS files don't exist. The next substep adds the styles.
Style the interview interface
The HTML structure is in place, but without styles it is just unstyled text. You will now add CSS to create a centered card layout, a prominent circular mic button that pulses red when recording, and clean typography.
- In VS Code's Explorer sidebar, right-click the static folder and select New File.
- Name the file style.css.
- Add the global reset and layout styles by pasting this code into style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 700px;
margin: 40px auto;
padding: 30px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #222;
}
.progress {
text-align: center;
font-size: 0.9rem;
color: #666;
margin-bottom: 20px;
}
.question-card {
background: #f9f9f9;
border-left: 4px solid #4a90d9;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.question-label {
font-size: 0.85rem;
color: #888;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.question-text {
font-size: 1.2rem;
color: #222;
}
.answer-section {
margin-bottom: 20px;
}
What does this CSS do?
- The * reset removes default browser margins and padding so spacing is predictable.
- .container creates a centered white card with rounded corners and a subtle shadow.
- .question-card uses a blue left border to visually highlight the current question.
- Add the interactive element styles (mic button, textarea, buttons, and summary) below the code you just pasted:
.mic-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.mic-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
background: #4a90d9;
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.mic-btn:hover {
background: #357abd;
}
.mic-btn.recording {
background: #e74c3c;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
.rec-status {
font-size: 0.9rem;
color: #e74c3c;
font-weight: 500;
}
textarea {
width: 100%;
min-height: 120px;
padding: 14px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
textarea:focus {
outline: none;
border-color: #4a90d9;
}
.next-btn {
display: block;
width: 100%;
padding: 14px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.next-btn:hover:not(:disabled) {
background: #357abd;
}
.next-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.hidden {
display: none;
}
#summary-section h2 {
margin-bottom: 20px;
text-align: center;
}
.summary-item {
background: #f9f9f9;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.summary-item .s-question {
font-weight: 600;
color: #4a90d9;
margin-bottom: 6px;
}
.summary-item .s-answer {
color: #333;
}
.summary-actions {
margin-top: 24px;
text-align: center;
}
.restart-btn {
padding: 12px 32px;
background: #666;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.restart-btn:hover {
background: #444;
}
What does this CSS do?
- .mic-btn creates a circular blue button. When the .recording class is added by JavaScript, it turns red and pulses with the @keyframes pulse animation.
- .next-btn:disabled grays out the button until an answer is provided, preventing the learner from skipping questions.
- .hidden is a utility class that hides the summary section until all questions are answered.
- Save style.css.
Styles not showing up?
- Make sure the file is saved at static/style.css (inside the static folder, not at the project root).
- Check that your HTML <link> tag uses href="/static/style.css" with the leading slash.
- Try a hard refresh with Ctrl+Shift+R (Windows) or Cmd+Shift+R (macOS) to clear cached styles.
Why isn't my CSS loading in Flask?
Create the JavaScript file
The HTML already links to static/script.js via a <script> tag. You need to create this file so the browser does not throw a 404 error. You will add the recording logic in the next step.
- In VS Code's Explorer sidebar, right-click the static folder and select New File.
- Name the file script.js.
- Leave it empty and save it.
- Open Chrome and navigate to http://127.0.0.1:5000.
You should see a styled white card in the center of the page. It has a Question 1 of 5 progress indicator, a blue question card (with no question text yet), a circular blue mic button, a textarea, and a grayed-out Next Question button.
Why is the question text empty?
The question text is populated by JavaScript, which you will write in the next step. For now, the page structure and styling are in place and ready to go.
Seeing a blank page or an error?
- Make sure your Flask server is still running in the terminal. If it stopped, run python app.py again.
- Confirm index.html is inside the templates folder (not static or the project root).
- Check the terminal for any error messages. A TemplateNotFound error means Flask cannot find your HTML file.
Help me fix my Flask frontend setup.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full content of each file at this point.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Voice Interview Tool</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Voice Interview</h1>
<div id="interview-section">
<div class="progress" id="progress">Question 1 of 5</div>
<div class="question-card">
<p class="question-label">Question:</p>
<p class="question-text" id="question-text"></p>
</div>
<div class="answer-section">
<div class="mic-row">
<button id="mic-btn" class="mic-btn" title="Click to record">
<span class="mic-icon">🎤</span>
</button>
<span id="rec-status" class="rec-status"></span>
</div>
<textarea id="answer-input" placeholder="Your answer will appear here after recording, or type manually..."></textarea>
</div>
<button id="next-btn" class="next-btn" disabled>Next Question</button>
</div>
<div id="summary-section" class="hidden">
<h2>Interview Summary</h2>
<div id="summary-list"></div>
<div class="summary-actions">
<button id="restart-btn" class="restart-btn">Start Over</button>
</div>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 700px;
margin: 40px auto;
padding: 30px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #222;
}
.progress {
text-align: center;
font-size: 0.9rem;
color: #666;
margin-bottom: 20px;
}
.question-card {
background: #f9f9f9;
border-left: 4px solid #4a90d9;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.question-label {
font-size: 0.85rem;
color: #888;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.question-text {
font-size: 1.2rem;
color: #222;
}
.answer-section {
margin-bottom: 20px;
}
.mic-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.mic-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
background: #4a90d9;
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.mic-btn:hover {
background: #357abd;
}
.mic-btn.recording {
background: #e74c3c;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
.rec-status {
font-size: 0.9rem;
color: #e74c3c;
font-weight: 500;
}
textarea {
width: 100%;
min-height: 120px;
padding: 14px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
textarea:focus {
outline: none;
border-color: #4a90d9;
}
.next-btn {
display: block;
width: 100%;
padding: 14px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.next-btn:hover:not(:disabled) {
background: #357abd;
}
.next-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.hidden {
display: none;
}
#summary-section h2 {
margin-bottom: 20px;
text-align: center;
}
.summary-item {
background: #f9f9f9;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.summary-item .s-question {
font-weight: 600;
color: #4a90d9;
margin-bottom: 6px;
}
.summary-item .s-answer {
color: #333;
}
.summary-actions {
margin-top: 24px;
text-align: center;
}
.restart-btn {
padding: 12px 32px;
background: #666;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.restart-btn:hover {
background: #444;
}
static/script.js should be an empty file.
Your interview page is styled and ready. Next up, you will add the JavaScript that records audio from the microphone and sends it to your Flask backend for transcription.
Add Voice Recording and Transcription
Your Flask backend is ready to receive audio and return transcriptions. Your frontend has a beautiful interface with a mic button, answer textarea, and question display. The missing piece is the JavaScript that connects them.
In this step, you will write the JavaScript that captures audio from the learner's microphone, sends it to your /transcribe endpoint, and displays the result. By the end, you will be able to speak an answer and see it transcribed in real time.
In this step, get ready to:
- Set up the interview questions and page state management.
- Implement microphone recording using the MediaRecorder API.
- Wire up the full interview flow with navigation and summary display.
Set up the questions and page logic
Before recording can work, the page needs to know what questions to display, which DOM elements to update, and how to track the interview state.
- In VS Code, open static/script.js (currently empty).
- Add the interview questions, state variables, DOM references, and the question-loading function by pasting this code:
const questions = [
"Tell me about yourself and your background.",
"What are your greatest strengths?",
"Describe a challenge you faced and how you overcame it.",
"Why are you interested in this role?",
"Where do you see yourself in five years?"
];
let currentQuestion = 0;
let answers = [];
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
const questionText = document.getElementById("question-text");
const progress = document.getElementById("progress");
const answerInput = document.getElementById("answer-input");
const micBtn = document.getElementById("mic-btn");
const recStatus = document.getElementById("rec-status");
const nextBtn = document.getElementById("next-btn");
const interviewSection = document.getElementById("interview-section");
const summarySection = document.getElementById("summary-section");
const summaryList = document.getElementById("summary-list");
const restartBtn = document.getElementById("restart-btn");
// Loads the current question into the UI and resets the answer area
function loadQuestion() {
questionText.textContent = questions[currentQuestion];
progress.textContent = `Question ${currentQuestion + 1} of ${questions.length}`;
answerInput.value = "";
nextBtn.disabled = true;
recStatus.textContent = "";
}
// Enable the Next button whenever the textarea has content
answerInput.addEventListener("input", function () {
nextBtn.disabled = answerInput.value.trim() === "";
});
What does this code do?
The questions array holds your five interview prompts. The let variables track which question the user is on, their answers so far, and the recording state.
The const references grab every DOM element the script needs to update. loadQuestion() displays the current question and clears the answer area. The input listener enables the Next Question button only when the textarea has content.
- Save script.js.
You will not see a change yet. The next chunk adds the recording logic that uses these variables.
Implement the microphone recording and transcription
The browser's MediaRecorder API lets you capture audio from the user's microphone. When the learner clicks the mic button, your code requests microphone access, starts recording, and then sends the audio to your Flask backend when they click again to stop.
- In script.js, add the mic button click handler and the transcription function below the code you just wrote:
micBtn.addEventListener("click", async function () {
if (!isRecording) {
try {
// Request microphone access from the browser
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
audioChunks = [];
// Collect audio data as it becomes available
mediaRecorder.ondataavailable = function (event) {
audioChunks.push(event.data);
};
// When recording stops, assemble chunks into a Blob and transcribe
mediaRecorder.onstop = async function () {
const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
stream.getTracks().forEach(function (track) {
track.stop();
});
await sendAudioForTranscription(audioBlob);
};
mediaRecorder.start();
isRecording = true;
micBtn.classList.add("recording");
recStatus.textContent = "Recording... Click to stop.";
} catch (err) {
recStatus.textContent = "Microphone access denied. Please allow access and try again.";
}
} else {
// Stop recording and trigger the onstop handler
mediaRecorder.stop();
isRecording = false;
micBtn.classList.remove("recording");
recStatus.textContent = "Transcribing...";
}
});
async function sendAudioForTranscription(audioBlob) {
const formData = new FormData();
formData.append("audio", audioBlob, "recording.webm");
try {
// Send the audio to the Flask /transcribe endpoint
const response = await fetch("/transcribe", {
method: "POST",
body: formData
});
if (!response.ok) {
throw new Error("Server error");
}
const data = await response.json();
if (data.error) {
recStatus.textContent = "Transcription failed: " + data.error;
return;
}
// Place the transcribed text in the answer textarea
answerInput.value = data.text;
nextBtn.disabled = false;
recStatus.textContent = "Transcription complete.";
} catch (err) {
recStatus.textContent = "Error connecting to server. Is it running?";
}
}
What does this code do?
The mic button handler toggles between two states. On the first click, it calls getUserMedia({ audio: true }) to request microphone permission, creates a MediaRecorder that records in audio/webm format, and starts capturing audio chunks.
On the second click, it stops the recorder. The onstop handler assembles the chunks into a single Blob and passes it to sendAudioForTranscription(). That function wraps the Blob in a FormData object and POSTs it to your /transcribe endpoint. When the response arrives, the transcribed text fills the answer textarea.
Why audio/webm?
Chrome's MediaRecorder defaults to the audio/webm format. Whisper (via ffmpeg) can decode webm directly, so no format conversion is needed between the browser and your Flask backend.
- Save script.js.
You will not see a change yet. The next chunk adds the interview navigation that initializes the page.
Add the interview flow logic
With recording and transcription working, the last piece is navigation between questions and a summary view at the end.
- In script.js, add the interview flow handlers below the sendAudioForTranscription function:
// Save current answer and advance to the next question
nextBtn.addEventListener("click", function () {
answers.push(answerInput.value.trim());
currentQuestion++;
if (currentQuestion < questions.length) {
loadQuestion();
} else {
showSummary();
}
});
// Display all questions and answers in the summary view
function showSummary() {
interviewSection.classList.add("hidden");
summarySection.classList.remove("hidden");
summaryList.innerHTML = "";
for (let i = 0; i < questions.length; i++) {
const item = document.createElement("div");
item.className = "summary-item";
item.innerHTML =
'<p class="s-question">' + questions[i] + "</p>" +
'<p class="s-answer">' + answers[i] + "</p>";
summaryList.appendChild(item);
}
}
// Reset everything and start the interview over
restartBtn.addEventListener("click", function () {
currentQuestion = 0;
answers = [];
summarySection.classList.add("hidden");
interviewSection.classList.remove("hidden");
loadQuestion();
});
// Initialize the first question on page load
loadQuestion();
What does this code do?
The nextBtn handler saves the current answer into the answers array, then either loads the next question or calls showSummary() when all questions have been answered.
The showSummary() function hides the interview UI and builds a list of question-answer pairs in the summary section. The restart handler resets the state and loads question one again. The final loadQuestion() call at the bottom initializes the page when it first loads.
- Save script.js.
- Switch to Chrome and refresh http://127.0.0.1:5000.
- You should see the first question ("Tell me about yourself and your background.") displayed in the question card, with the progress indicator showing "Question 1 of 5".
- Click the mic button. Chrome will ask for microphone permission. Click Allow.
- The button turns red and pulses, with "Recording... Click to stop." displayed beside it.
- Speak a short sentence, then click the mic button again to stop.
- After a few seconds, your spoken words appear in the answer textarea.
How long does transcription take?
The base.en model typically transcribes a 10-second clip in 2-5 seconds on most machines. You will see "Transcribing..." while Whisper processes the audio.
Mic button not working?
- If you see "Microphone access denied", click the camera/mic icon in Chrome's address bar and change the permission to Allow. Then refresh the page and try again.
- If you see "Error connecting to server. Is it running?", make sure your Flask server is still running in the terminal. Restart it with python app.py if needed.
- If transcription returns empty text, speak more clearly and loudly. Short recordings under 1 second sometimes produce no output.
Help me debug my voice recording and transcription setup.
✔️ Awesome, I've got everything!
Great. Double check you have saved script.js and that your recording and transcription are working before moving on.
ⓧ I'd like to double check the full code
Here is the complete static/script.js file at this point:
const questions = [
"Tell me about yourself and your background.",
"What are your greatest strengths?",
"Describe a challenge you faced and how you overcame it.",
"Why are you interested in this role?",
"Where do you see yourself in five years?"
];
let currentQuestion = 0;
let answers = [];
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
const questionText = document.getElementById("question-text");
const progress = document.getElementById("progress");
const answerInput = document.getElementById("answer-input");
const micBtn = document.getElementById("mic-btn");
const recStatus = document.getElementById("rec-status");
const nextBtn = document.getElementById("next-btn");
const interviewSection = document.getElementById("interview-section");
const summarySection = document.getElementById("summary-section");
const summaryList = document.getElementById("summary-list");
const restartBtn = document.getElementById("restart-btn");
function loadQuestion() {
questionText.textContent = questions[currentQuestion];
progress.textContent = `Question ${currentQuestion + 1} of ${questions.length}`;
answerInput.value = "";
nextBtn.disabled = true;
recStatus.textContent = "";
}
answerInput.addEventListener("input", function () {
nextBtn.disabled = answerInput.value.trim() === "";
});
micBtn.addEventListener("click", async function () {
if (!isRecording) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
audioChunks = [];
mediaRecorder.ondataavailable = function (event) {
audioChunks.push(event.data);
};
mediaRecorder.onstop = async function () {
const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
stream.getTracks().forEach(function (track) {
track.stop();
});
await sendAudioForTranscription(audioBlob);
};
mediaRecorder.start();
isRecording = true;
micBtn.classList.add("recording");
recStatus.textContent = "Recording... Click to stop.";
} catch (err) {
recStatus.textContent = "Microphone access denied. Please allow access and try again.";
}
} else {
mediaRecorder.stop();
isRecording = false;
micBtn.classList.remove("recording");
recStatus.textContent = "Transcribing...";
}
});
async function sendAudioForTranscription(audioBlob) {
const formData = new FormData();
formData.append("audio", audioBlob, "recording.webm");
try {
const response = await fetch("/transcribe", {
method: "POST",
body: formData
});
if (!response.ok) {
throw new Error("Server error");
}
const data = await response.json();
if (data.error) {
recStatus.textContent = "Transcription failed: " + data.error;
return;
}
answerInput.value = data.text;
nextBtn.disabled = false;
recStatus.textContent = "Transcription complete.";
} catch (err) {
recStatus.textContent = "Error connecting to server. Is it running?";
}
}
nextBtn.addEventListener("click", function () {
answers.push(answerInput.value.trim());
currentQuestion++;
if (currentQuestion < questions.length) {
loadQuestion();
} else {
showSummary();
}
});
function showSummary() {
interviewSection.classList.add("hidden");
summarySection.classList.remove("hidden");
summaryList.innerHTML = "";
for (let i = 0; i < questions.length; i++) {
const item = document.createElement("div");
item.className = "summary-item";
item.innerHTML =
'<p class="s-question">' + questions[i] + "</p>" +
'<p class="s-answer">' + answers[i] + "</p>";
summaryList.appendChild(item);
}
}
restartBtn.addEventListener("click", function () {
currentQuestion = 0;
answers = [];
summarySection.classList.add("hidden");
interviewSection.classList.remove("hidden");
loadQuestion();
});
loadQuestion();
Your interview tool now records voice, transcribes it with Whisper, and displays the result. Next up, you will complete the full interview flow with progress tracking, a summary screen, and error handling to make the experience polished.
Complete the Interview Flow
You have recording, transcription, question navigation, and a summary view all wired up across your files.
Now it is time to verify the full interview experience works end-to-end. You will test the progress indicator, the complete question flow with voice answers, error handling, and the restart button.
In this step, get ready to:
- Verify the progress indicator and Next button behavior.
- Complete a full interview and confirm the summary view.
- Test error handling for microphone denial and server issues.
Test the progress indicator and button states
The interview tracks which question the user is on and prevents advancing without an answer. Your loadQuestion() function handles this by updating the progress text, clearing the textarea, and disabling the Next button each time a new question loads.
- Make sure your Flask server is still running. If it stopped, start it again by running:
python app.py
- Open http://127.0.0.1:5000 in Chrome.
- Confirm the page displays "Question 1 of 5" at the top of the question card.
- Try clicking the Next Question button. It should be greyed out and unclickable because no answer has been provided.
- Type any text into the answer textarea. The Next Question button should turn blue and become clickable.
- Click Next Question. The progress should update to "Question 2 of 5" and a new question should appear.
How does the button disabling work?
The answerInput event listener checks whether the textarea is empty every time you type. If it is empty, nextBtn.disabled stays true. When transcription completes, the code explicitly sets nextBtn.disabled = false so the learner can advance.
Button stays greyed out after typing?
Make sure you saved script.js after your last edit. Unsaved changes mean the browser is running an older version of your code.
Also check that the answerInput.addEventListener("input", ...) block is present in your file. This is what watches for typing and enables the button.
Help me debug why my Next Question button won't enable.
Run through a full interview and check the summary
The real test is completing all five questions and seeing the summary appear. The showSummary() function hides the interview section and renders every question-answer pair.
- Refresh the page to start fresh.
- For each of the five questions, click the mic button, speak a short answer, and click the mic again to stop recording.
- Wait for the transcription to appear in the textarea, then click Next Question.
- After answering the fifth question, the interview UI should disappear and the Interview Summary section should appear.
- Confirm all five questions are listed alongside your transcribed answers.
- Click the Start Over button. The summary should disappear and you should return to Question 1 with a blank textarea.
What does Start Over actually reset?
The restartBtn click handler sets currentQuestion back to 0, empties the answers array, hides the summary section, shows the interview section, and calls loadQuestion() to reload the first question. This gives the respondent a completely fresh start.
Summary not showing after the last question?
Check the nextBtn click handler in script.js. After incrementing currentQuestion, it should compare against questions.length. If the current question equals the array length, showSummary() is called.
Also verify that your summary-section div exists in index.html with the hidden class applied initially.
Help me figure out why my interview summary doesn't appear.
Test the error handling
A good user experience means showing clear messages when things go wrong. Your code handles three failure scenarios: microphone access denied, server unreachable, and transcription errors.
- Test the mic-denied scenario: click the mic button, and if Chrome asks for microphone permission, click Block. You should see the message "Microphone access denied. Please allow access and try again." next to the mic button.
- To reset Chrome's mic permission, click the lock icon (or tune icon) in the address bar, find Microphone, and set it back to Allow. Refresh the page.
- Test the server-down scenario: stop your Flask server by pressing Ctrl+C in the terminal. Then record and stop a voice clip. You should see "Error connecting to server. Is it running?" appear.
- Start your server again by running:
python app.py
- Confirm that recording and transcription work normally again after restarting the server.
Where do these error messages come from?
The mic-denied message lives in the catch (err) block inside the mic button's click handler. If getUserMedia throws (because the user blocked permission), the catch sets recStatus.textContent to the friendly message.
The server-error message comes from the catch (err) block inside sendAudioForTranscription(). When fetch cannot reach the server, it throws a network error that the catch block handles gracefully.
Not seeing error messages when you block the mic?
Chrome may have cached your previous "Allow" decision. Click the lock or tune icon in the address bar, set Microphone to Block, then refresh the page and try clicking the mic button again.
Help me test my mic permission error handling in Chrome.
Now let's confirm your code matches the final version. All the features you just tested are already in your files from the previous steps.
✔️ Awesome, I've got everything!
Great. Double check that you have saved all your files.
ⓧ I'd like to double check the full code
Here is the complete script.js file with all interview flow logic:
const questions = [
"Tell me about yourself and your background.",
"What are your greatest strengths?",
"Describe a challenge you faced and how you overcame it.",
"Why are you interested in this role?",
"Where do you see yourself in five years?"
];
let currentQuestion = 0;
let answers = [];
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
const questionText = document.getElementById("question-text");
const progress = document.getElementById("progress");
const answerInput = document.getElementById("answer-input");
const micBtn = document.getElementById("mic-btn");
const recStatus = document.getElementById("rec-status");
const nextBtn = document.getElementById("next-btn");
const interviewSection = document.getElementById("interview-section");
const summarySection = document.getElementById("summary-section");
const summaryList = document.getElementById("summary-list");
const restartBtn = document.getElementById("restart-btn");
function loadQuestion() {
questionText.textContent = questions[currentQuestion];
progress.textContent = `Question ${currentQuestion + 1} of ${questions.length}`;
answerInput.value = "";
nextBtn.disabled = true;
recStatus.textContent = "";
}
answerInput.addEventListener("input", function () {
nextBtn.disabled = answerInput.value.trim() === "";
});
micBtn.addEventListener("click", async function () {
if (!isRecording) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: "audio/webm" });
audioChunks = [];
mediaRecorder.ondataavailable = function (event) {
audioChunks.push(event.data);
};
mediaRecorder.onstop = async function () {
const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
stream.getTracks().forEach(function (track) {
track.stop();
});
await sendAudioForTranscription(audioBlob);
};
mediaRecorder.start();
isRecording = true;
micBtn.classList.add("recording");
recStatus.textContent = "Recording... Click to stop.";
} catch (err) {
recStatus.textContent = "Microphone access denied. Please allow access and try again.";
}
} else {
mediaRecorder.stop();
isRecording = false;
micBtn.classList.remove("recording");
recStatus.textContent = "Transcribing...";
}
});
async function sendAudioForTranscription(audioBlob) {
const formData = new FormData();
formData.append("audio", audioBlob, "recording.webm");
try {
const response = await fetch("/transcribe", {
method: "POST",
body: formData
});
if (!response.ok) {
throw new Error("Server error");
}
const data = await response.json();
if (data.error) {
recStatus.textContent = "Transcription failed: " + data.error;
return;
}
answerInput.value = data.text;
nextBtn.disabled = false;
recStatus.textContent = "Transcription complete.";
} catch (err) {
recStatus.textContent = "Error connecting to server. Is it running?";
}
}
nextBtn.addEventListener("click", function () {
answers.push(answerInput.value.trim());
currentQuestion++;
if (currentQuestion < questions.length) {
loadQuestion();
} else {
showSummary();
}
});
function showSummary() {
interviewSection.classList.add("hidden");
summarySection.classList.remove("hidden");
summaryList.innerHTML = "";
for (let i = 0; i < questions.length; i++) {
const item = document.createElement("div");
item.className = "summary-item";
item.innerHTML =
'<p class="s-question">' + questions[i] + "</p>" +
'<p class="s-answer">' + answers[i] + "</p>";
summaryList.appendChild(item);
}
}
restartBtn.addEventListener("click", function () {
currentQuestion = 0;
answers = [];
summarySection.classList.add("hidden");
interviewSection.classList.remove("hidden");
loadQuestion();
});
loadQuestion();
And here is the complete app.py:
import os
import tempfile
from flask import Flask, render_template, request, jsonify
import whisper
app = Flask(__name__)
model = whisper.load_model("base.en")
@app.route("/")
def index():
return render_template("index.html")
@app.route("/transcribe", methods=["POST"])
def transcribe():
if "audio" not in request.files:
return jsonify({"error": "No audio file provided"}), 400
audio_file = request.files["audio"]
temp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".webm") as temp:
audio_file.save(temp)
temp_path = temp.name
result = model.transcribe(temp_path, language="en")
text = result["text"].strip()
return jsonify({"text": text})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
if __name__ == "__main__":
app.run(debug=True)
Here is templates/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Voice Interview Tool</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Voice Interview</h1>
<div id="interview-section">
<div class="progress" id="progress">Question 1 of 5</div>
<div class="question-card">
<p class="question-label">Question:</p>
<p class="question-text" id="question-text"></p>
</div>
<div class="answer-section">
<div class="mic-row">
<button id="mic-btn" class="mic-btn" title="Click to record">
<span class="mic-icon">🎤</span>
</button>
<span id="rec-status" class="rec-status"></span>
</div>
<textarea id="answer-input" placeholder="Your answer will appear here after recording, or type manually..."></textarea>
</div>
<button id="next-btn" class="next-btn" disabled>Next Question</button>
</div>
<div id="summary-section" class="hidden">
<h2>Interview Summary</h2>
<div id="summary-list"></div>
<div class="summary-actions">
<button id="restart-btn" class="restart-btn">Start Over</button>
</div>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>
And finally static/style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 700px;
margin: 40px auto;
padding: 30px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #222;
}
.progress {
text-align: center;
font-size: 0.9rem;
color: #666;
margin-bottom: 20px;
}
.question-card {
background: #f9f9f9;
border-left: 4px solid #4a90d9;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.question-label {
font-size: 0.85rem;
color: #888;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.question-text {
font-size: 1.2rem;
color: #222;
}
.answer-section {
margin-bottom: 20px;
}
.mic-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.mic-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
background: #4a90d9;
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.mic-btn:hover {
background: #357abd;
}
.mic-btn.recording {
background: #e74c3c;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
.rec-status {
font-size: 0.9rem;
color: #e74c3c;
font-weight: 500;
}
textarea {
width: 100%;
min-height: 120px;
padding: 14px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
textarea:focus {
outline: none;
border-color: #4a90d9;
}
.next-btn {
display: block;
width: 100%;
padding: 14px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.next-btn:hover:not(:disabled) {
background: #357abd;
}
.next-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.hidden {
display: none;
}
#summary-section h2 {
margin-bottom: 20px;
text-align: center;
}
.summary-item {
background: #f9f9f9;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.summary-item .s-question {
font-weight: 600;
color: #4a90d9;
margin-bottom: 6px;
}
.summary-item .s-answer {
color: #333;
}
.summary-actions {
margin-top: 24px;
text-align: center;
}
.restart-btn {
padding: 12px 32px;
background: #666;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.restart-btn:hover {
background: #444;
}
Your voice-powered interview tool is fully functional. You have a complete loop: ask a question, record a voice answer, transcribe it, move to the next question, and review everything at the end. Next up, you will extend this project with a bonus feature in the Secret Mission.
Secret mission
Download Interview Summary
Your interview tool captures great responses, but they disappear when you close the browser. Add a Download Interview button that exports all questions and answers as a formatted text file, plus a Copy to Clipboard button for quick sharing.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your resources on your machine or delete them entirely.
Resources you used:
- Project folder (voice-interview) containing your Flask app, HTML, CSS, JS, and Python virtual environment.
- Downloaded Whisper model (~140MB) cached at ~/.cache/whisper/ (macOS/Linux) or C:\Users\<username>\.cache\whisper\ (Windows).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with the interview tool or want to tweak the questions later.
- Your project stays on your machine ready to run anytime with python app.py.
- The Whisper model cache stays on disk so future runs start instantly without re-downloading.
- The virtual environment keeps your Python packages isolated from other projects.
Pause - I'll come back to this later
Stop the running Flask server to free up your terminal. Everything else stays in place for when you return.
- Switch to the terminal where Flask is running.
- Press Ctrl+C to stop the server.
- When you come back, reactivate your virtual environment and run python app.py to start again.
Delete - I don't want to use this again
Remove all project files and optionally clear the cached Whisper model to reclaim disk space.
- Stop the Flask server by pressing Ctrl+C in the terminal.
- Deactivate your virtual environment by running:
deactivate
- Delete the project folder on macOS/Linux by running:
cd ~/Desktop
rm -rf voice-interview
- On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force voice-interview
- To also clear the cached Whisper model (~140MB), run this on macOS/Linux:
rm -rf ~/.cache/whisper
- On Windows, run this instead:
Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\whisper"
Nice Work!
Nice Work!
You just built a full-stack voice-powered interview tool from scratch. Your app captures spoken answers in the browser, transcribes them locally with AI, and presents a complete interview summary.
You've learned how to:
- Built a Flask web server with a /transcribe API endpoint that accepts audio uploads and returns transcription text as JSON.
- Captured microphone audio in the browser using the MediaRecorder API and sent it to your Python backend for processing.
- Transcribed speech locally using OpenAI Whisper with no API costs or cloud dependencies.
- Secret Mission: Added a Download Interview button that exports all questions and answers as a formatted text file.
Ready to quiz yourself?