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 (seeapps/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(...)inrep-voice-utterance.ts(thelocalizepattern: explicit literalt("…")calls keyed by the English unit name frompackages/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:
- iOS →
AVSpeechSynthesizer - Android →
android.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
TrainingSession → ExerciseDetailView). 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.