// ===== 页面元素 ===== const elEngineModel = document.getElementById('engineModel'); const elModelServiceUrl = document.getElementById('modelServiceUrl'); const elSpeakerStatus = document.getElementById('speakerStatus'); const elDisplayMerge = document.getElementById('displayMerge'); const elSpeakerDiarization = document.getElementById('speakerDiarization'); const elDiarizationLabel = document.getElementById('diarizationLabel'); const elSentenceStrategy = document.getElementById('sentenceStrategy'); const elBtnStart = document.getElementById('btnStart'); const elBtnStop = document.getElementById('btnStop'); const elResultMeta = document.getElementById('resultMeta'); const elVoiceIdDisplay = document.getElementById('voiceIdDisplay'); const elBtnCopyVoiceId = document.getElementById('btnCopyVoiceId'); const elBtnExportWav = document.getElementById('btnExportWav'); const elStatusDot = document.getElementById('statusDot'); const elStatusText = document.getElementById('statusText'); const elResultArea = document.getElementById('resultArea'); const elResultPlaceholder = document.getElementById('resultPlaceholder'); const elLogArea = document.getElementById('logArea'); const elBtnClearLog = document.getElementById('btnClearLog'); const elMicStatus = document.getElementById('micStatus'); const elMicTimer = document.getElementById('micTimer'); const elMicElapsed = document.getElementById('micElapsed'); // 输入模式标签页 const elTabMic = document.getElementById('tabMic'); const elTabFile = document.getElementById('tabFile'); const elPanelMic = document.getElementById('panelMic'); const elPanelFile = document.getElementById('panelFile'); // 文件选择区域 const elAudioFile = document.getElementById('audioFile'); const elFileInfo = document.getElementById('fileInfo'); const elAudioMeta = document.getElementById('audioMeta'); const elMetaFormat = document.getElementById('metaFormat'); const elMetaSampleRate = document.getElementById('metaSampleRate'); const elMetaDuration = document.getElementById('metaDuration'); const elSpeedControl = document.getElementById('speedControl'); const elSpeedSlider = document.getElementById('speedSlider'); const elSpeedValue = document.getElementById('speedValue'); // ===== 说话人分离开关 ===== elSpeakerDiarization.addEventListener('change', () => { elDiarizationLabel.textContent = elSpeakerDiarization.checked ? '开启' : '关闭'; }); // ===== 日志区域 ===== elBtnClearLog.addEventListener('click', () => { elLogArea.innerHTML = ''; }); function appendLog(msg) { const now = new Date(); const ts = [now.getHours(), now.getMinutes(), now.getSeconds()] .map(n => String(n).padStart(2, '0')).join(':') + '.' + String(now.getMilliseconds()).padStart(3, '0'); const typeClass = 'log-type-' + (msg.type || 'unknown'); const entry = document.createElement('div'); entry.className = 'log-entry'; // 原始文本不作为 HTML 解释,转写中的标签也应原样显示。 const stamp = document.createElement('span'); stamp.className = 'log-time'; stamp.textContent = ts; const content = document.createElement('span'); content.className = typeClass; content.textContent = JSON.stringify(msg); entry.append(stamp, content); elLogArea.appendChild(entry); while (elLogArea.childNodes.length > 300) elLogArea.firstChild.remove(); elLogArea.scrollTop = elLogArea.scrollHeight; } // ===== 会话状态 ===== let ws = null; let sending = false; let stoppingByUser = false; let currentVoiceId = null; let audioChunks = []; let sessionId = 0; let micStream = null; let micAudioContext = null; let micWorklet = null; let micTimerInterval = null; let micStartTime = 0; // 输入模式(麦克风 / 文件) let inputMode = 'mic'; let selectedFile = null; // 文件扩展名 → SDK VoiceFormat 常量 const EXT_FORMAT_MAP = { 'pcm': 1, 'wav': 12, 'mp3': 8, 'm4a': 14, 'aac': 16, 'opus': 10, 'ogg': 10, 'silk': 6, 'speex': 4 }; // PCM/WAV 的默认发送倍速;实时验证默认按 1 倍速输入。 const DEFAULT_SPEED = { 'pcm': 1.0, 'wav': 1.0, 'mp3': 2.0, 'm4a': 2.0, 'aac': 2.0, 'opus': 3.0, 'ogg': 3.0, 'silk': 3.0, 'speex': 3.0 }; const MAX_SPEED = 3.0; // 实时 WebSocket 需要服务端逐帧读取音频;压缩格式必须等文件完整后才能解码, // 因此本次流式验证只允许 PCM/WAV,避免把整段上传伪装成实时识别。 const STREAMABLE_AUDIO_EXTENSIONS = new Set(['pcm', 'wav']); const SPEAKER_COLORS = ['#4a7dff', '#52c41a', '#faad14', '#ff4d4f', '#9254de', '#13c2c2']; let sentenceMap = {}; let speakerOrderMap = {}; let speakerOrderCounter = 0; let displayStateSupported = false; let displayRevision = -1; // ===== 输入模式标签页 ===== function switchMode(mode) { if (ws) return; inputMode = mode; elTabMic.classList.toggle('active', mode === 'mic'); elTabFile.classList.toggle('active', mode === 'file'); elPanelMic.style.display = mode === 'mic' ? '' : 'none'; elPanelFile.style.display = mode === 'file' ? '' : 'none'; if (mode === 'file') { elBtnStart.disabled = !selectedFile; } else { elBtnStart.disabled = false; } } elTabMic.addEventListener('click', () => switchMode('mic')); elTabFile.addEventListener('click', () => switchMode('file')); // ===== 文件选择 ===== elAudioFile.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const ext = getFileExt(file.name); if (!STREAMABLE_AUDIO_EXTENSIONS.has(ext)) { selectedFile = null; e.target.value = ''; elFileInfo.textContent = '实时测试只支持 PCM 或 WAV,请先转换音频格式'; elFileInfo.classList.remove('has-file'); elAudioMeta.style.display = 'none'; elSpeedControl.style.display = 'none'; elBtnStart.disabled = true; showToast('压缩音频不能按当前实时 WebSocket 逐帧识别,请转成 PCM 或 WAV', true); return; } selectedFile = file; const sizeMB = (file.size / 1024 / 1024).toFixed(1); elFileInfo.textContent = `${file.name} (${sizeMB}MB)`; elFileInfo.classList.add('has-file'); elBtnStart.disabled = false; parseAudioMeta(file); }); // 发送速度滑块 elSpeedSlider.addEventListener('input', () => { elSpeedValue.textContent = parseFloat(elSpeedSlider.value).toFixed(1) + 'x'; }); // ===== 音频元数据解析 ===== function getFileExt(filename) { const parts = filename.split('.'); return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; } function formatDuration(seconds) { if (!seconds || !isFinite(seconds)) return '—'; const min = Math.floor(seconds / 60); const sec = Math.floor(seconds % 60); const ms = Math.round((seconds % 1) * 10); return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${ms}`; } async function parseAudioMeta(file) { const ext = getFileExt(file.name); elMetaFormat.textContent = ext.toUpperCase(); // 根据格式设默认倍速 const defaultSpeed = Math.min(DEFAULT_SPEED[ext] || 1.0, MAX_SPEED); elSpeedSlider.value = defaultSpeed; elSpeedValue.textContent = defaultSpeed.toFixed(1) + 'x'; elAudioMeta.style.display = ''; elSpeedControl.style.display = ''; if (ext === 'pcm') { elMetaSampleRate.textContent = '16000 Hz (假设)'; const durationSec = file.size / (16000 * 2 * 1); elMetaDuration.textContent = formatDuration(durationSec) + ' (估算)'; } else if (ext === 'wav') { try { const headerBuf = await file.slice(0, 44).arrayBuffer(); const view = new DataView(headerBuf); const sampleRate = view.getUint32(24, true); const bitsPerSample = view.getUint16(34, true); const numChannels = view.getUint16(22, true); const byteRate = sampleRate * numChannels * (bitsPerSample / 8); const dataSize = file.size - 44; const durationSec = dataSize / byteRate; elMetaSampleRate.textContent = sampleRate + ' Hz, ' + bitsPerSample + 'bit, ' + numChannels + 'ch'; elMetaDuration.textContent = formatDuration(durationSec); } catch { elMetaSampleRate.textContent = '解析失败'; elMetaDuration.textContent = '—'; } } else { elMetaSampleRate.textContent = '—'; elMetaDuration.textContent = '解析中...'; try { const url = URL.createObjectURL(file); const audio = new Audio(); audio.preload = 'metadata'; audio.src = url; await new Promise((resolve, reject) => { audio.onloadedmetadata = resolve; audio.onerror = reject; setTimeout(reject, 5000); }); elMetaDuration.textContent = formatDuration(audio.duration); URL.revokeObjectURL(url); } catch { elMetaDuration.textContent = '无法解析'; } } } // ===== 复制和提示 ===== function showToast(message, isError) { const toast = document.createElement('div'); toast.className = 'copy-toast' + (isError ? ' copy-toast-error' : ''); toast.textContent = message; document.body.appendChild(toast); requestAnimationFrame(() => { toast.classList.add('visible'); }); setTimeout(() => { toast.classList.remove('visible'); setTimeout(() => toast.remove(), 300); }, 1500); } function handleCopyClick(btn, textEl) { const text = textEl.textContent.trim(); if (!text || text === '—') { showToast('暂无内容可复制', true); return; } navigator.clipboard.writeText(text).then(() => { btn.textContent = '✅'; showToast('已复制到剪贴板'); setTimeout(() => { btn.textContent = '📋'; }, 1500); }).catch(() => showToast('复制失败', true)); } elBtnCopyVoiceId.addEventListener('click', () => handleCopyClick(elBtnCopyVoiceId, elVoiceIdDisplay)); // ===== WAV 导出 ===== function buildWavBlob(pcmChunks) { let totalLen = 0; for (const c of pcmChunks) totalLen += c.byteLength; const buf = new ArrayBuffer(44 + totalLen); const view = new DataView(buf); const writeStr = (off, str) => { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)); }; const sampleRate = 16000, bitsPerSample = 16, numChannels = 1; const byteRate = sampleRate * numChannels * bitsPerSample / 8; const blockAlign = numChannels * bitsPerSample / 8; writeStr(0, 'RIFF'); view.setUint32(4, 36 + totalLen, true); writeStr(8, 'WAVE'); writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, byteRate, true); view.setUint16(32, blockAlign, true); view.setUint16(34, bitsPerSample, true); writeStr(36, 'data'); view.setUint32(40, totalLen, true); let offset = 44; for (const chunk of pcmChunks) { const src = new Uint8Array(chunk instanceof ArrayBuffer ? chunk : chunk.buffer || chunk); new Uint8Array(buf, offset).set(src); offset += src.byteLength; } return new Blob([buf], { type: 'audio/wav' }); } elBtnExportWav.addEventListener('click', () => { if (audioChunks.length === 0) { showToast('暂无音频数据', true); return; } const blob = buildWavBlob(audioChunks); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (currentVoiceId || 'audio') + '.wav'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); showToast('WAV 已导出'); }); // ===== 辅助函数 ===== function formatTime(ms) { const totalSec = Math.floor(ms / 1000); const min = String(Math.floor(totalSec / 60)).padStart(2, '0'); const sec = String(totalSec % 60).padStart(2, '0'); return `${min}:${sec}`; } function formatTimeRange(startMs, endMs) { return `${formatTime(startMs)} - ${formatTime(endMs)}`; } function setStatus(state, text) { elStatusDot.className = 'status-dot ' + state; elStatusText.textContent = text; } // ===== 渲染字幕(关闭说话人分离) ===== // 每个 sentence_id 对应一个独立气泡: // - 中间态(sentence_type===0):实时更新该气泡文本,末尾加 "..." 表示未定。 // - 稳态(sentence_type===1):去掉 "..." 并定格,下一个 sentence_id 自动创建新气泡。 function renderSubtitle(sentence) { const id = 'subtitle-' + sentence.sentence_id; const isInterim = sentence.sentence_type === 0; let entry = sentenceMap[id]; if (!entry) { const el = document.createElement('div'); el.className = 'subtitle-item subtitle-interim'; el.id = id; const timeSpan = document.createElement('span'); timeSpan.className = 'subtitle-time'; el.appendChild(timeSpan); const textSpan = document.createElement('span'); textSpan.className = 'subtitle-text'; el.appendChild(textSpan); elResultArea.appendChild(el); entry = { el: el }; sentenceMap[id] = entry; } const el = entry.el; el.querySelector('.subtitle-time').textContent = formatTimeRange(sentence.start_time, sentence.end_time); const textSpan = el.querySelector('.subtitle-text'); if (isInterim) { textSpan.textContent = sentence.sentence + ' ...'; textSpan.className = 'subtitle-text interim'; } else { textSpan.textContent = sentence.sentence; textSpan.className = 'subtitle-text'; el.className = 'subtitle-item'; // 去掉 interim 类名,定格 } elResultArea.scrollTop = elResultArea.scrollHeight; } // ===== 渲染说话人气泡 ===== // 未确认片段独立展示,不能临时塞进上一位说话人的气泡。 function renderBubble(sentence) { const id = 'sent-' + sentence.sentence_id; const speakerId = Number(sentence.speaker_id); const trusted = Number.isInteger(speakerId) && speakerId >= 0 && ['fresh', 'confirmed'].includes(sentence.speaker_evidence); const isInterim = sentence.sentence_type === 0; let entry = sentenceMap[id]; if (!entry) { const el = document.createElement('div'); el.id = id; const wrapper = document.createElement('div'); wrapper.className = 'bubble-wrapper'; const header = document.createElement('div'); header.className = 'bubble-header'; for (const name of ['speaker-badge', 'speaker-name', 'bubble-time']) { const span = document.createElement('span'); span.className = name; header.appendChild(span); } const body = document.createElement('div'); body.className = 'bubble-body'; wrapper.append(header, body); el.appendChild(wrapper); elResultArea.appendChild(el); entry = { el }; sentenceMap[id] = entry; } if (trusted && !(speakerId in speakerOrderMap)) speakerOrderMap[speakerId] = speakerOrderCounter++; const order = trusted ? speakerOrderMap[speakerId] : 0; const color = order % SPEAKER_COLORS.length; const el = entry.el; el.className = trusted ? `bubble-row speaker-${order % 2 ? 'right' : 'left'} speaker-${color}` : 'bubble-row speaker-left speaker-unknown'; el.querySelector('.speaker-badge').className = 'speaker-badge speaker-color-' + (trusted ? color : 'unknown'); // 未获得当前片段的可靠声纹证据时,标题保持简短;详细原因放到悬停提示, // 这样不会把“有效语音不足……”等内部诊断信息挤进说话人名称区域。 const speakerName = el.querySelector('.speaker-name'); speakerName.textContent = trusted ? (sentence.speaker_name || `说话人 ${speakerId + 1}`) : '未知说话人'; speakerName.title = trusted ? '' : (sentence.speaker_reason || '未匹配到说话人'); el.querySelector('.bubble-time').textContent = formatTimeRange(sentence.start_time, sentence.end_time); const body = el.querySelector('.bubble-body'); body.textContent = sentence.sentence + (isInterim ? ' ...' : ''); body.className = 'bubble-body' + (isInterim ? ' interim' : ''); elResultArea.scrollTop = elResultArea.scrollHeight; } // 按完整快照重建相邻块;序号防止两个后台 worker 的旧快照覆盖新状态。 function renderDisplayState(msg, useSpeaker) { if (msg.revision != null && msg.revision <= displayRevision) return; if (msg.revision != null) displayRevision = msg.revision; elResultArea.replaceChildren(); sentenceMap = {}; const raw = msg.raw_segments || msg.sentences || []; if (useSpeaker) { for (const block of msg.display_blocks || []) renderBubble({ ...block, sentence_id: block.block_id }); const confirmed = raw.filter(s => s.speaker_status === 'confirmed').length; const failed = raw.filter(s => ['service_error', 'service_unavailable', 'no_embedding', 'evidence_rejected'].includes(s.speaker_status)).length; elSpeakerStatus.textContent = `说话人:已确认 ${confirmed} / ${raw.length} 段` + (failed ? `,${failed} 段未识别成功(原因见气泡及日志)` : ''); } else { raw.forEach(renderSubtitle); elSpeakerStatus.textContent = '说话人分离已关闭'; } } elBtnStart.addEventListener('click', () => { if (inputMode === 'file' && !selectedFile) return; startRecognition(); }); async function startRecognition() { if (ws) return; if (inputMode === 'file' && selectedFile) { const ext = getFileExt(selectedFile.name); if (!STREAMABLE_AUDIO_EXTENSIONS.has(ext)) { showToast('实时流式测试只支持 PCM 或 WAV,请转换后再试', true); return; } } elResultArea.innerHTML = ''; elLogArea.innerHTML = ''; sentenceMap = {}; speakerOrderMap = {}; speakerOrderCounter = 0; displayStateSupported = false; displayRevision = -1; elSpeakerStatus.textContent = '正在检查说话人服务…'; audioChunks = []; elBtnExportWav.disabled = true; elResultPlaceholder?.remove(); elResultMeta.style.display = 'flex'; elVoiceIdDisplay.textContent = '—'; setStatus('connecting', '连接中...'); elBtnStart.disabled = true; elBtnStop.disabled = false; sending = true; const currentSession = ++sessionId; const useSpeaker = elSpeakerDiarization.checked; let receivedTerminal = false; // 构造 WebSocket 首条 start 消息。 let voiceFormat = 0, fileName = '', speedFactor = 0; if (inputMode === 'file') { const ext = getFileExt(selectedFile.name); voiceFormat = EXT_FORMAT_MAP[ext] || 0; fileName = selectedFile.name; speedFactor = Math.min(parseFloat(elSpeedSlider.value) || 1.0, MAX_SPEED); } const startPayload = { type: 'start', model: elEngineModel.value, model_service_url: elModelServiceUrl.value, display_merge: elDisplayMerge.checked, speaker_diarization: useSpeaker ? 1 : 0, sentence_strategy: parseInt(elSentenceStrategy.value), source: inputMode, voice_format: voiceFormat, file_name: fileName, speed_factor: speedFactor }; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${protocol}//${location.host}/ws`); ws.binaryType = 'arraybuffer'; ws.onopen = () => { if (currentSession !== sessionId) return; ws.send(JSON.stringify(startPayload)); // 在连接尚未建立时点击停止,也要在 start 后补发停止信号。 if (!sending) ws.send(JSON.stringify({ type: 'stop' })); }; ws.onmessage = (event) => { if (currentSession !== sessionId) return; const msg = JSON.parse(event.data); if (msg.type === 'end' || msg.type === 'error') receivedTerminal = true; if (msg.type !== 'sentences') { console.log('[ws] type=' + msg.type, msg); } appendLog(msg); handleServerMessage(msg, useSpeaker); }; ws.onerror = (err) => { if (currentSession !== sessionId) return; console.error('WebSocket error:', err); setStatus('error', '连接错误'); stopMicCapture(); resetControls(); }; ws.onclose = () => { if (currentSession !== sessionId) return; stopMicCapture(); if (!receivedTerminal) setStatus('error', '连接中断,最终识别结果可能尚未完成'); if (audioChunks.length > 0) elBtnExportWav.disabled = false; ws = null; resetControls(); }; } function handleServerMessage(msg, useSpeaker) { switch (msg.type) { case 'voice_id': if (msg.voice_id) { currentVoiceId = msg.voice_id; elVoiceIdDisplay.textContent = msg.voice_id; } break; case 'start': displayStateSupported = Boolean(msg.display_state_supported); currentVoiceId = msg.session_id; elVoiceIdDisplay.textContent = currentVoiceId || '—'; elSpeakerStatus.textContent = useSpeaker ? `说话人服务:${msg.speaker_service_url || '未配置'};片段结束后提取声纹` : '说话人分离已关闭'; if (!sending) break; setStatus('running', '识别中...'); if (inputMode === 'file') sendAudioFile(selectedFile).catch(handleInputError); else startMicCapture().catch(handleInputError); break; case 'display_state': renderDisplayState(msg, useSpeaker); break; case 'draining': setStatus('running', msg.message || '等待最终识别结果…'); break; case 'sentences': if (displayStateSupported) break; if (msg.sentences) { msg.sentences.forEach(s => { if (useSpeaker) renderBubble(s); else renderSubtitle(s); }); } break; case 'speaker_warning': // ASR 仍可继续输出,但必须让测试人员立即知道说话人链路没有生效。 elSpeakerStatus.textContent = '说话人服务异常:' + msg.message; showToast('说话人服务异常,详见状态和片段原因', true); break; case 'end': if (msg.display_blocks) renderDisplayState(msg, useSpeaker); setStatus('done', '识别完成'); sending = false; if (audioChunks.length > 0) elBtnExportWav.disabled = false; resetControls(); if (ws) { ws.close(); ws = null; } break; case 'error': setStatus('error', `错误: ${msg.message}`); sending = false; stopMicCapture(); if (ws) { ws.close(); ws = null; } resetControls(); break; } } // ===== 停止识别 ===== elBtnStop.addEventListener('click', () => stopRecognition()); function stopRecognition() { sending = false; stoppingByUser = true; stopMicCapture(); setStatus('running', '停止中...'); elBtnStop.disabled = true; if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify({ type: 'stop' })); } catch (e) {} } // 等待服务端排空 ASR/声纹队列后发送 end,不能用五秒计时器截断更新。 } function resetControls() { sending = false; stoppingByUser = false; currentVoiceId = null; if (inputMode === 'file') { elBtnStart.disabled = !selectedFile; } else { elBtnStart.disabled = false; } elBtnStop.disabled = true; } // ===== 发送音频文件 ===== // 按 16KB 切片发送,并按照音频实际时长等待,确保文件模式也是真实的 // 实时输入,而不是瞬间上传完整文件后再由服务端批量切片。 const UPLOAD_CHUNK_SIZE = 16000; async function sendAudioFile(file) { const ownerSession = sessionId; const buffer = await file.arrayBuffer(); if (ownerSession !== sessionId || !sending) return; const totalBytes = buffer.byteLength; let offset = 0; const ext = getFileExt(file.name); const isPcm = (ext === 'pcm'); let bytesPerSecond = 16000 * 2; if (ext === 'wav' && totalBytes >= 44) { const header = new DataView(buffer, 0, 44); const byteRate = header.getUint32(28, true); if (byteRate > 0) bytesPerSecond = byteRate; } const speedFactor = Math.max(parseFloat(elSpeedSlider.value) || 1.0, 0.1); while (ownerSession === sessionId && offset < totalBytes && sending && ws && ws.readyState === WebSocket.OPEN) { const end = Math.min(offset + UPLOAD_CHUNK_SIZE, totalBytes); const chunk = buffer.slice(offset, end); // 仅 PCM 数据可直接拼成 WAV 导出;当前实时模式不会接收压缩格式。 if (isPcm) audioChunks.push(chunk.slice(0)); ws.send(chunk); offset = end; const chunkDurationMs = (chunk.byteLength / bytesPerSecond) * 1000 / speedFactor; await new Promise(r => setTimeout(r, Math.max(0, Math.round(chunkDurationMs)))); } if (ownerSession === sessionId && ws && ws.readyState === WebSocket.OPEN && sending) { sending = false; setStatus('running', '音频已发送,等待最终结果…'); ws.send(JSON.stringify({ type: 'eof' })); } } // ===== 麦克风采集 ===== async function startMicCapture() { const ownerSession = sessionId; let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true } }); } catch (err) { if (ownerSession !== sessionId) return; handleInputError(err); console.error('getUserMedia error:', err); setStatus('error', '无法获取麦克风权限'); elMicStatus.textContent = '无法获取麦克风: ' + err.message; resetControls(); return; } if (ownerSession !== sessionId || !sending) { stream.getTracks().forEach(track => track.stop()); return; } micStream = stream; micAudioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); const source = micAudioContext.createMediaStreamSource(micStream); const processor = micAudioContext.createScriptProcessor(4096, 1, 1); processor.onaudioprocess = (e) => { if (!sending || !ws || ws.readyState !== WebSocket.OPEN) return; const float32 = e.inputBuffer.getChannelData(0); const int16 = new Int16Array(float32.length); for (let i = 0; i < float32.length; i++) { let s = Math.max(-1, Math.min(1, float32[i])); int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } ws.send(int16.buffer); audioChunks.push(int16.buffer.slice(0)); }; source.connect(processor); processor.connect(micAudioContext.destination); micWorklet = { source, processor }; micStartTime = Date.now(); elMicStatus.style.display = 'none'; elMicTimer.style.display = ''; micTimerInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - micStartTime) / 1000); const mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); const ss = String(elapsed % 60).padStart(2, '0'); elMicElapsed.textContent = `${mm}:${ss}`; }, 200); } function stopMicCapture() { if (micWorklet) { micWorklet.processor.disconnect(); micWorklet.source.disconnect(); micWorklet = null; } if (micAudioContext) { micAudioContext.close().catch(() => {}); micAudioContext = null; } if (micStream) { micStream.getTracks().forEach(t => t.stop()); micStream = null; } if (micTimerInterval) { clearInterval(micTimerInterval); micTimerInterval = null; } elMicTimer.style.display = 'none'; elMicStatus.style.display = ''; elMicStatus.textContent = '点击下方按钮开始录音'; elMicElapsed.textContent = '00:00'; } // 展示实际部署端点及模型,避免沿用旧 SDK 的无效引擎配置。 fetch('/api/config').then(response => response.json()).then(config => { if (!ws) { elEngineModel.value = config.model; elModelServiceUrl.value = config.model_service_url; elSpeakerStatus.textContent = '说话人辅助服务:' + (config.speaker_service_url || '未配置'); } }).catch(error => { elSpeakerStatus.textContent = '读取服务配置失败:' + error.message; }); // 输入端失败必须释放空会话,避免用户再次开始时留下旧连接。 function handleInputError(error) { sending = false; setStatus('error', error.message); stopMicCapture(); if (ws) { ws.close(); ws = null; } resetControls(); }