// ===== DOM Elements =====
const elEngineModel = document.getElementById('engineModel');
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');
// Input mode tabs
const elTabMic = document.getElementById('tabMic');
const elTabFile = document.getElementById('tabFile');
const elPanelMic = document.getElementById('panelMic');
const elPanelFile = document.getElementById('panelFile');
// File upload
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');
// ===== Speaker Diarization Toggle =====
elSpeakerDiarization.addEventListener('change', () => {
elDiarizationLabel.textContent = elSpeakerDiarization.checked ? '开启' : '关闭';
});
// ===== Log Area =====
elBtnClearLog.addEventListener('click', () => { elLogArea.innerHTML = ''; });
function appendLog(msg) {
const now = new Date();
const ts = [now.getHours(), now.getMinutes(), now.getSeconds()]
.map(n => String(n).padStart(2, '0')).join(':')
+ '.' + String(now.getMilliseconds()).padStart(3, '0');
const typeClass = 'log-type-' + (msg.type || 'unknown');
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = `${ts}${JSON.stringify(msg)}`;
elLogArea.appendChild(entry);
elLogArea.scrollTop = elLogArea.scrollHeight;
}
// ===== State =====
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;
// Input mode (mic / file)
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']);
const SPEAKER_COLORS = ['#4a7dff', '#52c41a', '#faad14', '#ff4d4f', '#9254de', '#13c2c2'];
let sentenceMap = {};
let speakerOrderMap = {};
let speakerOrderCounter = 0;
let lastConfirmedBubbleEl = null;
let pendingSpanMap = {};
// ===== Input Mode Tabs =====
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'));
// ===== File Selection =====
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);
});
// Speed slider
elSpeedSlider.addEventListener('input', () => {
elSpeedValue.textContent = parseFloat(elSpeedSlider.value).toFixed(1) + 'x';
});
// ===== Audio Meta Parsing =====
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 = '无法解析';
}
}
}
// ===== Copy & Toast =====
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 Export =====
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 已导出');
});
// ===== Helpers =====
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;
}
// ===== Render: Subtitle (no diarization) =====
// 每个 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;
}
// ===== Render: Speaker Bubble =====
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 ? ' ...' : '');
let pending = pendingSpanMap[id];
if (pending) {
pending.spanEl.textContent = pendingText;
elResultArea.scrollTop = elResultArea.scrollHeight;
return;
}
if (lastConfirmedBubbleEl) {
const body = lastConfirmedBubbleEl.querySelector('.bubble-body');
const span = document.createElement('span');
span.className = 'pending-text';
span.dataset.sentenceId = id;
span.textContent = pendingText;
body.appendChild(span);
pendingSpanMap[id] = { hostEl: lastConfirmedBubbleEl, spanEl: span };
} else {
renderFallbackPendingBubble(id, pendingText, isInterim);
}
elResultArea.scrollTop = elResultArea.scrollHeight;
return;
}
if (pendingSpanMap[id]) {
pendingSpanMap[id].spanEl.remove();
delete pendingSpanMap[id];
}
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);
body.querySelectorAll('.pending-text').forEach(s => s.remove());
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;
lastConfirmedBubbleEl = el;
elResultArea.scrollTop = elResultArea.scrollHeight;
}
function renderFallbackPendingBubble(id, text, isInterim) {
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;
}
const body = entry.el.querySelector('.bubble-body');
body.textContent = text;
body.className = 'bubble-body' + (isInterim ? ' interim' : '');
}
// ===== Start Recognition =====
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;
lastConfirmedBubbleEl = null;
pendingSpanMap = {};
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;
// 构造 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),
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;
}
}
// ===== Stop =====
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;
}
// ===== Send Audio File =====
// 按 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' }));
}
}
// ===== Microphone Capture =====
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';
}