ASR-demo/realtime_asr_optimization_demo/static/app.js

744 lines
29 KiB
JavaScript
Raw Normal View History

2026-09-10 05:47:09 +00:00
// ===== 页面元素 =====
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();
}