Реалізація Web Speech API (розпізнавання та синтез мовлення) на сайті
Web Speech API складається з двох незалежних частин: SpeechRecognition (мовлення → текст) та SpeechSynthesis (текст → мовлення). Перша потрібна для голосового управління, диктовки, голосового пошуку. Друга — для озвучування контенту, сповіщень, доступності.
Підтримка браузерами
SpeechRecognition: Chrome/Edge (з префіксом webkit), Android Chrome. Firefox та Safari — без підтримки. Для продакшену потрібен fallback на серверне ASR (Whisper/Deepgram).
SpeechSynthesis: усі сучасні браузери, включаючи Safari iOS.
Розпізнавання мовлення
const SpeechRecognition =
window.SpeechRecognition || (window as any).webkitSpeechRecognition
interface UseSpeechRecognitionOptions {
lang?: string
continuous?: boolean // Безперервна запис vs одна фраза
interimResults?: boolean // Проміжні результати в реальному часі
onResult: (transcript: string, isFinal: boolean) => void
onError?: (error: string) => void
}
function useSpeechRecognition({
lang = 'uk-UA',
continuous = false,
interimResults = true,
onResult,
onError,
}: UseSpeechRecognitionOptions) {
const recognitionRef = useRef<SpeechRecognition | null>(null)
const [isListening, setIsListening] = useState(false)
const [isSupported] = useState(() => 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
function start() {
if (!isSupported) {
onError?.('Браузер не підтримує розпізнавання мовлення')
return
}
const recognition = new SpeechRecognition()
recognition.lang = lang
recognition.continuous = continuous
recognition.interimResults = interimResults
recognition.maxAlternatives = 1
recognition.onstart = () => setIsListening(true)
recognition.onend = () => setIsListening(false)
recognition.onresult = (event: SpeechRecognitionEvent) => {
let finalTranscript = ''
let interimTranscript = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript
if (event.results[i].isFinal) {
finalTranscript += transcript
} else {
interimTranscript += transcript
}
}
if (finalTranscript) {
onResult(finalTranscript.trim(), true)
} else if (interimTranscript) {
onResult(interimTranscript.trim(), false)
}
}
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
const messages: Record<string, string> = {
'not-allowed': 'Доступ до мікрофону запрещено',
'no-speech': 'Мовлення не обнаружено',
'network': 'Помилка мережі під час розпізнавання',
'audio-capture': 'Мікрофон недоступен',
}
onError?.(messages[event.error] ?? event.error)
setIsListening(false)
}
recognitionRef.current = recognition
recognition.start()
}
function stop() {
recognitionRef.current?.stop()
recognitionRef.current = null
}
return { isListening, isSupported, start, stop }
}
Компонент голосової диктовки
function VoiceDictation({ onChange }: { onChange: (text: string) => void }) {
const [transcript, setTranscript] = useState('')
const [interim, setInterim] = useState('')
const { isListening, isSupported, start, stop } = useSpeechRecognition({
lang: 'uk-UA',
continuous: true,
interimResults: true,
onResult: (text, isFinal) => {
if (isFinal) {
setTranscript((prev) => {
const next = prev + (prev ? ' ' : '') + text
onChange(next)
return next
})
setInterim('')
} else {
setInterim(text)
}
},
onError: (err) => console.warn('Speech error:', err),
})
if (!isSupported) {
return <p className="text-sm text-gray-500">Голосовий ввід недоступен у цьому браузері</p>
}
return (
<div className="border rounded-lg p-3">
<div className="min-h-[80px] text-sm">
<span>{transcript}</span>
{interim && <span className="text-gray-400 italic"> {interim}</span>}
</div>
<div className="flex gap-2 mt-2 border-t pt-2">
<button
onClick={isListening ? stop : start}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm ${
isListening
? 'bg-red-100 text-red-700'
: 'bg-blue-100 text-blue-700'
}`}
>
{isListening ? (
<>
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
Стоп
</>
) : (
'Говорити'
)}
</button>
<button
onClick={() => { setTranscript(''); setInterim(''); onChange('') }}
className="text-sm text-gray-500 hover:text-gray-700"
>
Очистити
</button>
</div>
</div>
)
}
Голосові команди
function useVoiceCommands(commands: Record<string, () => void>) {
const { start, stop, isListening } = useSpeechRecognition({
lang: 'uk-UA',
continuous: true,
interimResults: false,
onResult: (transcript) => {
const lower = transcript.toLowerCase().trim()
for (const [phrase, action] of Object.entries(commands)) {
if (lower.includes(phrase)) {
action()
break
}
}
},
})
return { start, stop, isListening }
}
// Використання
const { start } = useVoiceCommands({
'наступний слайд': () => goToSlide(current + 1),
'попередній слайд': () => goToSlide(current - 1),
'перший слайд': () => goToSlide(0),
'повний екран': () => document.documentElement.requestFullscreen(),
})
Синтез мовлення (Text-to-Speech)
class TextToSpeech {
private synth = window.speechSynthesis
private currentUtterance: SpeechSynthesisUtterance | null = null
speak(text: string, options: {
lang?: string
rate?: number // 0.1–10, за замовчуванням 1
pitch?: number // 0–2, за замовчуванням 1
volume?: number // 0–1
voiceName?: string
onEnd?: () => void
} = {}) {
this.stop()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = options.lang ?? 'uk-UA'
utterance.rate = options.rate ?? 1
utterance.pitch = options.pitch ?? 1
utterance.volume = options.volume ?? 1
if (options.voiceName) {
const voices = this.synth.getVoices()
const voice = voices.find((v) => v.name === options.voiceName)
if (voice) utterance.voice = voice
}
if (options.onEnd) utterance.onend = options.onEnd
// Chrome workaround: довгий текст обрізається близько 15 секунд
utterance.onboundary = (event) => {
if (event.name === 'sentence') {
// Періодично "будимо" синтезатор
this.synth.pause()
this.synth.resume()
}
}
this.currentUtterance = utterance
this.synth.speak(utterance)
}
stop() {
this.synth.cancel()
this.currentUtterance = null
}
pause() { this.synth.pause() }
resume() { this.synth.resume() }
getVoices(): SpeechSynthesisVoice[] {
return this.synth.getVoices().filter((v) => v.lang.startsWith('uk'))
}
}
Fallback: Whisper API для серйозного ASR
Коли браузерного ASR недостатньо (низька якість, відсутність підтримки Firefox/Safari):
async function transcribeWithWhisper(audioBlob: Blob): Promise<string> {
const formData = new FormData()
formData.append('file', audioBlob, 'audio.webm')
formData.append('model', 'whisper-1')
formData.append('language', 'uk')
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
body: formData,
})
const data = await response.json()
return data.text
}
Запис через MediaRecorder API, отправка на /api/transcribe, яка проксирует в Whisper — ключ не утікає на фронт.
Що ми робимо
Визначаємо сценарій: голосовий пошук, диктовка, команди управління, TTS для доступності. Реалізуємо відповідну частину API, додаємо fallback (Whisper для ASR, браузерний TTS везде працює). Тестуємо на різних браузерах, враховуємо політику автоплея.
Строк: голосовий пошук або диктовка — 1–2 дні. Голосові команди + TTS — 2–3 дні. З Whisper fallback — плюс 1 день.







