Split the single ASR gateway into app/main.py (entrypoint) + app/core (shared env helpers) + app/asr (all existing ASR logic, unchanged behavior) + app/tts (placeholder router/config, no engine yet), so a real TTS backend can be dropped in later without reshuffling ASR code. API moved under /asr/* and /tts/* prefixes; UI gained a top-level ASR/TTS tab switcher built on a reusable nested .tab-group mechanism, with JS split into common.js/asr.js/tts.js. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
122 lines
4.1 KiB
JavaScript
122 lines
4.1 KiB
JavaScript
// ─── Shared utilities (used by both ASR and TTS tabs) ─────────────────────────
|
||
|
||
const SPEAKER_COLORS = [
|
||
'#4fa3e0','#e06b4f','#4fe09c','#e0c44f','#a04fe0',
|
||
'#e04fa3','#4fd4e0','#e0a44f','#7fe04f','#9b59b6',
|
||
];
|
||
const colorMap = {};
|
||
let colorIdx = 0;
|
||
|
||
function speakerColor(speaker) {
|
||
if (!(speaker in colorMap)) {
|
||
colorMap[speaker] = speaker === 'UNKNOWN'
|
||
? '#7a85a8'
|
||
: SPEAKER_COLORS[colorIdx++ % SPEAKER_COLORS.length];
|
||
}
|
||
return colorMap[speaker];
|
||
}
|
||
|
||
function resetColors() {
|
||
Object.keys(colorMap).forEach(k => delete colorMap[k]);
|
||
colorIdx = 0;
|
||
}
|
||
|
||
function esc(text) {
|
||
const d = document.createElement('div');
|
||
d.appendChild(document.createTextNode(text));
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function fmtTime(s) {
|
||
const m = Math.floor(s / 60);
|
||
const sec = (s % 60).toFixed(1).padStart(4, '0');
|
||
return `${String(m).padStart(2,'0')}:${sec}`;
|
||
}
|
||
|
||
function fmtTimestamp(id) {
|
||
// id: YYYYMMDD_HHMMSS_ffffff
|
||
if (!id || id.length < 15) return id;
|
||
const y = id.slice(0,4), mo = id.slice(4,6), d = id.slice(6,8);
|
||
const h = id.slice(9,11), mi = id.slice(11,13), s = id.slice(13,15);
|
||
return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
|
||
}
|
||
|
||
function renderSpeakerBlocks(segments, container, sectionEl) {
|
||
if (!segments || !segments.length || !segments.some(s => s.speaker)) {
|
||
sectionEl.style.display = 'none';
|
||
return;
|
||
}
|
||
resetColors();
|
||
const groups = [];
|
||
let cur = null;
|
||
for (const seg of segments) {
|
||
const spk = seg.speaker || 'UNKNOWN';
|
||
if (!cur || cur.speaker !== spk) { cur = {speaker: spk, segs: [seg]}; groups.push(cur); }
|
||
else cur.segs.push(seg);
|
||
}
|
||
let html = '';
|
||
for (const g of groups) {
|
||
const c = speakerColor(g.speaker);
|
||
const text = g.segs.map(s => (s.text||'').trim()).join(' ');
|
||
const t0 = g.segs[0].start, t1 = g.segs[g.segs.length-1].end;
|
||
html += `<div class="speaker-block">
|
||
<div class="speaker-badge">
|
||
<span class="speaker-dot" style="background:${c}"></span>
|
||
<span style="color:${c}">${esc(g.speaker)}</span>
|
||
<span class="speaker-time">${fmtTime(t0)} – ${fmtTime(t1)}</span>
|
||
</div>
|
||
<div class="speaker-text">${esc(text)}</div>
|
||
</div>`;
|
||
}
|
||
container.innerHTML = html;
|
||
sectionEl.style.display = '';
|
||
}
|
||
|
||
function downloadBlob(name, text, type) {
|
||
const blob = new Blob([text], {type});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = name;
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// ─── Collapsible toggles ──────────────────────────────────────────────────────
|
||
|
||
function makeToggle(btnId, panelId, chevronId) {
|
||
const btn = document.getElementById(btnId);
|
||
const panel = document.getElementById(panelId);
|
||
const chevron = document.getElementById(chevronId);
|
||
if (!btn || !panel) return;
|
||
btn.addEventListener('click', () => {
|
||
const open = panel.style.display !== 'none';
|
||
panel.style.display = open ? 'none' : '';
|
||
if (chevron) chevron.style.transform = open ? '' : 'rotate(90deg)';
|
||
});
|
||
}
|
||
|
||
// ─── Nestable tab groups (top-level ASR/TTS switch + each domain's sub-tabs) ───
|
||
//
|
||
// Each `.tab-group` owns one `.tabs` row of `.tab-btn` and a set of sibling
|
||
// `.tab-panel` elements. Scoping with `:scope >` means groups can nest freely
|
||
// (e.g. the top ASR/TTS switch contains a `.tab-group` per domain) without
|
||
// their click handlers interfering with each other.
|
||
|
||
function initTabGroups() {
|
||
document.querySelectorAll('.tab-group').forEach(group => {
|
||
const buttons = group.querySelectorAll(':scope > .tabs > .tab-btn');
|
||
const panels = group.querySelectorAll(':scope > .tab-panel');
|
||
buttons.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
buttons.forEach(b => b.classList.remove('active'));
|
||
panels.forEach(p => p.style.display = 'none');
|
||
btn.classList.add('active');
|
||
const target = document.getElementById(btn.dataset.target);
|
||
if (target) target.style.display = '';
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
initTabGroups();
|