/
Dgunamel
/
META_SCAN
Обзор
Документация
Войти
/
Dgunamel
/
META_SCAN
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
src/lib/voice.ts
143 строки
4 KB
studio-agent
Squash 'studio-workflow-fe2fa627-c50c-4d52-a35d-40c89921c945' into dev
30 июл 2026, 01:17
30 июл 2026, 01:17
72a8b45
Код
Авторство
О чём код?
"use client"; // Модульный синглтон состояния озвучки. Раньше каждый вызов useVoice() создавал // свой собственный enabledRef в отдельном инстансе хука — из-за этого тумблер // в Header обновлял инстанс в MetaScan, а инстанс в ProtocolRunner оставался // выключенным, и speakAsync резолвился мгновенно без речи. import { useEffect, useState } from "react"; type Listener = (enabled: boolean) => void; const store: { enabled: boolean; listeners: Set<Listener> } = { enabled: false, listeners: new Set(), }; function getSpeech(): SpeechSynthesis | null { if (typeof window === "undefined") return null; return window.speechSynthesis ?? null; } let voicesLoaded = false; let preferredVoice: SpeechSynthesisVoice | null = null; function ensureVoices(): void { const speech = getSpeech(); if (!speech) return; if (voicesLoaded) return; voicesLoaded = true; const pick = () => { const voices = speech.getVoices(); preferredVoice = voices.find((v) => /ru-RU|ru_RU|^ru$/i.test(v.lang)) || voices.find((v) => /ru/i.test(v.lang)) || voices[0] || null; }; pick(); // В Chrome список голосов приходит асинхронно speech.onvoiceschanged = pick; } function setEnabled(on: boolean): void { store.enabled = on; if (!on) { try { getSpeech()?.cancel(); } catch {} } store.listeners.forEach((l) => { try { l(on); } catch {} }); } function cancel(): void { try { getSpeech()?.cancel(); } catch {} } // Озвучивает и возвращает Promise, который резолвится после окончания речи // или после страховочного таймаута (на случай если onend не сработает — это // распространённая проблема на мобильных браузерах). function speakAsync( text: string, opts: { rate?: number; pitch?: number; timeoutMs?: number } = {}, ): Promise<void> { return new Promise((resolve) => { if (!store.enabled) { resolve(); return; } const speech = getSpeech(); if (!speech) { resolve(); return; } ensureVoices(); let settled = false; const settle = () => { if (settled) return; settled = true; resolve(); }; try { speech.cancel(); const utter = new SpeechSynthesisUtterance(text); if (preferredVoice) utter.voice = preferredVoice; utter.lang = preferredVoice?.lang || "ru-RU"; utter.rate = opts.rate ?? 1; utter.pitch = opts.pitch ?? 1; utter.onend = settle; utter.onerror = settle; speech.speak(utter); // Страховка: если ни onend, ни onerror не сработали (iOS Safari при // неактивной вкладке, политика автозапуска), резолвим по таймауту. const t = opts.timeoutMs ?? 8000; setTimeout(settle, t); } catch { settle(); } }); } function speak(text: string, opts?: { rate?: number; pitch?: number }): void { if (!store.enabled) return; const speech = getSpeech(); if (!speech) return; try { speech.cancel(); const utter = new SpeechSynthesisUtterance(text); if (preferredVoice) utter.voice = preferredVoice; utter.lang = preferredVoice?.lang || "ru-RU"; utter.rate = opts?.rate ?? 1; utter.pitch = opts?.pitch ?? 1; speech.speak(utter); } catch {} } function isSupported(): boolean { return getSpeech() !== null; } function isEnabled(): boolean { return store.enabled; } function subscribe(listener: Listener): () => void { store.listeners.add(listener); return () => { store.listeners.delete(listener); }; } // Хук-обёртка для компонентов. Возвращает те же методы, что и раньше, // но enabled-флаг теперь глобальный (один на всё приложение). export function useVoice() { const [enabled, setLocal] = useState<boolean>(() => store.enabled); useEffect(() => { const unsub = subscribe((v) => setLocal(v)); setLocal(store.enabled); return unsub; }, []); return { speak, speakAsync, cancel, setEnabled, isSupported, isEnabled: enabled, }; }