744 lines
29 KiB
JavaScript
744 lines
29 KiB
JavaScript
|
|
// ===== 页面元素 =====
|
|||
|
|
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();
|
|||
|
|
}
|