Step 26 to announce exercise
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run

This commit is contained in:
David 2026-07-06 15:33:33 +02:00
parent 49aaa0cd31
commit 1424029eae
14 changed files with 504 additions and 6 deletions

View file

@ -35,6 +35,7 @@
"Bash(python *)", "Bash(python *)",
"Bash(python3 *)", "Bash(python3 *)",
"Bash(pip *)", "Bash(pip *)",
"Bash(source *)",
"Bash(./venv/bin/python *)", "Bash(./venv/bin/python *)",
"Bash(cd tests && .venv/bin/python *)", "Bash(cd tests && .venv/bin/python *)",
"Bash(cd *)" "Bash(cd *)"

4
.gitignore vendored
View file

@ -35,3 +35,7 @@ tmp/
# Hugo build output # Hugo build output
website/public/ website/public/
website/resources/ website/resources/
# temporary files
*.tmp
tmp/

View file

@ -2,6 +2,16 @@
Record of architectural and technical decisions made during development. 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) ## 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 `<audio>` element (a 592-byte base64 WAV data URI, no asset file) when execution begins; the user's ▶ tap to start the session satisfies the autoplay policy. `MediaMetadata` (title = exercise, artist/album = combo/session title) is refreshed on every step change so the lock screen shows the current exercise. The whole composable feature-detects `'mediaSession' in navigator` and no-ops silently where unsupported. **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 `<audio>` element (a 592-byte base64 WAV data URI, no asset file) when execution begins; the user's ▶ tap to start the session satisfies the autoplay policy. `MediaMetadata` (title = exercise, artist/album = combo/session title) is refreshed on every step change so the lock screen shows the current exercise. The whole composable feature-detects `'mediaSession' in navigator` and no-ops silently where unsupported.

View file

@ -1,7 +1,7 @@
# TrainUs — Implementation Plan (v3) # TrainUs — Implementation Plan (v3)
> **Last updated:** 2026-07-05 > **Last updated:** 2026-07-06
> **Status:** Step 24 implemented (app-update modal) — awaiting manual validation; Step 25 implemented (headset media controls) — awaiting manual validation; Step 26 planned (voice announcements) > **Status:** Step 24 implemented (app-update modal) — awaiting manual validation; Step 25 implemented (headset media controls) — awaiting manual validation; Step 26 implemented (voice announcements) — awaiting manual validation
## Overview ## Overview
@ -869,7 +869,7 @@ Hugo 0.123.7 static site under `website/`. FR default at `/`, EN at `/en/`. Depl
### Step 26 — Voice Announcements (Web Speech API) ### Step 26 — Voice Announcements (Web Speech API)
**Status:** 🔲 Planned (2026-07-04) **Status:** ✅ Implemented (2026-07-06) — awaiting manual validation by the user. Implemented as planned below; 9 Playwright tests passing on Firefox + Chromium.
**Goal:** in execution mode, a synthesized voice (SpeechSynthesis — `window.speechSynthesis`, no audio assets, supported by Firefox and Chromium) announces the exercise title at each step change. In TABATA and EMOM the _next_ exercise is pre-announced. The voice can be disabled in Settings → Sound. **Goal:** in execution mode, a synthesized voice (SpeechSynthesis — `window.speechSynthesis`, no audio assets, supported by Firefox and Chromium) announces the exercise title at each step change. In TABATA and EMOM the _next_ exercise is pre-announced. The voice can be disabled in Settings → Sound.

View file

