调整启动方式
parent
adc35b6458
commit
d8e74d3293
17
README.md
17
README.md
|
|
@ -285,6 +285,23 @@ curl -X POST http://127.0.0.1:8000/v1/audio/transcriptions \
|
||||||
|
|
||||||
Replace `your_api_key` with the value in `.env`. If `API_KEY` is unset, omit the `Authorization` header. Stop the foreground process with `Ctrl+C`.
|
Replace `your_api_key` with the value in `.env`. If `API_KEY` is unset, omit the `Authorization` header. Stop the foreground process with `Ctrl+C`.
|
||||||
|
|
||||||
|
#### Test Qwen realtime WebSocket with the bundled Asr-demo UI
|
||||||
|
|
||||||
|
The test page and WebSocket bridge are included in this repository, so the server does not need a separate Asr-demo checkout. With Qwen-ASR already listening on port `33050`, start the bridge from the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python scripts/test_qwen_ws_with_asr_demo.py \
|
||||||
|
--backend-url ws://127.0.0.1:33050/ws/v1/asr/qwen
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge listens on `127.0.0.1:8188` by default. From your workstation, create an SSH tunnel and open the local URL; using `localhost` lets the browser grant microphone access over plain HTTP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 8188:127.0.0.1:8188 user@server
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://127.0.0.1:8188`. If `websockets` is missing from the selected Python environment, install it with `./.venv/bin/pip install websockets`. The UI supports live microphone audio and `.pcm` or `.wav` file input.
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|
||||||
**System Requirements:**
|
**System Requirements:**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,725 @@
|
||||||
|
// ===== 页面元素 =====
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>语音识别体验 Demo</title>
|
||||||
|
<link rel="stylesheet" href="style.css?v=212">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<!-- 左侧面板 -->
|
||||||
|
<aside class="panel-left">
|
||||||
|
<header>
|
||||||
|
<h1>🎤 语音识别体验</h1>
|
||||||
|
<p class="subtitle">实时语音识别 · 句子模式</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>识别配置</h2>
|
||||||
|
<div class="form-stack">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="engineModel">引擎模型</label>
|
||||||
|
<input type="text" id="engineModel" value="16k_zh_en_speaker" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sentenceStrategy">分句策略</label>
|
||||||
|
<select id="sentenceStrategy">
|
||||||
|
<option value="0" selected>语义单句</option>
|
||||||
|
<option value="1">段落</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>话者分离</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="speakerDiarization" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
<span class="toggle-label" id="diarizationLabel">开启</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="hotwords">热词 <span class="hint">逗号分隔,用于最终结果修正</span></label>
|
||||||
|
<input type="text" id="hotwords" placeholder="例如:FunASR,实时语音,说话人分离" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-divider"></div>
|
||||||
|
<div class="audio-input-stack">
|
||||||
|
<div class="input-mode-tabs">
|
||||||
|
<button class="mode-tab active" id="tabMic" data-mode="mic">🎙️ 麦克风</button>
|
||||||
|
<button class="mode-tab" id="tabFile" data-mode="file">📁 文件</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-mode-panel" id="panelMic">
|
||||||
|
<div class="mic-status" id="micStatus">点击下方按钮开始录音</div>
|
||||||
|
<div class="mic-timer" id="micTimer" style="display:none">🔴 录音中 <span id="micElapsed">00:00</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="input-mode-panel" id="panelFile" style="display:none">
|
||||||
|
<div class="file-select">
|
||||||
|
<label class="btn btn-outline" for="audioFile">选择音频文件</label>
|
||||||
|
<input type="file" id="audioFile" accept=".pcm,.wav,.mp3,.aac,.opus,.ogg,.silk,.speex" hidden>
|
||||||
|
</div>
|
||||||
|
<span class="file-info" id="fileInfo">支持 pcm/wav/mp3/aac/opus/silk/speex,m4a 请先转 wav/mp3</span>
|
||||||
|
<div class="audio-meta" id="audioMeta" style="display:none">
|
||||||
|
<div class="audio-meta-row"><span class="meta-k">格式</span><span class="meta-v" id="metaFormat">—</span></div>
|
||||||
|
<div class="audio-meta-row"><span class="meta-k">采样率</span><span class="meta-v" id="metaSampleRate">—</span></div>
|
||||||
|
<div class="audio-meta-row"><span class="meta-k">时长</span><span class="meta-v" id="metaDuration">—</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="speed-control" id="speedControl" style="display:none">
|
||||||
|
<label class="speed-label">发送倍速 <span id="speedValue">1.0x</span></label>
|
||||||
|
<input type="range" id="speedSlider" min="0.5" max="3" step="0.1" value="1.0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button class="btn btn-primary" id="btnStart">▶ 开始识别</button>
|
||||||
|
<button class="btn btn-danger" id="btnStop" disabled>⏹ 停止</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧面板:识别结果 + 日志 -->
|
||||||
|
<main class="panel-right">
|
||||||
|
<section class="card result-card">
|
||||||
|
<h2>识别结果</h2>
|
||||||
|
<div class="result-meta" id="resultMeta" style="display:none">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">VoiceID:</span>
|
||||||
|
<span class="meta-value" id="voiceIdDisplay">—</span>
|
||||||
|
<button class="btn-icon" id="btnCopyVoiceId" title="复制">📋</button>
|
||||||
|
<button class="btn btn-outline btn-sm btn-export" id="btnExportWav" title="导出音频" disabled>💾 导出WAV</button>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">状态:</span>
|
||||||
|
<span class="status-dot" id="statusDot"></span>
|
||||||
|
<span id="statusText">就绪</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-area" id="resultArea">
|
||||||
|
<div class="placeholder" id="resultPlaceholder">选择音频来源并点击"开始识别"</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="card log-card">
|
||||||
|
<div class="log-header">
|
||||||
|
<h2>ASR 原始日志</h2>
|
||||||
|
<button class="btn btn-outline btn-sm" id="btnClearLog">清空</button>
|
||||||
|
</div>
|
||||||
|
<div class="log-area" id="logArea"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js?v=212"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,803 @@
|
||||||
|
/* ===== 重置与基础样式 ===== */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
color: #1a1a2e;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 双栏布局 ===== */
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-left {
|
||||||
|
width: 340px;
|
||||||
|
min-width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid #e5e5e5;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-right {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 页头 ===== */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 卡片 ===== */
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-left .card {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 可折叠卡片标题 ===== */
|
||||||
|
.card-header-collapsible {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-collapsible h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-arrow {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-arrow.expanded {
|
||||||
|
color: #4a7dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-params-body {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 区域分隔线 ===== */
|
||||||
|
.section-divider {
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 表单(左侧面板) ===== */
|
||||||
|
.form-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row-3 {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row-3 .form-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label:first-child {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #999;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus,
|
||||||
|
input[type="text"]:focus {
|
||||||
|
border-color: #4a7dff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 125, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"][readonly] {
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: #666;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"][readonly]:focus {
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 开关控件 ===== */
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 6px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-slider {
|
||||||
|
background: #4a7dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-slider::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 音频输入(左侧面板) ===== */
|
||||||
|
.audio-input-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入模式选项卡 */
|
||||||
|
.input-mode-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 0;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab + .mode-tab {
|
||||||
|
border-left: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab.active {
|
||||||
|
background: #4a7dff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab:hover:not(.active) {
|
||||||
|
background: #f0f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件选择 */
|
||||||
|
.file-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info.has-file {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音频元信息 */
|
||||||
|
.audio-meta {
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-meta-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-meta-row .meta-k {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-meta-row .meta-v {
|
||||||
|
color: #333;
|
||||||
|
font-family: "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语速控制 */
|
||||||
|
.speed-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-label span {
|
||||||
|
color: #4a7dff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#speedSlider {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: #e5e5e5;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#speedSlider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4a7dff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 麦克风面板 */
|
||||||
|
.mic-status {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-timer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ff4d4f;
|
||||||
|
padding: 8px 0;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-timer span {
|
||||||
|
font-family: "SF Mono", Menlo, monospace;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 按钮 ===== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #4a7dff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #3a6ae8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #e63e40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: #fff;
|
||||||
|
color: #4a7dff;
|
||||||
|
border: 1px solid #4a7dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: #f0f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.copy-success {
|
||||||
|
background: #e6f7e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导出 WAV 按钮 */
|
||||||
|
.btn-export {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 11px !important;
|
||||||
|
padding: 2px 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 复制提示 ===== */
|
||||||
|
.copy-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-10px);
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s, transform 0.25s;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-toast.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-toast-error {
|
||||||
|
background: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 结果区域(右侧面板) ===== */
|
||||||
|
.result-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
color: #333;
|
||||||
|
font-family: "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connecting {
|
||||||
|
background: #faad14;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.running {
|
||||||
|
background: #52c41a;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.done {
|
||||||
|
background: #4a7dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error {
|
||||||
|
background: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
text-align: center;
|
||||||
|
color: #bbb;
|
||||||
|
padding: 60px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 字幕模式(不启用说话人分离) ===== */
|
||||||
|
.subtitle-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-item:hover {
|
||||||
|
background: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
font-family: "SF Mono", Menlo, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 100px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
word-break: break-word;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-text.interim {
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-text.final {
|
||||||
|
color: #000;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-item.subtitle-interim {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 气泡模式(启用说话人分离) ===== */
|
||||||
|
.bubble-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-row.speaker-left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-row.speaker-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-row.speaker-unknown .bubble-body {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
border-radius: 12px;
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-badge.speaker-color-unknown {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-wrapper {
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-header {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-right .bubble-header {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-badge {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-body {
|
||||||
|
padding: 9px 13px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-left .bubble-body {
|
||||||
|
background: #e8f0fe;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-right .bubble-body {
|
||||||
|
background: #e6f7e9;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-body.interim {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 待确认文本(speaker_id=-1)追加到已确认的气泡中 */
|
||||||
|
.pending-text {
|
||||||
|
display: inline;
|
||||||
|
color: #aaa;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: 2px;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-text::before {
|
||||||
|
content: ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bbb;
|
||||||
|
font-family: "SF Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 说话人颜色 ===== */
|
||||||
|
.speaker-color-0 { background-color: #4a7dff; }
|
||||||
|
.speaker-color-1 { background-color: #52c41a; }
|
||||||
|
.speaker-color-2 { background-color: #faad14; }
|
||||||
|
.speaker-color-3 { background-color: #ff4d4f; }
|
||||||
|
.speaker-color-4 { background-color: #9254de; }
|
||||||
|
.speaker-color-5 { background-color: #13c2c2; }
|
||||||
|
|
||||||
|
.bubble-row.speaker-2 .bubble-body { background: #fff7e6; }
|
||||||
|
.bubble-row.speaker-3 .bubble-body { background: #fff1f0; }
|
||||||
|
.bubble-row.speaker-4 .bubble-body { background: #f9f0ff; }
|
||||||
|
.bubble-row.speaker-5 .bubble-body { background: #e6fffb; }
|
||||||
|
|
||||||
|
/* ===== 响应式布局 ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.panel-left {
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
.panel-right {
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 日志面板 ===== */
|
||||||
|
.log-card {
|
||||||
|
height: 240px;
|
||||||
|
min-height: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #cdd6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: #6c7086;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-type-start { color: #a6e3a1; }
|
||||||
|
.log-type-voice_id { color: #cba6f7; }
|
||||||
|
.log-type-sentences { color: #89b4fa; }
|
||||||
|
.log-type-result { color: #89b4fa; }
|
||||||
|
.log-type-end { color: #f9e2af; }
|
||||||
|
.log-type-error { color: #f38ba8; }
|
||||||
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Serve the Asr-demo UI locally and bridge it to Qwen-ASR's realtime WS API.
|
"""Serve the bundled Asr-demo UI and bridge it to Qwen-ASR's realtime WS API.
|
||||||
|
|
||||||
Run from the Qwen-Asr repository root, for example:
|
Run from the Qwen-Asr repository root on the server where Qwen-ASR is listening:
|
||||||
|
|
||||||
python scripts/test_qwen_ws_with_asr_demo.py \
|
python scripts/test_qwen_ws_with_asr_demo.py \
|
||||||
--backend-url ws://127.0.0.1:33050/ws/v1/asr/qwen
|
--backend-url ws://127.0.0.1:33050/ws/v1/asr/qwen
|
||||||
|
|
||||||
The default frontend directory points to the sibling Asr-demo checkout. The
|
Then forward the UI port from your workstation with `ssh -L 8188:127.0.0.1:8188
|
||||||
checkout itself is never modified; the served HTML gets a small Qwen label and
|
user@server` and open http://127.0.0.1:8188. The bundled frontend means the
|
||||||
file-type adjustment, while this bridge translates the legacy protocol.
|
Asr-demo checkout is not needed on the server. The served HTML gets a small Qwen
|
||||||
Requires the `websockets` package, which is already listed by both projects.
|
label and file-type adjustment, while this bridge translates the legacy protocol.
|
||||||
|
Requires the `websockets` package, which is already listed by the project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -38,9 +39,8 @@ except ImportError as exc:
|
||||||
|
|
||||||
LOGGER = logging.getLogger("qwen_ws_frontend_test")
|
LOGGER = logging.getLogger("qwen_ws_frontend_test")
|
||||||
DEFAULT_BACKEND_URL = "ws://127.0.0.1:33050/ws/v1/asr/qwen"
|
DEFAULT_BACKEND_URL = "ws://127.0.0.1:33050/ws/v1/asr/qwen"
|
||||||
DEFAULT_FRONTEND_DIR = (
|
# Keep the test UI in-repository so a server deployment needs only this project.
|
||||||
Path(__file__).resolve().parents[1].parent / "Asr-demo" / "frontend" / "static"
|
DEFAULT_FRONTEND_DIR = Path(__file__).resolve().parent / "qwen_ws_test_frontend"
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _as_bool(value: Any) -> bool:
|
def _as_bool(value: Any) -> bool:
|
||||||
|
|
@ -258,7 +258,7 @@ def parse_args() -> argparse.Namespace:
|
||||||
"--frontend-dir",
|
"--frontend-dir",
|
||||||
type=Path,
|
type=Path,
|
||||||
default=DEFAULT_FRONTEND_DIR,
|
default=DEFAULT_FRONTEND_DIR,
|
||||||
help="Asr-demo frontend/static directory; defaults to the sibling checkout.",
|
help="Optional override for the bundled Asr-demo frontend directory.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--host",
|
"--host",
|
||||||
|
|
@ -279,8 +279,8 @@ def main() -> None:
|
||||||
frontend_dir = args.frontend_dir.resolve()
|
frontend_dir = args.frontend_dir.resolve()
|
||||||
if not (frontend_dir / "index.html").is_file():
|
if not (frontend_dir / "index.html").is_file():
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"Asr-demo frontend not found at {frontend_dir}. "
|
f"Test frontend not found at {frontend_dir}. "
|
||||||
"Pass --frontend-dir with its frontend/static directory."
|
"Restore scripts/qwen_ws_test_frontend or pass --frontend-dir."
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.basicConfig(level=getattr(logging, args.log_level.upper()))
|
logging.basicConfig(level=getattr(logging, args.log_level.upper()))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue