cd /news/developer-tools/how-to-fix-punctuation-in-speech-to-… · home topics developer-tools article
[ARTICLE · art-106893] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

How to fix punctuation in speech-to-text and LLM output without calling a model

A developer built sentencify, a lightweight JavaScript library that adds punctuation to speech-to-text and LLM output without calling a model. The library uses ordered regular expressions to classify sentences as interrogative, exclamatory, or declarative, and supports six languages including English, Spanish, French, and Japanese. The developer chose a first-match rule approach for debuggability, though acknowledges it can be fragile.

read6 min views1 publishedAug 22, 2026

If you've ever wired up the Web Speech API and rendered the result straight to the page, you've seen this:

const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)()
recognition.lang = 'en-US'

recognition.onresult = (event) => {
    const transcript = event.results[0][0].transcript
    console.log(transcript)  // 'can you send me the report'
}

recognition.start()

The transcription is correct. The formatting isn't. No leading capital, no question mark. The same thing happens with streamed LLM tokens that get cut off before the final punctuation lands, and with any form field where users type fast and don't bother.

The instinctive fix is to send it back through a model. "Clean up this text." It works, and it's a strange amount of machinery for the problem: a network round trip, tokens billed, latency you can feel, and a nondeterministic result for a task that is almost entirely mechanical.

Capitalizing the first letter is trivial. The actual question is which mark goes at the end — and that turns out to be the only interesting part.

To punctuate a sentence you first have to know what kind of sentence it is:

can you send me the report

→ interrogative → ?

this is amazing

→ exclamatory → !

the meeting is at three

→ declarative → .

That's a three-way classification problem. And for unpunctuated English it's a problem with a lot of surface structure to exploit — interrogatives overwhelmingly start with an auxiliary or a wh-word, exclamatives have their own recognizable openers. You don't need a language model to notice can you

.

Which is what I ended up building. sentencify

is a small library that does the classification with ordered regular expressions, then applies the punctuation:

npm install sentencify
js
import { correctSentence } from 'sentencify'

correctSentence('can you send me the report')  // 'Can you send me the report?'
correctSentence('this is amazing')             // 'This is amazing!'
correctSentence('the meeting is at three')     // 'The meeting is at three.'

No await. No model. No dependencies.

If you're only shipping English, you could write the naive version yourself in an afternoon. The moment you add a second language, the assumptions break.

Spanish opens a question and closes it — ¿Cuál es tu nombre?

— so you can't just append to the end, you have to prepend too. French typography puts a space before ?

, !

, and :

Comment vas-tu ?

— and text that omits it looks wrong to a French reader in the way Hello ,world

looks wrong to an English one. Japanese uses

and

, not the ASCII marks.

correctSentence('cuál es tu nombre', 'es')     // '¿Cuál es tu nombre?'
correctSentence('comment vas-tu', 'fr')        // 'Comment vas-tu ?'
correctSentence('kannst du mir helfen', 'de')  // 'Kannst du mir helfen?'
correctSentence('すごい', 'ja')                  // 'すごい!'
correctSentence('qual é o seu nome', 'pt')     // 'Qual é o seu nome?'

Six languages currently: English, Japanese, German, Spanish, French, Portuguese.

Each language is an ordered array of rules:

type SentenceTypeDetectExpressionSets = {
    expression: RegExp
    type: 'exclamatory' | 'interrogative' | 'declarative'
}[]

detectSentenceType

walks the array top to bottom and returns the type of the first rule that matches. Nothing matches, it falls through to declarative.

This is worth being explicit about, because it's the main thing that makes the library easy to reason about and also the main thing that makes it fragile. First-match means a broad interrogative pattern placed above a narrow exclamatory one will silently swallow it. The bug is invisible until someone reports one specific sentence coming out wrong.

The alternative design is to score every match and take the highest confidence. More robust, considerably harder to debug when it misfires. I chose debuggable, and I'm not certain that was the right call.

Because it's debuggable, the rule sets are a public export rather than a hidden internal:

import { expressionsByLanguage } from 'sentencify'

// Read the actual ordered rules for a language and see which one fires
console.log(expressionsByLanguage.en)

When a sentence classifies wrong, you can find the exact rule responsible instead of filing an issue against a black box.

This is the part that actually matters for production use:

Deterministic. Same input, same output, forever. You can write assertions against it.

Synchronous. No promise, no warm-up, no cold start. Cheap enough to run on every keystroke in a controlled input, or on every token in a stream.

Idempotent. Already-punctuated text passes through untouched. You will not get Hello world..

by calling it twice, which matters when the call sits somewhere in a pipeline you don't fully control.

correctSentence('Already punctuated.')  // 'Already punctuated.' — unchanged

Offline. No network call means no failure mode where your text formatting goes down because a provider had an incident.

Small. Zero runtime dependencies, ESM-only, sideEffects: false

, under 40 KB unpacked.

It is not a grammar checker. It won't fix spelling, agreement, or word choice. It won't split a run-on into sentences. It classifies and punctuates, and that's the whole scope.

And regex rules will get sentences wrong that a model would get right. Indirect questions are the obvious failure class — I wonder if you could send the report

is declarative but reads interrogative to a naive pattern. That's the trade. You give up ceiling accuracy and you get determinism, speed, inspectability, and no bill.

For a lot of pipelines that's the right trade, because the text was going to be displayed unpunctuated otherwise.

The pattern I'd suggest is treating this as a finishing pass, not a replacement for anything:

It runs after the expensive thing has already happened, and it costs nothing.

Which makes the fix to the example this article opened with a one-line change:

import { correctSentence } from 'sentencify'

recognition.onresult = (event) => {
    const transcript = event.results[0][0].transcript
    const clean = correctSentence(transcript, recognition.lang)
    // 'Can you send me the report?'
}

Note that recognition.lang

is a full locale — 'en-US'

, 'fr-FR'

, 'ja-JP'

— and gets passed straight through. Both correctSentence

and isPunctuationAvailable

match on the first two characters, so locale variants resolve to the base language and you don't have to slice it yourself. Unsupported languages still get capitalized, they just skip punctuation.

In TypeScript the language

parameter is typed as a narrow union of the six supported codes, so a raw locale string needs a guard or a cast — isPunctuationAvailable(recognition.lang)

is there for exactly that check.

npm install sentencify

It's new, and the English rules have had the most attention. If you throw a sentence at it that classifies wrong, open an issue — misclassified sentences are the single most useful thing anyone can send me right now.

Adding a language is one file: the ordered rule array plus that language's punctuation conventions, no core changes. Arabic (؟

), Hindi (

), and Greek (;

) would all be interesting, since in each of them the question mark isn't ?

.

── more in #developer-tools 4 stories · sorted by recency
── more on @sentencify 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-fix-punctuati…] indexed:0 read:6min 2026-08-22 ·