Engineering story · Helix

Saying numbers out loud

A chemical formula came out as a sneeze and a thousand as ten thousand; a speech contract and a deterministic scrubber give the voice speakable text while the screen keeps the written answer

Rust voice engineSay-and-show splitProperty-tested clause splitter
One answer · two forms
Screen
Voice
rewritten only on the way to the synthesiser; subtitles and history keep what the model wrote
2layers between model and speaker
  • ≈800lines in the deterministic scrubber
  • 500character ceiling on any clause, set by the synthesiser's own limits

A reasoning model writes for a screen. It writes "H₂O", "8.832", "abcdef.md", "U.S." and "1000", and every one of those is a trap for a synthesiser that speaks phonemes. In real use the formula for water came out as a sneeze, a thousand was spoken as ten thousand, a filename was read as a word, and the abbreviation for the country became "u dot s". Each is small. Together they are the difference between an assistant that sounds like a colleague and one that sounds like a machine reading a document.

Two layers

The first layer is a speech contract in the prompt. The model follows it most of the time, and most of the time is not enough for speech.

The second layer is a deterministic scrubber that runs on every clause after the splitter and before synthesis. What a person reads is what the model wrote, and what they hear is what a person would say.

From the model
After layer 1
the speech contract in the prompt, followed most of the time
After layer 2
the deterministic scrubber, every clause, every time
speakable clause
a symbol, a large number, a path, an abbreviation, an aside
Every real failure became a scrubber rule with a test. Clause counts are illustrative.

I pair a contract the model is asked to follow with a deterministic layer that does not rely on it following.

Asides are never spoken

When a person interrupts and the agent's answer is cut off, the engine keeps the answer as the room heard it, so the model treats the interrupted question as addressed and answers only the new words. An early version marked the cut with a bracketed note, and the model started imitating the bracket aloud in later turns. The scrubber's bracket rule fixed that and a class of similar leaks at once.

The counting bug

Asked to count from one thousand upward, the voice skipped and doubled numbers while the correct text reached the synthesiser. The cause was an alignment slip inside the model when many numbers arrived in one clause. The fix is in the contract, one item per sentence, which keeps each number in its own clause, and the clause splitter cuts only on sentence-ending punctuation with decimals and abbreviations protected, under a single forced ceiling that the synthesiser's own limits dictate.

One reply
cut only at sentence ends; the decimal and the abbreviation are protected
Longest clause
one forced ceiling, set by the synthesiser
A property test checks that every split lands on sentence-ending punctuation.

What this demonstrates

I close the gap between text that reads well and text that speaks well, and I do not trust a model to close it alone.

The say-and-show split and the contract were mine; AI coding agents, a mix of frontier and open-source models, built the scrubber and its tests under my governance system.