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;