Typing is fine until the user is driving, cooking, walking, holding a phone with one hand, filling out a form on a tiny screen, or staring at a support box thinking, “I do not want to type this whole thing.”
That is where speech-to-text starts feeling less like a fancy AI feature and more like basic product mercy.
A user taps a microphone button. They say what they need. The app records the audio, sends it for transcription, and turns the result into clean text that can be searched, summarized, saved, routed, or used inside a workflow.
For a JavaScript app, there are a few ways to do this:
- Use the browser’s built-in Web Speech API for quick in-browser recognition.
- Use
MediaRecorderto capture audio and send it to a backend. - Use a speech-to-text API for more reliable transcripts.
- Use LLMAPI after transcription to clean, structure, summarize, or route the text.
- Store the transcript in a format your app can actually use.
In this guide, we’ll build a practical JavaScript speech-to-text workflow that lets users talk instead of type, then turns audio into clean transcripts for chat apps, note tools, support forms, search boxes, CRMs, meeting tools, and voice-enabled workflows.
The voice feature we are building
A speech-to-text feature usually has four parts.
| Part | What it does |
|---|---|
| Capture | Records microphone audio in the browser |
| Upload | Sends audio to your backend |
| Transcribe | Converts speech into text |
| Clean and use | Formats transcript for your app |
A simple flow looks like this:
user taps microphone
→ browser records audio
→ audio blob goes to backend
→ backend sends audio to transcription API
→ transcript comes back
→ LLMAPI cleans or structures the text
→ app shows usable text
That can power a lot of features:
| App | Speech-to-text use case |
|---|---|
| Chat app | Voice messages converted into text |
| Support tool | Spoken complaint becomes ticket text |
| Notes app | Dictated notes become searchable notes |
| CRM | Sales rep records call notes |
| Healthcare admin tool | Staff dictate non-diagnostic notes |
| Education app | Students answer by voice |
| Accessibility feature | Users speak instead of typing |
| Search app | Voice query becomes searchable text |
| Meeting app | Audio becomes transcript and action items |
The user sees a microphone button.
The product needs a clean audio pipeline behind it.
Browser speech recognition or API transcription?
JavaScript gives us two main paths.
| Path | How it works | Best for |
|---|---|---|
| Web Speech API | Browser handles recognition directly | Quick demos, simple voice commands, lightweight dictation |
| Audio upload + API | Browser records audio, backend sends it to STT provider | Production apps, stored transcripts, longer audio, cleaner workflow control |
The Web Speech API includes speech recognition and speech synthesis capabilities. MDN describes SpeechRecognition as the controller interface for the browser’s recognition service, while the broader Web Speech API includes both speech recognition and text-to-speech.
The API transcription path usually gives us more control. We can store audio, retry failed jobs, choose providers, request timestamps, apply diarization if supported, and run post-processing with LLMAPI.
For a serious product, we usually want the second path.
For a quick voice search or prototype, browser recognition can be enough.
Why we can write this guide
We’ve spent around 6 years working with AI APIs, speech-to-text workflows, JavaScript backends, browser recording, LLM post-processing, structured outputs, and developer tutorials. We also checked current documentation from MDN, OpenAI audio transcription docs, Deepgram speech-to-text docs, and LLMAPI docs while preparing this article.
A few docs matter here. MDN’s MediaStream Recording API documentation explains that MediaRecorder records media from a MediaStream and gives the recorded data back for processing. MDN’s Web Speech API docs describe in-browser recognition and speech synthesis, which is useful for lightweight voice interfaces. OpenAI’s speech-to-text docs describe transcription endpoints that accept audio files and return transcript formats, while its API reference lists common audio formats such as mp3, mp4, mpeg, mpga, m4a, ogg, wav, and webm. LLMAPI’s quick-start docs show an OpenAI-compatible API gateway pattern for chat completions, which makes it useful for transcript cleanup, formatting, summaries, and downstream text workflows.
The practical lesson is simple: the browser can capture audio, a speech-to-text model can transcribe it, and LLMAPI can help turn that raw transcript into app-ready text.
What a good transcript should return
A basic transcript is just text.
{
"text": "I need help because I was charged twice for my subscription."
}
That is useful, but many apps need more.
A better response might include:
{
"text": "I need help because I was charged twice for my subscription.",
"clean_text": "I need help because I was charged twice for my subscription.",
"intent": "billing_support",
"language": "en",
"confidence": 0.94,
"warnings": [],
"created_at": "2026-08-24T15:18:00-05:00"
}
For longer audio, add segments:
{
"text": "I tried uploading the file twice, but it failed both times.",
"segments": [
{
"start": 0.0,
"end": 2.4,
"text": "I tried uploading the file twice,"
},
{
"start": 2.4,
"end": 4.8,
"text": "but it failed both times."
}
]
}
A transcript becomes more useful when the app knows what to do with it.
The architecture we’ll use
We’ll use this setup:
| Layer | Responsibility |
|---|---|
| Frontend | Ask for mic permission, record audio, send file |
| Backend | Receive audio, validate file, call transcription API |
| Transcription provider | Turn speech into text |
| LLMAPI | Clean transcript, extract intent, create structured output |
| App database | Store transcript and metadata |
| UI | Show transcript, let user edit, continue workflow |
We’ll use JavaScript on both sides:
- Browser JavaScript for recording.
- Node.js and Express for the backend.
- LLMAPI for transcript cleanup and structured text output.
Step 1: Create the Node.js project
Create a new project:
mkdir javascript-speech-to-text
cd javascript-speech-to-text
npm init -y
Install packages:
npm install express multer dotenv openai cors
Add this to package.json so we can use import syntax:
{
"type": "module"
}
Create .env:
PORT=3000
LLMAPI_API_KEY=your_llmapi_key_here
LLMAPI_BASE_URL=https://api.llmapi.ai/v1
Keep the API key on the backend.
A microphone feature that leaks API keys to the browser is asking for trouble in surround sound.
Step 2: Add a basic Express server
Create server.js:
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
app.get("/health", (req, res) => {
res.json({
status: "ok"
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Speech-to-text server running on port ${port}`);
});
Run it:
node server.js
Check:
curl http://localhost:3000/health
Step 3: Record audio in the browser
The browser needs microphone permission.
We can use navigator.mediaDevices.getUserMedia() to request audio, then MediaRecorder to record it.
MDN explains that the MediaStream Recording API uses MediaRecorder to record media from a stream, while getUserMedia() can provide microphone input as a MediaStream.
Create index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>JavaScript Speech-to-Text Demo</title>
</head>
<body>
<h1>Speech-to-Text Demo</h1>
<button id="startBtn">Start recording</button>
<button id="stopBtn" disabled>Stop recording</button>
<p id="status">Ready.</p>
<h2>Transcript</h2>
<textarea id="transcript" rows="8" cols="80"></textarea>
<script src="./app.js"></script>
</body>
</html>
Create app.js:
let mediaRecorder;
let audioChunks = [];
const startBtn = document.querySelector("#startBtn");
const stopBtn = document.querySelector("#stopBtn");
const statusEl = document.querySelector("#status");
const transcriptEl = document.querySelector("#transcript");
startBtn.addEventListener("click", async () => {
audioChunks = [];
const stream = await navigator.mediaDevices.getUserMedia({
audio: true
});
mediaRecorder = new MediaRecorder(stream, {
mimeType: "audio/webm"
});
mediaRecorder.addEventListener("dataavailable", event => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
});
mediaRecorder.addEventListener("stop", async () => {
const audioBlob = new Blob(audioChunks, {
type: "audio/webm"
});
statusEl.textContent = "Uploading audio for transcription...";
const transcript = await uploadAudio(audioBlob);
transcriptEl.value = transcript.clean_text || transcript.text || "";
statusEl.textContent = "Done.";
stream.getTracks().forEach(track => track.stop());
});
mediaRecorder.start();
startBtn.disabled = true;
stopBtn.disabled = false;
statusEl.textContent = "Recording...";
});
stopBtn.addEventListener("click", () => {
mediaRecorder.stop();
startBtn.disabled = false;
stopBtn.disabled = true;
});
async function uploadAudio(audioBlob) {
const formData = new FormData();
formData.append("audio", audioBlob, "recording.webm");
const response = await fetch("http://localhost:3000/transcribe", {
method: "POST",
body: formData
});
if (!response.ok) {
throw new Error("Transcription request failed.");
}
return response.json();
}
Open index.html from a local dev server.
For example:
npx serve .
Browsers often require secure contexts for microphone access, so use localhost during development and HTTPS in production.
Step 4: Receive audio on the backend
Update server.js:
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import multer from "multer";
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 25 * 1024 * 1024
}
});
app.get("/health", (req, res) => {
res.json({
status: "ok"
});
});
app.post("/transcribe", upload.single("audio"), async (req, res) => {
if (!req.file) {
return res.status(400).json({
error: "Audio file is required."
});
}
return res.json({
status: "received",
filename: req.file.originalname,
mime_type: req.file.mimetype,
size_bytes: req.file.size
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Speech-to-text server running on port ${port}`);
});
Now the frontend can send recorded audio to the backend.
Next, we need transcription.
Step 5: Add a transcription function
The exact transcription endpoint depends on your chosen speech-to-text provider and the audio models available through your LLMAPI setup. The app architecture stays the same:
- Receive audio.
- Send audio to a speech-to-text endpoint.
- Get transcript text.
- Send transcript to LLMAPI for cleanup and structure.
- Return app-ready JSON.
A provider-style transcription helper can look like this.
Create transcriptionProvider.js:
export async function transcribeAudioFile(file) {
/*
Replace this function with your speech-to-text provider call.
Expected return shape:
{
text: "raw transcript text",
language: "en",
confidence: 0.94,
segments: []
}
*/
throw new Error("Connect your speech-to-text provider here.");
}
If your provider supports an OpenAI-compatible audio transcription endpoint, the request usually sends multipart/form-data with the audio file and model name. OpenAI’s audio docs describe transcription endpoints that take an audio file and return a transcript, and the API reference lists supported file formats including webm, which is useful because browsers often record WebM audio.
The backend should normalize the result into one internal shape even if you switch providers later.
{
"text": "I need help because my file upload keeps failing.",
"language": "en",
"confidence": null,
"segments": []
}
That keeps the rest of your app stable.
Step 6: Clean the transcript with LLMAPI
Raw transcripts can be messy.
Common issues:
- Missing punctuation
- Repeated words
- Filler words
- Wrong casing
- Weird line breaks
- Misheard product names
- Rambling user speech
- Half-finished sentences
LLMAPI can clean the transcript while preserving meaning.
Create llmapiClient.js:
import OpenAI from "openai";
export const llmapi = new OpenAI({
apiKey: process.env.LLMAPI_API_KEY,
baseURL: process.env.LLMAPI_BASE_URL || "https://api.llmapi.ai/v1"
});
Create cleanTranscript.js:
import { llmapi } from "./llmapiClient.js";
export async function cleanTranscript(rawTranscript) {
const response = await llmapi.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `
You clean speech-to-text transcripts for a JavaScript app.
Return only valid JSON:
{
"clean_text": "string",
"summary": "string",
"warnings": ["string"]
}
Rules:
- Preserve the user's meaning.
- Fix punctuation and obvious formatting issues.
- Do not invent details.
- Do not remove important uncertainty.
- Add warnings if the transcript is unclear or incomplete.
`
},
{
role: "user",
content: rawTranscript
}
],
temperature: 0
});
return JSON.parse(response.choices[0].message.content);
}
LLMAPI’s quick-start docs show an OpenAI-compatible chat completions pattern, which is why this client style works for text cleanup and post-processing.
Step 7: Connect transcription and cleanup
Update the /transcribe route:
import { transcribeAudioFile } from "./transcriptionProvider.js";
import { cleanTranscript } from "./cleanTranscript.js";
app.post("/transcribe", upload.single("audio"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
error: "Audio file is required."
});
}
const rawResult = await transcribeAudioFile(req.file);
const cleaned = await cleanTranscript(rawResult.text);
return res.json({
text: rawResult.text,
clean_text: cleaned.clean_text,
summary: cleaned.summary,
language: rawResult.language || null,
confidence: rawResult.confidence || null,
segments: rawResult.segments || [],
warnings: cleaned.warnings || []
});
} catch (error) {
console.error(error);
return res.status(500).json({
error: "Transcription failed."
});
}
});
Now the app receives both raw and cleaned transcript text.
That is important because raw transcript text is useful for debugging, while cleaned text is nicer for users.
Step 8: Add browser-only speech recognition for quick voice input
For quick voice commands or short dictation, the Web Speech API can work directly in the browser.
Browser support and behavior vary, so treat this as a lightweight option. MDN notes that the Web Speech API provides speech recognition and synthesis, and SpeechRecognition.start() starts the recognition service to listen for incoming audio.
Example:
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.log("SpeechRecognition is not supported in this browser.");
} else {
const recognition = new SpeechRecognition();
recognition.lang = "en-US";
recognition.interimResults = true;
recognition.continuous = false;
recognition.addEventListener("result", event => {
let transcript = "";
for (const result of event.results) {
transcript += result[0].transcript;
}
console.log(transcript);
});
recognition.addEventListener("end", () => {
console.log("Recognition ended.");
});
recognition.start();
}
This is good for:
- Search boxes
- Voice commands
- Short text fields
- Accessibility helpers
- Quick prototypes
- Browser-only demos
For production transcription that needs stored results, consistent formatting, long audio, timestamps, or provider choice, recording audio and sending it to your backend is usually easier to control.
Step 9: Add transcript intent detection
Once we have clean text, we can classify it.
Example use cases:
| Transcript | Intent |
|---|---|
| “I was charged twice” | billing_support |
| “The upload keeps failing” | technical_support |
| “Cancel my subscription” | cancellation |
| “Add this to my notes” | create_note |
| “Search for refund policy” | search_query |
| “Schedule a follow-up” | task_request |
Create classifyTranscript.js:
import { llmapi } from "./llmapiClient.js";
export async function classifyTranscript(cleanText) {
const response = await llmapi.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `
Classify the user's transcript.
Return only valid JSON:
{
"intent": "billing_support | technical_support | cancellation | create_note | search_query | task_request | other",
"urgency": "low | medium | high",
"entities": {
"product": null,
"date": null,
"amount": null
}
}
Rules:
- Use null when an entity is not clearly stated.
- Do not invent missing details.
`
},
{
role: "user",
content: cleanText
}
],
temperature: 0
});
return JSON.parse(response.choices[0].message.content);
}
Now a spoken message can become workflow data.
{
"intent": "billing_support",
"urgency": "medium",
"entities": {
"product": null,
"date": null,
"amount": null
}
}
This is where voice input becomes more than a text box.
Step 10: Return an app-ready response
Update the route again:
import { classifyTranscript } from "./classifyTranscript.js";
app.post("/transcribe", upload.single("audio"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
error: "Audio file is required."
});
}
const rawResult = await transcribeAudioFile(req.file);
const cleaned = await cleanTranscript(rawResult.text);
const classification = await classifyTranscript(cleaned.clean_text);
return res.json({
raw_text: rawResult.text,
clean_text: cleaned.clean_text,
summary: cleaned.summary,
language: rawResult.language || null,
confidence: rawResult.confidence || null,
segments: rawResult.segments || [],
intent: classification.intent,
urgency: classification.urgency,
entities: classification.entities,
warnings: cleaned.warnings || []
});
} catch (error) {
console.error(error);
return res.status(500).json({
error: "Transcription failed."
});
}
});
Example final response:
{
"raw_text": "i got charged twice for the pro plan and i need someone to help",
"clean_text": "I got charged twice for the Pro plan, and I need someone to help.",
"summary": "The user reports a duplicate Pro plan charge and wants help.",
"language": "en",
"confidence": 0.94,
"segments": [],
"intent": "billing_support",
"urgency": "medium",
"entities": {
"product": "Pro plan",
"date": null,
"amount": null
},
"warnings": []
}
Now the JavaScript app can:
- Fill a text box.
- Create a support ticket.
- Route the issue.
- Search docs.
- Save a note.
- Trigger a workflow.
- Ask follow-up questions.
That is the product value.
Step 11: Add user editing
Always let users edit transcripts.
Speech-to-text can mishear:
- Names
- Product terms
- Accents
- Numbers
- Email addresses
- Dates
- Technical words
- Noisy audio
- Brand names
- Acronyms
A good UI should show:
- Transcript preview.
- Edit button.
- Confirm button.
- Retry recording button.
- Warning when audio was unclear.
Example frontend behavior:
async function handleTranscriptResult(result) {
transcriptEl.value = result.clean_text || result.raw_text || "";
if (result.warnings?.length) {
statusEl.textContent = result.warnings.join(" ");
} else {
statusEl.textContent = "Transcript ready. Please review before submitting.";
}
}
Voice input should feel helpful, not bossy.
Users should stay in control of the final text.
Step 12: Add recording limits
Do not let users accidentally record a 90-minute monologue into a tiny form field.
Add limits:
| Limit | Why |
|---|---|
| Max recording time | Controls cost and latency |
| Max file size | Protects backend |
| Allowed MIME types | Avoids unsupported uploads |
| Silence timeout | Stops dead recordings |
| Retry limit | Avoids abuse |
| User quota | Controls usage |
| Workspace quota | Protects team plans |
Frontend max duration:
let maxRecordingTimer;
startBtn.addEventListener("click", async () => {
audioChunks = [];
const stream = await navigator.mediaDevices.getUserMedia({
audio: true
});
mediaRecorder = new MediaRecorder(stream, {
mimeType: "audio/webm"
});
mediaRecorder.start();
maxRecordingTimer = setTimeout(() => {
if (mediaRecorder.state === "recording") {
mediaRecorder.stop();
statusEl.textContent = "Recording stopped after the maximum duration.";
}
}, 60 * 1000);
});
stopBtn.addEventListener("click", () => {
clearTimeout(maxRecordingTimer);
mediaRecorder.stop();
});
Backend limits should still exist because frontend limits can be bypassed.
Step 13: Handle long audio differently
Short voice input can be handled immediately.
Long audio needs jobs.
Use async processing for:
- Meetings
- Interviews
- Podcasts
- Lectures
- Calls
- Voice notes over a few minutes
- Uploaded audio files
- Batch transcription
A long-audio API flow:
upload audio
→ create transcription job
→ return job_id
→ process in worker
→ save transcript
→ frontend polls job status
Job response:
{
"job_id": "job_123",
"status": "queued"
}
Status response:
{
"job_id": "job_123",
"status": "processing",
"progress": 48
}
Finished response:
{
"job_id": "job_123",
"status": "completed",
"transcript_url": "/transcripts/job_123"
}
Do this before users start uploading full podcast episodes into a synchronous route.
Step 14: Add timestamps when useful
Short dictation does not always need timestamps.
Longer audio often does.
Timestamps help with:
- Captions
- Meeting playback
- Call review
- Search results
- Audio clipping
- Podcast chapters
- QA workflows
- Evidence review
- Jump-to-moment UI
Segment format:
{
"segments": [
{
"start": 0.0,
"end": 3.2,
"text": "I need help with my subscription."
},
{
"start": 3.2,
"end": 7.1,
"text": "I think I was charged twice."
}
]
}
If your transcription provider supports timestamps, store them.
Even when you do not need them today, they are hard to recreate later.
Step 15: Add language handling
Voice apps often need language support.
At minimum, track:
- User-selected language
- Auto-detected language
- Transcript language
- Translation need
- Unsupported language warnings
Frontend language selector:
<label for="language">Language</label>
<select id="language">
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="uk">Ukrainian</option>
<option value="pl">Polish</option>
</select>
Send it with the audio:
const languageEl = document.querySelector("#language");
formData.append("language", languageEl.value);
Backend:
app.post("/transcribe", upload.single("audio"), async (req, res) => {
const language = req.body.language || "en";
const rawResult = await transcribeAudioFile(req.file, {
language
});
const cleaned = await cleanTranscript(rawResult.text);
res.json({
language,
raw_text: rawResult.text,
clean_text: cleaned.clean_text
});
});
Language metadata matters when you later add translation, multilingual search, or locale-specific formatting.
Step 16: Add a voice search feature
Speech-to-text works nicely for search.
Flow:
user speaks query
→ transcription
→ clean query
→ semantic or keyword search
→ results
Example transcript:
how do I get my money back if I was charged twice
LLMAPI can clean it into:
How do I get a refund if I was charged twice?
Then your search system can use that.
A voice search response:
{
"clean_text": "How do I get a refund if I was charged twice?",
"intent": "search_query",
"search_results": [
{
"title": "Refund policy",
"url": "/docs/refunds"
},
{
"title": "Duplicate charges",
"url": "/docs/duplicate-charges"
}
]
}
This is a great use case because users often speak search queries naturally.
They do not need perfect grammar. They need the app to understand the request.
Step 17: Add support ticket creation
Another useful workflow:
voice complaint
→ transcript
→ clean text
→ classify intent
→ create ticket draft
→ user reviews and submits
LLMAPI can generate a ticket draft:
export async function createTicketDraft(cleanText) {
const response = await llmapi.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `
Create a support ticket draft from the user's transcript.
Return only valid JSON:
{
"title": "string",
"description": "string",
"category": "billing | technical | account | other",
"priority": "low | medium | high",
"missing_information": ["string"]
}
Rules:
- Use only the transcript.
- Do not invent account details.
- Keep the title short.
`
},
{
role: "user",
content: cleanText
}
],
temperature: 0
});
return JSON.parse(response.choices[0].message.content);
}
Example:
{
"title": "Duplicate Pro plan charge",
"description": "The user says they were charged twice for the Pro plan and needs help resolving the billing issue.",
"category": "billing",
"priority": "medium",
"missing_information": [
"Invoice number",
"Payment date",
"Amount charged"
]
}
That is much more useful than dumping raw transcript text into a ticket field.
Step 18: Add transcript safety and privacy rules
Audio can contain sensitive information.
People say things they would never type carefully.
That can include:
- Names
- Emails
- Phone numbers
- Addresses
- Account details
- Payment details
- Health information
- Workplace issues
- Legal details
- Customer data
- Private background speech
Basic rules:
- Ask for microphone permission clearly.
- Show when recording is active.
- Let users stop recording easily.
- Do not record without an explicit user action.
- Keep API keys on the backend.
- Validate file type and size.
- Store audio only when needed.
- Delete temporary audio after transcription if storage is unnecessary.
- Avoid logging raw transcripts.
- Add redaction for sensitive workflows.
- Let users review before submitting.
- Follow local consent and recording laws.
A clear UI message:
Tap the microphone to record your message. We use the recording to create a transcript, and you can review the text before submitting.
Do not hide recording behind vague UI.
A microphone feature should be obvious.
Step 19: Add redaction when needed
For some apps, redact sensitive data before storing or sending downstream.
Simple redaction example:
export function redactBasicSensitiveText(text) {
return text
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
.replace(/\+?\d[\d\s().-]{7,}\d/g, "[phone]");
}
Use it before logs:
const safeLogText = redactBasicSensitiveText(cleaned.clean_text);
console.log({
event: "transcription_completed",
transcript_preview: safeLogText.slice(0, 120)
});
For serious redaction, use a PII detection system or dedicated data protection workflow.
Regex is a start, not a compliance strategy.
Step 20: Handle errors without making the user feel cursed
Speech-to-text can fail for boring reasons:
| Problem | Better user message |
|---|---|
| Mic permission denied | “Microphone access is blocked. Please allow microphone access and try again.” |
| No speech detected | “We could not detect speech. Please try speaking closer to the microphone.” |
| Audio too long | “This recording is too long. Please record a shorter message or upload a file.” |
| File too large | “The audio file is too large for this feature.” |
| Provider timeout | “Transcription is taking longer than expected. Please try again.” |
| Unsupported format | “This audio format is not supported.” |
| Low confidence | “The transcript may need review because the audio was unclear.” |
Backend error shape:
{
"error": {
"code": "NO_SPEECH_DETECTED",
"message": "We could not detect speech in this recording."
}
}
Good voice UX needs calm failure states.
A raw API error in a voice feature feels especially bad because the user already did the awkward thing of talking to a website.
Step 21: Save transcripts with metadata
Store enough metadata for debugging and product workflows.
Example:
{
"transcript_id": "tr_123",
"user_id": "user_456",
"source": "browser_microphone",
"language": "en",
"raw_text": "i got charged twice for the pro plan",
"clean_text": "I got charged twice for the Pro plan.",
"intent": "billing_support",
"duration_seconds": 6.4,
"provider": "speech_to_text_provider",
"llm_model": "gpt-4o-mini",
"created_at": "2026-08-24T15:18:00-05:00",
"warnings": []
}
Useful metadata:
- Transcript ID
- User ID or workspace ID
- Duration
- Language
- Audio source
- Provider
- Model
- Raw text
- Clean text
- Intent
- Warnings
- User edits
- Created date
User edits are especially useful.
If users keep correcting a product name, add custom vocabulary or better cleanup prompts.
Step 22: Use custom vocabulary where possible
Speech-to-text systems often struggle with:
- Brand names
- Product names
- Internal tools
- Acronyms
- People’s names
- Technical terms
- Industry jargon
If your provider supports custom vocabulary, use it.
Examples:
{
"custom_terms": [
"LLMAPI",
"Webhook",
"RAG",
"SAML",
"Spendbase",
"Parachute"
]
}
If provider-level vocabulary is unavailable, use LLMAPI cleanup after transcription.
For example:
If the transcript says "LM API" or "Ellem API" and the context is our product, normalize it to "LLMAPI".
Be careful with automatic corrections.
Only normalize terms when the context supports it.
Step 23: Add streaming later if needed
Some apps need live transcription.
Examples:
- Live captions
- Meeting transcription
- Real-time assistants
- Voice agents
- Call center tools
- Dictation with immediate feedback
Streaming is more complex because audio is sent in chunks while the user is still speaking.
A streaming flow:
microphone audio stream
→ websocket
→ transcription provider
→ partial transcript
→ final transcript segments
→ LLMAPI post-processing after final text
Deepgram’s materials describe real-time streaming speech-to-text, including endpointing behavior where the system detects pauses and finalizes results for a processed time range.
For most apps, start with recorded clips.
Add streaming when the product truly needs live behavior.
Recorded audio is much easier to debug.
Step 24: Common mistakes
| Mistake | Better approach |
|---|---|
| Sending API keys from the browser | Keep transcription calls on the backend |
| No transcript review | Let users edit before submitting |
| Recording without clear UI state | Show active recording status |
| No file size limit | Add frontend and backend limits |
| Treating all audio as short | Use jobs for long recordings |
| Ignoring browser support | Provide fallback paths |
| Storing raw audio forever | Use retention rules |
| Logging raw transcripts | Redact or avoid sensitive logs |
| No language handling | Let users select or detect language |
| No custom vocabulary | Add product terms where possible |
| Using raw transcript directly | Clean and structure it first |
| No error messages | Explain mic, audio, and provider issues clearly |
The most common product mistake is treating voice input like a normal text field with a microphone icon attached.
Voice needs recording states, review, correction, privacy, and failure handling.
Where LLMAPI helps
LLMAPI is useful after speech becomes text.
Use it for:
| Need | LLMAPI role |
|---|---|
| Transcript cleanup | Fix punctuation and formatting |
| Summary | Turn rambling speech into a short note |
| Intent detection | Route spoken requests |
| Entity extraction | Pull dates, amounts, names, products |
| Ticket drafts | Create support-ready text |
| Search queries | Clean spoken search |
| Meeting notes | Extract decisions and action items |
| Translation support | Prepare transcript for multilingual workflows |
| Review warnings | Flag unclear or incomplete transcripts |
| Product formatting | Convert transcript into app-ready JSON |
A strong voice workflow looks like this:
record audio
→ transcribe speech
→ clean transcript with LLMAPI
→ classify or structure text
→ let user review
→ save or trigger workflow
That makes the microphone button useful instead of decorative.
Closing notes for builders
Speech-to-text works best when the app respects how people actually speak.
People pause. They ramble. They restart sentences. They mispronounce product names. They say “uh” and “wait” and “actually forget that.” The transcript layer has to handle that mess without turning the user’s meaning into something too polished or wrong.
So build the feature in layers.
Use the browser to record audio. Send it to the backend. Transcribe with a speech-to-text provider. Use LLMAPI to clean, summarize, classify, or structure the result. Let users review the transcript before it becomes a ticket, note, search query, or workflow action.
When this is done well, the app feels easier to use.
The user speaks, the app listens, and the final text is clean enough to be useful.