ASR-demo/realtime_asr_optimization_demo/static/app.js

744 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// ===== 页面元素 =====
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();
}