feat(#8): TTS-голосовые подсказки в окне напоминания (opt-in)

This commit is contained in:
AnRil
2026-05-22 13:36:29 +07:00
parent 72e54c579d
commit 50c56fec79
6 changed files with 127 additions and 2 deletions

View File

@@ -244,6 +244,11 @@ export function validateSettingsPatch(raw: unknown): Partial<Settings> | null {
if (v === undefined) return null if (v === undefined) return null
out.soundEnabled = v out.soundEnabled = v
} }
if ('voicePromptsEnabled' in raw) {
const v = bool(raw.voicePromptsEnabled)
if (v === undefined) return null
out.voicePromptsEnabled = v
}
if ('notificationMode' in raw) { if ('notificationMode' in raw) {
const v = oneOf(raw.notificationMode, VALID_NOTIFY) const v = oneOf(raw.notificationMode, VALID_NOTIFY)
if (v === undefined) return null if (v === undefined) return null

View File

@@ -20,6 +20,7 @@ import type {
import { statLabel } from '@shared/types' import { statLabel } from '@shared/types'
import { Icon } from './lib/icon' import { Icon } from './lib/icon'
import { formatInterval } from './lib/format' import { formatInterval } from './lib/format'
import { speak } from './lib/tts'
import { translate, translateN } from './i18n' import { translate, translateN } from './i18n'
type Mode = type Mode =
@@ -41,11 +42,32 @@ export default function ReminderApp(): JSX.Element {
const u0 = window.api.onStateChanged((s) => setSettings(s.settings)) const u0 = window.api.onStateChanged((s) => setSettings(s.settings))
const u1 = window.api.onFire((ex) => { const u1 = window.api.onFire((ex) => {
setMode({ kind: 'exercise', exercise: 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) => { const u2 = window.api.onMatchEnd((summary) => {
setMode({ kind: 'match', summary, done: new Set() }) 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 () => { return () => {
u0() 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 { function playBeep(): void {
try { try {
const Ctx = const Ctx =

View File

@@ -165,6 +165,9 @@ export const ru: Dict = {
'settings.notification_mode.both': 'Окно и уведомление', 'settings.notification_mode.both': 'Окно и уведомление',
'settings.sound.label': 'Звук уведомления', 'settings.sound.label': 'Звук уведомления',
'settings.sound.hint': 'Короткий сигнал при срабатывании', 'settings.sound.hint': 'Короткий сигнал при срабатывании',
'settings.voice.label': 'Голосовая подсказка',
'settings.voice.hint':
'Диктор произносит название упражнения и количество — полезно когда фокус на коде.',
'settings.snooze.label': '«Отложить» на', 'settings.snooze.label': '«Отложить» на',
'settings.snooze.hint': 'Сколько минут добавлять при отложении', 'settings.snooze.hint': 'Сколько минут добавлять при отложении',
'settings.snooze.1': '1 минута', 'settings.snooze.1': '1 минута',
@@ -427,6 +430,9 @@ export const en: Dict = {
'settings.notification_mode.both': 'Window and notification', 'settings.notification_mode.both': 'Window and notification',
'settings.sound.label': 'Notification sound', 'settings.sound.label': 'Notification sound',
'settings.sound.hint': 'Short beep on trigger', '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.label': '“Snooze” for',
'settings.snooze.hint': 'How many minutes to postpone', 'settings.snooze.hint': 'How many minutes to postpone',
'settings.snooze.1': '1 minute', 'settings.snooze.1': '1 minute',

View File

@@ -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<Language, string> = {
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 опционален.
}
}

View File

@@ -77,6 +77,12 @@ export default function SettingsPage(): JSX.Element {
checked={settings.soundEnabled} checked={settings.soundEnabled}
onChange={(v) => patch({ soundEnabled: v })} onChange={(v) => patch({ soundEnabled: v })}
/> />
<ToggleRow
label={t('settings.voice.label')}
hint={t('settings.voice.hint')}
checked={settings.voicePromptsEnabled}
onChange={(v) => patch({ voicePromptsEnabled: v })}
/>
<SelectRow <SelectRow
label={t('settings.snooze.label')} label={t('settings.snooze.label')}
hint={t('settings.snooze.hint')} hint={t('settings.snooze.hint')}

View File

@@ -30,6 +30,12 @@ export type Settings = {
globalEnabled: boolean globalEnabled: boolean
notificationMode: NotificationMode notificationMode: NotificationMode
soundEnabled: boolean soundEnabled: boolean
/**
* TTS голос диктора в окне напоминания: «Время приседать. Десять раз».
* Полезно когда работаешь head-down (например пишешь код) — beep можно
* пропустить, голос — нет.
*/
voicePromptsEnabled: boolean
startWithWindows: boolean startWithWindows: boolean
minimizeToTray: boolean minimizeToTray: boolean
startMinimized: boolean startMinimized: boolean
@@ -173,6 +179,7 @@ export const DEFAULT_SETTINGS: Settings = {
globalEnabled: true, globalEnabled: true,
notificationMode: 'modal', notificationMode: 'modal',
soundEnabled: true, soundEnabled: true,
voicePromptsEnabled: false, // opt-in — на работе с коллегами может смущать
startWithWindows: false, startWithWindows: false,
minimizeToTray: true, minimizeToTray: true,
startMinimized: false, startMinimized: false,