Реализация 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 = 'ru-RU',
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: 'ru-RU',
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: 'ru-RU',
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 ?? 'ru-RU'
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
// Workaround для Chrome: длинный текст обрезается через ~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('ru'))
}
}
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', 'ru')
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 для accessibility. Реализуем соответствующую часть API, добавляем fallback (Whisper для ASR, браузерный TTS везде работает без замены). Тестируем на различных браузерах, учитываем политику автоплея.
Срок: голосовой поиск или диктовка — 1–2 дня. Голосовые команды + TTS — 2–3 дня. С Whisper fallback — плюс 1 день.







