Step 26 to announce exercise
This commit is contained in:
parent
49aaa0cd31
commit
1424029eae
|
|
@ -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
4
.gitignore
vendored
|
|
@ -35,3 +35,7 @@ tmp/
|
||||||
# Hugo build output
|
# Hugo build output
|
||||||
website/public/
|
website/public/
|
||||||
website/resources/
|
website/resources/
|
||||||
|
|
||||||
|
# temporary files
|
||||||
|
*.tmp
|
||||||
|
tmp/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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) -->
|
||||||
|
|
|
||||||
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`
|
**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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue