/**
* Голосовой ввод для полей поиска.
* Автоматически добавляет кнопку микрофона к каждому 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();
}
})();