289 lines
9.1 KiB
JavaScript
289 lines
9.1 KiB
JavaScript
|
|
// 语音输入功能
|
|||
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|||
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|||
|
|
const agentInput = document.getElementById('agentInput');
|
|||
|
|
const sendAgentBtn = document.getElementById('sendAgentBtn');
|
|||
|
|
const status = document.getElementById('status');
|
|||
|
|
|
|||
|
|
if (!agentInput || !sendAgentBtn || !sendAgentBtn.parentNode) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const voiceBtn = document.createElement('button');
|
|||
|
|
voiceBtn.className = 'btn btn-voice';
|
|||
|
|
voiceBtn.id = 'voiceBtn';
|
|||
|
|
voiceBtn.type = 'button';
|
|||
|
|
voiceBtn.textContent = '🎤';
|
|||
|
|
voiceBtn.title = '按住说话,松开发送';
|
|||
|
|
sendAgentBtn.parentNode.insertBefore(voiceBtn, sendAgentBtn);
|
|||
|
|
|
|||
|
|
let recognition = null;
|
|||
|
|
let isRecordingVoice = false;
|
|||
|
|
let microphoneReady = false;
|
|||
|
|
let spacePressed = false;
|
|||
|
|
let shouldAutoSend = false;
|
|||
|
|
|
|||
|
|
function setStatusMessage(message) {
|
|||
|
|
if (status) {
|
|||
|
|
status.innerHTML = `<p>${message}</p>`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateVoiceButton(recording) {
|
|||
|
|
voiceBtn.classList.toggle('recording', recording);
|
|||
|
|
voiceBtn.textContent = recording ? '🔴' : '🎤';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hasSecureVoiceContext() {
|
|||
|
|
return window.isSecureContext;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getSecureContextMessage() {
|
|||
|
|
return '语音输入需要在 HTTPS 或 localhost 环境下使用;当前地址无法申请麦克风权限。';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getRecognitionErrorMessage(error) {
|
|||
|
|
switch (error) {
|
|||
|
|
case 'not-allowed':
|
|||
|
|
case 'service-not-allowed':
|
|||
|
|
return '浏览器已阻止麦克风权限,请在地址栏站点设置中允许麦克风后刷新页面。';
|
|||
|
|
case 'audio-capture':
|
|||
|
|
return '没有检测到可用麦克风,请检查设备或系统录音权限。';
|
|||
|
|
case 'network':
|
|||
|
|
return '语音识别服务连接失败,请检查网络后重试。';
|
|||
|
|
case 'no-speech':
|
|||
|
|
return '没有识别到语音,请按住按钮后再说话。';
|
|||
|
|
case 'aborted':
|
|||
|
|
return '语音识别已取消。';
|
|||
|
|
default:
|
|||
|
|
return `语音识别失败:${error}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handlePermissionError(error) {
|
|||
|
|
const permissionDeniedNames = ['NotAllowedError', 'PermissionDeniedError', 'SecurityError'];
|
|||
|
|
const deviceMissingNames = ['NotFoundError', 'DevicesNotFoundError'];
|
|||
|
|
|
|||
|
|
if (permissionDeniedNames.includes(error.name)) {
|
|||
|
|
setStatusMessage('浏览器未授予麦克风权限,请在站点权限中允许麦克风后重试。');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (deviceMissingNames.includes(error.name)) {
|
|||
|
|
setStatusMessage('没有找到可用的麦克风设备,请检查设备连接。');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setStatusMessage(`麦克风初始化失败:${error.message || error.name}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function ensureMicrophonePermission() {
|
|||
|
|
if (microphoneReady) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!hasSecureVoiceContext()) {
|
|||
|
|
setStatusMessage(getSecureContextMessage());
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|||
|
|
// 某些支持 SpeechRecognition 的浏览器不会暴露 getUserMedia;
|
|||
|
|
// 这种情况下交给 recognition.start() 自己触发权限请求。
|
|||
|
|
microphoneReady = true;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|||
|
|
stream.getTracks().forEach((track) => track.stop());
|
|||
|
|
microphoneReady = true;
|
|||
|
|
return true;
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('麦克风权限申请失败:', error);
|
|||
|
|
handlePermissionError(error);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function initSpeechRecognition() {
|
|||
|
|
if (!SpeechRecognition) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
recognition = new SpeechRecognition();
|
|||
|
|
recognition.continuous = false;
|
|||
|
|
recognition.interimResults = true;
|
|||
|
|
recognition.lang = 'zh-CN';
|
|||
|
|
|
|||
|
|
recognition.onstart = function() {
|
|||
|
|
isRecordingVoice = true;
|
|||
|
|
updateVoiceButton(true);
|
|||
|
|
setStatusMessage('正在聆听,请开始说话...');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
recognition.onresult = function(event) {
|
|||
|
|
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) {
|
|||
|
|
agentInput.value = finalTranscript.trim();
|
|||
|
|
} else if (interimTranscript) {
|
|||
|
|
agentInput.value = interimTranscript.trim();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
recognition.onerror = function(event) {
|
|||
|
|
console.error('语音识别错误:', event.error);
|
|||
|
|
isRecordingVoice = false;
|
|||
|
|
updateVoiceButton(false);
|
|||
|
|
|
|||
|
|
if (event.error !== 'aborted') {
|
|||
|
|
setStatusMessage(getRecognitionErrorMessage(event.error));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
recognition.onend = function() {
|
|||
|
|
const hasText = Boolean(agentInput.value.trim());
|
|||
|
|
isRecordingVoice = false;
|
|||
|
|
updateVoiceButton(false);
|
|||
|
|
|
|||
|
|
if (hasText && shouldAutoSend) {
|
|||
|
|
window.setTimeout(function() {
|
|||
|
|
sendAgentBtn.click();
|
|||
|
|
}, 300);
|
|||
|
|
} else if (!hasText) {
|
|||
|
|
setStatusMessage('语音识别已结束。');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
shouldAutoSend = false;
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function startVoiceRecognition() {
|
|||
|
|
if (!SpeechRecognition) {
|
|||
|
|
setStatusMessage('当前浏览器不支持语音识别,请使用最新版 Chrome 或 Edge。');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isRecordingVoice) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!recognition) {
|
|||
|
|
initSpeechRecognition();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const permissionReady = await ensureMicrophonePermission();
|
|||
|
|
if (!permissionReady || !recognition) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
shouldAutoSend = true;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
recognition.start();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('启动语音识别失败:', error);
|
|||
|
|
|
|||
|
|
if (error.name === 'InvalidStateError') {
|
|||
|
|
try {
|
|||
|
|
recognition.stop();
|
|||
|
|
} catch (stopError) {
|
|||
|
|
console.error('停止语音识别失败:', stopError);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setStatusMessage(`启动语音识别失败:${error.message || error.name}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopVoiceRecognition() {
|
|||
|
|
if (recognition && isRecordingVoice) {
|
|||
|
|
recognition.stop();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!SpeechRecognition) {
|
|||
|
|
voiceBtn.disabled = true;
|
|||
|
|
voiceBtn.title = '当前浏览器不支持语音识别';
|
|||
|
|
setStatusMessage('当前浏览器不支持语音识别,请使用最新版 Chrome 或 Edge。');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!hasSecureVoiceContext()) {
|
|||
|
|
voiceBtn.title = '当前页面不是 HTTPS 或 localhost,浏览器不会授予麦克风权限';
|
|||
|
|
setStatusMessage(getSecureContextMessage());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
voiceBtn.addEventListener('pointerdown', async function(event) {
|
|||
|
|
if (event.pointerType === 'mouse' && event.button !== 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
event.preventDefault();
|
|||
|
|
shouldAutoSend = true;
|
|||
|
|
|
|||
|
|
if (voiceBtn.setPointerCapture) {
|
|||
|
|
try {
|
|||
|
|
voiceBtn.setPointerCapture(event.pointerId);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.debug('setPointerCapture skipped:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await startVoiceRecognition();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
voiceBtn.addEventListener('pointerup', function(event) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
stopVoiceRecognition();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
voiceBtn.addEventListener('pointercancel', function() {
|
|||
|
|
stopVoiceRecognition();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
voiceBtn.addEventListener('lostpointercapture', function() {
|
|||
|
|
stopVoiceRecognition();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
voiceBtn.addEventListener('contextmenu', function(event) {
|
|||
|
|
event.preventDefault();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('keydown', function(event) {
|
|||
|
|
if (event.repeat) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (event.code === 'Space' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
|
|||
|
|
event.preventDefault();
|
|||
|
|
|
|||
|
|
if (!spacePressed) {
|
|||
|
|
spacePressed = true;
|
|||
|
|
shouldAutoSend = true;
|
|||
|
|
startVoiceRecognition();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener('keyup', function(event) {
|
|||
|
|
if (event.code === 'Space' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
|
|||
|
|
event.preventDefault();
|
|||
|
|
spacePressed = false;
|
|||
|
|
stopVoiceRecognition();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|