// ===== DOM Elements ===== const elEngineModel = document.getElementById('engineModel'); 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'); // Input mode tabs const elTabMic = document.getElementById('tabMic'); const elTabFile = document.getElementById('tabFile'); const elPanelMic = document.getElementById('panelMic'); const elPanelFile = document.getElementById('panelFile'); // File upload 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'); // ===== Speaker Diarization Toggle ===== elSpeakerDiarization.addEventListener('change', () => { elDiarizationLabel.textContent = elSpeakerDiarization.checked ? '开启' : '关闭'; }); // ===== Log Area ===== 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'; entry.innerHTML = `${ts}${JSON.stringify(msg)}`; elLogArea.appendChild(entry); elLogArea.scrollTop = elLogArea.scrollHeight; } // ===== State ===== 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; // Input mode (mic / file) 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 实时速度 1x,压缩格式解压快可提速 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; const UNSUPPORTED_STREAMING_EXTENSIONS = new Set(['m4a']); const SPEAKER_COLORS = ['#4a7dff', '#52c41a', '#faad14', '#ff4d4f', '#9254de', '#13c2c2']; let sentenceMap = {}; let speakerOrderMap = {}; let speakerOrderCounter = 0; // ===== Input Mode Tabs ===== function switchMode(mode) { 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')); // ===== File Selection ===== elAudioFile.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const ext = getFileExt(file.name); if (UNSUPPORTED_STREAMING_EXTENSIONS.has(ext)) { selectedFile = null; e.target.value = ''; elFileInfo.textContent = 'M4A 暂不支持直接上传,请先转成 WAV 或 MP3'; elFileInfo.classList.remove('has-file'); elAudioMeta.style.display = 'none'; elSpeedControl.style.display = 'none'; elBtnStart.disabled = true; showToast('M4A 容器格式无法按当前实时切片方式直接识别,请转成 WAV 或 MP3', 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); }); // Speed slider elSpeedSlider.addEventListener('input', () => { elSpeedValue.textContent = parseFloat(elSpeedSlider.value).toFixed(1) + 'x'; }); // ===== Audio Meta Parsing ===== 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 = '无法解析'; } } } // ===== Copy & Toast ===== 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 Export ===== 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 已导出'); }); // ===== Helpers ===== 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; } // ===== Render: Subtitle (no diarization) ===== // 每个 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; } // ===== Render: Speaker Bubble ===== function renderBubble(sentence) { const id = 'sent-' + sentence.sentence_id; const speakerId = sentence.speaker_id; const isUnknown = speakerId < 0; const isInterim = sentence.sentence_type === 0; if (isUnknown) { const pendingText = sentence.sentence + (isInterim ? ' ...' : ''); // Keep unclassified text separate so it is not attributed to the prior speaker. renderFallbackPendingBubble(id, pendingText, isInterim, sentence); elResultArea.scrollTop = elResultArea.scrollHeight; return; } let entry = sentenceMap[id]; let insertBefore = null; if (entry && entry.speakerId !== speakerId) { insertBefore = entry.el.nextSibling; entry.el.remove(); entry = null; delete sentenceMap[id]; } if (!entry) { if (!(speakerId in speakerOrderMap)) { speakerOrderMap[speakerId] = speakerOrderCounter++; } const orderIdx = speakerOrderMap[speakerId]; const side = orderIdx % 2 === 0 ? 'left' : 'right'; const colorIdx = orderIdx % SPEAKER_COLORS.length; const el = document.createElement('div'); el.id = id; el.className = `bubble-row speaker-${side} speaker-${colorIdx}`; const wrapper = document.createElement('div'); wrapper.className = 'bubble-wrapper'; const header = document.createElement('div'); header.className = 'bubble-header'; const badge = document.createElement('span'); badge.className = `speaker-badge speaker-color-${colorIdx}`; const nameSpan = document.createElement('span'); nameSpan.className = 'speaker-name'; nameSpan.textContent = `说话人 ${speakerId}`; const timeSpan = document.createElement('span'); timeSpan.className = 'bubble-time'; header.appendChild(badge); header.appendChild(nameSpan); header.appendChild(timeSpan); const body = document.createElement('div'); body.className = 'bubble-body'; wrapper.appendChild(header); wrapper.appendChild(body); el.appendChild(wrapper); if (insertBefore) elResultArea.insertBefore(el, insertBefore); else elResultArea.appendChild(el); entry = { el: el, speakerId: speakerId }; sentenceMap[id] = entry; } const el = entry.el; const timeSpan = el.querySelector('.bubble-time'); const body = el.querySelector('.bubble-body'); timeSpan.textContent = formatTimeRange(sentence.start_time, sentence.end_time); Array.from(body.childNodes).filter(n => n.nodeType === Node.TEXT_NODE).forEach(n => n.remove()); const textNode = document.createTextNode(sentence.sentence + (isInterim ? ' ...' : '')); body.insertBefore(textNode, body.firstChild); body.className = 'bubble-body' + (isInterim ? ' interim' : ''); entry.speakerId = speakerId; elResultArea.scrollTop = elResultArea.scrollHeight; } function renderFallbackPendingBubble(id, text, isInterim, sentence) { let entry = sentenceMap[id]; if (!entry) { const el = document.createElement('div'); el.id = id; el.className = 'bubble-row speaker-left speaker-unknown'; const wrapper = document.createElement('div'); wrapper.className = 'bubble-wrapper'; const header = document.createElement('div'); header.className = 'bubble-header'; const badge = document.createElement('span'); badge.className = 'speaker-badge speaker-color-unknown'; const nameSpan = document.createElement('span'); nameSpan.className = 'speaker-name'; nameSpan.textContent = '说话人不确定'; const timeSpan = document.createElement('span'); timeSpan.className = 'bubble-time'; header.appendChild(badge); header.appendChild(nameSpan); header.appendChild(timeSpan); const body = document.createElement('div'); body.className = 'bubble-body'; wrapper.appendChild(header); wrapper.appendChild(body); el.appendChild(wrapper); elResultArea.appendChild(el); entry = { el: el, speakerId: -1 }; sentenceMap[id] = entry; } entry.el.querySelector('.bubble-time').textContent = formatTimeRange( sentence.start_time, sentence.end_time ); const body = entry.el.querySelector('.bubble-body'); body.textContent = text; body.className = 'bubble-body' + (isInterim ? ' interim' : ''); } // ===== Start Recognition ===== elBtnStart.addEventListener('click', () => { if (inputMode === 'file' && !selectedFile) return; startRecognition(); }); async function startRecognition() { if (inputMode === 'file' && selectedFile) { const ext = getFileExt(selectedFile.name); if (UNSUPPORTED_STREAMING_EXTENSIONS.has(ext)) { showToast('当前 demo 不支持直接流式上传 M4A,请转成 WAV 或 MP3', true); return; } } elResultArea.innerHTML = ''; elLogArea.innerHTML = ''; sentenceMap = {}; speakerOrderMap = {}; speakerOrderCounter = 0; 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; // 构造 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', engine_model_type: elEngineModel.value, 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)); if (inputMode === 'file') { sendAudioFile(selectedFile); } else { startMicCapture(); } }; ws.onmessage = (event) => { if (currentSession !== sessionId) return; const msg = JSON.parse(event.data); 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 (sending) setStatus('error', '连接意外断开'); else if (stoppingByUser) setStatus('done', '已停止'); 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': setStatus('running', '识别中...'); break; case 'sentences': if (msg.sentences) { msg.sentences.forEach(s => { if (useSpeaker) renderBubble(s); else renderSubtitle(s); }); } break; case 'end': 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; } } // ===== Stop ===== elBtnStop.addEventListener('click', () => stopRecognition()); function stopRecognition() { sending = false; stoppingByUser = true; stopMicCapture(); setStatus('running', '停止中...'); elBtnStop.disabled = true; if (currentVoiceId) { fetch(`/api/stop?voice_id=${encodeURIComponent(currentVoiceId)}`) .then(r => r.json()) .catch(err => console.error('[stop] error:', err)); } if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify({ type: 'stop' })); } catch (e) {} } const stopSession = sessionId; setTimeout(() => { if (sessionId !== stopSession) return; if (ws) { try { ws.close(); } catch (e) {} ws = null; setStatus('done', '已停止(超时)'); resetControls(); } }, 5000); } function resetControls() { sending = false; stoppingByUser = false; currentVoiceId = null; if (inputMode === 'file') { elBtnStart.disabled = !selectedFile; } else { elBtnStart.disabled = false; } elBtnStop.disabled = true; } // ===== Send Audio File ===== // 按 16KB 切片发送,后端会缓冲成 6400 字节块并按 speed_factor 限流 const UPLOAD_CHUNK_SIZE = 16000; async function sendAudioFile(file) { const buffer = await file.arrayBuffer(); const totalBytes = buffer.byteLength; let offset = 0; const ext = getFileExt(file.name); const isPcm = (ext === 'pcm'); while (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; await new Promise(r => setTimeout(r, 0)); } if (ws && ws.readyState === WebSocket.OPEN && sending) { ws.send(JSON.stringify({ type: 'eof' })); } } // ===== Microphone Capture ===== async function startMicCapture() { try { micStream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true } }); } catch (err) { console.error('getUserMedia error:', err); setStatus('error', '无法获取麦克风权限'); elMicStatus.textContent = '无法获取麦克风: ' + err.message; resetControls(); return; } 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'; }