diff --git a/README.md b/README.md index 1517440..7669d7c 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,23 @@ curl -X POST http://127.0.0.1:8000/v1/audio/transcriptions \ Replace `your_api_key` with the value in `.env`. If `API_KEY` is unset, omit the `Authorization` header. Stop the foreground process with `Ctrl+C`. +#### Test Qwen realtime WebSocket with the bundled Asr-demo UI + +The test page and WebSocket bridge are included in this repository, so the server does not need a separate Asr-demo checkout. With Qwen-ASR already listening on port `33050`, start the bridge from the project root: + +```bash +./.venv/bin/python scripts/test_qwen_ws_with_asr_demo.py \ + --backend-url ws://127.0.0.1:33050/ws/v1/asr/qwen +``` + +The bridge listens on `127.0.0.1:8188` by default. From your workstation, create an SSH tunnel and open the local URL; using `localhost` lets the browser grant microphone access over plain HTTP: + +```bash +ssh -L 8188:127.0.0.1:8188 user@server +``` + +Then open `http://127.0.0.1:8188`. If `websockets` is missing from the selected Python environment, install it with `./.venv/bin/pip install websockets`. The UI supports live microphone audio and `.pcm` or `.wav` file input. + ### Local Development **System Requirements:** diff --git a/scripts/qwen_ws_test_frontend/app.js b/scripts/qwen_ws_test_frontend/app.js new file mode 100644 index 0000000..e40d726 --- /dev/null +++ b/scripts/qwen_ws_test_frontend/app.js @@ -0,0 +1,725 @@ +// ===== 页面元素 ===== +const elEngineModel = document.getElementById('engineModel'); +const elSpeakerDiarization = document.getElementById('speakerDiarization'); +const elHotwords = document.getElementById('hotwords'); +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'; + entry.innerHTML = `${ts}${JSON.stringify(msg)}`; + elLogArea.appendChild(entry); + 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 实时速度 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']); + +// 统一前端热词分隔符并去重,后端再转换为 Contextual Paraformer 的输入格式。 +function normalizeHotwords(value) { + const terms = value.split(/[,,、;;\r\n]+/).map(term => term.trim()).filter(Boolean); + return [...new Set(terms)].join(','); +} + +const SPEAKER_COLORS = ['#4a7dff', '#52c41a', '#faad14', '#ff4d4f', '#9254de', '#13c2c2']; + +let sentenceMap = {}; +let speakerOrderMap = {}; +let speakerOrderCounter = 0; + +// ===== 输入模式选项卡 ===== +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')); + +// ===== 文件选择 ===== +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); +}); + +// 语速滑块 +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 = sentence.speaker_id; + const isUnknown = speakerId < 0; + const isInterim = sentence.sentence_type === 0; + + if (isUnknown) { + const pendingText = sentence.sentence + (isInterim ? ' ...' : ''); + // 将未分类文本单独显示,避免错误归到上一位说话人名下。 + 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' : ''); +} + +// ===== 开始识别 ===== +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; + elHotwords.disabled = true; + 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), + hotwords: normalizeHotwords(elHotwords.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; + } +} + +// ===== 停止 ===== +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; + elHotwords.disabled = false; +} + +// ===== 发送音频文件 ===== +// 按 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' })); + } +} + +// ===== 麦克风采集 ===== +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'; +} diff --git a/scripts/qwen_ws_test_frontend/index.html b/scripts/qwen_ws_test_frontend/index.html new file mode 100644 index 0000000..d0e9413 --- /dev/null +++ b/scripts/qwen_ws_test_frontend/index.html @@ -0,0 +1,115 @@ + + + + + + 语音识别体验 Demo + + + +
+ + + + +
+
+

识别结果

+ +
+
选择音频来源并点击"开始识别"
+
+
+
+
+

ASR 原始日志

