ENESFRPT

Depth in everything. Superficiality in nothing.

Translator.create needs a real click

Translator.create() requires transient user activation whenever the language pack still has to download, which is every fresh profile. Call it from a speech callback and it rejects with NotAllowedError, on the machines where it matters most: the ones that have never run it before. The warm-up has to happen synchronously inside a click.

Why it works on your machine

Once the pack is on disk, create resolves without activation and the whole thing looks fine. So the bug is invisible to whoever built it, on the second run onwards, and appears only for people opening the feature for the first time. That is the worst possible distribution: it is broken exactly for new users and working for everyone testing it.

What transient activation actually allows

It is a short window opened by a real user gesture, and it is spent by the first await. That is the part that catches people. A click handler that awaits getUserMedia and then calls create has already lost the activation by the time it gets there, even though the code is still inside the handler and reads as if it is not.

// Inside the click handler. No await before Translator.create, because the
// first await spends the activation and the call then rejects.
function onStart() {
  // Wrong: await getUserMedia() here and the activation is gone.
  const warm = Translator.create({ sourceLanguage: 'es', targetLanguage: 'en' });
  warm.then((t) => (translator = t)).catch(() => (translator = null));
  // Everything that can wait, waits after the create call is already in flight.
  void startCaptureAndJoin();
}

So the ordering rule is narrow: start create before the first await, and let it settle in the background. A speech recognition callback is far too late; by then the gesture is minutes gone.

Design the feature around the gesture

This is not a workaround to bolt on, it constrains the interface. There has to be a moment where the person deliberately turns translation on, and that moment has to be the one that warms the pack. If translation is meant to start silently when speech is detected, there is no gesture to spend and the feature cannot work on a first visit.

I prefer this path anyway when it is available, because it costs nothing and nothing leaves the machine. Chrome desktop can do speech recognition locally and translate locally. Everything else falls back to a hosted service, which works and is the fallback rather than the default.

How to test it

  • Test on a fresh profile every time. A warm profile cannot reproduce the failure at all.
  • Emulate the rule rather than trusting it: a stub that rejects with NotAllowedError unless a gesture happened in the last few seconds catches the regression in CI, without downloading anything.
  • Assert the failure path too. If create rejects, captions must still appear untranslated rather than disappearing, because a missing caption reads as a broken microphone.
  • speechSynthesis cannot be captured

    The Web Speech API hands you no MediaStreamTrack, so a spoken translation can never go into a WebRTC sender. Here is the route that does work.

  • srcObject is DOM state, not a prop

    The self-view goes black while the other side sees you perfectly. React never restores srcObject, and binding it once inside getUserMedia misses.

Working on something that has to hold?

If you are somewhere in the gap between a demo and a system real people depend on, that is the part I do.