Files
speech-to-text/lib/frontend.html

1438 lines
44 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Speech to Text</title>
<style>
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1e222b;
--border: #2a2f3a;
--text: #e6e9ef;
--muted: #9aa3b2;
--accent: #6ee7b7;
--accent-dim: #2dd4bf;
--danger: #f87171;
--radius: 12px;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
display: flex;
flex-direction: column;
min-height: 100vh;
line-height: 1.5;
}
header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 12px;
}
header h1 { font-size: 18px; margin: 0; font-weight: 600; letter-spacing: -0.01em; }
header .tag { color: var(--muted); font-size: 13px; font-family: var(--mono); }
header .gear { color: var(--muted); font-size: 16px; cursor: pointer; margin-left: auto; }
header .gear:hover { color: var(--text); }
main {
flex: 1;
width: 100%;
max-width: 920px;
margin: 0 auto;
padding: 28px 24px 48px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
margin-bottom: 20px;
}
.controls label { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
select, .controls input[type=text] {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 10px;
font-family: var(--mono);
font-size: 13px;
}
input[type=file] { display: none; }
/* --- Unified input zone --- */
.source-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
background: var(--panel);
padding: 36px 24px 24px;
text-align: center;
transition: border-color .15s ease, background .15s ease;
cursor: pointer;
user-select: none;
position: relative;
}
.source-zone.drag { border-color: var(--accent-dim); background: var(--panel-2); }
.source-zone.recording { border-color: var(--danger); border-style: solid; cursor: default; }
.source-zone .big { font-size: 16px; font-weight: 500; color: var(--text); }
.source-zone .formats { color: var(--muted); font-size: 12px; margin-top: 14px; font-family: var(--mono); }
.source-actions {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
font-size: 13px;
color: var(--muted);
}
.source-actions a {
color: var(--accent-dim);
text-decoration: none;
cursor: pointer;
}
.source-actions a:hover { color: var(--accent); }
/* recording state */
.rec-indicator {
display: none;
}
.source-zone.recording .source-idle { display: none; }
.source-zone.recording .rec-indicator { display: block; }
.rec-dot {
display: inline-block;
width: 12px; height: 12px;
background: var(--danger);
border-radius: 50%;
margin-right: 8px;
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-dot { 0%,100%{ opacity: 1; } 50%{ opacity: .4; } }
.rec-time {
font-family: var(--mono);
font-size: 28px;
font-weight: 600;
color: var(--text);
margin: 12px 0;
}
.rec-stop {
margin-top: 16px;
background: var(--danger);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 28px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.rec-stop:hover { opacity: .85; }
.storage-info {
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
margin-top: 8px;
}
.storage-info.warning { color: var(--danger); }
.job audio {
width: 100%;
height: 32px;
margin-top: 8px;
border-radius: 8px;
background: var(--panel-2);
display: none;
}
.job audio::-webkit-media-controls-panel {
background: var(--panel-2);
}
.job audio::-webkit-media-controls-current-time-display,
.job audio::-webkit-media-controls-time-remaining-display {
color: var(--text);
font-family: var(--mono);
font-size: 11px;
}
/* path row */
.path-row {
display: flex;
gap: 8px;
margin-top: 12px;
max-height: 0;
overflow: hidden;
transition: max-height .2s ease, margin-top .2s ease, opacity .2s ease;
opacity: 0;
}
.path-row.open {
max-height: 60px;
opacity: 1;
margin-top: 12px;
}
.path-row input[type=text] {
flex: 1;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-family: var(--mono);
font-size: 13px;
}
.path-row input[type=text]::placeholder { color: var(--muted); opacity: .6; }
.path-row button { white-space: nowrap; }
.search-row {
display: flex;
gap: 8px;
margin-top: 20px;
margin-bottom: 4px;
}
.search-row input[type=text] {
flex: 1;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-family: var(--mono);
font-size: 13px;
}
.search-row input[type=text]::placeholder { color: var(--muted); opacity: .6; }
.search-row label {
font-size: 12px;
color: var(--muted);
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.queue { display: flex; flex-direction: column; gap: 14px; }
.sentinel { min-height: 1px; }
.job {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.job .row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.job .name { font-family: var(--mono); font-size: 13px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job .timestamp { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.job .status { font-size: 12px; font-family: var(--mono); padding: 2px 8px; border-radius: 999px; }
.status.pending { color: var(--muted); background: var(--panel-2); }
.status.working { color: #0f1115; background: var(--accent); }
.status.done { color: #0f1115; background: var(--accent-dim); }
.status.error { color: #0f1115; background: var(--danger); }
.bar { height: 3px; background: var(--panel-2); overflow: hidden; }
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .2s ease; }
.bar.indeterminate > i {
width: 35%;
animation: slide 1.1s ease-in-out infinite;
}
@keyframes slide { 0%{margin-left:-35%} 100%{margin-left:100%} }
.progress-info {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
padding: 2px 16px 8px;
}
.result { padding: 0 16px 16px; }
.result textarea {
width: 100%;
min-height: 120px;
resize: vertical;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
font-family: var(--mono);
font-size: 13px;
line-height: 1.55;
}
.result .actions { display: flex; gap: 8px; margin-top: 10px; }
button {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
}
button:hover { border-color: var(--accent-dim); }
button.primary { background: var(--accent); color: #0f1115; border-color: var(--accent); font-weight: 600; }
button:disabled { opacity: .5; cursor: not-allowed; }
.err-msg { color: var(--danger); font-size: 13px; font-family: var(--mono); padding: 0 16px 14px; }
.note-area { padding: 0 16px 8px; }
.note-area label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px; }
.note-area textarea {
width: 100%;
min-height: 48px;
max-height: 120px;
resize: vertical;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-family: var(--sans);
font-size: 13px;
line-height: 1.4;
}
.summary-box {
margin-top: 10px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
min-height: 40px;
}
.summary-box h2, .summary-box h3 { margin: 0.4em 0; font-size: 14px; }
.summary-box ul, .summary-box ol { margin: 0.2em 0; padding-left: 1.4em; }
.summary-box li { margin: 0.15em 0; }
.summary-spinner { color: var(--muted); font-family: var(--mono); font-size: 13px; padding: 12px 0; }
.summary-error { color: var(--danger); font-family: var(--mono); font-size: 13px; }
footer { color: var(--muted); font-size: 12px; text-align: center; padding: 18px; border-top: 1px solid var(--border); }
footer code { font-family: var(--mono); }
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.6);
z-index: 100;
}
.modal-overlay.open { display: flex; align-items: center; justify-content: center; }
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
width: 420px;
max-width: 90vw;
}
.modal h2 { margin: 0 0 16px; font-size: 16px; }
.modal label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; margin-top: 12px; }
.modal input[type=text] {
width: 100%;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-family: var(--mono);
font-size: 13px;
}
.modal .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
</style>
</head>
<body>
<header>
<h1>Speech to Text</h1>
<span class="tag">__MODEL_TAG__</span>
<span class="gear" id="settingsBtn" title="Settings">&#x2699;</span>
</header>
<main>
<div class="controls">
<label>Language
<select id="lang">
<option value="fr">fr</option>
<option value="en">en</option>
<option value="es">es</option>
<option value="de">de</option>
<option value="it">it</option>
<option value="pt">pt</option>
<option value="nl">nl</option>
<option value="pl">pl</option>
<option value="ru">ru</option>
</select>
</label>
<label>Model
<select id="model"></select>
</label>
<label>Format
<select id="fmt">
<option value="json" selected>json</option>
<option value="text">text</option>
<option value="srt">srt</option>
<option value="vtt">vtt</option>
<option value="verbose_json">verbose_json</option>
</select>
</label>
</div>
<div class="source-zone" id="sourceZone" tabindex="0">
<div class="source-idle">
<div class="big">Drop audio here</div>
<div class="source-actions">
<a id="chooseLink">choose file</a>
<a id="recordLink">record</a>
<a id="pathLink">paste path</a>
</div>
<div class="formats">m4a &middot; ogg &middot; mp3 &middot; wav &middot; flac &middot; aac &middot; opus &middot; webm</div>
<div class="storage-info" id="storageInfo"></div>
</div>
<div class="rec-indicator">
<div style="display:flex;align-items:center;justify-content:center;gap:6px">
<span class="rec-dot"></span> Recording
</div>
<div class="rec-time" id="recTimer">0:00</div>
<button class="rec-stop" id="recStop">Stop</button>
</div>
</div>
<input type="file" id="file" multiple
accept=".m4a,.ogg,.mp3,.wav,.flac,.aac,.opus,.webm,audio/*">
<div class="path-row" id="pathRow">
<input type="text" id="filepath" placeholder="/path/to/recording.m4a" spellcheck="false">
<button id="pathBtn" class="primary">Transcribe</button>
</div>
<div class="search-row" id="searchRow">
<input type="text" id="searchInput" placeholder="Search transcripts&#8230;">
<label><input type="checkbox" id="searchAll" checked> all text</label>
</div>
<div class="queue" id="queue"></div>
<div class="sentinel" id="sentinel"></div>
</main>
<footer>
Backend: <code id="backend">checking...</code> &nbsp;&middot;&nbsp; Max upload: <code>__MAX_MB__ MB</code>
</footer>
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<h2>Settings</h2>
<label>LLM Endpoint <span style="font-size:11px;color:var(--muted)">(OpenAI-compatible, e.g. https://api.openai.com/v1)</span></label>
<input type="text" id="settingsUrl" placeholder="https://api.openai.com/v1">
<label>API Key</label>
<input type="text" id="settingsApiKey" placeholder="sk-...">
<label>Model</label>
<input type="text" id="settingsModel" placeholder="gpt-4o-mini">
<div class="actions">
<button id="settingsCancel">Cancel</button>
<button id="settingsSave" class="primary">Save</button>
</div>
</div>
</div>
<script>
const STORAGE_KEY = 'stt_jobs';
const MAX_BYTES = __MAX_BYTES__;
const BATCH = 20;
const sourceZone = document.getElementById('sourceZone');
const fileInput = document.getElementById('file');
const queue = document.getElementById('queue');
const sentinel = document.getElementById('sentinel');
const langSel = document.getElementById('lang');
const fmtSel = document.getElementById('fmt');
const modelSel = document.getElementById('model');
const pathRow = document.getElementById('pathRow');
const pathInput = document.getElementById('filepath');
const pathBtn = document.getElementById('pathBtn');
langSel.value = "__DEFAULT_LANG__";
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
// --- IndexedDB Audio Storage ---
const DB_NAME = 'stt_audio';
const DB_VERSION = 1;
const STORE_NAME = 'blobs';
let db = null;
function openAudioDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
});
}
async function withDB() {
if (!db) db = await openAudioDB();
return db;
}
async function saveBlobToDB(id, blob) {
const database = await withDB();
return new Promise((resolve, reject) => {
const tx = database.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.put({ id, blob, ts: Date.now() });
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function getBlobFromDB(id) {
const database = await withDB();
return new Promise((resolve, reject) => {
const tx = database.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get(id);
req.onsuccess = () => resolve(req.result?.blob || null);
req.onerror = () => reject(req.error);
});
}
async function deleteBlobFromDB(id) {
const database = await withDB();
return new Promise((resolve, reject) => {
const tx = database.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function getAllBlobIds() {
const database = await withDB();
return new Promise((resolve, reject) => {
const tx = database.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAllKeys();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function cleanupOrphanedBlobs() {
try {
const storedIds = await getAllBlobIds();
const jobIds = new Set(allData.map(j => j.id));
for (const id of storedIds) {
if (!jobIds.has(String(id))) {
await deleteBlobFromDB(id);
}
}
} catch (e) { /* ignore */ }
}
// --- Storage Estimate ---
const FALLBACK_BPS = 128 * 1024 / 8; // 128 kbps = 16 KB/s
function getBitrateEstimate() {
const saved = localStorage.getItem('stt_observed_bps');
if (saved) return parseFloat(saved);
return FALLBACK_BPS;
}
function recordObservedBitrate(bytes, seconds) {
if (seconds < 3) return;
const bps = bytes / seconds;
const saved = localStorage.getItem('stt_observed_bps');
const old = saved ? parseFloat(saved) : bps;
const newBps = old * 0.7 + bps * 0.3;
localStorage.setItem('stt_observed_bps', String(Math.round(newBps)));
}
async function updateStorageDisplay() {
const el = document.getElementById('storageInfo');
if (!el) return;
try {
if (!navigator.storage || !navigator.storage.estimate) {
el.textContent = '';
return;
}
const estimate = await navigator.storage.estimate();
const used = estimate.usage || 0;
const quota = estimate.quota || 0;
if (!quota) {
el.textContent = '';
return;
}
const remaining = Math.max(0, quota - used);
const bps = getBitrateEstimate();
const mins = Math.floor(remaining / (bps * 60));
if (mins < 5) {
el.classList.add('warning');
el.textContent = `~${mins} min storage left`;
} else {
el.classList.remove('warning');
el.textContent = `~${mins} min storage left`;
}
} catch (e) {
el.textContent = '';
}
}
// --- Data layer ---
let allData = loadJobs();
const liveEls = new Set();
const renderedIds = new Set();
let displayData = null;
function loadJobs() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
}
function saveJobs() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(allData)); } catch {}
}
function syncToData(el) {
const entry = {
id: el._id,
name: el.querySelector('.name').textContent,
text: el._text || '',
result: el._result || null,
summary: el._summary || '',
note: el._note || '',
ts: el._ts || Date.now(),
hasBlob: el._hasBlob || false,
language: el._language || '',
source: el._source || '',
path: el._path || '',
};
const idx = allData.findIndex(j => j.id === entry.id);
if (idx >= 0) allData[idx] = entry;
else allData.unshift(entry);
}
function getDisplayData() {
return displayData || allData;
}
window.addEventListener('beforeunload', saveJobs);
const debouncedSave = debounce(saveJobs, 1000);
// --- Search ---
const searchInput = document.getElementById('searchInput');
const searchAll = document.getElementById('searchAll');
function fuzzyMatch(query, text) {
const q = query.toLowerCase();
const t = text.toLowerCase();
let qi = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) qi++;
}
return qi === q.length;
}
function doSearch() {
const q = searchInput.value.trim();
if (!q) {
displayData = null;
} else {
const all = searchAll.checked;
displayData = allData.filter(job => {
const haystack = all
? ((job.text || '') + ' ' + (job.summary || '') + ' ' + (job.note || '') + ' ' + (job.name || ''))
: (job.summary || '');
return fuzzyMatch(q, haystack);
});
}
clearAndRender();
}
const debouncedSearch = debounce(doSearch, 500);
// --- Rendering ---
function clearAndRender() {
for (const el of [...queue.querySelectorAll('.job')]) {
if (!liveEls.has(el._id)) {
renderedIds.delete(el._id);
teardownAudioPlayer(el);
el.remove();
}
}
renderBatch();
}
function createRestoredEl(job) {
const el = makeJobEl(job.name, job.id);
el._text = job.text || '';
el._result = job.result;
el._summary = job.summary || '';
el._note = job.note || '';
el._ts = job.ts || Date.now();
el._hasBlob = job.hasBlob || false;
el._language = job.language || '';
el._source = job.source || '';
el._path = job.path || '';
setStatus(el, 'done', 'done');
el.querySelector('.bar').style.display = 'none';
const r = el.querySelector('.result');
r.style.display = 'block';
const ta = r.querySelector('textarea');
ta.value = job.text || '';
ta.style.height = Math.min(400, Math.max(120, (job.text || '').split('\n').length * 22 + 40)) + 'px';
const noteTa = el.querySelector('.note-area textarea');
if (noteTa && job.note) noteTa.value = job.note;
if (job.summary) {
const sa = el.querySelector('.summary-area');
sa.style.display = 'block';
sa.innerHTML = '<div class="summary-box">' + renderMarkdown(job.summary) + '</div>';
}
const ts = el.querySelector('.timestamp');
if (ts) ts.textContent = fmtTime(job.ts);
finishButtons(el);
setupAudioPlayer(el);
return el;
}
function renderBatch() {
const data = getDisplayData();
let count = 0;
for (const job of data) {
if (count >= BATCH) break;
if (renderedIds.has(job.id) || liveEls.has(job.id)) continue;
const el = createRestoredEl(job);
queue.appendChild(el);
renderedIds.add(job.id);
count++;
}
const remaining = data.filter(j => !renderedIds.has(j.id) && !liveEls.has(j.id)).length;
sentinel.style.display = remaining === 0 ? 'none' : '';
}
const sentinelObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) renderBatch();
}, { rootMargin: '400px' });
sentinelObserver.observe(sentinel);
searchInput.addEventListener('input', debouncedSearch);
searchAll.addEventListener('change', debouncedSearch);
// --- Settings ---
const settingsModal = document.getElementById('settingsModal');
const settingsUrl = document.getElementById('settingsUrl');
const settingsApiKey = document.getElementById('settingsApiKey');
const settingsModel = document.getElementById('settingsModel');
function loadSettings() {
settingsUrl.value = localStorage.getItem('stt_llm_url') || '';
settingsApiKey.value = localStorage.getItem('stt_llm_api_key') || '';
settingsModel.value = localStorage.getItem('stt_llm_model') || '';
if (!settingsUrl.value) {
fetch('/api/config').then(r => r.json()).then(c => {
if (!settingsUrl.value) settingsUrl.value = c.llm_url || '';
if (!settingsModel.value) settingsModel.value = c.llm_model || '';
}).catch(() => {});
}
}
document.getElementById('settingsBtn').addEventListener('click', () => {
loadSettings();
settingsModal.classList.add('open');
});
document.getElementById('settingsCancel').addEventListener('click', () => {
settingsModal.classList.remove('open');
});
settingsModal.addEventListener('click', e => { if (e.target === settingsModal) settingsModal.classList.remove('open'); });
document.getElementById('settingsSave').addEventListener('click', () => {
localStorage.setItem('stt_llm_url', settingsUrl.value.trim());
localStorage.setItem('stt_llm_api_key', settingsApiKey.value.trim());
localStorage.setItem('stt_llm_model', settingsModel.value.trim());
settingsModal.classList.remove('open');
});
function getLLMConfig() {
return {
url: localStorage.getItem('stt_llm_url') || '',
api_key: localStorage.getItem('stt_llm_api_key') || '',
model: localStorage.getItem('stt_llm_model') || '',
};
}
// --- Models & Health ---
fetch('/api/models').then(r => r.json()).then(j => {
const sel = modelSel;
sel.innerHTML = '';
for (const m of j.models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m + (m === j.default ? ' (default)' : '');
sel.appendChild(opt);
}
if (j.default) sel.value = j.default;
}).catch(() => {});
fetch('/api/health').then(r => r.json()).then(j => {
document.getElementById('backend').textContent =
j.backend_ok ? (j.backend_url + ' \u00b7 ok') : (j.backend_url + ' \u00b7 DOWN');
document.getElementById('backend').style.color = j.backend_ok ? '' : 'var(--danger)';
}).catch(() => {
document.getElementById('backend').textContent = 'unreachable';
});
// --- Source zone interactions ---
sourceZone.addEventListener('dragenter', e => { e.preventDefault(); sourceZone.classList.add('drag'); });
sourceZone.addEventListener('dragover', e => { e.preventDefault(); sourceZone.classList.add('drag'); });
sourceZone.addEventListener('dragleave', e => { e.preventDefault(); sourceZone.classList.remove('drag'); });
sourceZone.addEventListener('drop', e => { e.preventDefault(); sourceZone.classList.remove('drag'); handleFiles(e.dataTransfer.files); });
sourceZone.addEventListener('click', e => {
if (e.target.closest('#recordLink') || e.target.closest('#pathLink') || e.target.closest('#recStop')) return;
fileInput.click();
});
sourceZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
document.getElementById('chooseLink').addEventListener('click', e => { e.stopPropagation(); fileInput.click(); });
document.getElementById('recordLink').addEventListener('click', e => { e.stopPropagation(); startRecording(); });
document.getElementById('pathLink').addEventListener('click', e => {
e.stopPropagation();
pathRow.classList.toggle('open');
if (pathRow.classList.contains('open')) pathInput.focus();
});
document.getElementById('recStop').addEventListener('click', e => { e.stopPropagation(); stopRecording(); });
fileInput.addEventListener('change', e => { handleFiles(e.target.files); fileInput.value = ''; });
pathBtn.addEventListener('click', () => {
const p = pathInput.value.trim();
if (!p) return;
transcribePath(p);
pathInput.value = '';
pathRow.classList.remove('open');
});
pathInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); pathBtn.click(); }
});
function handleFiles(files) {
for (const f of files) enqueue(f);
}
// --- Recording ---
let mediaRecorder = null;
let recChunks = [];
let recStart = null;
let recTimerInterval = null;
const recTimer = document.getElementById('recTimer');
async function startRecording() {
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
alert('Microphone access denied: ' + e.message);
return;
}
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm'
: 'audio/ogg';
mediaRecorder = new MediaRecorder(stream, { mimeType });
recChunks = [];
recStart = Date.now();
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data); };
mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
clearInterval(recTimerInterval);
sourceZone.classList.remove('recording');
mediaRecorder = null;
if (recChunks.length === 0) return;
const ext = mimeType.startsWith('audio/webm') ? 'webm' : 'ogg';
const blob = new Blob(recChunks, { type: mimeType });
const now = new Date();
const ts = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0') + '_' + String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0');
const name = 'recording_' + ts + '.' + ext;
const el = makeJobEl(name);
liveEls.add(el._id);
renderedIds.add(el._id);
queue.prepend(el);
el._source = 'record';
el._language = langSel.value;
el._autoSummarize = true;
syncToData(el);
saveJobs();
saveBlobToDB(el._id, blob).then(() => {
el._hasBlob = true;
syncToData(el);
saveJobs();
updateStorageDisplay();
setupAudioPlayer(el);
}).catch(err => {
console.warn('Failed to persist recording to IndexedDB:', err);
});
const recSeconds = (Date.now() - recStart) / 1000;
recordObservedBitrate(blob.size, recSeconds);
setStatus(el, 'working', 'uploading\u2026');
const bar = el.querySelector('.bar');
bar.classList.add('indeterminate');
const fd = new FormData();
fd.append('file', new File([blob], name, { type: mimeType }), name);
fd.append('language', langSel.value);
fd.append('response_format', fmtSel.value);
fd.append('model', modelSel.value);
streamRequest(el, '/api/transcribe', { method: 'POST', body: fd });
};
mediaRecorder.start(1000);
sourceZone.classList.add('recording');
recTimer.textContent = '0:00';
recTimerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - recStart) / 1000);
const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
recTimer.textContent = m + ':' + String(s).padStart(2, '0');
}, 500);
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
}
async function setupAudioPlayer(el) {
const audio = el.querySelector('audio');
if (!audio) return;
if (el._audioUrl) {
URL.revokeObjectURL(el._audioUrl);
el._audioUrl = null;
}
let blob;
try {
blob = await getBlobFromDB(el._id);
} catch (e) { return; }
if (!blob) return;
const url = URL.createObjectURL(blob);
el._audioUrl = url;
audio.src = url;
audio.style.display = 'block';
}
function teardownAudioPlayer(el) {
if (el._audioUrl) {
URL.revokeObjectURL(el._audioUrl);
el._audioUrl = null;
}
}
// --- Progress formatting ---
function fmtDur(s) {
s = Math.floor(s);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return h + 'h' + String(m).padStart(2, '0') + 'm' + String(sec).padStart(2, '0') + 's';
if (m > 0) return m + 'm' + String(sec).padStart(2, '0') + 's';
return sec + 's';
}
function fmtTime(ts) {
const d = new Date(ts);
const pad = n => String(n).padStart(2, '0');
return pad(d.getHours()) + ':' + pad(d.getMinutes());
}
// --- Job elements ---
function makeJobEl(name, id) {
const el = document.createElement('div');
el.className = 'job';
el._id = id || Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
el._ts = Date.now();
el.innerHTML = `
<div class="row">
<span class="name"></span>
<span class="timestamp"></span>
<span class="status pending">queued</span>
</div>
<div class="bar"><i></i></div>
<div class="progress-info" style="display:none"></div>
<audio controls></audio>
<div class="result" style="display:none">
<textarea readonly></textarea>
<div class="actions">
<button class="copy">Copy</button>
<button class="summarize">Summarize</button>
<button class="retranscribe">Retranscribe</button>
<button class="download primary">Download</button>
<button class="delete" style="margin-left:auto; color:var(--danger)">Delete</button>
</div>
<div class="summary-area" style="display:none"></div>
</div>
<div class="note-area">
<label>Notes</label>
<textarea placeholder="Add a note\u2026"></textarea>
</div>
<div class="err-msg" style="display:none"></div>`;
el.querySelector('.name').textContent = name;
el.querySelector('.timestamp').textContent = fmtTime(el._ts);
el.querySelector('.retranscribe').addEventListener('click', async () => {
if (el.querySelector('.status').classList.contains('working')) return;
await doRetranscribe(el, langSel.value);
});
const noteTa = el.querySelector('.note-area textarea');
noteTa.addEventListener('input', () => { el._note = noteTa.value; syncToData(el); debouncedSave(); });
el.querySelector('.delete').addEventListener('click', () => {
if (confirm('Delete this transcription?')) {
allData = allData.filter(j => j.id !== el._id);
liveEls.delete(el._id);
teardownAudioPlayer(el);
el.remove();
saveJobs();
if (el._hasBlob) {
deleteBlobFromDB(el._id).catch(() => {}).then(updateStorageDisplay);
}
}
});
return el;
}
function setStatus(el, cls, text) {
const s = el.querySelector('.status');
s.className = 'status ' + cls;
s.textContent = text;
}
function setProgress(el, evt) {
const bar = el.querySelector('.bar');
const fill = bar.querySelector('i');
const info = el.querySelector('.progress-info');
const pct = evt.duration > 0 ? Math.round((evt.elapsed / evt.duration) * 100) : 0;
if (evt.total > 1) {
setStatus(el, 'working', 'chunk ' + evt.chunk + '/' + evt.total);
} else {
setStatus(el, 'working', 'transcribing');
}
bar.classList.remove('indeterminate');
fill.style.width = pct + '%';
if (evt.total > 1) {
info.style.display = 'block';
info.textContent = 'chunk ' + evt.chunk + '/' + evt.total
+ ' \u00b7 ' + fmtDur(evt.elapsed) + '/' + fmtDur(evt.duration)
+ ' \u00b7 ' + pct + '%';
} else {
info.style.display = 'block';
info.textContent = fmtDur(evt.duration);
}
}
function fail(el, msg) {
setStatus(el, 'error', 'error');
el.querySelector('.bar').style.display = 'none';
const p = el.querySelector('.progress-info');
p.style.display = 'none';
const e = el.querySelector('.err-msg');
e.style.display = 'block';
e.textContent = msg;
if (el._text) {
const r = el.querySelector('.result');
r.style.display = 'block';
const ta = r.querySelector('textarea');
ta.value = el._text;
}
if (el._summary) {
const sa = el.querySelector('.summary-area');
sa.style.display = 'block';
sa.innerHTML = '<div class="summary-box">' + renderMarkdown(el._summary) + '</div>';
}
}
function finishButtons(el) {
const displayText = el.querySelector('.result textarea').value;
el.querySelector('.copy').onclick = () => {
navigator.clipboard.writeText(displayText);
};
el.querySelector('.download').onclick = () => {
if (!el._result) return;
const fmt = el._result.format;
const rawContent = el._result.content;
const ext = fmt === 'srt' ? 'srt' : fmt === 'vtt' ? 'vtt' : 'txt';
const base = el.querySelector('.name').textContent.replace(/\.[^.]+$/, '');
const blob = new Blob([rawContent], {type: 'text/plain'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = base + '.' + ext;
a.click();
URL.revokeObjectURL(a.href);
};
el.querySelector('.summarize').onclick = () => doSummarize(el);
}
async function doRetranscribe(el, newLanguage) {
if (el.querySelector('.status').classList.contains('working')) return;
el._language = newLanguage;
el._segments = [];
const resultArea = el.querySelector('.result');
resultArea.style.display = 'none';
const summaryArea = el.querySelector('.summary-area');
summaryArea.style.display = 'none';
const bar = el.querySelector('.bar');
bar.style.display = '';
bar.classList.add('indeterminate');
const fill = bar.querySelector('i');
fill.style.width = '0%';
const info = el.querySelector('.progress-info');
info.style.display = 'none';
info.textContent = '';
setStatus(el, 'working', 'transcribing');
liveEls.add(el._id);
syncToData(el);
saveJobs();
// Try IndexedDB blob first (works for old jobs too if blob was saved)
let blob = null;
try { blob = await getBlobFromDB(el._id); } catch (e) {}
if (blob) {
const name = el.querySelector('.name').textContent;
const fd = new FormData();
fd.append('file', new File([blob], name, { type: blob.type || 'audio/webm' }), name);
fd.append('language', newLanguage);
fd.append('response_format', fmtSel.value);
fd.append('model', modelSel.value);
streamRequest(el, '/api/transcribe', { method: 'POST', body: fd });
return;
}
if (el._source === 'path' && el._path) {
streamRequest(el, '/api/transcribe/path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: el._path,
language: newLanguage,
response_format: fmtSel.value,
model: modelSel.value
})
});
return;
}
fail(el, 'Audio not saved locally. Jobs created before this feature cannot be re-transcribed.');
}
// --- Upload & transcribe ---
function enqueue(file) {
const el = makeJobEl(file.name);
liveEls.add(el._id);
renderedIds.add(el._id);
queue.prepend(el);
if (file.size > MAX_BYTES) {
fail(el, 'File is ' + (file.size/1048576).toFixed(1) + ' MB; limit is ' + (MAX_BYTES/1048576|0) + ' MB.');
return el;
}
el._source = 'upload';
el._language = langSel.value;
syncToData(el);
saveJobs();
saveBlobToDB(el._id, file).then(() => {
el._hasBlob = true;
syncToData(el);
saveJobs();
updateStorageDisplay();
setupAudioPlayer(el);
}).catch(err => {
console.warn('Failed to persist upload to IndexedDB:', err);
});
setStatus(el, 'working', 'uploading\u2026');
const bar = el.querySelector('.bar');
bar.classList.add('indeterminate');
const fd = new FormData();
fd.append('file', file, file.name);
fd.append('language', langSel.value);
fd.append('response_format', fmtSel.value);
fd.append('model', modelSel.value);
streamRequest(el, '/api/transcribe', { method: 'POST', body: fd });
return el;
}
function transcribePath(filepath) {
const basename = filepath.split('/').pop();
const el = makeJobEl(basename);
liveEls.add(el._id);
renderedIds.add(el._id);
queue.prepend(el);
el._source = 'path';
el._path = filepath;
el._language = langSel.value;
syncToData(el);
saveJobs();
setStatus(el, 'working', 'transcribing');
const bar = el.querySelector('.bar');
bar.classList.add('indeterminate');
streamRequest(el, '/api/transcribe/path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: filepath,
language: langSel.value,
response_format: fmtSel.value,
model: modelSel.value
})
});
return el;
}
async function streamRequest(el, url, opts) {
el._segments = [];
el._result = null;
let response;
try {
response = await fetch(url, opts);
} catch (e) {
fail(el, 'Network error.');
liveEls.delete(el._id);
return;
}
if (!response.ok) {
let msg = 'Error ' + response.status;
try {
const data = await response.json();
if (data.error) msg = data.error;
} catch (_) {}
fail(el, msg);
liveEls.delete(el._id);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let doneSeen = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const evt = JSON.parse(line);
if (evt.type === 'progress') {
setProgress(el, evt);
} else if (evt.type === 'segment') {
el._segments.push(evt);
showLiveText(el, el._segments);
} else if (evt.type === 'done') {
doneSeen = true;
el._result = { content: evt.content, format: evt.format };
finishStream(el);
} else if (evt.type === 'error') {
fail(el, evt.error || 'transcription failed');
}
} catch (_) {}
}
}
if (buffer.trim()) {
try {
const evt = JSON.parse(buffer);
if (evt.type === 'done') {
doneSeen = true;
el._result = { content: evt.content, format: evt.format };
finishStream(el);
} else if (evt.type === 'error') {
fail(el, evt.error || 'transcription failed');
}
} catch (_) {}
}
} catch (e) {
if (!doneSeen) fail(el, 'Connection lost.');
}
if (!doneSeen && el.querySelector('.status').classList.contains('working')) {
fail(el, 'No result received.');
}
liveEls.delete(el._id);
}
function showLiveText(el, segments) {
const r = el.querySelector('.result');
r.style.display = 'block';
let ta = r.querySelector('textarea');
if (!ta) return null;
const texts = segments.map(s => s.text).filter(Boolean);
ta.value = texts.join(' ');
ta.style.height = Math.min(400, Math.max(120, texts.length * 22 + 40)) + 'px';
return ta;
}
function finishStream(el) {
setStatus(el, 'done', 'done');
const bar = el.querySelector('.bar');
bar.style.display = 'none';
const p = el.querySelector('.progress-info');
if (p) p.style.display = 'none';
el._text = el.querySelector('.result textarea').value;
finishButtons(el);
liveEls.delete(el._id);
syncToData(el);
saveJobs();
if (el._autoSummarize && getLLMConfig().url) {
doSummarize(el);
}
}
// --- Summarize ---
async function doSummarize(el) {
const cfg = getLLMConfig();
if (!cfg.url) {
alert('No LLM endpoint configured. Click the \u2699 gear icon to set one up.');
return;
}
const text = el._text || el.querySelector('.result textarea').value;
if (!text) return;
const area = el.querySelector('.summary-area');
area.style.display = 'block';
area.innerHTML = '<div class="summary-spinner">Summarizing\u2026</div>';
let response;
try {
response = await fetch('/api/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text, url: cfg.url, api_key: cfg.api_key, model: cfg.model })
});
} catch (e) {
area.innerHTML = '<div class="summary-error">Request failed: ' + e.message + '</div>';
return;
}
if (!response.ok) {
let msg = 'Error ' + response.status;
try { const d = await response.json(); if (d.error) msg = d.error; } catch (_) {}
area.innerHTML = '<div class="summary-error">' + msg + '</div>';
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let summary = '';
let done = false;
try {
while (true) {
const { done: rd, value } = await reader.read();
if (rd) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const evt = JSON.parse(line);
if (evt.type === 'token') {
summary += evt.text;
area.innerHTML = '<div class="summary-box">' + renderMarkdown(summary) + '</div>';
} else if (evt.type === 'done') {
done = true;
} else if (evt.type === 'error') {
area.innerHTML = '<div class="summary-error">' + (evt.error || 'summarization failed') + '</div>';
return;
}
} catch (_) {}
}
}
if (buffer.trim()) {
try {
const evt = JSON.parse(buffer);
if (evt.type === 'error') {
area.innerHTML = '<div class="summary-error">' + (evt.error || 'summarization failed') + '</div>';
return;
}
} catch (_) {}
}
} catch (e) {
if (!done) area.innerHTML = '<div class="summary-error">Connection lost.</div>';
return;
}
el._summary = summary;
syncToData(el);
saveJobs();
}
function renderMarkdown(md) {
let html = md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
const lines = html.split('\n');
let out = '';
let inList = false;
let listType = '';
for (const line of lines) {
const olMatch = line.match(/^(\d+)\. (.+)$/);
const ulMatch = line.match(/^[-*] (.+)$/);
if (olMatch) {
if (!inList || listType !== 'ol') { if (inList) out += '</' + listType + '>'; out += '<ol>'; inList = true; listType = 'ol'; }
out += '<li>' + olMatch[2] + '</li>';
} else if (ulMatch) {
if (!inList || listType !== 'ul') { if (inList) out += '</' + listType + '>'; out += '<ul>'; inList = true; listType = 'ul'; }
out += '<li>' + ulMatch[1] + '</li>';
} else {
if (inList) { out += '</' + listType + '>'; inList = false; }
if (line.startsWith('<h')) {
out += line;
} else if (line.trim()) {
out += '<p>' + line + '</p>';
}
}
}
if (inList) out += '</' + listType + '>';
return out;
}
// --- Init ---
(async function init() {
await cleanupOrphanedBlobs();
updateStorageDisplay();
clearAndRender();
setInterval(updateStorageDisplay, 30000);
})();
</script>
</body>
</html>