+ +
+
+
+
+
+ + + + diff --git a/scripts/qwen_ws_test_frontend/style.css b/scripts/qwen_ws_test_frontend/style.css new file mode 100644 index 0000000..2e8ec0c --- /dev/null +++ b/scripts/qwen_ws_test_frontend/style.css @@ -0,0 +1,803 @@ +/* ===== 重置与基础样式 ===== */ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: #f0f2f5; + color: #1a1a2e; + line-height: 1.6; + min-height: 100vh; +} + +/* ===== 双栏布局 ===== */ +.layout { + display: flex; + height: 100vh; + overflow: hidden; +} + +.panel-left { + width: 340px; + min-width: 300px; + flex-shrink: 0; + padding: 20px 16px; + overflow-y: auto; + border-right: 1px solid #e5e5e5; + background: #fff; +} + +.panel-right { + flex: 1; + padding: 20px 20px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ===== 页头 ===== */ +header { + text-align: center; + margin-bottom: 20px; +} + +header h1 { + font-size: 22px; + font-weight: 700; + color: #1a1a2e; +} + +.subtitle { + color: #666; + font-size: 13px; + margin-top: 2px; +} + +/* ===== 卡片 ===== */ +.card { + background: #fff; + border-radius: 10px; + padding: 16px 18px; + margin-bottom: 14px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.panel-left .card { + background: #f9fafb; +} + +.card h2 { + font-size: 14px; + font-weight: 600; + color: #333; + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid #eee; +} + +/* ===== 可折叠卡片标题 ===== */ +.card-header-collapsible { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + padding-bottom: 0; +} + +.card-header-collapsible h2 { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.collapse-arrow { + font-size: 11px; + color: #999; + transition: transform 0.2s; +} + +.collapse-arrow.expanded { + color: #4a7dff; +} + +.advanced-params-body { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid #eee; +} + +/* ===== 区域分隔线 ===== */ +.section-divider { + border-top: 1px solid #eee; + margin: 12px 0; +} + +/* ===== 表单(左侧面板) ===== */ +.form-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.form-row { + display: flex; + gap: 16px; +} + +.form-row .form-group { + flex: 1; +} + +.form-row-3 { + display: flex; + gap: 10px; +} + +.form-row-3 .form-group { + flex: 1; + min-width: 0; +} + +.form-group label:first-child { + display: block; + font-size: 12px; + font-weight: 500; + color: #555; + margin-bottom: 4px; +} + +.hint { + color: #999; + font-weight: 400; +} + +select, +input[type="text"] { + width: 100%; + padding: 7px 10px; + border: 1px solid #d9d9d9; + border-radius: 6px; + font-size: 13px; + outline: none; + transition: border-color 0.2s; +} + +select:focus, +input[type="text"]:focus { + border-color: #4a7dff; + box-shadow: 0 0 0 2px rgba(74, 125, 255, 0.15); +} + +input[type="text"][readonly] { + background: #f7f8fa; + color: #666; + cursor: default; +} + +input[type="text"][readonly]:focus { + border-color: #d9d9d9; + box-shadow: none; +} + +/* ===== 开关控件 ===== */ +.toggle { + display: inline-flex; + align-items: center; + cursor: pointer; + gap: 6px; + user-select: none; +} + +.toggle input { + display: none; +} + +.toggle-slider { + width: 36px; + height: 20px; + background: #ccc; + border-radius: 10px; + position: relative; + transition: background 0.2s; +} + +.toggle-slider::after { + content: ""; + position: absolute; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + top: 2px; + left: 2px; + transition: transform 0.2s; +} + +.toggle input:checked + .toggle-slider { + background: #4a7dff; +} + +.toggle input:checked + .toggle-slider::after { + transform: translateX(16px); +} + +.toggle-label { + font-size: 12px; + color: #666; +} + +/* ===== 音频输入(左侧面板) ===== */ +.audio-input-stack { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* 输入模式选项卡 */ +.input-mode-tabs { + display: flex; + gap: 0; + border: 1px solid #d9d9d9; + border-radius: 6px; + overflow: hidden; +} + +.mode-tab { + flex: 1; + padding: 6px 0; + border: none; + background: #fff; + font-size: 12px; + font-weight: 500; + color: #666; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.mode-tab + .mode-tab { + border-left: 1px solid #d9d9d9; +} + +.mode-tab.active { + background: #4a7dff; + color: #fff; +} + +.mode-tab:hover:not(.active) { + background: #f0f5ff; +} + +/* 文件选择 */ +.file-select { + display: flex; + align-items: center; + gap: 10px; +} + +.file-info { + font-size: 12px; + color: #888; + word-break: break-all; +} + +.file-info.has-file { + color: #333; + font-weight: 500; +} + +/* 音频元信息 */ +.audio-meta { + background: #f7f8fa; + border-radius: 6px; + padding: 8px 10px; + font-size: 12px; +} + +.audio-meta-row { + display: flex; + justify-content: space-between; + padding: 2px 0; +} + +.audio-meta-row .meta-k { + color: #888; +} + +.audio-meta-row .meta-v { + color: #333; + font-family: "SF Mono", Menlo, monospace; + font-size: 11px; +} + +/* 语速控制 */ +.speed-control { + display: flex; + flex-direction: column; + gap: 4px; +} + +.speed-label { + font-size: 12px; + font-weight: 500; + color: #555; +} + +.speed-label span { + color: #4a7dff; + font-weight: 600; +} + +#speedSlider { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: #e5e5e5; + border-radius: 2px; + outline: none; +} + +#speedSlider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #4a7dff; + cursor: pointer; +} + +/* 麦克风面板 */ +.mic-status { + text-align: center; + color: #888; + font-size: 13px; + padding: 8px 0; +} + +.mic-timer { + text-align: center; + font-size: 14px; + font-weight: 600; + color: #ff4d4f; + padding: 8px 0; + animation: pulse 1s infinite; +} + +.mic-timer span { + font-family: "SF Mono", Menlo, monospace; + margin-left: 4px; +} + +.controls { + display: flex; + gap: 8px; +} + +/* ===== 按钮 ===== */ +.btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 7px 16px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-primary { + background: #4a7dff; + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: #3a6ae8; +} + +.btn-danger { + background: #ff4d4f; + color: #fff; +} + +.btn-danger:hover:not(:disabled) { + background: #e63e40; +} + +.btn-outline { + background: #fff; + color: #4a7dff; + border: 1px solid #4a7dff; +} + +.btn-outline:hover { + background: #f0f5ff; +} + +.btn-icon { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + padding: 2px 4px; + border-radius: 4px; + transition: background 0.15s; +} + +.btn-icon:hover { + background: #f0f0f0; +} + +.btn-icon.copy-success { + background: #e6f7e9; +} + +/* 导出 WAV 按钮 */ +.btn-export { + margin-left: 4px; + font-size: 11px !important; + padding: 2px 8px !important; +} + +.btn-export:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* ===== 复制提示 ===== */ +.copy-toast { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%) translateY(-10px); + background: #333; + color: #fff; + padding: 6px 18px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + z-index: 9999; + opacity: 0; + transition: opacity 0.25s, transform 0.25s; + pointer-events: none; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.copy-toast.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.copy-toast-error { + background: #ff4d4f; +} + +/* ===== 结果区域(右侧面板) ===== */ +.result-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: #fff; +} + +.result-meta { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 10px; + padding: 6px 10px; + background: #f7f8fa; + border-radius: 6px; + font-size: 12px; + flex-shrink: 0; +} + +.meta-item { + display: flex; + align-items: center; + gap: 5px; +} + +.meta-label { + color: #888; +} + +.meta-value { + color: #333; + font-family: "SF Mono", Menlo, monospace; + font-size: 11px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ccc; +} + +.status-dot.connecting { + background: #faad14; + animation: pulse 1s infinite; +} + +.status-dot.running { + background: #52c41a; + animation: pulse 1s infinite; +} + +.status-dot.done { + background: #4a7dff; +} + +.status-dot.error { + background: #ff4d4f; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.result-area { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.placeholder { + text-align: center; + color: #bbb; + padding: 60px 0; + font-size: 14px; +} + +/* ===== 字幕模式(不启用说话人分离) ===== */ +.subtitle-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 5px 8px; + border-radius: 6px; + margin-bottom: 3px; + transition: background 0.15s; +} + +.subtitle-item:hover { + background: #f7f8fa; +} + +.subtitle-time { + font-size: 11px; + color: #999; + font-family: "SF Mono", Menlo, monospace; + white-space: nowrap; + min-width: 100px; + padding-top: 2px; +} + +.subtitle-text { + font-size: 14px; + color: #1a1a2e; + word-break: break-word; + transition: color 0.3s ease; +} + +.subtitle-text.interim { + color: #999; + font-style: italic; +} + +.subtitle-text.final { + color: #000; + font-style: normal; + font-weight: 500; +} + +.subtitle-item.subtitle-interim { + opacity: 0.8; +} + +/* ===== 气泡模式(启用说话人分离) ===== */ +.bubble-row { + display: flex; + margin-bottom: 10px; +} + +.bubble-row.speaker-left { + justify-content: flex-start; +} + +.bubble-row.speaker-right { + justify-content: flex-end; +} + +.bubble-row.speaker-unknown .bubble-body { + background: #f0f0f0; + color: #999; + font-style: italic; + border-radius: 12px; + border-top-left-radius: 4px; +} + +.speaker-badge.speaker-color-unknown { + background-color: #ccc; +} + +.bubble-wrapper { + max-width: 80%; +} + +.bubble-header { + font-size: 11px; + color: #888; + margin-bottom: 3px; + display: flex; + align-items: center; + gap: 5px; +} + +.speaker-right .bubble-header { + justify-content: flex-end; +} + +.speaker-badge { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.bubble-body { + padding: 9px 13px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; + word-break: break-word; +} + +.speaker-left .bubble-body { + background: #e8f0fe; + color: #1a1a2e; + border-top-left-radius: 4px; +} + +.speaker-right .bubble-body { + background: #e6f7e9; + color: #1a1a2e; + border-top-right-radius: 4px; + text-align: right; +} + +.bubble-body.interim { + opacity: 0.6; + font-style: italic; +} + +/* 待确认文本(speaker_id=-1)追加到已确认的气泡中 */ +.pending-text { + display: inline; + color: #aaa; + font-style: italic; + opacity: 0.7; + margin-left: 2px; + transition: opacity 0.3s ease; +} + +.pending-text::before { + content: ' '; +} + +.bubble-time { + font-size: 10px; + color: #bbb; + font-family: "SF Mono", Menlo, monospace; +} + +/* ===== 说话人颜色 ===== */ +.speaker-color-0 { background-color: #4a7dff; } +.speaker-color-1 { background-color: #52c41a; } +.speaker-color-2 { background-color: #faad14; } +.speaker-color-3 { background-color: #ff4d4f; } +.speaker-color-4 { background-color: #9254de; } +.speaker-color-5 { background-color: #13c2c2; } + +.bubble-row.speaker-2 .bubble-body { background: #fff7e6; } +.bubble-row.speaker-3 .bubble-body { background: #fff1f0; } +.bubble-row.speaker-4 .bubble-body { background: #f9f0ff; } +.bubble-row.speaker-5 .bubble-body { background: #e6fffb; } + +/* ===== 响应式布局 ===== */ +@media (max-width: 768px) { + .layout { + flex-direction: column; + height: auto; + } + .panel-left { + width: 100%; + min-width: unset; + border-right: none; + border-bottom: 1px solid #e5e5e5; + max-height: 40vh; + } + .panel-right { + min-height: 60vh; + } +} + +/* ===== 日志面板 ===== */ +.log-card { + height: 240px; + min-height: 180px; + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.log-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid #eee; +} + +.log-header h2 { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.btn-sm { + padding: 3px 10px; + font-size: 11px; +} + +.log-area { + flex: 1; + overflow-y: auto; + background: #1e1e2e; + border-radius: 6px; + padding: 8px 10px; + font-family: "SF Mono", Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.5; + color: #cdd6f4; +} + +.log-entry { + padding: 2px 0; + border-bottom: 1px solid rgba(255,255,255,0.05); + word-break: break-all; +} + +.log-time { + color: #6c7086; + margin-right: 6px; +} + +.log-type-start { color: #a6e3a1; } +.log-type-voice_id { color: #cba6f7; } +.log-type-sentences { color: #89b4fa; } +.log-type-result { color: #89b4fa; } +.log-type-end { color: #f9e2af; } +.log-type-error { color: #f38ba8; } + diff --git a/scripts/test_qwen_ws_with_asr_demo.py b/scripts/test_qwen_ws_with_asr_demo.py index 0712db6..f04de93 100644 --- a/scripts/test_qwen_ws_with_asr_demo.py +++ b/scripts/test_qwen_ws_with_asr_demo.py @@ -1,16 +1,17 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""Serve the Asr-demo UI locally and bridge it to Qwen-ASR's realtime WS API. +"""Serve the bundled Asr-demo UI and bridge it to Qwen-ASR's realtime WS API. -Run from the Qwen-Asr repository root, for example: +Run from the Qwen-Asr repository root on the server where Qwen-ASR is listening: python scripts/test_qwen_ws_with_asr_demo.py \ --backend-url ws://127.0.0.1:33050/ws/v1/asr/qwen -The default frontend directory points to the sibling Asr-demo checkout. The -checkout itself is never modified; the served HTML gets a small Qwen label and -file-type adjustment, while this bridge translates the legacy protocol. -Requires the `websockets` package, which is already listed by both projects. +Then forward the UI port from your workstation with `ssh -L 8188:127.0.0.1:8188 +user@server` and open http://127.0.0.1:8188. The bundled frontend means the +Asr-demo checkout is not needed on the server. The served HTML gets a small Qwen +label and file-type adjustment, while this bridge translates the legacy protocol. +Requires the `websockets` package, which is already listed by the project. """ from __future__ import annotations @@ -38,9 +39,8 @@ except ImportError as exc: LOGGER = logging.getLogger("qwen_ws_frontend_test") DEFAULT_BACKEND_URL = "ws://127.0.0.1:33050/ws/v1/asr/qwen" -DEFAULT_FRONTEND_DIR = ( - Path(__file__).resolve().parents[1].parent / "Asr-demo" / "frontend" / "static" -) +# Keep the test UI in-repository so a server deployment needs only this project. +DEFAULT_FRONTEND_DIR = Path(__file__).resolve().parent / "qwen_ws_test_frontend" def _as_bool(value: Any) -> bool: @@ -258,7 +258,7 @@ def parse_args() -> argparse.Namespace: "--frontend-dir", type=Path, default=DEFAULT_FRONTEND_DIR, - help="Asr-demo frontend/static directory; defaults to the sibling checkout.", + help="Optional override for the bundled Asr-demo frontend directory.", ) parser.add_argument( "--host", @@ -279,8 +279,8 @@ def main() -> None: frontend_dir = args.frontend_dir.resolve() if not (frontend_dir / "index.html").is_file(): raise SystemExit( - f"Asr-demo frontend not found at {frontend_dir}. " - "Pass --frontend-dir with its frontend/static directory." + f"Test frontend not found at {frontend_dir}. " + "Restore scripts/qwen_ws_test_frontend or pass --frontend-dir." ) logging.basicConfig(level=getattr(logging, args.log_level.upper()))