diff --git a/README.md b/README.md index 4573733..6e2b310 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ uvicorn app.main:app --reload --host 127.0.0.1 --port 8025 브라우저에서 `http://127.0.0.1:8025` 접속. -웹 UI는 **faster-whisper** 전사와(옵션) **pyannote 화자 분리**를 지원합니다. OpenAI Whisper 기반 CLI는 **`whisper_stt.py`** 를 사용하세요. +웹 UI는 **faster-whisper** 전사와(옵션) **pyannote 화자 분리**를 지원합니다. OpenAI Whisper 기반 CLI는 **`whisper_stt.py`** 를 사용하세요. 비동기 작업(`POST /api/jobs`)은 **서버 스레드에서 계속** 돌아가며, UI는 **`?job=`·`localStorage`** 로 새로고침 후에도 같은 작업을 다시 조회·폴링합니다(메모리 TTL `APP_JOB_TTL_SEC` 초과 시 job 은 사라짐). ### HTTPS 도메인(`https://ncue.net/stt/…`)에서 503 (Service Unavailable) diff --git a/app/static/index.html b/app/static/index.html index d1de38a..9395faa 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -320,7 +320,8 @@
- 허용: mp3, m4a, wav, mp4, aac, ogg, flac, webm
- 첫 실행 시 Whisper 모델 다운로드로 시간이 걸릴 수 있습니다.
- - 화자 분리 켜짐: ./models/pyannote-diarization-3.1 및 gated HF 모델 동의(README 참고). + - 화자 분리 켜짐: ./models/pyannote-diarization-3.1 및 gated HF 모델 동의(README 참고).
+ - 전사는 서버에서 계속 실행됩니다. 새로고침·다른 탭 후에도 주소의 ?job=… 또는 이 브라우저 저장값으로 진행 상황을 다시 붙입니다.
@@ -432,6 +433,9 @@ const timingEl = $("timing"); const allowedExt = [".mp3", ".m4a", ".wav", ".mp4", ".aac", ".ogg", ".flac", ".webm"]; + const STORAGE_ACTIVE_JOB = "stt_active_job_id"; + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + let currentJobId = null; let pollTimer = null; let startedAt = null; @@ -439,6 +443,107 @@ let lastSegCount = 0; let lastTextLen = 0; + /** 서버 작업 ID를 브라우저에 남겨 새로고침 후에도 폴링 복구 */ + function syncActiveJobToBrowser(jobId) { + try { + if (jobId) { + localStorage.setItem(STORAGE_ACTIVE_JOB, jobId); + const u = new URL(window.location.href); + u.searchParams.set("job", jobId); + history.replaceState(null, "", u.pathname + u.search + u.hash); + } else { + localStorage.removeItem(STORAGE_ACTIVE_JOB); + const u = new URL(window.location.href); + u.searchParams.delete("job"); + history.replaceState(null, "", u.pathname + u.search + u.hash); + } + } catch (_) {} + } + + async function tryResumeJob() { + let jobId = null; + try { + const u = new URL(window.location.href); + jobId = u.searchParams.get("job") || localStorage.getItem(STORAGE_ACTIVE_JOB); + } catch (_) { + return; + } + if (!jobId || !UUID_RE.test(jobId.trim())) { + if (jobId) syncActiveJobToBrowser(null); + return; + } + jobId = jobId.trim(); + + const r = await fetch(`api/jobs/${encodeURIComponent(jobId)}`); + if (!r.ok) { + syncActiveJobToBrowser(null); + return; + } + const body = await r.json().catch(() => ({})); + const st = body.status; + if (!st) { + syncActiveJobToBrowser(null); + return; + } + + currentJobId = jobId; + lastSegCount = 0; + lastTextLen = 0; + + const text = body.text || ""; + outEl.value = text; + lastTextLen = text.length; + const segs = Array.isArray(body.segments) ? body.segments : []; + segEl.value = segs.length ? JSON.stringify(segs, null, 2) : ""; + lastSegCount = segs.length; + setProgress(typeof body.progress === "number" ? body.progress : null); + + const lang = body.detected_language ? `${body.detected_language}` : "-"; + const prob = typeof body.language_probability === "number" ? body.language_probability.toFixed(3) : "-"; + const dur = typeof body.duration_sec === "number" ? `${body.duration_sec.toFixed(1)}s` : "-"; + metaEl.textContent = `감지 언어: ${lang} (p=${prob}), 오디오 길이: ${dur}`; + + if (st === "completed" || st === "failed" || st === "cancelled") { + syncActiveJobToBrowser(null); + currentJobId = null; + stopPolling(); + if (st === "completed") { + setStatus("완료(재접속 시 결과 표시)"); + setProgress(1); + setError(""); + } else if (st === "failed") { + setStatus("실패"); + setError(body.error || "실패"); + } else { + setStatus("취소됨"); + setError(""); + } + setIdle(); + downloadEl.disabled = !outEl.value?.trim(); + return; + } + + syncActiveJobToBrowser(jobId); + const t0 = typeof body.created_at === "number" ? body.created_at : Date.now() / 1000; + startedAt = performance.now() - (Date.now() / 1000 - t0) * 1000; + setStatus(st === "queued" ? "대기 중 — 서버에서 처리 예정(재접속됨)" : "전사(STT) 처리 중 — 서버에서 실행 중(재접속됨)"); + metaEl.textContent = `${metaEl.textContent} · 새로고침해도 서버 작업은 계속됩니다`; + setRunning(); + + await pollJobOnce(); + stopPolling(); + pollTimer = setInterval(() => { + pollJobOnce().catch((e) => { + stopPolling(); + setError(String(e?.message || e)); + setStatus("실패"); + currentJobId = null; + syncActiveJobToBrowser(null); + setIdle(); + }); + }, 700); + } + // Tabs function setTab(which) { const isAdmin = which === "admin"; @@ -537,7 +642,17 @@ }); clearEl.addEventListener("click", () => { - cancelCurrent(); + stopPolling(); + if (uploadController) { + try { + uploadController.abort(); + } catch (_) {} + uploadController = null; + } else if (currentJobId) { + fetch(`api/jobs/${encodeURIComponent(currentJobId)}/cancel`, { method: "POST" }).catch(() => {}); + } + syncActiveJobToBrowser(null); + currentJobId = null; fileEl.value = ""; outEl.value = ""; segEl.value = ""; @@ -549,6 +664,7 @@ goEl.disabled = true; cancelEl.disabled = true; downloadEl.disabled = true; + setIdle(); }); downloadEl.addEventListener("click", () => { @@ -632,6 +748,7 @@ setProgress(1); currentJobId = null; uploadController = null; + syncActiveJobToBrowser(null); setIdle(); downloadEl.disabled = !outEl.value?.trim(); return; @@ -641,6 +758,7 @@ setStatus("취소됨"); currentJobId = null; uploadController = null; + syncActiveJobToBrowser(null); setIdle(); return; } @@ -650,6 +768,7 @@ setError(body?.error || "실패"); currentJobId = null; uploadController = null; + syncActiveJobToBrowser(null); setIdle(); return; } @@ -663,6 +782,7 @@ if (!f) return; stopPolling(); + syncActiveJobToBrowser(null); currentJobId = null; lastSegCount = 0; lastTextLen = 0; @@ -695,6 +815,7 @@ } currentJobId = body.job_id; + syncActiveJobToBrowser(currentJobId); uploadController = null; setStatus("전사(STT) 처리 중…"); setRunning(); @@ -707,6 +828,7 @@ setStatus("실패"); currentJobId = null; uploadController = null; + syncActiveJobToBrowser(null); setIdle(); }); }, 700); @@ -729,6 +851,7 @@ checkHealth(); setInterval(checkHealth, 5000); + tryResumeJob().catch(() => {}); // Admin (DB Records) const adminQEl = $("admin-q");