test/scripts/qwen_ws_test_frontend/app.js

726 lines
26 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 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 = `<span class="log-time">${ts}</span><span class="${typeClass}">${JSON.stringify(msg)}</span>`;
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';
}