diff --git a/src/main/validate.ts b/src/main/validate.ts index 8a4b12d..5b711fe 100644 --- a/src/main/validate.ts +++ b/src/main/validate.ts @@ -244,6 +244,11 @@ export function validateSettingsPatch(raw: unknown): Partial | null { if (v === undefined) return null out.soundEnabled = v } + if ('voicePromptsEnabled' in raw) { + const v = bool(raw.voicePromptsEnabled) + if (v === undefined) return null + out.voicePromptsEnabled = v + } if ('notificationMode' in raw) { const v = oneOf(raw.notificationMode, VALID_NOTIFY) if (v === undefined) return null diff --git a/src/renderer/src/ReminderApp.tsx b/src/renderer/src/ReminderApp.tsx index 5494fa6..99a9508 100644 --- a/src/renderer/src/ReminderApp.tsx +++ b/src/renderer/src/ReminderApp.tsx @@ -20,6 +20,7 @@ import type { import { statLabel } from '@shared/types' import { Icon } from './lib/icon' import { formatInterval } from './lib/format' +import { speak } from './lib/tts' import { translate, translateN } from './i18n' type Mode = @@ -41,11 +42,32 @@ export default function ReminderApp(): JSX.Element { const u0 = window.api.onStateChanged((s) => setSettings(s.settings)) const u1 = window.api.onFire((ex) => { setMode({ kind: 'exercise', exercise: ex }) - if (settingsRef.current?.soundEnabled) playBeep() + const s = settingsRef.current + if (s?.soundEnabled) playBeep() + if (s?.voicePromptsEnabled) { + // «{exercise.name}, {n} раз/раза/раз». Простая локальная фраза без + // ключа в dict — короткая команда, не нуждается в полном переводе. + const lang = s.language ?? 'ru' + const phrase = + lang === 'ru' + ? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}` + : `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}` + speak(phrase, lang) + } }) const u2 = window.api.onMatchEnd((summary) => { setMode({ kind: 'match', summary, done: new Set() }) - if (settingsRef.current?.soundEnabled) playBeep() + const s = settingsRef.current + if (s?.soundEnabled) playBeep() + if (s?.voicePromptsEnabled) { + const total = summary.results.reduce((acc, r) => acc + r.reps, 0) + const lang = s.language ?? 'ru' + const phrase = + lang === 'ru' + ? `Матч завершён. ${total} ${repWordRu(total)} ждут.` + : `Match complete. ${total} ${total === 1 ? 'rep' : 'reps'} await.` + speak(phrase, lang) + } }) return () => { u0() @@ -478,6 +500,18 @@ function ChallengeRow({ ) } +/** + * CLDR-минимум для русского склонения «раз». 1 раз / 2 раза / 5 раз. + * Не тащим сюда полную плюрализацию из i18n — это TTS-only фраза. + */ +function repWordRu(n: number): string { + const m10 = Math.abs(n) % 10 + const m100 = Math.abs(n) % 100 + if (m10 === 1 && m100 !== 11) return 'раз' + if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return 'раза' + return 'раз' +} + function playBeep(): void { try { const Ctx = diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 4f0d812..b5c21ca 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -165,6 +165,9 @@ export const ru: Dict = { 'settings.notification_mode.both': 'Окно и уведомление', 'settings.sound.label': 'Звук уведомления', 'settings.sound.hint': 'Короткий сигнал при срабатывании', + 'settings.voice.label': 'Голосовая подсказка', + 'settings.voice.hint': + 'Диктор произносит название упражнения и количество — полезно когда фокус на коде.', 'settings.snooze.label': '«Отложить» на', 'settings.snooze.hint': 'Сколько минут добавлять при отложении', 'settings.snooze.1': '1 минута', @@ -427,6 +430,9 @@ export const en: Dict = { 'settings.notification_mode.both': 'Window and notification', 'settings.sound.label': 'Notification sound', 'settings.sound.hint': 'Short beep on trigger', + 'settings.voice.label': 'Voice prompt', + 'settings.voice.hint': + 'Speaks the exercise name and count — useful when your eyes are on the code.', 'settings.snooze.label': '“Snooze” for', 'settings.snooze.hint': 'How many minutes to postpone', 'settings.snooze.1': '1 minute', diff --git a/src/renderer/src/lib/tts.ts b/src/renderer/src/lib/tts.ts new file mode 100644 index 0000000..6c36064 --- /dev/null +++ b/src/renderer/src/lib/tts.ts @@ -0,0 +1,67 @@ +/** + * Тонкая обёртка над Web Speech API для голосовых подсказок упражнений. + * + * Используется в ReminderApp при `settings.voicePromptsEnabled`. Голос + * подбирается под `settings.language`: ищем первый локальный голос + * с правильным `lang` префиксом (ru-RU / en-US), fallback на default. + * + * Тихий fail: если браузер/Electron не поддерживает Speech Synthesis + * (мало вероятно в Chromium) — просто ничего не делаем, не падаем. + */ +import type { Language } from '@shared/types' + +const LANG_BCP47: Record = { + ru: 'ru-RU', + en: 'en-US' +} + +let voicesLoaded = false +let cachedVoices: SpeechSynthesisVoice[] = [] + +function ensureVoices(): SpeechSynthesisVoice[] { + if (typeof window === 'undefined' || !('speechSynthesis' in window)) return [] + if (voicesLoaded && cachedVoices.length) return cachedVoices + cachedVoices = window.speechSynthesis.getVoices() + // Chromium часто отдаёт пустой массив до voiceschanged-event'а — подпишемся + // один раз, чтобы при следующем speak() voices уже были. + if (cachedVoices.length === 0) { + window.speechSynthesis.onvoiceschanged = () => { + cachedVoices = window.speechSynthesis.getVoices() + voicesLoaded = true + } + } else { + voicesLoaded = true + } + return cachedVoices +} + +function pickVoice(lang: Language): SpeechSynthesisVoice | undefined { + const target = LANG_BCP47[lang] + const voices = ensureVoices() + // 1. Точное совпадение `ru-RU` / `en-US`. + const exact = voices.find((v) => v.lang === target) + if (exact) return exact + // 2. Любой голос с правильным language-кодом (`ru`, `en`). + const partial = voices.find((v) => v.lang.startsWith(lang)) + if (partial) return partial + // 3. Default — fallback на default-голос системы. + return voices.find((v) => v.default) +} + +export function speak(text: string, lang: Language): void { + if (typeof window === 'undefined' || !('speechSynthesis' in window)) return + try { + // Прервать предыдущее озвучивание (если игрок быстро жмёт reminder'ы). + window.speechSynthesis.cancel() + const utter = new SpeechSynthesisUtterance(text) + const voice = pickVoice(lang) + if (voice) utter.voice = voice + utter.lang = LANG_BCP47[lang] + utter.rate = 1.0 + utter.pitch = 1.0 + utter.volume = 0.85 + window.speechSynthesis.speak(utter) + } catch { + // Не критично — TTS опционален. + } +} diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 0c12571..f4df79a 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -77,6 +77,12 @@ export default function SettingsPage(): JSX.Element { checked={settings.soundEnabled} onChange={(v) => patch({ soundEnabled: v })} /> + patch({ voicePromptsEnabled: v })} + />