From 1424029eae3f032bcd451736bc237dcef9b91622 Mon Sep 17 00:00:00 2001 From: kaivalya Date: Mon, 6 Jul 2026 15:33:33 +0200 Subject: [PATCH] Step 26 to announce exercise --- .claude/settings.json | 1 + .gitignore | 4 + docs/decisions-log.md | 10 + docs/plan.md | 6 +- src/composables/useSessionExecution.js | 27 ++ src/composables/useSpeech.js | 33 ++ src/i18n/locales/da.json | 6 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/fr.json | 6 +- src/views/SettingsView.vue | 16 + tests/test_step26_voice_announcements.py | 356 ++++++++++++++++++++ website/content/developers/technical.en.md | 27 ++ website/content/docs/user-guide/index.en.md | 6 + website/content/docs/user-guide/index.fr.md | 6 + 14 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 src/composables/useSpeech.js create mode 100644 tests/test_step26_voice_announcements.py diff --git a/.claude/settings.json b/.claude/settings.json index bcda697..04e52f1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -35,6 +35,7 @@ "Bash(python *)", "Bash(python3 *)", "Bash(pip *)", + "Bash(source *)", "Bash(./venv/bin/python *)", "Bash(cd tests && .venv/bin/python *)", "Bash(cd *)" diff --git a/.gitignore b/.gitignore index fd5a7d7..693a4ca 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ tmp/ # Hugo build output website/public/ website/resources/ + +# temporary files +*.tmp +tmp/ diff --git a/docs/decisions-log.md b/docs/decisions-log.md index af24811..29fd371 100644 --- a/docs/decisions-log.md +++ b/docs/decisions-log.md @@ -2,6 +2,16 @@ Record of architectural and technical decisions made during development. +## 2026-07-06 — Voice announcements via the Web Speech API (Step 26) + +**Decision:** Added `useSpeech.js`, a thin wrapper around `window.speechSynthesis` exposing `init()` (reads the new `voice_enabled` settings key, default enabled), `speak(text, { volume })` (cancels any pending utterance first, so a new announcement always preempts a stale one, and maps the current i18next language to a BCP-47 tag: `en` → `en-US`, `fr` → `fr-FR`, `da` → `da-DK`), `cancel()`, and `isSupported`. `useSessionExecution.js` calls `speak()` with the exercise title whenever a step becomes current (`loadStepState()`), and pre-announces the *next* exercise ("Next: X") in the two places the on-screen UI already does the same thing silently: 10 s before an EMOM slot ends (hooked into the existing `_emomSounds` tick handler) and the instant a TABATA rest phase opens (`_startRestPhase`). AMRAP and plain (NONE) combos only get the step-change announcement, matching their on-screen behavior of not showing a "next" preview until the step actually changes. To avoid the same exercise being spoken twice in quick succession, the queue index that was just pre-announced is tracked in a closure variable; `loadStepState()` skips the step-change announcement exactly once when it reaches that index. `speechSynthesis.cancel()` is also called on `exit()` and `_finish()` so nothing keeps talking after the user leaves the execution view. + +The Settings → Sound section gained a "Voice announcements" checkbox next to the existing volume slider, persisted the same way as `audio_volume`. + +**Rationale:** `speechSynthesis` is native, works offline, and needs no audio asset or dependency, fitting the PWA's existing Web Audio beep approach (Step 12) rather than replacing it — beeps and voice share the one `audio_volume` knob. Reusing the exact moments the UI already surfaces a "next" hint (EMOM's 10 s countdown, TABATA's rest-phase open) means the voice cue never says something the screen doesn't already imply. `'speechSynthesis' in window` feature-detection keeps the app working unchanged where the API is unsupported. + +**Testing note:** Firefox exposes `window.speechSynthesis` as a getter-only property, so a test mock must replace it with `Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: ... })` — a plain `window.speechSynthesis = ...` assignment silently no-ops and the real (unmocked) API keeps running underneath, which looks identical to "nothing was called" from the test's point of view. The same caveat applies to the red-path test removing the API (`delete Window.prototype.speechSynthesis`, mirroring the Step 25 `navigator.mediaSession` mock). + ## 2026-07-05 — Headset / media-key control of session execution (Step 25) **Decision:** Added `useMediaSession.js`, wiring the Media Session API (`navigator.mediaSession`) into `SessionExecuteView` so headphone buttons, OS media keys, and lock-screen controls can drive a workout: play/pause toggles the active timer, next/previous step through the queue, and stop exits immediately. Hardware media keys are only routed to a page actively playing audio through a media element — the Web Audio beeps from Step 12 don't qualify — so `useMediaSession.js` also starts a tiny silent looping `