@ -1,4 +1,5 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import i18next from 'i18next'
import { sessionRepository } from '../db/repositories/sessionRepository.js' import { sessionRepository } from '../db/repositories/sessionRepository.js'
import { comboRepository } from '../db/repositories/comboRepository.js' import { comboRepository } from '../db/repositories/comboRepository.js'
import { exerciseRepository } from '../db/repositories/exerciseRepository.js' import { exerciseRepository } from '../db/repositories/exerciseRepository.js'
@ -6,6 +7,7 @@ import { executionLogRepository } from '../db/repositories/executionLogRepositor
import { settingsRepository } from '../db/repositories/settingsRepository.js' import { settingsRepository } from '../db/repositories/settingsRepository.js'
import { useTimer } from './useTimer.js' import { useTimer } from './useTimer.js'
import { useAudioCues } from './useAudioCues.js' import { useAudioCues } from './useAudioCues.js'
import { useSpeech } from './useSpeech.js'
export function useSessionExecution(sessionId) { export function useSessionExecution(sessionId) {
const sessionTitle = ref('') const sessionTitle = ref('')
@ -35,24 +37,45 @@ export function useSessionExecution(sessionId) {
stop: stopTimer, stop: stopTimer,
} = useTimer() } = useTimer()
const { playBip, playBIP } = useAudioCues() const { playBip, playBIP } = useAudioCues()
const { init: initSpeech, speak, cancel: cancelSpeech } = useSpeech()
const audioVolume = ref(0.5) const audioVolume = ref(0.5)
let preAnnouncedIndex = null
const currentStep = computed(() => queue.value[currentIndex.value] || null) const currentStep = computed(() => queue.value[currentIndex.value] || null)
const nextStep = computed(() => queue.value[currentIndex.value + 1] || null) const nextStep = computed(() => queue.value[currentIndex.value + 1] || null)
const totalSteps = computed(() => queue.value.length) const totalSteps = computed(() => queue.value.length)
const timerActive = computed(() => timerPhase.value !== null && !isAmrapTimeUp.value) const timerActive = computed(() => timerPhase.value !== null && !isAmrapTimeUp.value)
function _announceStep(step) {
speak(step.exerciseTitle, { volume: audioVolume.value })
}
function _preAnnounceNext() {
const next = nextStep.value
if (!next) return
speak(i18next.t('execution.voice.next', { title: next.exerciseTitle }), {
volume: audioVolume.value,
})
preAnnouncedIndex = currentIndex.value + 1
}
function loadStepState() { function loadStepState() {
const step = currentStep.value const step = currentStep.value
if (!step) return if (!step) return
currentReps.value = step.prefillReps currentReps.value = step.prefillReps
currentWeight.value = step.prefillWeight currentWeight.value = step.prefillWeight
currentSide.value = step.prefillSide currentSide.value = step.prefillSide
if (preAnnouncedIndex === currentIndex.value) {
preAnnouncedIndex = null
} else {
_announceStep(step)
}
_startStepTimer(step) _startStepTimer(step)
} }
function _emomSounds(remaining) { function _emomSounds(remaining) {
if (remaining === 30 || remaining === 2 || remaining === 1) playBip(audioVolume.value) if (remaining === 30 || remaining === 2 || remaining === 1) playBip(audioVolume.value)
if (remaining === 10) _preAnnounceNext()
} }
function _amrapLastMinuteSounds(remaining) { function _amrapLastMinuteSounds(remaining) {
@ -101,6 +124,7 @@ export function useSessionExecution(sessionId) {
function _startRestPhase(step) { function _startRestPhase(step) {
timerPhase.value = 'rest' timerPhase.value = 'rest'
playBIP(audioVolume.value) playBIP(audioVolume.value)
_preAnnounceNext()
startTimer(step.restTime, { startTimer(step.restTime, {
onTick: (r) => _tabataCountdown(r, step.restTime), onTick: (r) => _tabataCountdown(r, step.restTime),
onExpire: async () => { onExpire: async () => {
@ -154,6 +178,7 @@ export function useSessionExecution(sessionId) {
const volumeStr = await settingsRepository.get('audio_volume') const volumeStr = await settingsRepository.get('audio_volume')
const parsed = parseInt(volumeStr, 10) const parsed = parseInt(volumeStr, 10)
audioVolume.value = isNaN(parsed) ? 0.5 : parsed / 100 audioVolume.value = isNaN(parsed) ? 0.5 : parsed / 100
await initSpeech()
const session = await sessionRepository.getById(sessionId) const session = await sessionRepository.getById(sessionId)
if (!session) throw new Error('Session not found') if (!session) throw new Error('Session not found')
@ -414,6 +439,7 @@ export function useSessionExecution(sessionId) {
} }
async function _finish() { async function _finish() {
cancelSpeech()
await executionLogRepository.update(logId.value, { await executionLogRepository.update(logId.value, {
finished_at: new Date().toISOString(), finished_at: new Date().toISOString(),
status: 'completed', status: 'completed',
@ -425,6 +451,7 @@ export function useSessionExecution(sessionId) {
if (sessionDone.value) return if (sessionDone.value) return
stopTimer() stopTimer()
timerPhase.value = null timerPhase.value = null
cancelSpeech()
await executionLogRepository.update(logId.value, { await executionLogRepository.update(logId.value, {
finished_at: new Date().toISOString(), finished_at: new Date().toISOString(),
status: 'aborted', status: 'aborted',

View file

@ -0,0 +1,33 @@
import i18next from 'i18next'
import { settingsRepository } from '../db/repositories/settingsRepository.js'
const LANG_MAP = { en: 'en-US', fr: 'fr-FR', da: 'da-DK' }
export function useSpeech() {
const isSupported = typeof window !== 'undefined' && 'speechSynthesis' in window
let enabled = true
async function init() {
const val = await settingsRepository.get('voice_enabled')
enabled = val === null ? true : val === 'true'
}
function speak(text, { volume = 1.0 } = {}) {
if (!enabled || !isSupported) return
try {
window.speechSynthesis.cancel()
const utterance = new window.SpeechSynthesisUtterance(text)
utterance.lang = LANG_MAP[i18next.language] || 'en-US'
utterance.volume = volume
window.speechSynthesis.speak(utterance)
} catch (_e) {
// Ignore speech errors — announcement is best-effort
}
}
function cancel() {
if (isSupported) window.speechSynthesis.cancel()
}
return { init, speak, cancel, isSupported }
}

View file

@ -274,6 +274,7 @@
"sound": "Lyd", "sound": "Lyd",
"soundVolume": "Lydstyrke", "soundVolume": "Lydstyrke",
"soundTest": "Test", "soundTest": "Test",
"voiceAnnouncements": "Stemmemeddelelser",
"credits": "Kreditering", "credits": "Kreditering",
"manageJournal": "Administrer øvelseslogge", "manageJournal": "Administrer øvelseslogge",
"manageJournalHint": "Slet øvelseslogposter inden for et datointerval.", "manageJournalHint": "Slet øvelseslogposter inden for et datointerval.",
@ -416,7 +417,10 @@
"showDetails": "Vis øvelsesdetaljer", "showDetails": "Vis øvelsesdetaljer",
"hideDetails": "Skjul øvelsesdetaljer", "hideDetails": "Skjul øvelsesdetaljer",
"videoOffline": "Videoer er ikke tilgængelige offline.", "videoOffline": "Videoer er ikke tilgængelige offline.",
"leaveConfirm": "Du er midt i en session. Forlad alligevel? Sessionen vil blive markeret som afbrudt." "leaveConfirm": "Du er midt i en session. Forlad alligevel? Sessionen vil blive markeret som afbrudt.",
"voice": {
"next": "Næste: {{title}}"
}
}, },
"backup": { "backup": {
"reminder": { "reminder": {

View file

@ -274,6 +274,7 @@
"sound": "Sound", "sound": "Sound",
"soundVolume": "Volume", "soundVolume": "Volume",
"soundTest": "Test", "soundTest": "Test",
"voiceAnnouncements": "Voice announcements",
"credits": "Credits", "credits": "Credits",
"manageJournal": "Manage Exercise Logs", "manageJournal": "Manage Exercise Logs",
"manageJournalHint": "Delete exercise log entries within a date range.", "manageJournalHint": "Delete exercise log entries within a date range.",
@ -416,7 +417,10 @@
"showDetails": "Show exercise details", "showDetails": "Show exercise details",
"hideDetails": "Hide exercise details", "hideDetails": "Hide exercise details",
"videoOffline": "Videos are not available offline.", "videoOffline": "Videos are not available offline.",
"leaveConfirm": "You are in the middle of a session. Leave anyway? The session will be marked as aborted." "leaveConfirm": "You are in the middle of a session. Leave anyway? The session will be marked as aborted.",
"voice": {
"next": "Next: {{title}}"
}
}, },
"backup": { "backup": {
"reminder": { "reminder": {

View file

@ -274,6 +274,7 @@
"sound": "Son", "sound": "Son",
"soundVolume": "Volume", "soundVolume": "Volume",
"soundTest": "Tester", "soundTest": "Tester",
"voiceAnnouncements": "Annonces vocales",
"credits": "Crédits", "credits": "Crédits",
"manageJournal": "Gérer les journaux d'exercices", "manageJournal": "Gérer les journaux d'exercices",
"manageJournalHint": "Supprimez les entrées du journal d'exercices sur une plage de dates.", "manageJournalHint": "Supprimez les entrées du journal d'exercices sur une plage de dates.",
@ -416,7 +417,10 @@
"showDetails": "Afficher les détails de l'exercice", "showDetails": "Afficher les détails de l'exercice",
"hideDetails": "Masquer les détails de l'exercice", "hideDetails": "Masquer les détails de l'exercice",
"videoOffline": "Les vidéos ne sont pas disponibles hors ligne.", "videoOffline": "Les vidéos ne sont pas disponibles hors ligne.",
"leaveConfirm": "Vous êtes en pleine séance. Partir quand même ? La séance sera marquée comme interrompue." "leaveConfirm": "Vous êtes en pleine séance. Partir quand même ? La séance sera marquée comme interrompue.",
"voice": {
"next": "Suivant : {{title}}"
}
}, },
"backup": { "backup": {
"reminder": { "reminder": {

View file

@ -30,6 +30,7 @@ const userUuid = ref('')
const language = ref('en') const language = ref('en')
const theme = ref('light') const theme = ref('light')
const audioVolume = ref(50) const audioVolume = ref(50)
const voiceEnabled = ref(true)
const nameError = ref('') const nameError = ref('')
const restoring = ref(false) const restoring = ref(false)
const restoreReport = ref(null) const restoreReport = ref(null)
@ -72,6 +73,8 @@ onMounted(async () => {
const volumeStr = await get('audio_volume') const volumeStr = await get('audio_volume')
const parsed = parseInt(volumeStr, 10) const parsed = parseInt(volumeStr, 10)
audioVolume.value = isNaN(parsed) ? 50 : parsed audioVolume.value = isNaN(parsed) ? 50 : parsed
const voiceStr = await get('voice_enabled')
voiceEnabled.value = voiceStr === null ? true : voiceStr === 'true'
await loadSuffixes() await loadSuffixes()
await loadBackupInfo() await loadBackupInfo()
await loadBackupStatus() await loadBackupStatus()
@ -104,6 +107,10 @@ async function onVolumeChange() {
await set('audio_volume', String(audioVolume.value)) await set('audio_volume', String(audioVolume.value))
} }
async function onVoiceEnabledChange() {
await set('voice_enabled', String(voiceEnabled.value))
}
function onSoundTest() { function onSoundTest() {
const vol = audioVolume.value / 100 const vol = audioVolume.value / 100
playBip(vol) playBip(vol)
@ -418,6 +425,15 @@ async function onBackupReminderDaysChange() {
{{ $t('settings.soundTest') }} {{ $t('settings.soundTest') }}
</button> </button>
</div> </div>
<label class="checkbox-label" data-testid="settings-voice-enabled-label">
<input
v-model="voiceEnabled"
type="checkbox"
data-testid="settings-voice-enabled"
@change="onVoiceEnabledChange"
/>
<span>{{ $t('settings.voiceAnnouncements') }}</span>
</label>
</div> </div>
<!-- Restore overlay (spinner) --> <!-- Restore overlay (spinner) -->

View file

@ -0,0 +1,356 @@
"""
Step 26 Voice Announcements (Web Speech API)
`speechSynthesis` / `SpeechSynthesisUtterance` are replaced by a lightweight
mock injected before every page load: each call to `speak()` is recorded into
`window.__spokenTexts` as `{ text, lang, volume }`.
page.clock.install() controls fake time so EMOM/TABATA timer-driven
pre-announcements are deterministic (as in Step 15).
- test_first_step_title_spoken_on_start: the first step's exercise title is
announced when execution begins
- test_title_spoken_on_advance: advancing to the next step announces its title
- test_emom_speaks_next_at_10s: EMOM speaks "Next: X" 10 s before a slot ends
- test_tabata_speaks_next_on_rest: TABATA speaks "Next: X" when the rest phase
opens
- test_no_duplicate_announcement_after_preannounce: the step-change
announcement is skipped right after a pre-announcement for that same step
- test_utterance_lang_follows_app_language: utterance `lang` matches the
current i18next language
- test_utterance_volume_follows_audio_volume: utterance `volume` matches the
`audio_volume` setting
- test_voice_disabled_nothing_spoken: unchecking the setting silences all
announcements, and the setting persists across reload
- test_works_with_speech_synthesis_absent: execution works normally (red path)
when `speechSynthesis` does not exist
"""
import json
import pytest
from playwright.sync_api import Page, expect
# ── Speech mock injected before every page load ───────────────────────────────
_SPEECH_MOCK = """
window.__spokenTexts = [];
function FakeUtterance(text) {
this.text = text;
this.lang = '';
this.volume = 1;
}
window.SpeechSynthesisUtterance = FakeUtterance;
Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: {
cancel: function () {},
speak: function (u) {
window.__spokenTexts.push({ text: u.text, lang: u.lang, volume: u.volume });
},
},
});
"""
_SPEECH_ABSENT = """
delete Window.prototype.speechSynthesis;
"""
def _setup(page: Page, app_url: str, mock_speech=True, install_clock=False):
if install_clock:
page.clock.install()
if mock_speech:
page.add_init_script(_SPEECH_MOCK)
else:
page.add_init_script(_SPEECH_ABSENT)
page.goto(app_url)
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
def _create_exercise(page: Page, title: str) -> str:
return page.evaluate(f"""async () => {{
const uid = await window.__repos.settings.get('user_uuid')
return window.__repos.exercises.create({{
title: {repr(title)}, description: '', asymmetric: false, alternate: false,
image_urls: '[]', video_urls: '[]',
default_reps: 10, default_weight: 0, created_by: uid
}})
}}""")
def _create_combo(page: Page, title: str, combo_type: str, exercise_ids: list,
timer_config: dict = None):
tc_json = json.dumps(timer_config or {})
ex_json = json.dumps([{"exerciseId": eid, "defaultReps": 10, "defaultWeight": 0}
for eid in exercise_ids])
return page.evaluate(f"""async () => {{
const uid = await window.__repos.settings.get('user_uuid')
const cid = await window.__repos.combos.create({{
title: {repr(title)}, description: '', type: {repr(combo_type)},
timer_config: {tc_json}, created_by: uid
}})
await window.__repos.combos.setExercises(cid, {ex_json})
return cid
}}""")
def _create_session(page: Page, title: str, items: list) -> str:
items_json = json.dumps(items)
return page.evaluate(f"""async () => {{
const uid = await window.__repos.settings.get('user_uuid')
const sid = await window.__repos.sessions.create({{
title: {repr(title)}, description: '', created_by: uid
}})
const items = {items_json}
await window.__repos.sessions.setItems(sid, items.map((it, i) => ({{
item_id: it.item_id, item_type: it.item_type,
position: i, repetitions: it.repetitions, weight: 0
}})))
return sid
}}""")
def _clean_all(page: Page):
page.evaluate("""async () => {
for (const l of await window.__repos.executionLogs.getAll())
await window.__repos.executionLogs.delete(l.id)
for (const s of await window.__repos.sessions.getAll())
await window.__repos.sessions.delete(s.id)
for (const c of await window.__repos.combos.getAll())
await window.__repos.combos.delete(c.id)
for (const e of await window.__repos.exercises.getAll())
await window.__repos.exercises.delete(e.id)
}""")
def _navigate_to_execute(page: Page, app_url: str, sess_id: str):
page.goto(f"{app_url}#/sessions/{sess_id}/execute")
page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000)
page.wait_for_timeout(300)
def _spoken(page: Page) -> list[dict]:
return page.evaluate("window.__spokenTexts")
# ── Tests ──────────────────────────────────────────────────────────────────────
def test_first_step_title_spoken_on_start(page: Page, app_url: str):
"""The first step's exercise title is announced when execution begins."""
_setup(page, app_url)
_clean_all(page)
ex1 = _create_exercise(page, "Voice Ex1")
sess_id = _create_session(page, "Voice Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
spoken = _spoken(page)
assert any(s["text"] == "Voice Ex1" for s in spoken), f"Unexpected spoken texts: {spoken}"
def test_title_spoken_on_advance(page: Page, app_url: str):
"""Advancing to the next step announces its title."""
_setup(page, app_url)
_clean_all(page)
ex1 = _create_exercise(page, "Voice Adv Ex1")
ex2 = _create_exercise(page, "Voice Adv Ex2")
sess_id = _create_session(page, "Voice Adv Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
{"item_id": ex2, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
page.click('[data-testid="execute-done-btn"]')
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("Voice Adv Ex2")
spoken = _spoken(page)
assert any(s["text"] == "Voice Adv Ex2" for s in spoken), f"Unexpected spoken texts: {spoken}"
def test_emom_speaks_next_at_10s(page: Page, app_url: str):
"""EMOM speaks 'Next: X' 10 s before a slot ends."""
_setup(page, app_url, install_clock=True)
_clean_all(page)
ex1 = _create_exercise(page, "Voice EMOM Ex1")
ex2 = _create_exercise(page, "Voice EMOM Ex2")
combo_id = _create_combo(page, "Voice EMOM", "EMOM", [ex1, ex2])
sess_id = _create_session(page, "Voice EMOM Session", [
{"item_id": combo_id, "item_type": "combo", "repetitions": 2},
])
_navigate_to_execute(page, app_url, sess_id)
page.clock.run_for(50_000) # 60s slot - 10s = 50s elapsed
page.wait_for_timeout(300)
spoken = _spoken(page)
assert any(s["text"] == "Next: Voice EMOM Ex2" for s in spoken), \
f"Expected pre-announcement, got: {spoken}"
def test_tabata_speaks_next_on_rest(page: Page, app_url: str):
"""TABATA speaks 'Next: X' when the rest phase opens."""
_setup(page, app_url, install_clock=True)
_clean_all(page)
ex1 = _create_exercise(page, "Voice Tab Ex1")
ex2 = _create_exercise(page, "Voice Tab Ex2")
combo_id = _create_combo(page, "Voice Tab", "TABATA", [ex1, ex2],
timer_config={"workTime": 10, "restTime": 10})
sess_id = _create_session(page, "Voice Tab Session", [
{"item_id": combo_id, "item_type": "combo", "repetitions": 1},
])
_navigate_to_execute(page, app_url, sess_id)
page.clock.run_for(10_000) # work phase ends, rest phase opens
page.wait_for_timeout(300)
spoken = _spoken(page)
assert any(s["text"] == "Next: Voice Tab Ex2" for s in spoken), \
f"Expected pre-announcement, got: {spoken}"
def test_no_duplicate_announcement_after_preannounce(page: Page, app_url: str):
"""The step-change announcement is skipped right after a pre-announcement."""
_setup(page, app_url, install_clock=True)
_clean_all(page)
ex1 = _create_exercise(page, "Voice Dup Ex1")
ex2 = _create_exercise(page, "Voice Dup Ex2")
combo_id = _create_combo(page, "Voice Dup", "EMOM", [ex1, ex2])
sess_id = _create_session(page, "Voice Dup Session", [
{"item_id": combo_id, "item_type": "combo", "repetitions": 2},
])
_navigate_to_execute(page, app_url, sess_id)
page.clock.run_for(60_000) # full first slot: pre-announce at 50s, expire+advance at 60s
page.wait_for_timeout(300)
spoken = _spoken(page)
ex2_mentions = [s["text"] for s in spoken if "Voice Dup Ex2" in s["text"]]
assert ex2_mentions == ["Next: Voice Dup Ex2"], \
f"Expected only the pre-announcement, no duplicate step-change announcement: {spoken}"
def test_utterance_lang_follows_app_language(page: Page, app_url: str):
"""Utterance `lang` matches the current i18next language."""
_setup(page, app_url)
_clean_all(page)
page.evaluate("""async () => {
await window.__repos.settings.set('language', 'fr')
}""")
page.reload()
page.wait_for_function(
"document.documentElement.getAttribute('data-db-ready') === 'true'",
timeout=15000,
)
ex1 = _create_exercise(page, "Voice Lang Ex1")
sess_id = _create_session(page, "Voice Lang Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
spoken = _spoken(page)
match = next((s for s in spoken if s["text"] == "Voice Lang Ex1"), None)
assert match is not None, f"Expected title spoken: {spoken}"
assert match["lang"] == "fr-FR", f"Expected fr-FR, got {match['lang']}"
# Reset language back to English for other tests
page.evaluate("""async () => {
await window.__repos.settings.set('language', 'en')
}""")
def test_utterance_volume_follows_audio_volume(page: Page, app_url: str):
"""Utterance `volume` matches the `audio_volume` setting."""
_setup(page, app_url)
_clean_all(page)
page.evaluate("""async () => {
await window.__repos.settings.set('audio_volume', '30')
}""")
ex1 = _create_exercise(page, "Voice Vol Ex1")
sess_id = _create_session(page, "Voice Vol Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
spoken = _spoken(page)
match = next((s for s in spoken if s["text"] == "Voice Vol Ex1"), None)
assert match is not None, f"Expected title spoken: {spoken}"
assert match["volume"] == pytest.approx(0.3), f"Expected 0.3, got {match['volume']}"
page.evaluate("""async () => {
await window.__repos.settings.set('audio_volume', '50')
}""")
def test_voice_disabled_nothing_spoken(page: Page, app_url: str):
"""Unchecking the setting silences announcements and persists across reload."""
page.on("dialog", lambda dialog: dialog.accept())
_setup(page, app_url)
_clean_all(page)
page.goto(f"{app_url}#/settings")
page.wait_for_selector('[data-testid="settings-voice-enabled"]')
checkbox = page.locator('[data-testid="settings-voice-enabled"]')
expect(checkbox).to_be_checked()
checkbox.uncheck()
expect(checkbox).not_to_be_checked()
page.reload()
page.wait_for_selector('[data-testid="settings-voice-enabled"]')
expect(page.locator('[data-testid="settings-voice-enabled"]')).not_to_be_checked()
ex1 = _create_exercise(page, "Voice Off Ex1")
sess_id = _create_session(page, "Voice Off Session", [
{"item_id": ex1, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
spoken = _spoken(page)
assert spoken == [], f"Expected no announcements, got: {spoken}"
# Re-enable for other tests
page.goto(f"{app_url}#/settings")
page.wait_for_selector('[data-testid="settings-voice-enabled"]')
page.locator('[data-testid="settings-voice-enabled"]').check()
def test_works_with_speech_synthesis_absent(page: Page, app_url: str):
"""Execution works normally when `speechSynthesis` does not exist (red path)."""
_setup(page, app_url, mock_speech=False)
_clean_all(page)
errors = []
page.on("pageerror", lambda exc: errors.append(str(exc)))
ex_id = _create_exercise(page, "Voice Absent Ex")
sess_id = _create_session(page, "Voice Absent Session", [
{"item_id": ex_id, "item_type": "exercise", "repetitions": 5},
])
_navigate_to_execute(page, app_url, sess_id)
expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
"Voice Absent Ex"
)
page.click('[data-testid="execute-done-btn"]')
page.wait_for_function(
f"window.location.hash.includes('/sessions/{sess_id}') && !window.location.hash.includes('/execute')",
timeout=5000,
)
logs = page.evaluate("window.__repos.executionLogs.getAll()")
assert len(logs) == 1
assert logs[0]["status"] == "completed"
assert errors == [], f"Unexpected page errors: {errors}"

View file

@ -769,6 +769,33 @@ Action mapping deliberately reuses the on-screen handlers rather than inventing
**Key files:** `src/composables/useMediaSession.js`, `src/composables/useSessionExecution.js` (`goBack()`), `src/views/SessionExecuteView.vue` **Key files:** `src/composables/useMediaSession.js`, `src/composables/useSessionExecution.js` (`goBack()`), `src/views/SessionExecuteView.vue`
#### Voice Announcements
`useSpeech.js` wraps `window.speechSynthesis` / `SpeechSynthesisUtterance` (native, no dependency, works offline):
- `isSupported``'speechSynthesis' in window`; `speak()`/`cancel()` no-op when `false`.
- `init()` — reads the `voice_enabled` settings key once (default enabled) and caches it for the rest of the execution.
- `speak(text, { volume })` — calls `speechSynthesis.cancel()` first (a new announcement always preempts a stale one), then builds a `SpeechSynthesisUtterance` with `utterance.lang` mapped from the current i18next language (`en` → `en-US`, `fr``fr-FR`, `da``da-DK`) and `utterance.volume` set from the same `audio_volume` setting used for the Web Audio beeps.
- `cancel()` — stops any in-progress or queued utterance.
`useSessionExecution.js` calls `speak()` at two moments:
| Moment | Spoken text | Hook |
| ---------------------------------- | ------------------------------- | ------------------------------------------------------------------ |
| A step becomes current | The exercise title | `loadStepState()` — every step, including the first |
| EMOM: 10 s left in the current slot | `"Next: {title}"` (translated) | `_emomSounds(remaining)`, alongside the existing beep thresholds |
| TABATA: rest phase opens | `"Next: {title}"` | `_startRestPhase(step)`, right where the on-screen "Next:" label is already shown |
AMRAP and NONE combos only get the step-change announcement — matching the on-screen UI, which likewise shows no "next" preview until the step actually advances for those types.
To avoid the same exercise being announced twice within seconds (once as a pre-announcement, once when it becomes current), `useSessionExecution` tracks the queue index that was just pre-announced in a closure variable (`preAnnouncedIndex`); `loadStepState()` skips the step-change announcement exactly once when `currentIndex` reaches that index, then clears it.
`cancel()` is called on `exit()` and `_finish()` so nothing keeps talking after the session ends or the user leaves the view. The **Voice announcements** checkbox in Settings → Sound persists `voice_enabled` the same way `audio_volume` is persisted.
**Testing:** `speechSynthesis` is replaced by a mock in `page.add_init_script`, recording each `speak()` call's `{ text, lang, volume }`. Firefox exposes `window.speechSynthesis` as a getter-only property, so the mock must install it via `Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: … })` — a plain assignment silently no-ops and the real API keeps running underneath. `page.clock` drives the EMOM/TABATA timer-based pre-announcement tests deterministically, as in §"Audio Cues" above. A red-path test deletes `Window.prototype.speechSynthesis` to confirm execution still works with the API entirely absent.
**Key files:** `src/composables/useSpeech.js`, `src/composables/useSessionExecution.js`, `src/views/SettingsView.vue`
--- ---
### 3.8 Statistics ### 3.8 Statistics

View file

@ -509,6 +509,12 @@ Going back to a previous step and completing it again logs it a second time; not
This feature depends on your browser and OS supporting the Media Session API — where it isn't supported, the app works exactly as before, just without headset control. This feature depends on your browser and OS supporting the Media Session API — where it isn't supported, the app works exactly as before, just without headset control.
### Voice Announcements
During execution, a synthesized voice announces the exercise title whenever a new step begins — handy when you're not looking at the screen. For EMOM and TABATA combos, the app also pre-announces the *next* exercise ("Next: Squat") a few seconds ahead: 10 seconds before an EMOM slot ends, and as soon as a TABATA rest phase starts.
The voice follows your selected language and shares the same volume as the audio beeps. You can turn it off in **Settings → Sound** with the **Voice announcements** checkbox; it depends on your browser supporting speech synthesis — where it isn't supported, the app works exactly as before, just silently.
--- ---
## Statistics ## Statistics

View file

@ -511,6 +511,12 @@ Revenir à une étape précédente puis la terminer à nouveau l'enregistre une
Cette fonctionnalité dépend de la prise en charge de la Media Session API par votre navigateur et votre système — là où elle n'est pas prise en charge, l'application fonctionne exactement comme avant, simplement sans contrôle par écouteurs. Cette fonctionnalité dépend de la prise en charge de la Media Session API par votre navigateur et votre système — là où elle n'est pas prise en charge, l'application fonctionne exactement comme avant, simplement sans contrôle par écouteurs.
### Annonces vocales
Pendant l'exécution, une voix synthétisée annonce le nom de l'exercice à chaque nouvelle étape — pratique quand vous ne regardez pas l'écran. Pour les combos EMOM et TABATA, l'application annonce aussi à l'avance le *prochain* exercice (« Suivant : Squat ») quelques secondes avant : 10 secondes avant la fin d'un créneau EMOM, et dès le début d'une phase de repos TABATA.
La voix suit la langue sélectionnée et partage le même volume que les bips sonores. Vous pouvez la désactiver dans **Paramètres → Son** avec la case **Annonces vocales** ; elle dépend de la prise en charge de la synthèse vocale par votre navigateur — là où elle n'est pas prise en charge, l'application fonctionne exactement comme avant, simplement en silence.
--- ---
## Statistiques ## Statistiques