egorin Рация: качественный движок чтения — умный выбор голоса (приоритет онлайн/нейронных ru), последовательное чтение очереди через onend-цепочку

This commit is contained in:
german
2026-07-12 13:30:06 +03:00
parent 2f339803b4
commit 786b4ef4bb
+42 -9
View File
@@ -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<RadioState>((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;