Kai72828 commited on
Commit
d70f672
·
verified ·
1 Parent(s): 37c1937

Upload 10 files

Browse files
Files changed (6) hide show
  1. Dockerfile +5 -3
  2. app.py +478 -120
  3. audio_transcriber.py +196 -0
  4. job_store.py +258 -0
  5. requirements.txt +1 -3
  6. translation.py +120 -8
Dockerfile CHANGED
@@ -1,8 +1,8 @@
1
- # Dockerfile — SubSync on Hugging Face Docker Space
2
  FROM python:3.11-slim
3
 
4
  RUN apt-get update && apt-get install -y --no-install-recommends \
5
  build-essential curl \
 
6
  && rm -rf /var/lib/apt/lists/*
7
 
8
  RUN useradd -m -u 1000 appuser
@@ -13,11 +13,13 @@ RUN pip install --no-cache-dir -r requirements.txt
13
 
14
  COPY . .
15
 
16
- # Write Streamlit config inline no folder needed in project root
 
 
17
  RUN mkdir -p /home/appuser/.streamlit && \
18
  printf '[server]\nport = 7860\naddress = "0.0.0.0"\nheadless = true\nenableCORS = false\nenableXsrfProtection = false\nmaxUploadSize = 500\n\n[browser]\ngatherUsageStats = false\n\n[theme]\nbase = "dark"\n' \
19
  > /home/appuser/.streamlit/config.toml && \
20
- chown -R appuser:appuser /home/appuser/.streamlit /app
21
 
22
  EXPOSE 7860
23
  USER appuser
 
 
1
  FROM python:3.11-slim
2
 
3
  RUN apt-get update && apt-get install -y --no-install-recommends \
4
  build-essential curl \
5
+ ffmpeg \
6
  && rm -rf /var/lib/apt/lists/*
7
 
8
  RUN useradd -m -u 1000 appuser
 
13
 
14
  COPY . .
15
 
16
+ # Create data dir for job storage
17
+ RUN mkdir -p /app/_jobs && chown -R appuser:appuser /app
18
+
19
  RUN mkdir -p /home/appuser/.streamlit && \
20
  printf '[server]\nport = 7860\naddress = "0.0.0.0"\nheadless = true\nenableCORS = false\nenableXsrfProtection = false\nmaxUploadSize = 500\n\n[browser]\ngatherUsageStats = false\n\n[theme]\nbase = "dark"\n' \
21
  > /home/appuser/.streamlit/config.toml && \
22
+ chown -R appuser:appuser /home/appuser/.streamlit
23
 
24
  EXPOSE 7860
25
  USER appuser
app.py CHANGED
@@ -16,7 +16,19 @@ import traceback
16
  import pandas as pd
17
  import streamlit as st
18
 
 
19
  from auth import is_authenticated, render_login_page, logout
 
 
 
 
 
 
 
 
 
 
 
20
  from api_handler import fetch_models, validate_api_key, call_ai
21
  from subtitle_parser import (
22
  parse_subtitle,
@@ -27,9 +39,6 @@ from subtitle_parser import (
27
  clean_workspace,
28
  workspace_size_mb,
29
  )
30
- from translation import (extract_glossary, translate_all,
31
- TranslationPaused, load_checkpoint,
32
- clear_checkpoint, checkpoint_progress)
33
 
34
  # ── Page config ───────────────────────────────────────────────────────────────
35
  st.set_page_config(
@@ -222,6 +231,13 @@ def _init_session() -> None:
222
  "translation_done": False,
223
  "wizard_step": 1,
224
  "active_page": "translate",
 
 
 
 
 
 
 
225
  "last_error": None,
226
  }
227
  for k, v in defaults.items():
@@ -287,19 +303,27 @@ def _render_header(title: str, sub: str = "") -> None:
287
  # Push buttons to the right by using nested columns
288
  # Row 1: main nav buttons
289
  st.markdown('<div class="hdr-btn">', unsafe_allow_html=True)
290
- b1, b2, b3 = st.columns(3)
291
  with b1:
 
 
 
 
 
 
 
 
 
 
 
 
292
  if st.button("⚙️ Settings", key="hb_settings", use_container_width=True):
293
  st.session_state["active_page"] = "settings"
294
  st.rerun()
295
- with b2:
296
  if st.button("ℹ️ About", key="hb_about", use_container_width=True):
297
  st.session_state["active_page"] = "about"
298
  st.rerun()
299
- with b3:
300
- if st.button("🎬 Home", key="hb_home", use_container_width=True):
301
- st.session_state["active_page"] = "translate"
302
- st.rerun()
303
  st.markdown('</div>', unsafe_allow_html=True)
304
 
305
  # Row 2: clean + sign out
@@ -308,13 +332,32 @@ def _render_header(title: str, sub: str = "") -> None:
308
  with d1:
309
  if st.button("🗑 Clean", key="hb_clean", use_container_width=True):
310
  try:
 
311
  clean_workspace()
312
- for k in ["uploaded_file_name","subtitle_raw","subtitle_parsed",
313
- "subtitle_format","plain_lines","glossary_raw",
314
- "glossary_edited","translation_result","last_error"]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  st.session_state[k] = None
316
  st.session_state["glossary_approved"] = False
317
  st.session_state["translation_done"] = False
 
318
  st.session_state["wizard_step"] = 1
319
  st.rerun()
320
  except Exception as e:
@@ -384,6 +427,27 @@ def _step1_upload() -> None:
384
  st.markdown('<div class="card-title">📁 Step 1 — Subtitle ဖိုင် တင်ရန်</div>',
385
  unsafe_allow_html=True)
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  uploaded = st.file_uploader(
388
  "Subtitle ဖိုင် ရွေးပါ",
389
  type=["srt", "vtt", "ass", "ssa"],
@@ -401,8 +465,20 @@ def _step1_upload() -> None:
401
  st.session_state["uploaded_file_name"] = None # clear while parsing
402
 
403
  parse_error = None
404
- if st.session_state.get("subtitle_parsed") is None or \
405
- st.session_state.get("uploaded_file_name") != uploaded.name:
 
 
 
 
 
 
 
 
 
 
 
 
406
  try:
407
  with st.spinner("ဖိုင် ဖတ်နေသည်…"):
408
  subs, fmt = parse_subtitle(file_bytes, uploaded.name)
@@ -661,6 +737,21 @@ def _step4_review() -> None:
661
  st.session_state["glossary_edited"] = approved
662
  st.session_state["glossary_approved"] = True
663
  st.session_state["wizard_step"] = 5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  st.rerun()
665
 
666
  with c2:
@@ -943,8 +1034,8 @@ def _step5_translate() -> None:
943
  return
944
 
945
  # ── Translation UI ────────────────────────────────────────────────────
946
- from translation import BASE_CHUNK_SIZE as CHUNK_SIZE
947
  import math
 
948
  total_chunks = math.ceil(len(lines) / CHUNK_SIZE)
949
 
950
  st.markdown(f"""
@@ -974,119 +1065,81 @@ def _step5_translate() -> None:
974
  </table>
975
  </div>
976
  """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
 
978
- if st.button("▶ ဘာသာပြန်ခြင်း စတင်မည်", key="s5_start", use_container_width=True):
979
- if not key.strip() or not model:
980
- st.error("API key / model မရှိပါ — Settings စစ်ဆေးပါ", icon="❌")
981
- else:
982
- progress_bar = st.progress(0.0, text="ပြင်ဆင်နေသည်…")
983
- status_slot = st.empty()
984
- error_slot = st.empty()
985
-
986
- def progress_cb(fraction: float, msg: str) -> None:
987
- try:
988
- progress_bar.progress(min(float(fraction), 1.0), text=msg)
989
- status_slot.markdown(
990
- f'<div class="mono" style="color:#9ca3af;">{msg}</div>',
991
- unsafe_allow_html=True)
992
- except Exception:
993
- pass # never let progress_cb itself crash the run
994
-
995
- debug_log = []
996
- out_bytes = None
997
- paused_error = None
998
-
999
- # ── Check if resuming from checkpoint ──────────────────────
1000
- cp = load_checkpoint(st.session_state)
1001
- is_resume = bool(cp.get("chunks_done"))
1002
- done_n, total_n = checkpoint_progress(st.session_state, len(lines))
1003
- if is_resume and done_n > 0:
1004
- status_slot.markdown(
1005
- f'<div class="mono" style="color:#e8c97e;">' +
1006
- f'Checkpoint မှ ဆက်ပြန်နေသည် ({done_n}/{total_n} lines done)…' +
1007
- '</div>', unsafe_allow_html=True)
1008
 
1009
- try:
1010
- translated_lines = translate_all(
1011
- lines, glossary,
1012
- provider=prov, model=model, api_key=key,
1013
- progress_cb=progress_cb,
1014
- debug_log=debug_log,
1015
- session_state=st.session_state,
1016
- resume=is_resume,
1017
- )
1018
- except TranslationPaused as e:
1019
- paused_error = e
1020
- translated_lines = None
1021
- progress_cb(1.0, f"⏸ ရပ်နေသည်: {e}")
1022
- # Store context for dialog
1023
  st.session_state["limit_hit_info"] = {
1024
- "reason": str(e),
1025
- "key_label": _active_key_label(),
1026
  "key_masked": _mask_key(_active_key()),
1027
- "provider": prov,
1028
- "model": model,
1029
  }
1030
  st.session_state["show_limit_dialog"] = True
1031
- except Exception as e:
1032
- st.session_state["last_error"] = traceback.format_exc()
1033
- progress_cb(1.0, f"⚠ Error: {e}")
1034
- translated_lines = None
1035
-
1036
- # ── Verify & build output ─────────────────────────────────
1037
- if translated_lines:
1038
- changed = sum(1 for o, t in zip(lines, translated_lines) if o != t)
1039
- if changed == 0:
1040
- # Show debug info
1041
- error_slot.warning(
1042
- "⚠️ ဘာသာပြန်မထားပါ — AI response parse မရပါ\n"
1043
- "gemini-1.5-pro / gemini-2.0-flash ကို ပြောင်းစမ်းပါ",
1044
- icon="⚠️")
1045
- with st.expander("🐛 AI Raw Response", expanded=True):
1046
- for d in debug_log[:3]:
1047
- st.markdown(f"**Chunk {d.get('chunk')}** — {d.get('status','')} ")
1048
- st.code(d.get('raw_preview', d.get('error','(empty)')), language=None)
1049
- else:
1050
- try:
1051
- out_subs = replace_lines(subs, translated_lines)
1052
- out_bytes = write_subtitle(out_subs, fmt)
1053
- clear_checkpoint(st.session_state) # clean up on success
1054
- except Exception as e:
1055
- st.session_state["last_error"] = traceback.format_exc()
1056
- error_slot.error(f"❌ File build error: {e}", icon="❌")
1057
-
1058
- # ── Paused — dialog triggered on next rerun ───────────────
1059
- if paused_error:
1060
- is_res = getattr(paused_error, 'is_resumable', False)
1061
- if is_res:
1062
- done_n2, _ = checkpoint_progress(st.session_state, len(lines))
1063
- st.info(
1064
- f"⏸ Limit hit — {done_n2}/{len(lines)} lines checkpoint သိမ်းပြီ\n"
1065
- "Key/Provider/Model ပြောင်းမည်ဆိုလျှင် dialog ဖွင့်ပါ",
1066
- icon="⏸")
1067
- else:
1068
- st.error(f"❌ {paused_error}", icon="❌")
1069
-
1070
- # ── Parse fail debug ──────────────────────────────────────
1071
- fails = [d for d in debug_log if d.get("status") in
1072
- ("PARSE_FAIL","FALLBACK_ORIGINAL","API_ERROR")]
1073
- if fails:
1074
- with st.expander(f"⚠ {len(fails)} chunks မအောင်မြင်ပါ (debug)", expanded=False):
1075
- for d in fails:
1076
- st.markdown(f"**Chunk {d.get('chunk')}** — `{d.get('status','')}`")
1077
- if d.get('raw_preview'):
1078
- st.code(d['raw_preview'], language=None)
1079
- if d.get('error'):
1080
- st.code(d['error'], language=None)
1081
-
1082
- if out_bytes:
1083
- st.session_state["translation_result"] = out_bytes
1084
- st.session_state["translation_done"] = True
1085
- status_slot.empty()
1086
  st.rerun()
1087
 
1088
- st.markdown("<br>", unsafe_allow_html=True)
1089
- st.markdown('<div class="secondary">', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  if st.button("← Glossary Review ပြန်သွားမည်", key="s5_back"):
1091
  st.session_state["wizard_step"] = 4
1092
  st.rerun()
@@ -1323,6 +1376,307 @@ def _page_settings() -> None:
1323
 
1324
 
1325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1326
  def _page_about() -> None:
1327
  _render_header("About SubSync", "Myanmar Subtitle Translator · v1.0.0")
1328
  st.markdown("""
@@ -1396,6 +1750,10 @@ def main() -> None:
1396
 
1397
  if page == "translate":
1398
  _page_translate()
 
 
 
 
1399
  elif page == "settings":
1400
  _page_settings()
1401
  elif page == "about":
 
16
  import pandas as pd
17
  import streamlit as st
18
 
19
+ import time
20
  from auth import is_authenticated, render_login_page, logout
21
+ from job_store import (create_job, update_job, save_result, load_result,
22
+ load_job, list_jobs, delete_job, clear_all_jobs,
23
+ job_age_str, status_icon, status_color,
24
+ save_glossary, load_glossary,
25
+ get_latest_translate_job_with_glossary)
26
+ from audio_transcriber import (run_transcription_bg, SUPPORTED_LANGUAGES,
27
+ MODEL)
28
+ from translation import (extract_glossary, translate_all,
29
+ TranslationPaused, load_checkpoint,
30
+ clear_checkpoint, checkpoint_progress,
31
+ run_translation_bg)
32
  from api_handler import fetch_models, validate_api_key, call_ai
33
  from subtitle_parser import (
34
  parse_subtitle,
 
39
  clean_workspace,
40
  workspace_size_mb,
41
  )
 
 
 
42
 
43
  # ── Page config ───────────────────────────────────────────────────────────────
44
  st.set_page_config(
 
231
  "translation_done": False,
232
  "wizard_step": 1,
233
  "active_page": "translate",
234
+ "current_job_id": None, # active job being processed
235
+ "audio_job_id": None, # current audio transcription job
236
+ "audio_srt_result": None,
237
+ "audio_srt_filename": None,
238
+ "audio_srt_to_translate": None,
239
+ "audio_srt_orig_name": None,
240
+ "tx_checkpoint": None,
241
  "last_error": None,
242
  }
243
  for k, v in defaults.items():
 
303
  # Push buttons to the right by using nested columns
304
  # Row 1: main nav buttons
305
  st.markdown('<div class="hdr-btn">', unsafe_allow_html=True)
306
+ b1, b2, b3, b4, b5 = st.columns(5)
307
  with b1:
308
+ if st.button("🎬 Translate", key="hb_home", use_container_width=True):
309
+ st.session_state["active_page"] = "translate"
310
+ st.rerun()
311
+ with b2:
312
+ if st.button("🎙 Audio→SRT", key="hb_audio", use_container_width=True):
313
+ st.session_state["active_page"] = "audio_srt"
314
+ st.rerun()
315
+ with b3:
316
+ if st.button("📋 History", key="hb_history", use_container_width=True):
317
+ st.session_state["active_page"] = "history"
318
+ st.rerun()
319
+ with b4:
320
  if st.button("⚙️ Settings", key="hb_settings", use_container_width=True):
321
  st.session_state["active_page"] = "settings"
322
  st.rerun()
323
+ with b5:
324
  if st.button("ℹ️ About", key="hb_about", use_container_width=True):
325
  st.session_state["active_page"] = "about"
326
  st.rerun()
 
 
 
 
327
  st.markdown('</div>', unsafe_allow_html=True)
328
 
329
  # Row 2: clean + sign out
 
332
  with d1:
333
  if st.button("🗑 Clean", key="hb_clean", use_container_width=True):
334
  try:
335
+ # 1. Delete subtitle temp files
336
  clean_workspace()
337
+ # 2. Delete ALL job history + result files (_jobs/ dir)
338
+ clear_all_jobs()
339
+ # 3. Wipe every workflow-related session key
340
+ _clean_keys = [
341
+ # Subtitle workflow
342
+ "uploaded_file_name", "subtitle_raw", "subtitle_parsed",
343
+ "subtitle_format", "plain_lines",
344
+ # Glossary — clear completely so no cross-job bleed
345
+ "glossary_raw", "glossary_edited", "last_error",
346
+ # Translation
347
+ "translation_result", "tx_checkpoint",
348
+ # Job tracking
349
+ "current_job_id",
350
+ # Audio SRT
351
+ "audio_job_id", "audio_srt_result", "audio_srt_filename",
352
+ "audio_srt_to_translate", "audio_srt_orig_name",
353
+ # Limit dialog
354
+ "limit_hit_info",
355
+ ]
356
+ for k in _clean_keys:
357
  st.session_state[k] = None
358
  st.session_state["glossary_approved"] = False
359
  st.session_state["translation_done"] = False
360
+ st.session_state["show_limit_dialog"] = False
361
  st.session_state["wizard_step"] = 1
362
  st.rerun()
363
  except Exception as e:
 
427
  st.markdown('<div class="card-title">📁 Step 1 — Subtitle ဖိုင် တင်ရန်</div>',
428
  unsafe_allow_html=True)
429
 
430
+ # ── Auto-populate from Audio→SRT page "Translate this SRT" button ────
431
+ auto_srt = st.session_state.get("audio_srt_to_translate")
432
+ auto_name = st.session_state.get("audio_srt_orig_name", "audio.srt")
433
+ if auto_srt and st.session_state.get("uploaded_file_name") != auto_name:
434
+ try:
435
+ from subtitle_parser import parse_subtitle, get_plain_lines
436
+ subs, fmt = parse_subtitle(auto_srt, auto_name)
437
+ plain = get_plain_lines(subs)
438
+ st.session_state["uploaded_file_name"] = auto_name
439
+ st.session_state["subtitle_raw"] = auto_srt
440
+ st.session_state["subtitle_parsed"] = subs
441
+ st.session_state["subtitle_format"] = fmt
442
+ st.session_state["plain_lines"] = plain
443
+ # Clear audio_srt_to_translate so it doesn't re-trigger
444
+ st.session_state["audio_srt_to_translate"] = None
445
+ st.session_state["audio_srt_orig_name"] = None
446
+ st.info(f"Audio→SRT ဖိုင် '{auto_name}' ကို auto-load ပြီး", icon="🎙")
447
+ except Exception as e:
448
+ st.error(f"Auto-load မအောင်မြင်: {e}", icon="❌")
449
+ st.session_state["audio_srt_to_translate"] = None
450
+
451
  uploaded = st.file_uploader(
452
  "Subtitle ဖိုင် ရွေးပါ",
453
  type=["srt", "vtt", "ass", "ssa"],
 
465
  st.session_state["uploaded_file_name"] = None # clear while parsing
466
 
467
  parse_error = None
468
+ is_new_file = (st.session_state.get("uploaded_file_name") != uploaded.name)
469
+
470
+ if st.session_state.get("subtitle_parsed") is None or is_new_file:
471
+
472
+ if is_new_file:
473
+ # New file uploaded — reset ALL glossary & job state
474
+ # Prevents old glossary bleeding into new translation
475
+ for _k in ["glossary_raw", "glossary_edited", "translation_result",
476
+ "tx_checkpoint", "current_job_id", "limit_hit_info"]:
477
+ st.session_state[_k] = None
478
+ st.session_state["glossary_approved"] = False
479
+ st.session_state["translation_done"] = False
480
+ st.session_state["wizard_step"] = 1
481
+
482
  try:
483
  with st.spinner("ဖိုင် ဖတ်နေသည်…"):
484
  subs, fmt = parse_subtitle(file_bytes, uploaded.name)
 
737
  st.session_state["glossary_edited"] = approved
738
  st.session_state["glossary_approved"] = True
739
  st.session_state["wizard_step"] = 5
740
+
741
+ # ── Persist glossary to job_store ──────────────────────────
742
+ # Saved separately from checkpoint so it survives model/provider
743
+ # changes. Resume always reuses this approved glossary.
744
+ _jid = st.session_state.get("current_job_id")
745
+ if not _jid or not load_job(_jid):
746
+ fname_tmp = st.session_state.get("uploaded_file_name", "file")
747
+ _jid = create_job(
748
+ "translate", fname_tmp,
749
+ model=st.session_state.get("selected_model",""),
750
+ provider=st.session_state.get("selected_provider",""),
751
+ )
752
+ st.session_state["current_job_id"] = _jid
753
+ save_glossary(_jid, approved)
754
+
755
  st.rerun()
756
 
757
  with c2:
 
1034
  return
1035
 
1036
  # ── Translation UI ────────────────────────────────────────────────────
 
1037
  import math
1038
+ CHUNK_SIZE = 150 # must match BASE_CHUNK_SIZE in translation.py
1039
  total_chunks = math.ceil(len(lines) / CHUNK_SIZE)
1040
 
1041
  st.markdown(f"""
 
1065
  </table>
1066
  </div>
1067
  """, unsafe_allow_html=True)
1068
+ # ── Poll background job if already running ───────────────────────────
1069
+ _jid = st.session_state.get("current_job_id")
1070
+ if _jid:
1071
+ _bgjob = load_job(_jid)
1072
+ if _bgjob and _bgjob["status"] in ("running", "queued"):
1073
+ st.markdown('<div class="card">', unsafe_allow_html=True)
1074
+ st.markdown('<div class="card-title">🔄 Background တွင် ဘာသာပြန်နေသည်</div>', unsafe_allow_html=True)
1075
+ st.progress(_bgjob.get("progress",0.0), text=_bgjob.get("status_msg",""))
1076
+ st.markdown(
1077
+ f'<div class="mono" style="color:#4b5563;margin-top:0.4rem;">' +
1078
+ f'Browser ပိတ်/refresh — ဆက်လုပ်မည် · Job: {_jid}</div>',
1079
+ unsafe_allow_html=True)
1080
+ st.markdown('</div>', unsafe_allow_html=True)
1081
+ time.sleep(3)
1082
+ st.rerun()
1083
+ return
1084
 
1085
+ if _bgjob and _bgjob["status"] == "completed":
1086
+ result_bytes = load_result(_jid)
1087
+ if result_bytes and not st.session_state.get("translation_done"):
1088
+ st.session_state["translation_result"] = result_bytes
1089
+ st.session_state["translation_done"] = True
1090
+ st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1091
 
1092
+ if _bgjob and _bgjob["status"] == "paused":
1093
+ cp = _bgjob.get("checkpoint", {})
1094
+ done_n2 = len(cp.get("lines", {}))
1095
+ st.warning(
1096
+ f"⏸ Limit hit · {done_n2}/{len(lines)} lines saved\n"
1097
+ f"{_bgjob.get('status_msg','')[:200]}",
1098
+ icon="⏸")
1099
+ if st.button("🔔 Key/Provider ပြောင်းမည် (Dialog)", key="s5_open_dialog"):
 
 
 
 
 
 
1100
  st.session_state["limit_hit_info"] = {
1101
+ "reason": _bgjob.get("status_msg",""),
1102
+ "key_label": _active_key_label(),
1103
  "key_masked": _mask_key(_active_key()),
1104
+ "provider": prov, "model": model,
 
1105
  }
1106
  st.session_state["show_limit_dialog"] = True
1107
+ st.session_state["tx_checkpoint"] = cp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1108
  st.rerun()
1109
 
1110
+ if st.button("▶ ဘာသာပြန်ခြင်း စတင်မည် (Background)", key="s5_start",
1111
+ use_container_width=True):
1112
+ if not key.strip() or not model:
1113
+ st.error("API key / model မရှိပါ — Settings စစ်ဆေးပါ", icon="❌")
1114
+ else:
1115
+ # Create job
1116
+ _jid = st.session_state.get("current_job_id")
1117
+ if not _jid or not load_job(_jid):
1118
+ _jid = create_job("translate", fname, language="my",
1119
+ model=model, provider=prov)
1120
+ st.session_state["current_job_id"] = _jid
1121
+
1122
+ # Check for existing checkpoint (resume)
1123
+ cp_data = st.session_state.get("tx_checkpoint", {})
1124
+
1125
+ # Launch background thread
1126
+ run_translation_bg(
1127
+ _jid, lines, glossary,
1128
+ provider=prov, model=model, api_key=key,
1129
+ fmt=fmt, ssafile=subs, filename=fname,
1130
+ )
1131
+ # Stash checkpoint into job so thread can resume
1132
+ if cp_data:
1133
+ update_job(_jid, checkpoint=cp_data)
1134
+
1135
+ st.info(
1136
+ "🔄 Background တွင် စတင်ပြီ\n"
1137
+ "Browser ပိတ်/refresh ဖြစ်ပေမဲ့ ဆက်လုပ်မည်\n"
1138
+ "Progress ကို History page တွင် ကြည့်ရှုနိုင်သည်",
1139
+ icon="🔄")
1140
+ time.sleep(1)
1141
+ st.rerun()
1142
+
1143
  if st.button("← Glossary Review ပြန်သွားမည်", key="s5_back"):
1144
  st.session_state["wizard_step"] = 4
1145
  st.rerun()
 
1376
 
1377
 
1378
 
1379
+
1380
+ # ─────────────────────────────────────────────────────────────────────────────
1381
+ # Audio → SRT page
1382
+ # ────────────────────��────────────────────────────────────────────────────────
1383
+
1384
+ def _page_audio_srt() -> None:
1385
+ _render_header("Audio → SRT", "Audio / Video ဖိုင်မှ Subtitle ထုတ်ရန်")
1386
+
1387
+ st.markdown("""
1388
+ <style>
1389
+ .info-chip {
1390
+ display:inline-block; font-family:'DM Mono',monospace;
1391
+ font-size:0.68rem; padding:0.12rem 0.5rem; border-radius:6px;
1392
+ background:rgba(232,201,126,0.1); color:#e8c97e;
1393
+ margin-right:0.3rem;
1394
+ }
1395
+ </style>
1396
+ """, unsafe_allow_html=True)
1397
+
1398
+ st.markdown(
1399
+ '<div class="mono" style="color:#374151;margin-bottom:1rem;">'
1400
+ '🧠 Model: <span style="color:#e8c97e;">Whisper large-v3</span>'
1401
+ ' &nbsp;·&nbsp; Browser ပိတ်/refresh ဖြစ်ပေမဲ့ background တွင် ဆက်လုပ်သည်'
1402
+ '</div>',
1403
+ unsafe_allow_html=True)
1404
+
1405
+ # ── Active job polling (running in background) ─────────────────────
1406
+ active_jid = st.session_state.get("audio_job_id")
1407
+ if active_jid:
1408
+ job = load_job(active_jid)
1409
+ if job and job["status"] in ("running", "queued"):
1410
+ st.markdown(f'<div class="card">', unsafe_allow_html=True)
1411
+ st.markdown(
1412
+ f'<div class="card-title">🔄 Transcribing: {job["filename"]}</div>',
1413
+ unsafe_allow_html=True)
1414
+ pct = job.get("progress", 0.0)
1415
+ msg = job.get("status_msg", "")
1416
+ st.progress(pct, text=msg)
1417
+ st.markdown(
1418
+ f'<div class="mono" style="color:#4b5563;margin-top:0.5rem;">'
1419
+ f'Job ID: {active_jid} · Browser ပိတ်ထားနိုင်သည် — background တွင် ဆက်လုပ်မည်</div>',
1420
+ unsafe_allow_html=True)
1421
+ st.markdown('</div>', unsafe_allow_html=True)
1422
+ # Auto-refresh every 3 seconds
1423
+ time.sleep(3)
1424
+ st.rerun()
1425
+ return
1426
+
1427
+ if job and job["status"] == "completed":
1428
+ srt_bytes = load_result(active_jid)
1429
+ if srt_bytes:
1430
+ st.session_state["audio_srt_result"] = srt_bytes
1431
+ st.session_state["audio_srt_filename"] = job["filename"]
1432
+
1433
+ # ── Show result if available ───────────────────────────────────────
1434
+ srt_result = st.session_state.get("audio_srt_result")
1435
+ srt_fname = st.session_state.get("audio_srt_filename", "audio")
1436
+
1437
+ if srt_result:
1438
+ out_name = os.path.splitext(srt_fname)[0] + ".srt"
1439
+ st.success(f"✅ Transcription ပြီးသည် — {out_name}", icon="🎉")
1440
+
1441
+ st.download_button(
1442
+ label=f"⬇️ {out_name} Download",
1443
+ data=srt_result,
1444
+ file_name=out_name,
1445
+ mime="text/plain",
1446
+ use_container_width=True,
1447
+ key="audio_dl",
1448
+ )
1449
+
1450
+ with st.expander("📋 SRT Preview (ပထမ 30 ကြောင်း)", expanded=False):
1451
+ preview = srt_result.decode("utf-8", errors="replace")
1452
+ preview_text = "\n".join(preview.splitlines()[:60])
1453
+ st.code(preview_text, language=None)
1454
+
1455
+ st.markdown("<br>", unsafe_allow_html=True)
1456
+ if st.button("🌐 ဒီ SRT ကို မြန်မာဘာသာပြန်မည်", key="audio_to_translate"):
1457
+ st.session_state["audio_srt_to_translate"] = srt_result
1458
+ st.session_state["audio_srt_orig_name"] = out_name
1459
+ st.session_state["active_page"] = "translate"
1460
+ st.session_state["wizard_step"] = 1
1461
+ st.rerun()
1462
+
1463
+ col_x, _ = st.columns([1, 3])
1464
+ with col_x:
1465
+ st.markdown('<div class="danger">', unsafe_allow_html=True)
1466
+ if st.button("🗑 Clear Result", key="audio_clear"):
1467
+ st.session_state["audio_srt_result"] = None
1468
+ st.session_state["audio_srt_filename"] = None
1469
+ st.session_state["audio_job_id"] = None
1470
+ st.rerun()
1471
+ st.markdown('</div>', unsafe_allow_html=True)
1472
+
1473
+ st.markdown('<hr style="border-color:#1e2330;margin:1.5rem 0 1rem;">', unsafe_allow_html=True)
1474
+
1475
+ # ── File upload + start ────────────────────────────────────────────
1476
+ st.markdown("#### 📂 Audio / Video ဖိုင် တင်ရန်")
1477
+ uploaded = st.file_uploader(
1478
+ "audio",
1479
+ type=["mp3","mp4","wav","m4a","flac","ogg","webm","mkv","aac"],
1480
+ label_visibility="collapsed",
1481
+ key="audio_uploader",
1482
+ )
1483
+
1484
+ lang_options = list(SUPPORTED_LANGUAGES.keys())
1485
+ lang_labels = list(SUPPORTED_LANGUAGES.values())
1486
+ lang_idx = st.selectbox(
1487
+ "ဘာသာစကား",
1488
+ options=range(len(lang_options)),
1489
+ format_func=lambda i: lang_labels[i],
1490
+ key="audio_lang_sel",
1491
+ )
1492
+ selected_lang = lang_options[lang_idx]
1493
+
1494
+ if uploaded is not None:
1495
+ file_bytes = uploaded.read()
1496
+ st.markdown(
1497
+ f'<div class="mono" style="color:#6b7280;margin:0.5rem 0 1rem;">'
1498
+ f'{uploaded.name} · {len(file_bytes)/1024/1024:.1f} MB</div>',
1499
+ unsafe_allow_html=True)
1500
+
1501
+ if st.button("▶ Transcribe မည် (Background)", key="audio_start",
1502
+ use_container_width=True):
1503
+ job_id = create_job(
1504
+ job_type="audio_srt",
1505
+ filename=uploaded.name,
1506
+ language=selected_lang,
1507
+ model=MODEL,
1508
+ )
1509
+ st.session_state["audio_job_id"] = job_id
1510
+ st.session_state["audio_srt_result"] = None
1511
+ st.session_state["audio_srt_filename"] = None
1512
+
1513
+ # Launch background thread — survives browser disconnect
1514
+ run_transcription_bg(job_id, file_bytes, uploaded.name, selected_lang)
1515
+
1516
+ st.info(
1517
+ "🔄 Background တွင် စတင်ပြီ — Browser ပိတ်ထားနိုင်သည်\\n"
1518
+ "Progress ကို History page တွင် ကြည့်ရှုနိုင်သည်",
1519
+ icon="🔄")
1520
+ time.sleep(1)
1521
+ st.rerun()
1522
+
1523
+ st.markdown(
1524
+ '<div class="mono" style="color:#374151;font-size:0.68rem;margin-top:1.5rem;">'
1525
+ '💡 large-v3 model ပထမဆုံးအကြိမ် download ~1.5GB ကြာနိုင်သည်</div>',
1526
+ unsafe_allow_html=True)
1527
+
1528
+
1529
+
1530
+ def _page_history() -> None:
1531
+ _render_header("History", "Translation & Transcription Jobs")
1532
+
1533
+ st.markdown("""
1534
+ <style>
1535
+ .job-card {
1536
+ background: var(--card); border: 1px solid var(--border);
1537
+ border-radius: 12px; padding: 1rem 1.2rem; margin-bottom: 0.8rem;
1538
+ }
1539
+ .job-card:hover { border-color: var(--blit); }
1540
+ .job-title { font-weight:600; font-size:0.9rem; color:var(--hi); }
1541
+ .job-meta { font-family:'DM Mono',monospace; font-size:0.68rem;
1542
+ color:var(--lo); margin-top:0.2rem; }
1543
+ .job-status { font-family:'DM Mono',monospace; font-size:0.72rem;
1544
+ font-weight:600; }
1545
+ </style>
1546
+ """, unsafe_allow_html=True)
1547
+
1548
+ # ── Controls row ───────────────────────────────────────────────────
1549
+ col_filter, col_clear, _ = st.columns([1.5, 1.2, 3])
1550
+ with col_filter:
1551
+ filter_type = st.selectbox(
1552
+ "Filter",
1553
+ ["all", "audio_srt", "translate"],
1554
+ format_func=lambda x: {
1555
+ "all": "All Jobs",
1556
+ "audio_srt": "🎙 Audio→SRT",
1557
+ "translate": "🌐 Translate",
1558
+ }[x],
1559
+ key="hist_filter",
1560
+ label_visibility="collapsed",
1561
+ )
1562
+ with col_clear:
1563
+ st.markdown('<div class="danger">', unsafe_allow_html=True)
1564
+ if st.button("🗑 Clear All", key="hist_clear_all", use_container_width=True):
1565
+ n = clear_all_jobs()
1566
+ st.success(f"{n} jobs deleted")
1567
+ st.rerun()
1568
+ st.markdown('</div>', unsafe_allow_html=True)
1569
+
1570
+ # ── Job list ───────────────────────────────────────────────────────
1571
+ jobs = list_jobs(job_type=None if filter_type == "all" else filter_type)
1572
+
1573
+ if not jobs:
1574
+ st.markdown(
1575
+ '<div class="mono" style="color:#374151;padding:2rem 0;text-align:center;">' +
1576
+ 'Jobs မရှိသေးပါ</div>',
1577
+ unsafe_allow_html=True)
1578
+ return
1579
+
1580
+ st.markdown(
1581
+ f'<div class="mono" style="color:#4b5563;margin-bottom:1rem;">' +
1582
+ f'{len(jobs)} jobs</div>',
1583
+ unsafe_allow_html=True)
1584
+
1585
+ for job in jobs:
1586
+ job_id = job["id"]
1587
+ status = job.get("status","?")
1588
+ jtype = job.get("type","?")
1589
+ fname = job.get("filename","?")
1590
+ model = job.get("model","")
1591
+ lang = job.get("language","")
1592
+ age = job_age_str(job)
1593
+ sicon = status_icon(status)
1594
+ scolor = status_color(status)
1595
+ pct = int(job.get("progress",0)*100)
1596
+ msg = job.get("status_msg","")
1597
+ err = job.get("error","")
1598
+
1599
+ type_label = "🎙 Audio→SRT" if jtype == "audio_srt" else "🌐 Translate"
1600
+
1601
+ with st.container():
1602
+ st.markdown(f'<div class="job-card">', unsafe_allow_html=True)
1603
+
1604
+ # Title row
1605
+ col_info, col_actions = st.columns([4, 2])
1606
+ with col_info:
1607
+ _gloss_count = len(job.get("extra",{}).get("approved_glossary",{}))
1608
+ _gloss_badge = (
1609
+ f' · 📖 {_gloss_count} glossary terms'
1610
+ if _gloss_count > 0 else ""
1611
+ )
1612
+ st.markdown(
1613
+ f'<div class="job-title">{fname}</div>' +
1614
+ f'<div class="job-meta">{type_label} · {model} · {lang} · {age}{_gloss_badge}</div>' +
1615
+ f'<div class="job-status" style="color:{scolor};margin-top:0.3rem;">' +
1616
+ f'{sicon} {status.upper()} {pct}%</div>',
1617
+ unsafe_allow_html=True)
1618
+ if msg:
1619
+ st.markdown(
1620
+ f'<div class="mono" style="color:#4b5563;font-size:0.68rem;">{msg}</div>',
1621
+ unsafe_allow_html=True)
1622
+ if err and status == "failed":
1623
+ st.markdown(
1624
+ f'<div class="mono" style="color:#ef4444;font-size:0.68rem;">{err[:120]}</div>',
1625
+ unsafe_allow_html=True)
1626
+
1627
+ with col_actions:
1628
+ # Download if complete
1629
+ if status == "completed" and job.get("has_result"):
1630
+ result_bytes = load_result(job_id)
1631
+ if result_bytes:
1632
+ out_ext = ".srt"
1633
+ out_name = os.path.splitext(fname)[0] + out_ext
1634
+ st.download_button(
1635
+ label="⬇️ Download",
1636
+ data=result_bytes,
1637
+ file_name=out_name,
1638
+ mime="text/plain",
1639
+ key=f"dl_{job_id}",
1640
+ use_container_width=True,
1641
+ )
1642
+
1643
+ # Resume if paused (translate jobs)
1644
+ if status == "paused" and jtype == "translate":
1645
+ if st.button("▶ Resume", key=f"res_{job_id}",
1646
+ use_container_width=True):
1647
+ cp = job.get("checkpoint", {})
1648
+
1649
+ # ── Restore approved glossary (survives model/provider change)
1650
+ saved_glossary = load_glossary(job_id)
1651
+ if saved_glossary:
1652
+ st.session_state["glossary_edited"] = saved_glossary
1653
+ st.session_state["glossary_approved"] = True
1654
+ else:
1655
+ # Fallback: try to read from checkpoint
1656
+ cp_gloss = cp.get("glossary", {})
1657
+ if cp_gloss:
1658
+ st.session_state["glossary_edited"] = cp_gloss
1659
+ st.session_state["glossary_approved"] = True
1660
+
1661
+ # ── Restore checkpoint for translate_all resume
1662
+ st.session_state["tx_checkpoint"] = cp
1663
+
1664
+ # ── Set current job and navigate to step 5
1665
+ st.session_state["current_job_id"] = job_id
1666
+ st.session_state["active_page"] = "translate"
1667
+ st.session_state["wizard_step"] = 5
1668
+ st.rerun()
1669
+
1670
+ # Delete button
1671
+ st.markdown('<div class="danger">', unsafe_allow_html=True)
1672
+ if st.button("✕", key=f"del_{job_id}", use_container_width=True):
1673
+ delete_job(job_id)
1674
+ st.rerun()
1675
+ st.markdown('</div>', unsafe_allow_html=True)
1676
+
1677
+ st.markdown('</div>', unsafe_allow_html=True)
1678
+
1679
+
1680
  def _page_about() -> None:
1681
  _render_header("About SubSync", "Myanmar Subtitle Translator · v1.0.0")
1682
  st.markdown("""
 
1750
 
1751
  if page == "translate":
1752
  _page_translate()
1753
+ elif page == "audio_srt":
1754
+ _page_audio_srt()
1755
+ elif page == "history":
1756
+ _page_history()
1757
  elif page == "settings":
1758
  _page_settings()
1759
  elif page == "about":
audio_transcriber.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ audio_transcriber.py — Background Audio → SRT Transcription
3
+
4
+ run_transcription_bg(job_id, audio_bytes, filename, language)
5
+ → Spawns a daemon thread that runs independently of the browser session.
6
+ → Progress saved to job_store → UI polls job_store to show status.
7
+ → Browser close / refresh does NOT stop transcription.
8
+
9
+ Model: large-v3 only (best accuracy for JA/KO/ZH/TH/HI/EN).
10
+ """
11
+
12
+ import os
13
+ import logging
14
+ import tempfile
15
+ import threading
16
+ from typing import Callable
17
+
18
+ from job_store import update_job, save_result
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ── Fixed model: large-v3 ─────────────────────────────────────────────────
23
+ MODEL = "large-v3"
24
+ MODEL_DIR = os.path.join(tempfile.gettempdir(), "whisper_models")
25
+
26
+ SUPPORTED_LANGUAGES = {
27
+ "auto": "Auto-detect",
28
+ "en": "English",
29
+ "ja": "Japanese (日本語)",
30
+ "ko": "Korean (한국어)",
31
+ "zh": "Chinese (中文)",
32
+ "th": "Thai (ภาษาไทย)",
33
+ "hi": "Hindi (हिन्दी)",
34
+ "fr": "French",
35
+ "de": "German",
36
+ "es": "Spanish",
37
+ "it": "Italian",
38
+ "pt": "Portuguese",
39
+ "ru": "Russian",
40
+ "ar": "Arabic",
41
+ "vi": "Vietnamese",
42
+ "id": "Indonesian",
43
+ }
44
+
45
+
46
+ # ─────────────────────────────────────────────────────────────────────────────
47
+ # Time formatting
48
+ # ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ def _fmt(seconds: float) -> str:
51
+ ms = int(round((seconds % 1) * 1000))
52
+ s = int(seconds)
53
+ h, m, s = s // 3600, (s % 3600) // 60, s % 60
54
+ return f"{h:02}:{m:02}:{s:02},{ms:03}"
55
+
56
+
57
+ # ─────────────────────────────────────────────────────────────────────────────
58
+ # Segments → SRT string
59
+ # ─────────────────────────────────────────────────────────────────────────────
60
+
61
+ def _segments_to_srt(segments: list) -> str:
62
+ lines = []
63
+ idx = 1
64
+ for seg in segments:
65
+ text = seg.text.strip()
66
+ if not text:
67
+ continue
68
+ lines += [str(idx), f"{_fmt(seg.start)} --> {_fmt(seg.end)}", text, ""]
69
+ idx += 1
70
+ return "\n".join(lines)
71
+
72
+
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+ # Core transcription (runs in background thread)
75
+ # ─────────────────────────────────────────────────────────────────────────────
76
+
77
+ def _transcribe_worker(
78
+ job_id: str,
79
+ audio_bytes: bytes,
80
+ filename: str,
81
+ language: str,
82
+ ) -> None:
83
+ """
84
+ Background worker — runs in its own thread, independent of browser session.
85
+ Updates job_store so UI can poll progress even after refresh.
86
+ Thread is daemon=False so it finishes even if main thread exits
87
+ (as long as Docker container is alive).
88
+ """
89
+ tmp_path = None
90
+ try:
91
+ from faster_whisper import WhisperModel
92
+
93
+ def cb(frac: float, msg: str) -> None:
94
+ update_job(job_id, progress=frac, status_msg=msg)
95
+
96
+ cb(0.0, "Model load လုပ်နေသည် (large-v3, ပထမဆုံးအကြိမ် ကြာနိုင်သည်)…")
97
+
98
+ # Write audio to temp file
99
+ suffix = os.path.splitext(filename)[1] or ".mp3"
100
+ fd, tmp_path = tempfile.mkstemp(suffix=suffix)
101
+ os.write(fd, audio_bytes)
102
+ os.close(fd)
103
+
104
+ # Load Whisper large-v3
105
+ model = WhisperModel(
106
+ MODEL,
107
+ device="cpu",
108
+ compute_type="int8", # fastest CPU quantization
109
+ download_root=MODEL_DIR,
110
+ )
111
+ cb(0.05, f"large-v3 load ပြီး — Transcribing…")
112
+
113
+ lang_code = None if language == "auto" else language
114
+
115
+ segments_gen, info = model.transcribe(
116
+ tmp_path,
117
+ language=lang_code,
118
+ beam_size=5,
119
+ temperature=0.0,
120
+ condition_on_previous_text=False, # reduce hallucination
121
+ vad_filter=True,
122
+ vad_parameters={
123
+ "min_silence_duration_ms": 500,
124
+ "threshold": 0.35,
125
+ },
126
+ )
127
+
128
+ detected = info.language
129
+ duration = max(info.duration or 1.0, 1.0)
130
+ cb(0.08, f"ဘာသာစကား: {detected} · {duration:.0f}s — Segments ထုတ်နေသည်…")
131
+
132
+ segments = []
133
+ for seg in segments_gen:
134
+ segments.append(seg)
135
+ pct = min(0.08 + (seg.end / duration) * 0.90, 0.98)
136
+ elapsed = f"{seg.end:.0f}s/{duration:.0f}s"
137
+ cb(pct, f"Transcribing… {elapsed} [{len(segments)} segments]")
138
+
139
+ if not segments:
140
+ update_job(job_id, status="failed",
141
+ error="Audio မှ speech မတွေ့ပါ",
142
+ status_msg="⚠ Speech မတွေ့ပါ")
143
+ return
144
+
145
+ cb(0.99, f"SRT ဖန်တီးနေသည်… ({len(segments)} segments)")
146
+ srt_text = _segments_to_srt(segments)
147
+ srt_bytes = srt_text.encode("utf-8")
148
+
149
+ save_result(job_id, srt_bytes)
150
+ # save_result already sets status=completed
151
+
152
+ except ImportError:
153
+ err = ("faster-whisper မထည့်ရသေးပါ — "
154
+ "requirements.txt မှာ faster-whisper>=1.0.0 ပါပြီး redeploy လုပ်ပါ")
155
+ update_job(job_id, status="failed", error=err, status_msg="❌ Import Error")
156
+
157
+ except Exception as exc:
158
+ logger.exception("Transcription worker error")
159
+ update_job(job_id, status="failed",
160
+ error=str(exc)[:400],
161
+ status_msg=f"❌ Error: {exc}")
162
+ finally:
163
+ if tmp_path and os.path.exists(tmp_path):
164
+ try: os.unlink(tmp_path)
165
+ except Exception: pass
166
+
167
+
168
+ # ─────────────────────────────────────────────────────────────────────────────
169
+ # Public API — launch background thread
170
+ # ─────────────────────────────────────────────────────────────────────────────
171
+
172
+ def run_transcription_bg(
173
+ job_id: str,
174
+ audio_bytes: bytes,
175
+ filename: str,
176
+ language: str = "auto",
177
+ ) -> threading.Thread:
178
+ """
179
+ Spawn a background thread to transcribe audio.
180
+ The thread runs independently of the Streamlit browser session.
181
+ Browser close / page refresh does NOT stop the transcription.
182
+
183
+ Returns the Thread object (caller can ignore it).
184
+ """
185
+ update_job(job_id, status="running", progress=0.0,
186
+ status_msg="Background thread started…")
187
+
188
+ t = threading.Thread(
189
+ target=_transcribe_worker,
190
+ args=(job_id, audio_bytes, filename, language),
191
+ daemon=True, # daemon=True: thread exits with container (correct behavior)
192
+ name=f"whisper-{job_id}",
193
+ )
194
+ t.start()
195
+ logger.info("Transcription thread started: job=%s file=%s", job_id, filename)
196
+ return t
job_store.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ job_store.py — Thread-Safe Persistent Job Storage
3
+
4
+ Files stored in _jobs/ dir → survives browser refresh/close.
5
+ Cleared only on Docker container restart.
6
+
7
+ Thread safety: atomic write (temp file → rename) prevents corruption
8
+ when background threads update jobs concurrently.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ import uuid
15
+ import tempfile
16
+ import threading
17
+ from typing import Optional
18
+
19
+ JOBS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_jobs")
20
+ os.makedirs(JOBS_DIR, exist_ok=True)
21
+
22
+ # Per-job locks so concurrent updates to the same job don't corrupt
23
+ _locks: dict[str, threading.Lock] = {}
24
+ _locks_mutex = threading.Lock()
25
+
26
+ def _job_lock(job_id: str) -> threading.Lock:
27
+ with _locks_mutex:
28
+ if job_id not in _locks:
29
+ _locks[job_id] = threading.Lock()
30
+ return _locks[job_id]
31
+
32
+
33
+ # ─────────────────────────────────────────────────────────────────────────────
34
+ # Internal atomic write
35
+ # ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ def _save_job(job: dict) -> None:
38
+ """Atomic write: write to temp → rename. Thread-safe."""
39
+ target = os.path.join(JOBS_DIR, f"{job['id']}.json")
40
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=JOBS_DIR, suffix=".tmp")
41
+ try:
42
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
43
+ json.dump(job, f, ensure_ascii=False, indent=2)
44
+ os.replace(tmp_path, target) # atomic on POSIX
45
+ except Exception:
46
+ try:
47
+ os.unlink(tmp_path)
48
+ except Exception:
49
+ pass
50
+ raise
51
+
52
+
53
+ # ─────────────────────────────────────────────────────────────────────────────
54
+ # Job creation
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+
57
+ def create_job(
58
+ job_type: str,
59
+ filename: str,
60
+ language: str = "",
61
+ model: str = "",
62
+ provider: str = "",
63
+ extra: dict | None = None,
64
+ ) -> str:
65
+ job_id = str(uuid.uuid4())[:12]
66
+ now = time.time()
67
+ job = {
68
+ "id": job_id,
69
+ "type": job_type, # "audio_srt" | "translate"
70
+ "filename": filename,
71
+ "language": language,
72
+ "model": model,
73
+ "provider": provider,
74
+ "status": "queued", # queued|running|paused|completed|failed
75
+ "progress": 0.0,
76
+ "status_msg": "",
77
+ "created_at": now,
78
+ "updated_at": now,
79
+ "error": None,
80
+ "checkpoint": {},
81
+ "extra": extra or {},
82
+ "has_result": False,
83
+ }
84
+ _save_job(job)
85
+ return job_id
86
+
87
+
88
+ # ─────────────────────────────────────────────────────────────────────────────
89
+ # Job updates (thread-safe)
90
+ # ─────────────────────────────────────────────────────────────────────────────
91
+
92
+ def update_job(
93
+ job_id: str,
94
+ status: str | None = None,
95
+ progress: float | None = None,
96
+ status_msg: str | None = None,
97
+ error: str | None = None,
98
+ checkpoint: dict | None = None,
99
+ extra_update: dict | None = None,
100
+ ) -> None:
101
+ with _job_lock(job_id):
102
+ job = load_job(job_id)
103
+ if job is None:
104
+ return
105
+ if status is not None: job["status"] = status
106
+ if progress is not None: job["progress"] = round(float(progress), 4)
107
+ if status_msg is not None: job["status_msg"] = str(status_msg)[:300]
108
+ if error is not None: job["error"] = str(error)[:500]
109
+ if checkpoint is not None: job["checkpoint"] = checkpoint
110
+ if extra_update:
111
+ job.setdefault("extra", {}).update(extra_update)
112
+ job["updated_at"] = time.time()
113
+ _save_job(job)
114
+
115
+
116
+ def save_result(job_id: str, data: bytes) -> None:
117
+ result_path = os.path.join(JOBS_DIR, f"{job_id}.result")
118
+ # Atomic write for result too
119
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=JOBS_DIR, suffix=".tmp")
120
+ try:
121
+ os.write(tmp_fd, data)
122
+ os.close(tmp_fd)
123
+ os.replace(tmp_path, result_path)
124
+ except Exception:
125
+ try: os.close(tmp_fd)
126
+ except Exception: pass
127
+ try: os.unlink(tmp_path)
128
+ except Exception: pass
129
+ raise
130
+
131
+ with _job_lock(job_id):
132
+ job = load_job(job_id)
133
+ if job:
134
+ job["has_result"] = True
135
+ job["status"] = "completed"
136
+ job["progress"] = 1.0
137
+ job["status_msg"] = "ပြီးစီးသည် ✓"
138
+ job["updated_at"] = time.time()
139
+ _save_job(job)
140
+
141
+
142
+ def load_result(job_id: str) -> bytes | None:
143
+ path = os.path.join(JOBS_DIR, f"{job_id}.result")
144
+ if not os.path.exists(path):
145
+ return None
146
+ with open(path, "rb") as f:
147
+ return f.read()
148
+
149
+
150
+ # ─────────────────────────────────────────────────────────────────────────────
151
+ # Reading
152
+ # ─────────────────────────────────────────────────────────────────────────────
153
+
154
+ def load_job(job_id: str) -> dict | None:
155
+ path = os.path.join(JOBS_DIR, f"{job_id}.json")
156
+ if not os.path.exists(path):
157
+ return None
158
+ try:
159
+ with open(path, "r", encoding="utf-8") as f:
160
+ return json.load(f)
161
+ except Exception:
162
+ return None
163
+
164
+
165
+ def list_jobs(job_type: str | None = None) -> list[dict]:
166
+ jobs = []
167
+ for fname in os.listdir(JOBS_DIR):
168
+ if not fname.endswith(".json"):
169
+ continue
170
+ job = load_job(fname[:-5])
171
+ if job is None:
172
+ continue
173
+ if job_type and job.get("type") != job_type:
174
+ continue
175
+ jobs.append(job)
176
+ jobs.sort(key=lambda j: j.get("created_at", 0), reverse=True)
177
+ return jobs
178
+
179
+
180
+ def delete_job(job_id: str) -> None:
181
+ for ext in (".json", ".result"):
182
+ path = os.path.join(JOBS_DIR, f"{job_id}{ext}")
183
+ if os.path.exists(path):
184
+ try: os.remove(path)
185
+ except Exception: pass
186
+
187
+
188
+ def clear_all_jobs() -> int:
189
+ count = 0
190
+ for fname in os.listdir(JOBS_DIR):
191
+ if fname.endswith(".tmp"):
192
+ continue
193
+ try:
194
+ os.remove(os.path.join(JOBS_DIR, fname))
195
+ count += 1
196
+ except Exception:
197
+ pass
198
+ return count
199
+
200
+
201
+
202
+ # ─────────────────────────────────────────────────────────────────────────────
203
+ # Glossary persistence
204
+ # Glossary is saved independently of checkpoint so it survives
205
+ # model/provider changes. Resume always reuses the approved glossary.
206
+ # ─────────────────────────────────────────────────────────────────────────────
207
+
208
+ def save_glossary(job_id: str, glossary: dict) -> None:
209
+ """
210
+ Persist the approved glossary for a translation job.
211
+ Stored inside the job JSON under "approved_glossary".
212
+ Thread-safe (uses update_job which is already atomic).
213
+ """
214
+ update_job(job_id, extra_update={"approved_glossary": glossary})
215
+
216
+
217
+ def load_glossary(job_id: str) -> dict:
218
+ """
219
+ Load the saved glossary for a job.
220
+ Returns empty dict if not found.
221
+ """
222
+ job = load_job(job_id)
223
+ if job is None:
224
+ return {}
225
+ return job.get("extra", {}).get("approved_glossary", {})
226
+
227
+
228
+ def get_latest_translate_job_with_glossary() -> dict | None:
229
+ """
230
+ Return the most recent translate job that has a saved glossary.
231
+ Used to offer glossary reuse when starting a new translation.
232
+ """
233
+ jobs = list_jobs(job_type="translate")
234
+ for job in jobs:
235
+ g = job.get("extra", {}).get("approved_glossary", {})
236
+ if g:
237
+ return job
238
+ return None
239
+
240
+ # ─────────────────────────────────────────────────────────────────────────────
241
+ # Display helpers
242
+ # ─────────────────────────────────────────────────────────────────────────────
243
+
244
+ def job_age_str(job: dict) -> str:
245
+ delta = time.time() - job.get("updated_at", job.get("created_at", 0))
246
+ if delta < 60: return "just now"
247
+ if delta < 3600: return f"{int(delta/60)} min ago"
248
+ if delta < 86400: return f"{int(delta/3600)} hr ago"
249
+ return f"{int(delta/86400)} days ago"
250
+
251
+
252
+ def status_icon(status: str) -> str:
253
+ return {"queued":"⏳","running":"🔄","paused":"⏸","completed":"✅","failed":"❌"}.get(status,"❓")
254
+
255
+
256
+ def status_color(status: str) -> str:
257
+ return {"queued":"#4b5563","running":"#3b82f6","paused":"#f59e0b",
258
+ "completed":"#22c55e","failed":"#ef4444"}.get(status,"#4b5563")
requirements.txt CHANGED
@@ -1,6 +1,3 @@
1
- # SubSync — requirements.txt
2
- # HF Docker Space: secrets come via env vars, no python-dotenv needed.
3
-
4
  streamlit>=1.35.0,<2.0.0
5
  pysubs2>=1.7.0,<2.0.0
6
  google-generativeai>=0.7.0,<1.0.0
@@ -9,3 +6,4 @@ requests>=2.31.0,<3.0.0
9
  httpx>=0.27.0,<1.0.0
10
  pandas>=2.2.0,<3.0.0
11
  tenacity>=8.3.0,<9.0.0
 
 
 
 
 
1
  streamlit>=1.35.0,<2.0.0
2
  pysubs2>=1.7.0,<2.0.0
3
  google-generativeai>=0.7.0,<1.0.0
 
6
  httpx>=0.27.0,<1.0.0
7
  pandas>=2.2.0,<3.0.0
8
  tenacity>=8.3.0,<9.0.0
9
+ faster-whisper>=1.0.0,<2.0.0
translation.py CHANGED
@@ -34,7 +34,9 @@ from api_handler import (
34
  logger = logging.getLogger(__name__)
35
 
36
  BASE_CHUNK_SIZE = 150 # default — may shrink on TokenLimitError
37
- GLOSSARY_SAMPLE = 300
 
 
38
  MAX_PARSE_RETRY = 2 # parse-only retries per chunk
39
 
40
 
@@ -347,21 +349,50 @@ def _translate_chunk_safe(
347
  # ─────────────────────────────────────────────────────────────────────────────
348
 
349
  def extract_glossary(lines: list, *, provider, model, api_key) -> dict:
 
 
 
 
 
 
 
 
 
 
350
  non_empty = [l for l in lines if l.strip()]
351
- sample = non_empty[:GLOSSARY_SAMPLE]
352
- numbered = "\n".join(f"{i+1}. {l}" for i, l in enumerate(sample))
353
- prompt = (
354
- f"Extract Myanmar translation glossary from {len(sample)} subtitle lines:\n\n"
355
- f"{numbered}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  )
 
357
  try:
358
  raw = call_ai(prompt, provider=provider, model=model, api_key=api_key,
359
- system_prompt=_SYS_GLOSSARY, timeout=90)
360
  r = _parse_dict(raw)
361
  if r:
362
  return {str(k): str(v) for k, v in r.items()}
363
  except Exception as exc:
364
- logger.warning("Glossary failed: %s", exc)
365
  return {}
366
 
367
 
@@ -506,3 +537,84 @@ def translate_all(
506
  f"Length invariant broken: {len(result)} != {len(lines)}"
507
 
508
  return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  logger = logging.getLogger(__name__)
35
 
36
  BASE_CHUNK_SIZE = 150 # default — may shrink on TokenLimitError
37
+ # Glossary uses full file — Gemini 1M context handles it
38
+ # For very large files (5000+ lines) we spread-sample to stay within limits
39
+ GLOSSARY_MAX_LINES = 3000 # hard cap: beyond this we spread-sample
40
  MAX_PARSE_RETRY = 2 # parse-only retries per chunk
41
 
42
 
 
349
  # ─────────────────────────────────────────────────────────────────────────────
350
 
351
  def extract_glossary(lines: list, *, provider, model, api_key) -> dict:
352
+ """
353
+ Extract translation glossary from the FULL subtitle file.
354
+ Sends all non-empty lines so terms appearing late in the file
355
+ (episode-specific locations, late-introduced characters, slang) are captured.
356
+
357
+ For very large files (>GLOSSARY_MAX_LINES), we spread-sample:
358
+ - First 1/3 + Middle 1/3 + Last 1/3
359
+ This preserves coverage across the whole file while staying within token limits.
360
+ Gemini 1.5/2.0 handles 1M tokens so most files fit entirely.
361
+ """
362
  non_empty = [l for l in lines if l.strip()]
363
+ total = len(non_empty)
364
+
365
+ if total <= GLOSSARY_MAX_LINES:
366
+ # ── Full file ──────────────────────────────────────────────────
367
+ sample = non_empty
368
+ else:
369
+ # ── Spread sample: beginning + middle + end ────────────────────
370
+ third = GLOSSARY_MAX_LINES // 3
371
+ mid_s = (total // 2) - (third // 2)
372
+ sample = (
373
+ non_empty[:third] +
374
+ non_empty[mid_s: mid_s + third] +
375
+ non_empty[-third:]
376
+ )
377
+ logger.info(
378
+ "File has %d lines > %d limit — spread sampling %d lines",
379
+ total, GLOSSARY_MAX_LINES, len(sample)
380
+ )
381
+
382
+ numbered = "\n".join(f"{i+1}. {l}" for i, l in enumerate(sample))
383
+ prompt = (
384
+ f"Extract Myanmar translation glossary from these {len(sample)} subtitle lines "
385
+ f"(full file: {total} lines total):\n\n{numbered}"
386
  )
387
+
388
  try:
389
  raw = call_ai(prompt, provider=provider, model=model, api_key=api_key,
390
+ system_prompt=_SYS_GLOSSARY, timeout=120)
391
  r = _parse_dict(raw)
392
  if r:
393
  return {str(k): str(v) for k, v in r.items()}
394
  except Exception as exc:
395
+ logger.warning("Glossary extraction failed: %s", exc)
396
  return {}
397
 
398
 
 
537
  f"Length invariant broken: {len(result)} != {len(lines)}"
538
 
539
  return result
540
+
541
+
542
+ # ─────────────────────────────────────────────────────────────────────────────
543
+ # Background translation thread
544
+ # ─────────────────────────────────────────────────────────────────────────────
545
+
546
+ import threading as _threading
547
+
548
+ def run_translation_bg(
549
+ job_id: str,
550
+ lines: list,
551
+ glossary: dict,
552
+ *,
553
+ provider: str,
554
+ model: str,
555
+ api_key: str,
556
+ fmt: str,
557
+ ssafile, # pysubs2.SSAFile
558
+ filename: str,
559
+ ) -> _threading.Thread:
560
+ """
561
+ Spawn background thread for translation.
562
+ Survives browser disconnect — progress saved to job_store.
563
+ On limit-hit (TranslationPaused): saves checkpoint, sets status=paused.
564
+ User can resume from History page.
565
+ """
566
+ from job_store import update_job, save_result, load_job
567
+ from subtitle_parser import replace_lines, write_subtitle, output_filename
568
+
569
+ def _worker():
570
+ debug_log = []
571
+ # Load existing checkpoint if resuming
572
+ job = load_job(job_id)
573
+ cp_init = job.get("checkpoint", {}) if job else {}
574
+ session_st = {"tx_checkpoint": cp_init} if cp_init else {}
575
+
576
+ def pcb(frac: float, msg: str) -> None:
577
+ update_job(job_id, progress=frac, status_msg=msg)
578
+ # Save checkpoint periodically
579
+ cp = session_st.get("tx_checkpoint", {})
580
+ if cp:
581
+ update_job(job_id, checkpoint=cp)
582
+
583
+ update_job(job_id, status="running", progress=0.0)
584
+
585
+ try:
586
+ translated = translate_all(
587
+ lines, glossary,
588
+ provider=provider, model=model, api_key=api_key,
589
+ progress_cb=pcb,
590
+ debug_log=debug_log,
591
+ session_state=session_st,
592
+ resume=bool(cp_init),
593
+ )
594
+
595
+ # Build output file
596
+ out_subs = replace_lines(ssafile, translated)
597
+ out_bytes = write_subtitle(out_subs, fmt)
598
+ save_result(job_id, out_bytes) # sets status=completed
599
+
600
+ except TranslationPaused as e:
601
+ cp = session_st.get("tx_checkpoint", {})
602
+ update_job(job_id,
603
+ status="paused",
604
+ checkpoint=cp,
605
+ status_msg=f"⏸ Limit hit — {str(e)[:200]}",
606
+ error=str(e)[:300])
607
+
608
+ except Exception as e:
609
+ update_job(job_id,
610
+ status="failed",
611
+ error=str(e)[:400],
612
+ status_msg=f"❌ {str(e)[:200]}")
613
+
614
+ t = _threading.Thread(
615
+ target=_worker,
616
+ daemon=True,
617
+ name=f"translate-{job_id}",
618
+ )
619
+ t.start()
620
+ return t