browser
Why Browser Text-to-Speech Sounds Different on Every Machine
speechSynthesis is a thin wrapper over voices you did not install and cannot ship. The first getVoices() call legitimately returns an empty list, Chrome has required a user gesture since M71, long text dies around 15 seconds, and half the voices send your text to a server. What the spec guarantees, and what it does not.
You build a read-aloud feature. It works on your Mac. On the tester’s Windows box the voice is robotic, on their phone it is a different accent entirely, in Firefox the dropdown is empty on first paint, and on a long article it stops mid-sentence after about fifteen seconds. Every one of those is the API working as specified.
speechSynthesis is not a speech engine. It is a thin wrapper around voices installed on the user’s operating system, plus whatever cloud voices the browser vendor decides to add. You cannot ship a voice, you cannot depend on one being present, and you cannot assume the list is populated when your code runs. Here is what the Web Speech API specification actually promises, and where the browsers add their own rules on top.
The voice list is not yours, and it starts empty
The spec is explicit that an empty list is a legal answer, not a failure:
This method returns the available voices. It is user agent dependent which voices are available. If there are no voices available, or if the the list of available voices is not yet known (for example: server-side synthesis where the list is determined asynchronously), then this method must return a SpeechSynthesisVoiceList of length zero.
So getVoices() called at module scope will hand you [] on a cold load in most browsers, and your dropdown renders empty. The fix is the companion event:
voiceschanged event — Fired when the contents of the SpeechSynthesisVoiceList, that the getVoices method will return, have changed. Examples include: server-side synthesis where the list is determined asynchronously, or when client-side voices are installed/uninstalled.
Which means you must do both — call it once, and subscribe — because in the browsers that populate synchronously the event may never fire, and in the ones that do not, the first call is worthless:
const load = () => setVoices(speechSynthesis.getVoices())
load()
speechSynthesis.addEventListener('voiceschanged', load)
That is verbatim the pattern our text-to-speech tool uses, and it is the single most common bug in TTS integrations. Note the second half of the spec sentence too: the list can change while your page is open, because the user installed a language pack. Handle it as a live list, not a one-time fetch.
localService is the privacy switch, and the spec says so
Each SpeechSynthesisVoice carries a boolean that most integrations ignore:
localService — This attribute is true for voices supplied by a local speech synthesizer, and is false for voices supplied by a remote speech synthesizer service. (This may be useful because remote services may imply additional latency, bandwidth or cost, whereas local voices may imply lower quality, however there is no guarantee that any of these implications are true.)
Read that in privacy terms. localService === false means your text is sent to a server — the browser vendor’s, not yours — to be synthesized and streamed back. For a demo sentence that is nothing. For someone pasting a medical letter, a legal draft, or an unpublished manuscript into a “free text to speech” page, it is the entire question, and no page that only shows you voice names is answering it.
This is why our tool groups voices by language with local ones sorted first, labels the remote ones (network), and says so in the default text on the page rather than in a footnote. The tool itself uploads nothing — but the browser will, for any voice where localService is false, and no amount of client-side engineering changes that.
It is also why the voice list is a fingerprinting signal: the exact set of installed voices is a fairly distinctive property of a machine. Browser fingerprinting explained covers how signals like this combine, and the fingerprint tool shows you what your own browser exposes.
Chrome requires a user gesture (since M71)
If speak() silently does nothing on a page load or a timer, this is why. In a blink-dev “Intent to Remove” posted 13 September 2018, the Chrome team wrote that “the SpeechSynthesis API is actively being abused on the web. Since other autoplay avenues are starting to be closed, abuse is moving to the Web Speech API, which doesn’t follow autoplay rules.” Their sampling found roughly 65% of identifiable sites using it were full-page ads or deceptive software-install prompts.
The change deprecated in M70 and removed in M71: speak() now “immediately fire[s] a ‘not-allowed’ error if specific autoplay rules are not satisfied”, succeeding only if the current frame or an ancestor has previously received user activation. Safari on iOS is widely reported to behave the same way; unlike the Chrome change it is not written down anywhere authoritative, so treat “a gesture is required” as the portable assumption.
The practical consequence: speak() must be reachable from a click or tap, and the activation is per-frame, so a speak() inside an iframe that has never been clicked will fail even though the top page was. Listen for the error event and check error === 'not-allowed' rather than assuming silence means success.
Long text stops around 15 seconds
This one is not in any spec — it is a Chromium bug with a long tail, filed as issue 41294170 (“Speech Synthesis stops abruptly after about 15 seconds”, originally crbug 679437) and issue 41346274 (“speechSynthesis fails for long text without warning and blocks the API”). Playback simply stops mid-utterance, most reliably with non-local voices.
The workaround that circulates is a timer calling speechSynthesis.resume() every 14 seconds while speaking. It works, and it is a hack layered on a bug — it also fights with a legitimately paused utterance and leaves an interval running if your cleanup misses.
The sturdier fix is to never queue a long utterance in the first place. The spec has a matching error code for the honest version of this failure: "text-too-long", defined as “The contents of the SpeechSynthesisUtterance text attribute is too long to synthesize.” Chunk your text and queue the chunks. Our tool splits at 200 characters on sentence boundaries — packing whole sentences up to the limit, hard-splitting anything longer at a space, and never splitting a surrogate pair — then plays the chunks in sequence and highlights the current one. Sentence boundaries matter here beyond tidiness: each chunk is a separate utterance, so the engine re-plans intonation at every boundary, and a reset at a full stop sounds like punctuation rather than a glitch.
The rest of the error codes
The spec defines twelve, and they are more useful than the silence most integrations settle for:
| Code | Meaning (spec) |
|---|---|
canceled | cancel() was called before this utterance started |
interrupted | cancel() was called while it was speaking |
audio-busy | The audio output is unavailable right now |
audio-hardware | No usable audio output device |
network | Needed the network and could not reach it — a remote voice with no connection |
synthesis-unavailable | No engine available; the user may need to install one |
synthesis-failed | The engine errored |
language-unavailable | No voice for the utterance’s lang |
voice-unavailable | The specific voice requested is not available |
text-too-long | The text is too long to synthesize |
invalid-argument | The rate, pitch, or volume value is unsupported |
not-allowed | Not permitted to start in the current context — the user-activation case |
Notice how many describe conditions on the user’s machine. Nearly every failure mode of this API lives outside your code.
Rate, pitch, and where our sliders differ
The spec’s ranges: volume is 0 to 1 inclusive, default 1. pitch is 0 to 2 inclusive, default 1. rate is relative to the voice’s own default, where “2 is twice as fast, and 0.5 is half as fast”, and “values below 0.1 or above 10 are strictly disallowed” — plus a caveat worth quoting, that “speech synthesis engines or specific voices may constrain the minimum and maximum rates further, for example, a particular voice may not actually speak faster than 3 times normal even if you specify a value larger than 3.”
So rate = 6 is legal, frequently ignored, and unintelligible when it is not. Our tool caps both rate and pitch at 0.5–2.0 rather than exposing the full legal range, because the far ends of the spec’s ranges produce output nobody wants and voices honor them inconsistently anyway. If you need 4× playback for skimming, you want an audio player with a speed control, not a synthesizer.
Designing around it
| Assumption | Do this instead |
|---|---|
| ”I’ll pick voice X for everyone” | Enumerate at runtime, fall back by lang, then to the default voice (utterance.voice = null) |
| “The list is ready on load” | Call getVoices() and subscribe to voiceschanged; re-render on both |
| ”I’ll start speaking when the page loads” | Require a click; handle the not-allowed error |
”I’ll pass the whole article to speak()” | Chunk on sentence boundaries; queue the chunks |
| ”Speech is client-side, so it’s private” | Only when localService is true — say so in the UI |
| ”It stopped, so the user paused it” | Distinguish interrupted from canceled from synthesis-failed |
”speechSynthesis.cancel() on unmount is optional” | It is not — the queue is global to the window and outlives your component |
That last row is easy to miss: speechSynthesis is a property of window, not of your page’s lifecycle. Navigate away mid-utterance without cancelling and the voice keeps talking.
If you are working through browser-media APIs generally, why browser microphone access fails is the input-side counterpart — the same pattern of an API whose failures are mostly about permissions, policy, and hardware you cannot see — and the microphone test checks the other half of the audio path.