html实现websocket
·
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>实时语音识别系统 (多客户端支持)</title>
<style>
:root {
--primary-color: #4285f4;
--error-color: #ea4335;
--success-color: #34a853;
--warning-color: #fbbc05;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: var(--primary-color);
text-align: center;
margin-bottom: 30px;
}
.panel {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 20px;
margin-bottom: 20px;
}
.control-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 12px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s;
}
button:hover {
opacity: 0.9;
transform: translateY(-2px);
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
transform: none;
}
button.danger {
background-color: var(--error-color);
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-disconnected {
background-color: var(--error-color);
}
.status-connecting {
background-color: var(--warning-color);
}
.status-connected {
background-color: var(--success-color);
}
.status-recording {
background-color: var(--primary-color);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
#audioVisualizer {
width: 100%;
height: 120px;
background: linear-gradient(to bottom, #f0f0f0, #ffffff);
border-radius: 4px;
margin: 20px 0;
}
#transcript {
min-height: 200px;
max-height: 300px;
overflow-y: auto;
padding: 15px;
background-color: #f9f9f9;
border-radius: 4px;
border: 1px solid #ddd;
white-space: pre-wrap;
font-family: 'Consolas', monospace;
}
.connection-info {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.client-id {
font-weight: bold;
color: var(--primary-color);
}
.latency {
color: #666;
font-size: 0.9em;
}
.message-log {
max-height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: 0.9em;
padding: 10px;
background-color: #f0f0f0;
border-radius: 4px;
margin-top: 20px;
}
.tab-container {
display: flex;
margin-bottom: 15px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border-bottom: 3px solid transparent;
}
.tab.active {
border-bottom-color: var(--primary-color);
font-weight: bold;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<h1>实时语音识别系统</h1>
<div class="panel">
<div class="connection-info">
<div>
<span id="statusIndicator" class="status-indicator status-disconnected"></span>
<span id="connectionStatus">未连接</span>
<span class="client-id" id="clientId"></span>
</div>
<div class="latency" id="latencyDisplay">延迟: -- ms</div>
</div>
<div class="control-panel">
<button id="connectBtn">连接服务器</button>
<button id="startBtn" disabled>开始录音</button>
<button id="stopBtn" disabled class="danger">停止录音</button>
<button id="clearBtn">清空记录</button>
</div>
<div id="audioVisualizer"></div>
</div>
<div class="panel">
<div class="tab-container">
<div class="tab active" data-tab="transcript">识别结果</div>
<div class="tab" data-tab="messages">通信日志</div>
<div class="tab" data-tab="settings">设置</div>
</div>
<div id="transcript" class="tab-content active"></div>
<div id="messages" class="tab-content message-log"></div>
<div id="settings" class="tab-content">
<h3>音频设置</h3>
<div>
<label for="silenceThreshold">静音检测阈值: </label>
<input type="range" id="silenceThreshold" min="0.001" max="0.1" step="0.001" value="0.02">
<span id="thresholdValue">0.02</span>
</div>
<div>
<label for="silenceTimeout">静音超时(秒): </label>
<input type="number" id="silenceTimeout" min="0.5" max="5" step="0.1" value="1.5">
</div>
<h3>服务器设置</h3>
<div>
<label for="serverUrl">服务器地址: </label>
<input type="text" id="serverUrl" value="ws://localhost:8765" style="width: 300px;">
</div>
<button id="saveSettings">保存设置</button>
</div>
</div>
<script>
// 配置参数
const config = {
sampleRate: 16000,
chunkSize: 1024,
silenceThreshold: 0.02,
silenceTimeout: 1.5,
serverUrl: "ws://localhost:8765"
};
// 全局变量
let websocket;
let audioContext;
let processor;
let microphone;
let isRecording = false;
let clientId;
let latencyStartTime;
let silenceCounter = 0;
let audioChunks = [];
let canvas, canvasCtx;
// DOM元素
const connectBtn = document.getElementById('connectBtn');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const clearBtn = document.getElementById('clearBtn');
const statusIndicator = document.getElementById('statusIndicator');
const connectionStatus = document.getElementById('connectionStatus');
const clientIdDisplay = document.getElementById('clientId');
const latencyDisplay = document.getElementById('latencyDisplay');
const transcriptEl = document.getElementById('transcript');
const messageLog = document.getElementById('messages');
const audioVisualizer = document.getElementById('audioVisualizer');
const tabs = document.querySelectorAll('.tab');
const tabContents = document.querySelectorAll('.tab-content');
// 初始化
function init() {
// 加载保存的设置
loadSettings();
// 初始化音频可视化
initVisualizer();
// 生成客户端ID
clientId = 'web-' + Math.random().toString(36).substring(2, 8);
clientIdDisplay.textContent = `客户端ID: ${clientId}`;
// 事件监听
connectBtn.addEventListener('click', toggleConnection);
startBtn.addEventListener('click', startRecording);
stopBtn.addEventListener('click', stopRecording);
clearBtn.addEventListener('click', clearTranscript);
// 标签页切换
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabName = tab.getAttribute('data-tab');
document.getElementById(tabName).classList.add('active');
});
});
// 设置保存
document.getElementById('saveSettings').addEventListener('click', saveSettings);
document.getElementById('silenceThreshold').addEventListener('input', (e) => {
document.getElementById('thresholdValue').textContent = e.target.value;
});
}
// 初始化音频可视化
function initVisualizer() {
canvas = document.createElement('canvas');
canvas.width = audioVisualizer.offsetWidth;
canvas.height = audioVisualizer.offsetHeight;
audioVisualizer.appendChild(canvas);
canvasCtx = canvas.getContext('2d');
window.addEventListener('resize', () => {
canvas.width = audioVisualizer.offsetWidth;
canvas.height = audioVisualizer.offsetHeight;
});
}
// 加载设置
function loadSettings() {
const savedSettings = localStorage.getItem('asrClientSettings');
if (savedSettings) {
Object.assign(config, JSON.parse(savedSettings));
document.getElementById('serverUrl').value = config.serverUrl;
document.getElementById('silenceThreshold').value = config.silenceThreshold;
document.getElementById('thresholdValue').textContent = config.silenceThreshold;
document.getElementById('silenceTimeout').value = config.silenceTimeout;
}
}
// 保存设置
function saveSettings() {
config.silenceThreshold = parseFloat(document.getElementById('silenceThreshold').value);
config.silenceTimeout = parseFloat(document.getElementById('silenceTimeout').value);
config.serverUrl = document.getElementById('serverUrl').value;
localStorage.setItem('asrClientSettings', JSON.stringify(config));
addMessage('设置已保存');
}
// 连接/断开服务器
async function toggleConnection() {
if (websocket && websocket.readyState === WebSocket.OPEN) {
await disconnect();
} else {
await connect();
}
}
// 连接服务器
async function connect() {
try {
updateStatus('连接中...', 'status-connecting');
connectBtn.disabled = true;
websocket = new WebSocket(config.serverUrl);
websocket.onopen = () => {
updateStatus('已连接', 'status-connected');
connectBtn.textContent = '断开连接';
connectBtn.disabled = false;
startBtn.disabled = false;
// 发送客户端信息
websocket.send(JSON.stringify({
type: 'clientInfo',
clientId: clientId,
sampleRate: config.sampleRate
}));
// 开始心跳检测
startHeartbeat();
};
websocket.onmessage = (event) => {
if (typeof event.data === 'string') {
handleTextMessage(event.data);
} else {
// 二进制数据 (本示例不处理服务器发来的二进制)
}
};
websocket.onclose = () => {
updateStatus('已断开', 'status-disconnected');
connectBtn.textContent = '连接服务器';
connectBtn.disabled = false;
startBtn.disabled = true;
stopBtn.disabled = true;
if (isRecording) {
stopRecording();
}
stopHeartbeat();
};
websocket.onerror = (error) => {
addMessage(`连接错误: ${error.message}`);
updateStatus('连接错误', 'status-disconnected');
connectBtn.textContent = '连接服务器';
connectBtn.disabled = false;
};
} catch (error) {
addMessage(`连接异常: ${error}`);
updateStatus('连接失败', 'status-disconnected');
connectBtn.disabled = false;
}
}
// 断开连接
async function disconnect() {
if (websocket) {
try {
await websocket.close(1000, '用户主动断开');
} catch (error) {
addMessage(`断开连接错误: ${error}`);
}
}
}
// 处理文本消息
function handleTextMessage(message) {
if (message === 'SERVER_READY') {
addMessage('服务器已就绪');
return;
}
if (message === 'PONG') {
const latency = Date.now() - latencyStartTime;
latencyDisplay.textContent = `延迟: ${latency} ms`;
return;
}
if (message.startsWith('RESULT:')) {
const text = message.substring(7);
transcriptEl.textContent += text + '\n';
transcriptEl.scrollTop = transcriptEl.scrollHeight;
addMessage(`收到识别结果: ${text}`);
return;
}
// 其他消息
addMessage(`服务器消息: ${message}`);
}
// 开始录音
async function startRecording() {
if (isRecording) return;
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: config.sampleRate
});
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
microphone = audioContext.createMediaStreamSource(stream);
processor = audioContext.createScriptProcessor(config.chunkSize, 1, 1);
processor.onaudioprocess = processAudio;
microphone.connect(processor);
processor.connect(audioContext.destination);
isRecording = true;
startBtn.disabled = true;
stopBtn.disabled = false;
updateStatus('录音中...', 'status-recording');
addMessage('录音已开始');
} catch (error) {
addMessage(`录音错误: ${error.message}`);
updateStatus('录音失败', 'status-connected');
}
}
// 停止录音
function stopRecording() {
if (!isRecording) return;
if (microphone) {
microphone.disconnect();
}
if (processor) {
processor.disconnect();
}
if (audioContext) {
audioContext.close();
}
isRecording = false;
startBtn.disabled = false;
stopBtn.disabled = true;
updateStatus('已连接', 'status-connected');
addMessage('录音已停止');
// 发送剩余音频
if (audioChunks.length > 0) {
sendAudio(audioChunks);
audioChunks = [];
}
}
// 处理音频数据
function processAudio(event) {
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
const inputData = event.inputBuffer.getChannelData(0);
const rawData = new Int16Array(inputData.length);
// 计算RMS值用于VAD
let sum = 0;
for (let i = 0; i < inputData.length; i++) {
const sample = Math.max(-1, Math.min(1, inputData[i]));
rawData[i] = sample * 32767;
sum += sample * sample;
}
const rms = Math.sqrt(sum / inputData.length);
// 可视化
visualizeAudio(inputData);
// VAD检测
if (rms > config.silenceThreshold) {
silenceCounter = 0;
audioChunks.push(rawData.buffer);
} else {
silenceCounter += inputData.length / config.sampleRate;
// 静音超时,发送音频
if (silenceCounter >= config.silenceTimeout && audioChunks.length > 0) {
sendAudio(audioChunks);
audioChunks = [];
}
}
}
// 发送音频数据
function sendAudio(chunks) {
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
try {
// 合并所有chunks
const merged = mergeArrayBuffers(chunks);
// 检查最小长度 (0.5秒)
if (merged.byteLength >= config.sampleRate * 0.5 * 2) {
websocket.send(merged);
addMessage(`发送音频数据: ${(merged.byteLength / (config.sampleRate * 2)).toFixed(2)}秒`);
}
} catch (error) {
addMessage(`发送音频错误: ${error}`);
}
}
// 合并ArrayBuffer
function mergeArrayBuffers(buffers) {
const totalLength = buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
buffers.forEach(buffer => {
result.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
});
return result.buffer;
}
// 音频可视化
function visualizeAudio(data) {
if (!canvasCtx) return;
const width = canvas.width;
const height = canvas.height;
canvasCtx.fillStyle = 'rgb(250, 250, 250)';
canvasCtx.fillRect(0, 0, width, height);
canvasCtx.lineWidth = 2;
canvasCtx.strokeStyle = 'rgb(66, 133, 244)';
canvasCtx.beginPath();
const sliceWidth = width / data.length;
let x = 0;
for (let i = 0; i < data.length; i++) {
const v = data[i] * height / 2;
const y = height / 2 + v;
if (i === 0) {
canvasCtx.moveTo(x, y);
} else {
canvasCtx.lineTo(x, y);
}
x += sliceWidth;
}
canvasCtx.lineTo(width, height / 2);
canvasCtx.stroke();
}
// 心跳检测
function startHeartbeat() {
latencyStartTime = Date.now();
websocket.send('PING');
heartbeatInterval = setInterval(() => {
latencyStartTime = Date.now();
websocket.send('PING');
}, 10000); // 每10秒一次
}
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
}
// 更新状态显示
function updateStatus(text, className) {
connectionStatus.textContent = text;
// 移除所有状态类
statusIndicator.classList.remove(
'status-disconnected',
'status-connecting',
'status-connected',
'status-recording'
);
// 添加新类
if (className) {
statusIndicator.classList.add(className);
}
}
// 添加消息到日志
function addMessage(message) {
const now = new Date();
const timeStr = now.toLocaleTimeString();
messageLog.innerHTML += `[${timeStr}] ${message}\n`;
messageLog.scrollTop = messageLog.scrollHeight;
}
// 清空识别结果
function clearTranscript() {
transcriptEl.textContent = '';
addMessage('已清空识别结果');
}
// 初始化应用
window.addEventListener('load', init);
</script>
</body>
</html>
更多推荐
所有评论(0)