Skip to main content

Voice feedback (text-to-speech)

Spoken rep feedback for live tracking: when a repetition is recorded, the app speaks that rep's first focus-metric value aloud in the app's language — e.g. "0.45 meters per second". It is a single-station-only convenience for velocity-based-training style coaching, where the athlete hears their bar speed without looking at the screen.

What gets spoken

The value is read from the same source as the per-rep bars, so spoken always matches shown:

  • The metric is useFocusMetric(exerciseGroupID) — the first selected focus metric (see apps/tracking/src/app/workouts/today/focus-metric.tsx).
  • The per-rep value is buildRepValueMap(rep, loadMass)[metric.key] — the identical map the live overlay/bars use (rep-feedback-shared.tsx).
  • The number text is useUnits().formatMetric(value, metric).text (locale- and unit-system-aware).
  • The unit is spoken as a long word ("meters per second"), not the display symbol ("M/S") — a synthesizer reads "m/s" as "m slash s". The spoken unit is localized through t(...) in rep-voice-utterance.ts (the localize pattern: explicit literal t("…") calls keyed by the English unit name from packages/core/src/units.ts).

Local language

The voice language follows the active app locale (useTranslation().locale). resolveVoiceLang (in @enode/core/text-to-speech) normalizes the locale to a BCP-47 tag the on-device engine actually supports — it tries the exact tag, then any installed tag sharing the base language ("de" → "de-DE"), and caches the result. When no voice is installed for the language, nothing is spoken (the feature degrades silently rather than reading in the wrong language).

Platforms

One common implementation via @capacitor-community/text-to-speech:

  • iOSAVSpeechSynthesizer
  • Androidandroid.speech.tts.TextToSpeech
  • Web → Web Speech API (best-effort bonus; gated on speechSynthesis)

The plugin module touches window at evaluation time, so @enode/core/text-to-speech loads it lazily via dynamic import() — importing it eagerly breaks Next.js static prerender (window is not defined).

speakValue uses QueueStrategy.Flush, so a burst of reps interrupts the previous utterance instead of queueing and lagging behind the athlete.

Rate & latency

Speech rate is set per platform (VOICE_RATE_IOS / VOICE_RATE_DEFAULT in @enode/core/text-to-speech) because the plugin scales rate differently: iOS compresses it (0.1*rate + 0.4 on AVSpeech's 0–1 scale, max 1.0 — so a large value like 4.5 → ~0.85 is needed for a fast callout), while Android applies it straight to setSpeechRate (1.0 = normal). Both are tunable from device testing.

prepareVoice(locale) removes the first-rep cold start: the dynamic import, the supported-languages probe, and the engine spin-up + voice load would otherwise all land on rep 1. The tracking hook calls it the moment voice is switched on (well before the first rep) — it loads the plugin, primes the language cache, and runs a silent (volume 0) warm-up utterance. Spoken reps are also kept short (the unit only on the first rep of a set) to minimise per-rep speech time.

iOS audio session

IOS_AUDIO_CATEGORY in @enode/core/text-to-speech selects the AVAudioSession category. The default "ambient" mixes with other audio and respects the hardware silent switch (a muted phone stays silent); "playback" is always audible but interrupts the athlete's music. This is the single knob to tune from device testing. Voice also shares the audio session with video recording (@enode/core/video, AVFoundation) — verify the two don't fight when recording a set.

Single-station gate

The toggle and the speaking only activate when exactly one tracking station is active (soleStation, derived in page.tsx and threaded through TrainingSessionExerciseDetailView). Multiple stations speaking over each other is meaningless, so on a multi-station pad the toggle is hidden.

UI & persistence

A "Voice feedback" item (speaker icon) lives in the active tracking view's top-right "⋯" options menu (ExerciseDetailView.tsx). State is the voiceFeedback flag in @enode/core/training/flags, persisted in localStorage (enode.voiceFeedback, default off). The whole feature sits behind the reversible VOICE_FEEDBACK constant in ExerciseDetailView.tsx.