feat(#8): TTS-голосовые подсказки в окне напоминания (opt-in)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
67
src/renderer/src/lib/tts.ts
Normal file
67
src/renderer/src/lib/tts.ts
Normal 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 опционален.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user