From 786b4ef4bb92fa7046488db9401a72f2dbabe965 Mon Sep 17 00:00:00 2001 From: german Date: Sun, 12 Jul 2026 13:30:06 +0300 Subject: [PATCH] =?UTF-8?q?egorin=20=D0=A0=D0=B0=D1=86=D0=B8=D1=8F:=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BE=D0=BA=20=D1=87?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=80=94=20=D1=83=D0=BC=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D1=81=D0=B0=20(=D0=BF=D1=80=D0=B8=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D1=82=20=D0=BE=D0=BD=D0=BB=D0=B0=D0=B9=D0=BD?= =?UTF-8?q?/=D0=BD=D0=B5=D0=B9=D1=80=D0=BE=D0=BD=D0=BD=D1=8B=D1=85=20ru),?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D1=87=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D0=B8=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20onend-=D1=86=D0=B5=D0=BF=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/radio.ts | 51 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/core/radio.ts b/src/core/radio.ts index 63f5c3c..223f2bd 100644 --- a/src/core/radio.ts +++ b/src/core/radio.ts @@ -46,18 +46,47 @@ function chime(sev: MonitorEvent['severity']) { } } -export function speak(text: string) { +// Умный выбор голоса: приоритет качества — онлайн/нейронные (Google/Yandex/Microsoft), +// затем именованные женские, затем любой ru. Кэш + переизбор на voiceschanged. +let cachedVoice: SpeechSynthesisVoice | null = null; +let voiceHooked = false; +function pickVoice(): SpeechSynthesisVoice | null { + if (!('speechSynthesis' in window)) return null; + const ru = window.speechSynthesis.getVoices().filter((v) => v.lang.toLowerCase().startsWith('ru')); + if (!ru.length) return null; + const score = (v: SpeechSynthesisVoice) => { + const n = v.name.toLowerCase(); + let s = 0; + if (!v.localService) s += 4; // remote-голоса обычно качественнее + if (/google|yandex|neural|natural|online|premium/.test(n)) s += 3; + if (/milena|dariya|alena|katya|irina|svetlana|tatyana|elena/.test(n)) s += 2; + if (/microsoft/.test(n)) s += 1; + return s; + }; + return ru.slice().sort((a, b) => score(b) - score(a))[0]; +} +function bestVoice(): SpeechSynthesisVoice | null { + if (!cachedVoice) cachedVoice = pickVoice(); + if (!voiceHooked && 'speechSynthesis' in window) { + voiceHooked = true; + window.speechSynthesis.addEventListener('voiceschanged', () => { cachedVoice = pickVoice(); }); + } + return cachedVoice; +} + +export function speak(text: string, opts?: { onend?: () => void }) { try { - if (!('speechSynthesis' in window)) return; + if (!('speechSynthesis' in window) || !text) { opts?.onend?.(); return; } const u = new SpeechSynthesisUtterance(text); u.lang = 'ru-RU'; - u.rate = 1; - u.pitch = 1; - const ru = window.speechSynthesis.getVoices().find((v) => v.lang.toLowerCase().startsWith('ru')); - if (ru) u.voice = ru; + u.rate = 1.0; + u.pitch = 1.03; // чуть живее, менее «робот» + const v = bestVoice(); + if (v) u.voice = v; + if (opts?.onend) { u.onend = () => opts.onend!(); u.onerror = () => opts.onend!(); } window.speechSynthesis.speak(u); } catch { - /* ignore */ + opts?.onend?.(); } } @@ -151,8 +180,12 @@ export const useRadio = create((set, get) => ({ clearRead: () => set({ readList: [] }), readAll: () => { ctx(); // прайм аудио на жесте - const list = get().readList; - list.forEach((t, i) => setTimeout(() => speak(t), i * 200)); // speechSynthesis сам ставит в очередь + if (!('speechSynthesis' in window)) return; + window.speechSynthesis.cancel(); // сбросить прошлую очередь + const list = get().readList.slice(); + let i = 0; + const next = () => { if (i >= list.length) return; speak(list[i++], { onend: next }); }; // последовательно, без наложения + next(); }, toggle: () => { const on = !get().on;