Hi, how can I help you today?
The mark, at rest.
The word budget
In the room, the audio is the product. The screen above is a secondary surface: there for whoever wants to read along, change register or ask a question, in the pocket the rest of the time. That inverts what you optimise: a screen gives you space, a voice gives you time. The visitor is standing in front of a work, other people around, and every word you write spends a second of their patience (2.6 words a second, at the pace the prototype measures). So the first token of a voice component isn't a color. It's a word budget.
The narration for one of the sculptures (Immersion register) is 126 words, which at 2.6 words a second is about 48 seconds of voice. That duration appears on screen before you press play, the way an article shows its reading time. Standing in front of a work, knowing it is forty-eight seconds and not five minutes is what decides whether you listen to the end.
That same 2.6 does a second job, and this one is purely technical. While the voice speaks, the screen can show the text, highlighted as it goes. But some browser voices never report where they are in the text, and without that signal there is no way to know which word is being spoken. So the engine guesses: word count divided by 2.6. The rate has to suit the slowest voice, or the subtitle runs ahead of what the visitor is hearing, and the guess is capped at 97% so the text never announces the end before the voice gets there. The pace you design with is the pace your worst voice can hold.
Three depths, one component
Three registers is not three scripts to maintain. It is one component with three variants, the three tabs you used up top: same subject, same facts, three ways of telling it.
Look at what actually changes. Immersion opens on your eyes: “Look at this silhouette.” Expert opens on the record: “a bronze from 1960, about six feet tall.” Essential compresses the whole thing into one image: “a man reduced to a line.” Cut a narration shorter and you get a summary. Change its register and you get a variant. And like any good prop, it persists: pick Immersion once and every artwork in the museum speaks Immersion. The choice follows you, stored locally, never asked twice.
A museum does not have one visitor. There is the one who came for the feeling, the one who wants the catalogue entry, and the one with eight minutes before closing. The same work has to speak to each of them differently, so I defined three visitor profiles.
Look at this silhouette.
126 words ≈ 48 s
Choosing the voice
The visitor does not hear a voice being synthesised live. They hear a file, generated ahead of time with a TTS model, one voice per language, and served like an image. Two reasons for that. A museum basement has no network, and a voice that needs an API in order to speak does not speak. And a recorded take is the same for everyone: the same reading, the same pauses, on every visit.
That leaves the case where the file is missing. The phone's own speech synthesis reads the text instead, and there the choice stops being yours: every device ships its own voices, excellent ones and terrible ones in the same list. So the engine does not take the first one offered. It ranks them.
return (
pool.find((v) => /premium|enhanced|siri|améliorée|natural/i.test(v.name)) ||
PREFERRED_VOICES[lang]
.map((n) => pool.find((v) => v.name.toLowerCase().includes(n)))
.find(Boolean) ||
pool.find((v) => v.default) ||
pool[0]
);The worst of them is a macOS speciality: it ships novelty voices in the same list as the good ones. Nothing kills a museum like Zarvox reading Giacometti, so they are struck out by name.
const NOVELTY_VOICES =
/albert|bad news|bahh|bells|boing|bubbles|cellos|good news|jester|organ|superstar|trinoids|whisper|wobble|zarvox|grandma|grandpa|rocko|sandy|shelley|eddy|flo\b|reed/i;The states of a voice
A voice component has states the way a button does, except that its states own the room. It waits, silent, with nothing on screen but the gallery. A beacon detects the work in front of you and the narration attaches to it, before any tap. It speaks, and the subtitles follow sentence by sentence. It pauses when the visitor stops to look, holding its place instead of restarting. It finishes, and offers a replay or the next work. And it answers out loud when the visitor asks out loud. A button has hover and press; this one has a room, a distance and a silence.
Beacon detected
const SENTENCE_RE = /[^.!?…]+[.!?…]+[»"]?\s*/g;The engine, and its scars
The core of the component is simple. You give it a text, a language, a rate, and an audio file if one exists. It gives back one thing: how far along the reading is. That single number drives everything else, the subtitles, the bar, the states. That part is clean. What surrounds it is not, because browser speech synthesis behaves differently on every browser, and each difference had to be worked around. Here are five.
- Cut a narration and start another one straight away, and Chrome stays silent. It needs a moment to clear what it was holding, so the engine waits 80 milliseconds between the two.
- On desktop Chrome, a narration stops on its own after about fifteen seconds. The workaround is to pause it and resume it every ten seconds, which resets the count. Desktop Chrome only: elsewhere the ping does more harm than good.
- The browser's paused state is global, and it survives a page change. So you can land on the page with the synthesiser already stuck, because of another tab. The engine resumes it before doing anything else.
- The text handed over to be read is an object in memory. If nothing in the code still holds it, Chrome can drop it mid-sentence and the voice stops dead, so the engine keeps hold of it until the reading is over.
- On iOS, a voice can only start in the immediate wake of a gesture: a real tap, not a timer. So when there is nothing to cancel first, the engine starts right away, to stay inside the window the system allows.
// Desktop Chrome stops long utterances around 15 s; a periodic
// pause/resume ping keeps it alive. Other platforms dislike it.
if (isDesktopChrome()) {
pingRef.current = window.setInterval(() => {
if (synth.speaking && !synth.paused) {
synth.pause();
synth.resume();
}
}, 10000);
}The mediator answers back
The last state is the strangest: the visitor asks a question out loud, and the voice answers. In the prototype I built, that is real. The question goes to a model given a very narrow role: museum mediator, eighty words maximum, factual, and required to say it does not know rather than invent.
Asking a question sends a request to a model, and every request costs money. An address open on the internet always gets found in the end, so it is fenced: it only accepts requests coming from the site, five questions per minute per visitor, three hundred characters maximum. And if the key to the model is missing, it does not return an error, it falls back to the hand-written answers. A voice that answers is a feature; a voice that fails politely is a product.
Press a suggestion, or ask your own. This runs the prototype's scripted matcher, so an unknown question gets the prototype's real answer for not knowing.

Where this lives
None of this fits in a frame. A word budget, a register, a beacon state, a fallback voice: these are design decisions, and they belong in a component, versioned next to the buttons, testable in the browser, tuned by ear.
The audioguide of 2028 won't be a device you rent at the desk. It will be a component.