Step 26 to announce exercise
This commit is contained in:
parent
49aaa0cd31
commit
1424029eae
|
|
@ -35,6 +35,7 @@
|
|||
"Bash(python *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(pip *)",
|
||||
"Bash(source *)",
|
||||
"Bash(./venv/bin/python *)",
|
||||
"Bash(cd tests && .venv/bin/python *)",
|
||||
"Bash(cd *)"
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -35,3 +35,7 @@ tmp/
|
|||
# Hugo build output
|
||||
website/public/
|
||||
website/resources/
|
||||
|
||||
# temporary files
|
||||
*.tmp
|
||||
tmp/
|
||||
|
|
|
|||
|
|
@ -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 `<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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# TrainUs — Implementation Plan (v3)
|
||||
|
||||
> **Last updated:** 2026-07-05
|
||||
> **Status:** Step 24 implemented (app-update modal) — awaiting manual validation; Step 25 implemented (headset media controls) — awaiting manual validation; Step 26 planned (voice announcements)
|
||||
> **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 implemented (voice announcements) — awaiting manual validation
|
||||
|
||||
## 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)
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import i18next from 'i18next'
|
||||
import { sessionRepository } from '../db/repositories/sessionRepository.js'
|
||||
import { comboRepository } from '../db/repositories/comboRepository.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 { useTimer } from './useTimer.js'
|
||||
import { useAudioCues } from './useAudioCues.js'
|
||||
import { useSpeech } from './useSpeech.js'
|
||||
|
||||
export function useSessionExecution(sessionId) {
|
||||
const sessionTitle = ref('')
|
||||
|
|
@ -35,24 +37,45 @@ export function useSessionExecution(sessionId) {
|
|||
stop: stopTimer,
|
||||
} = useTimer()
|
||||
const { playBip, playBIP } = useAudioCues()
|
||||
const { init: initSpeech, speak, cancel: cancelSpeech } = useSpeech()
|
||||
const audioVolume = ref(0.5)
|
||||
let preAnnouncedIndex = null
|
||||
|
||||
const currentStep = computed(() => queue.value[currentIndex.value] || null)
|
||||
const nextStep = computed(() => queue.value[currentIndex.value + 1] || null)
|
||||
const totalSteps = computed(() => queue.value.length)
|
||||
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() {
|
||||
const step = currentStep.value
|
||||
if (!step) return
|
||||
currentReps.value = step.prefillReps
|
||||
currentWeight.value = step.prefillWeight
|
||||
currentSide.value = step.prefillSide
|
||||
if (preAnnouncedIndex === currentIndex.value) {
|
||||
preAnnouncedIndex = null
|
||||
} else {
|
||||
_announceStep(step)
|
||||
}
|
||||
_startStepTimer(step)
|
||||
}
|
||||
|
||||
function _emomSounds(remaining) {
|
||||
if (remaining === 30 || remaining === 2 || remaining === 1) playBip(audioVolume.value)
|
||||
if (remaining === 10) _preAnnounceNext()
|
||||
}
|
||||
|
||||
function _amrapLastMinuteSounds(remaining) {
|
||||
|
|
@ -101,6 +124,7 @@ export function useSessionExecution(sessionId) {
|
|||
function _startRestPhase(step) {
|
||||
timerPhase.value = 'rest'
|
||||
playBIP(audioVolume.value)
|
||||
_preAnnounceNext()
|
||||
startTimer(step.restTime, {
|
||||
onTick: (r) => _tabataCountdown(r, step.restTime),
|
||||
onExpire: async () => {
|
||||
|
|
@ -154,6 +178,7 @@ export function useSessionExecution(sessionId) {
|
|||
const volumeStr = await settingsRepository.get('audio_volume')
|
||||
const parsed = parseInt(volumeStr, 10)
|
||||
audioVolume.value = isNaN(parsed) ? 0.5 : parsed / 100
|
||||
await initSpeech()
|
||||
|
||||
const session = await sessionRepository.getById(sessionId)
|
||||
if (!session) throw new Error('Session not found')
|
||||
|
|
@ -414,6 +439,7 @@ export function useSessionExecution(sessionId) {
|
|||
}
|
||||
|
||||
async function _finish() {
|
||||
cancelSpeech()
|
||||
await executionLogRepository.update(logId.value, {
|
||||
finished_at: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
|
|
@ -425,6 +451,7 @@ export function useSessionExecution(sessionId) {
|
|||
if (sessionDone.value) return
|
||||
stopTimer()
|
||||
timerPhase.value = null
|
||||
cancelSpeech()
|
||||
await executionLogRepository.update(logId.value, {
|
||||
finished_at: new Date().toISOString(),
|
||||
status: 'aborted',
|
||||
|
|
|
|||
33
src/composables/useSpeech.js
Normal file
33
src/composables/useSpeech.js
Normal 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 }
|
||||
}
|
||||
|
|
@ -274,6 +274,7 @@
|
|||
"sound": "Lyd",
|
||||
"soundVolume": "Lydstyrke",
|
||||
"soundTest": "Test",
|
||||
"voiceAnnouncements": "Stemmemeddelelser",
|
||||
"credits": "Kreditering",
|
||||
"manageJournal": "Administrer øvelseslogge",
|
||||
"manageJournalHint": "Slet øvelseslogposter inden for et datointerval.",
|
||||
|
|
@ -416,7 +417,10 @@
|
|||
"showDetails": "Vis øvelsesdetaljer",
|
||||
"hideDetails": "Skjul øvelsesdetaljer",
|
||||
"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": {
|
||||
"reminder": {
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@
|
|||
"sound": "Sound",
|
||||
"soundVolume": "Volume",
|
||||
"soundTest": "Test",
|
||||
"voiceAnnouncements": "Voice announcements",
|
||||
"credits": "Credits",
|
||||
"manageJournal": "Manage Exercise Logs",
|
||||
"manageJournalHint": "Delete exercise log entries within a date range.",
|
||||
|
|
@ -416,7 +417,10 @@
|
|||
"showDetails": "Show exercise details",
|
||||
"hideDetails": "Hide exercise details",
|
||||
"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": {
|
||||
"reminder": {
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@
|
|||
"sound": "Son",
|
||||
"soundVolume": "Volume",
|
||||
"soundTest": "Tester",
|
||||
"voiceAnnouncements": "Annonces vocales",
|
||||
"credits": "Crédits",
|
||||
"manageJournal": "Gérer les journaux d'exercices",
|
||||
"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",
|
||||
"hideDetails": "Masquer les détails de l'exercice",
|
||||
"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": {
|
||||
"reminder": {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const userUuid = ref('')
|
|||
const language = ref('en')
|
||||
const theme = ref('light')
|
||||
const audioVolume = ref(50)
|
||||
const voiceEnabled = ref(true)
|
||||
const nameError = ref('')
|
||||
const restoring = ref(false)
|
||||
const restoreReport = ref(null)
|
||||
|
|
@ -72,6 +73,8 @@ onMounted(async () => {
|
|||
const volumeStr = await get('audio_volume')
|
||||
const parsed = parseInt(volumeStr, 10)
|
||||
audioVolume.value = isNaN(parsed) ? 50 : parsed
|
||||
const voiceStr = await get('voice_enabled')
|
||||
voiceEnabled.value = voiceStr === null ? true : voiceStr === 'true'
|
||||
await loadSuffixes()
|
||||
await loadBackupInfo()
|
||||
await loadBackupStatus()
|
||||
|
|
@ -104,6 +107,10 @@ async function onVolumeChange() {
|
|||
await set('audio_volume', String(audioVolume.value))
|
||||
}
|
||||
|
||||
async function onVoiceEnabledChange() {
|
||||
await set('voice_enabled', String(voiceEnabled.value))
|
||||
}
|
||||
|
||||
function onSoundTest() {
|
||||
const vol = audioVolume.value / 100
|
||||
playBip(vol)
|
||||
|
|
@ -418,6 +425,15 @@ async function onBackupReminderDaysChange() {
|
|||
{{ $t('settings.soundTest') }}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Restore overlay (spinner) -->
|
||||
|
|
|
|||
356
tests/test_step26_voice_announcements.py
Normal file
356
tests/test_step26_voice_announcements.py
Normal 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}"
|
||||
|
|
@ -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`
|
||||
|
||||
#### 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue