/** * Голосовой ввод для полей поиска. * Автоматически добавляет кнопку микрофона к каждому input с классом "voice-input". * Если на сервере задан _SPEECHKIT_API_KEY — запись с микрофона → LPCM 16 кГц моно → * ajax-send/send_request_speechkit.php → Yandex SpeechKit. Иначе — Web Speech API браузера (Chrome, Edge, Safari). * Режим: зажать кнопку — говорить — отпустить. */ (function() { var SPEECHKIT_URL = '/ajax-send/send_request_speechkit.php'; var TARGET_RATE = 16000; // Лимит синхронного распознавания SpeechKit — 30 с, оставляем запас var MAX_SECONDS = 29; var MIN_SECONDS = 0.3; var MIC_ICON = ''; var BUSY_ICON = ''; // null — ответ сервера ещё не пришёл (пока работает браузерный распознаватель) var yandexEnabled = null; var activeRecognition = null; var activeBtn = null; // Запись для SpeechKit: {btn, input, pressed, stream, ctx, source, proc, chunks, timer} var session = null; var busy = false; function speechkitUrl(action) { return SPEECHKIT_URL + '?guid=' + encodeURIComponent(window.guid || '') + (action ? '&action=' + action : ''); } function loadStatus() { var xhr = new XMLHttpRequest(); xhr.open('GET', speechkitUrl('status')); xhr.onload = function() { var data = null; try { data = JSON.parse(xhr.responseText); } catch (e) {} yandexEnabled = !!(data && data.ok && data.enabled); }; xhr.onerror = function() { yandexEnabled = false; }; xhr.send(); } // Симулируем нажатие Enter для запуска поиска function submitSearch(input) { input.blur(); if (input.value.length >= 3) { var enterEvent = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, which: 13, bubbles: true }); input.dispatchEvent(enterEvent); } } // ===== Web Speech API браузера ===== function stopRecognition() { if (activeRecognition) { activeRecognition.stop(); activeRecognition = null; } if (activeBtn) { activeBtn.classList.remove('voice-input-btn--active'); activeBtn = null; } } function startBrowserRecording(btn, input) { var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { alert('Голосовой ввод не поддерживается в этом браузере. Используйте Chrome или Edge.'); return; } if (activeRecognition) { stopRecognition(); return; } var recognition = new SpeechRecognition(); recognition.lang = 'ru-RU'; recognition.interimResults = true; recognition.continuous = true; recognition.maxAlternatives = 1; activeRecognition = recognition; activeBtn = btn; btn.classList.add('voice-input-btn--active'); // Очищаем поле и ставим фокус при начале записи input.value = ''; input.focus(); recognition.onresult = function(event) { var finalText = ''; var interimText = ''; for (var i = 0; i < event.results.length; i++) { if (event.results[i].isFinal) { finalText += event.results[i][0].transcript; } else { interimText += event.results[i][0].transcript; } } input.value = finalText + interimText; }; recognition.onerror = function() { stopRecognition(); }; recognition.onend = function() { if (activeBtn === btn) { btn.classList.remove('voice-input-btn--active'); activeRecognition = null; activeBtn = null; } submitSearch(input); }; recognition.start(); } // ===== Yandex SpeechKit ===== function cleanupAudio(s) { if (s.timer) clearTimeout(s.timer); if (s.proc) { s.proc.onaudioprocess = null; s.proc.disconnect(); } if (s.source) s.source.disconnect(); if (s.stream) s.stream.getTracks().forEach(function(t) { t.stop(); }); if (s.ctx && s.ctx.close) s.ctx.close(); } // Склейка блоков Float32 и понижение частоты усреднением до TARGET_RATE → Int16 LE function encodeLpcm(chunks, inputRate) { var total = 0, i; for (i = 0; i < chunks.length; i++) total += chunks[i].length; var samples = new Float32Array(total); var offset = 0; for (i = 0; i < chunks.length; i++) { samples.set(chunks[i], offset); offset += chunks[i].length; } var ratio = inputRate / TARGET_RATE; var outLength = Math.floor(total / ratio); var view = new DataView(new ArrayBuffer(outLength * 2)); var pos = 0; for (i = 0; i < outLength; i++) { var end = Math.min(Math.floor((i + 1) * ratio), total); var sum = 0, count = 0; for (; pos < end; pos++) { sum += samples[pos]; count++; } var v = count ? sum / count : 0; v = Math.max(-1, Math.min(1, v)); view.setInt16(i * 2, v < 0 ? v * 0x8000 : v * 0x7FFF, true); } return view.buffer; } function setBusy(btn, on) { busy = on; btn.classList.toggle('voice-input-btn--busy', on); btn.innerHTML = on ? BUSY_ICON : MIC_ICON; } function recognize(s, audio) { var btn = s.btn, input = s.input; setBusy(btn, true); var xhr = new XMLHttpRequest(); xhr.open('POST', speechkitUrl()); xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.timeout = 30000; xhr.onload = function() { setBusy(btn, false); var data = null; try { data = JSON.parse(xhr.responseText); } catch (e) {} if (!data || !data.ok) { alert((data && data.error) || 'Не удалось распознать речь'); return; } if (!data.text) return; input.value = data.text; submitSearch(input); }; xhr.onerror = xhr.ontimeout = function() { setBusy(btn, false); alert('Сервис распознавания речи недоступен'); }; xhr.send(audio); } function stopYandexRecording() { var s = session; if (!s) return; session = null; s.pressed = false; s.btn.classList.remove('voice-input-btn--active'); // Поток ещё не получен (отпустили во время запроса доступа) — дочистит startYandexRecording if (!s.ctx) return; var rate = s.ctx.sampleRate; var chunks = s.chunks; cleanupAudio(s); var length = 0; for (var i = 0; i < chunks.length; i++) length += chunks[i].length; if (length / rate < MIN_SECONDS) return; recognize(s, encodeLpcm(chunks, rate)); } function startYandexRecording(btn, input) { if (busy) return; if (session) { stopYandexRecording(); return; } if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert('Голосовой ввод недоступен: браузер не даёт доступ к микрофону (нужен HTTPS).'); return; } var AudioCtx = window.AudioContext || window.webkitAudioContext; if (!AudioCtx) { alert('Голосовой ввод не поддерживается в этом браузере.'); return; } var s = { btn: btn, input: input, pressed: true, chunks: [] }; session = s; // AudioContext создаём синхронно в обработчике нажатия — иначе Safari оставит его suspended var ctx = new AudioCtx(); navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } }).then(function(stream) { if (!s.pressed) { // Кнопку отпустили, пока браузер спрашивал разрешение stream.getTracks().forEach(function(t) { t.stop(); }); if (ctx.close) ctx.close(); return; } s.stream = stream; s.ctx = ctx; if (ctx.resume) ctx.resume(); s.source = ctx.createMediaStreamSource(stream); s.proc = ctx.createScriptProcessor(4096, 1, 1); s.proc.onaudioprocess = function(ev) { s.chunks.push(new Float32Array(ev.inputBuffer.getChannelData(0))); }; s.source.connect(s.proc); // Без подключения к выходу Chrome не вызывает onaudioprocess (выход — тишина) s.proc.connect(ctx.destination); s.timer = setTimeout(stopYandexRecording, MAX_SECONDS * 1000); btn.classList.add('voice-input-btn--active'); }).catch(function(err) { if (session === s) session = null; if (ctx.close) ctx.close(); alert(err && err.name === 'NotAllowedError' ? 'Нет доступа к микрофону. Разрешите его в настройках браузера.' : 'Не удалось включить микрофон.'); }); } function initVoiceInput(input) { var parent = input.parentNode; var wrap; var parentStyle = window.getComputedStyle(parent); if (parentStyle.position === 'relative') { wrap = parent; } else { wrap = document.createElement('div'); wrap.className = 'voice-input-wrap'; parent.insertBefore(wrap, input); wrap.appendChild(input); } var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'voice-input-btn'; btn.title = 'Зажмите и говорите'; btn.innerHTML = MIC_ICON; wrap.appendChild(btn); var clearBtn = wrap.querySelector('.search-clear-btn'); if (clearBtn) { btn.classList.add('voice-input-btn--with-clear'); clearBtn.classList.add('search-clear-btn--with-voice'); input.style.paddingRight = '5em'; } function startRecording(e) { e.preventDefault(); e.stopPropagation(); if (yandexEnabled) { startYandexRecording(btn, input); } else { startBrowserRecording(btn, input); } } function endRecording(e) { e.preventDefault(); e.stopPropagation(); if (session && session.btn === btn) { stopYandexRecording(); } if (activeRecognition && activeBtn === btn) { stopRecognition(); } } btn.addEventListener('mousedown', startRecording); btn.addEventListener('mouseup', endRecording); btn.addEventListener('mouseleave', endRecording); btn.addEventListener('touchstart', startRecording, { passive: false }); btn.addEventListener('touchend', endRecording, { passive: false }); btn.addEventListener('touchcancel', endRecording, { passive: false }); btn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }); } function init() { var inputs = document.querySelectorAll('input.voice-input'); if (!inputs.length) return; loadStatus(); for (var i = 0; i < inputs.length; i++) { initVoiceInput(inputs[i]); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();