<!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>
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