PlotweaverModel commited on
Commit
cd1ae53
·
verified ·
1 Parent(s): 8ff53c5

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +11 -7
  2. app.py +124 -23
README.md CHANGED
@@ -51,9 +51,10 @@ Nigerian English, Akan, Swahili, Nigerian Pidgin, … add more any time).
51
 
52
  | Variable | Default | Purpose |
53
  |----------|---------|---------|
54
- | `MOS_DATA_DIR` | `./data` | Where the SQLite DB, audio, and exports live. Point at your persistent mount (e.g. `/data` on a Space bucket). |
 
55
  | `ADMIN_CODE` | `plotweaver-admin` | Entered on the signup form to create an admin account. **Override this.** |
56
- | `MOS_JOURNAL_MODE` | `DELETE` | SQLite journal mode. Leave as `DELETE` on a bucket/network mount; set `WAL` only on a real local disk for extra concurrency. |
57
  | `PORT` | `7860` | Port the app binds to. |
58
 
59
  ---
@@ -105,11 +106,14 @@ for the older fixed `/data` storage tier).
105
  Notes:
106
  - The app already calls `launch(allowed_paths=[MOS_DATA_DIR])`, so Gradio is permitted to serve the
107
  audio files stored on the bucket. Without this, the audio player can't load clips from `/data`.
108
- - Buckets are object storage mounted as a filesystem (FUSE). SQLite's **WAL** mode relies on shared
109
- memory that doesn't work on such mounts, which is why the default journal mode is `DELETE`. At a
110
- few dozen reviewers this is comfortable; the only risk is a small chance of DB corruption if the
111
- Space is killed mid-write. For a bulletproof setup, keep the DB on local disk and sync periodic
112
- backups to the bucket (not built in easy to add if you need it).
 
 
 
113
 
114
  ### Self-hosted (AlmaLinux / AWS, behind nginx)
115
 
 
51
 
52
  | Variable | Default | Purpose |
53
  |----------|---------|---------|
54
+ | `MOS_DATA_DIR` | `./data` | **Persistent** location for audio files, exports, and the DB backup. Point at your bucket mount (e.g. `/data`). |
55
+ | `MOS_LOCAL_DIR` | system temp `/…/mos_live` | Fast **local** disk where the live SQLite DB runs. It is backed up to `MOS_DATA_DIR` after every change and restored on startup. Usually leave as default. |
56
  | `ADMIN_CODE` | `plotweaver-admin` | Entered on the signup form to create an admin account. **Override this.** |
57
+ | `MOS_SECRET` | derived from `ADMIN_CODE` | Secret used to sign browser session tokens (keeps a refreshed page logged in). Set a stable value in production. |
58
  | `PORT` | `7860` | Port the app binds to. |
59
 
60
  ---
 
106
  Notes:
107
  - The app already calls `launch(allowed_paths=[MOS_DATA_DIR])`, so Gradio is permitted to serve the
108
  audio files stored on the bucket. Without this, the audio player can't load clips from `/data`.
109
+ - **Database on buckets:** the live SQLite database does **not** run directly on the bucket, because
110
+ object-store/FUSE mounts don't provide reliable file locking and writes can silently fail to
111
+ appear on later reads (causing "my ratings/files disappeared"). Instead the live DB runs on fast
112
+ local disk (`MOS_LOCAL_DIR`) and is atomically backed up to the bucket (`MOS_DATA_DIR/mos.db`)
113
+ after every change, then restored on startup. Audio files live on the bucket (static, write-once,
114
+ no locking issues). This survives Space restarts as long as the bucket stays mounted.
115
+ - **Staying logged in:** a signed token is stored in the browser (`gr.BrowserState`) so a page
116
+ refresh keeps you signed in. Set a stable `MOS_SECRET` in production.
117
 
118
  ### Self-hosted (AlmaLinux / AWS, behind nginx)
119
 
app.py CHANGED
@@ -28,9 +28,11 @@ The effective admin code is printed to the logs on startup.
28
 
29
  import os
30
  import re
 
31
  import sqlite3
32
  import hashlib
33
  import secrets
 
34
  import datetime as dt
35
  import shutil
36
 
@@ -40,15 +42,75 @@ import gradio as gr
40
  # --------------------------------------------------------------------------- #
41
  # Configuration
42
  # --------------------------------------------------------------------------- #
 
 
43
  DATA_DIR = os.environ.get("MOS_DATA_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "data"))
44
  AUDIO_DIR = os.path.join(DATA_DIR, "audio")
45
  EXPORT_DIR = os.path.join(DATA_DIR, "exports")
46
- DB_PATH = os.path.join(DATA_DIR, "mos.db")
47
  ADMIN_CODE = os.environ.get("ADMIN_CODE", "plotweaver-admin")
48
 
49
- for d in (DATA_DIR, AUDIO_DIR, EXPORT_DIR):
 
 
 
 
 
 
 
 
 
50
  os.makedirs(d, exist_ok=True)
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  # The 7 MOS criteria, in the order they appear on the evaluation form.
53
  # (db_column, display_label, short_definition)
54
  CRITERIA = [
@@ -178,6 +240,7 @@ def create_user(username, email, password, role="reviewer", language_ids=None):
178
  uid = cur.lastrowid
179
  for lid in (language_ids or []):
180
  conn.execute("INSERT OR IGNORE INTO user_languages (user_id, language_id) VALUES (?,?)", (uid, lid))
 
181
  return uid
182
 
183
 
@@ -221,6 +284,7 @@ def add_language(code, name):
221
  conn.execute("INSERT INTO languages (code, name, created_at) VALUES (?,?,?)", (code, name, now_iso()))
222
  except sqlite3.IntegrityError:
223
  raise ValueError(f"Language code '{code}' already exists.")
 
224
 
225
 
226
  def list_languages():
@@ -233,6 +297,7 @@ def set_user_languages(uid, language_ids):
233
  conn.execute("DELETE FROM user_languages WHERE user_id = ?", (uid,))
234
  for lid in language_ids:
235
  conn.execute("INSERT OR IGNORE INTO user_languages (user_id, language_id) VALUES (?,?)", (uid, lid))
 
236
 
237
 
238
  # --------------------------------------------------------------------------- #
@@ -256,6 +321,7 @@ def add_sample(language_id, src_path, sample_name=None, model_name="unspecified"
256
  (language_id, sample_name, (model_name or "unspecified").strip(), dst,
257
  1 if is_reference else 0, (transcript or "").strip(), now_iso()),
258
  )
 
259
 
260
 
261
  def list_samples(language_id=None):
@@ -279,12 +345,14 @@ def delete_sample(sample_id):
279
  except OSError:
280
  pass
281
  conn.execute("DELETE FROM samples WHERE id = ?", (sample_id,))
 
282
 
283
 
284
  def set_sample_transcript(sample_id, transcript):
285
  with get_conn() as conn:
286
  conn.execute("UPDATE samples SET transcript = ? WHERE id = ?",
287
  ((transcript or "").strip(), sample_id))
 
288
 
289
 
290
  def get_sample_transcript(sample_id):
@@ -309,6 +377,7 @@ def upsert_rating(user_id, sample_id, scores, comments=""):
309
  f"comments=excluded.comments, updated_at=excluded.updated_at",
310
  [user_id, sample_id, *vals, (comments or "").strip(), now_iso()],
311
  )
 
312
 
313
 
314
  def get_rating(user_id, sample_id):
@@ -418,8 +487,9 @@ def export_results(language_id):
418
  init_db()
419
  print("=" * 64)
420
  print("Plotweaver AI — TTS MOS Evaluation Platform")
421
- print(f"Data directory : {DATA_DIR}")
422
- print(f"Database : {DB_PATH}")
 
423
  print(f"Admin code : {ADMIN_CODE} (use on signup to create an admin)")
424
  print("=" * 64)
425
 
@@ -459,6 +529,7 @@ with gr.Blocks(title="Plotweaver AI — TTS MOS Evaluation",
459
  css=CSS) as demo:
460
  session = gr.State(None) # logged-in user session dict
461
  current_sample = gr.State(None) # sample id currently being rated
 
462
 
463
  gr.Markdown("# 🎧 Plotweaver AI — TTS MOS Evaluation", elem_id="app-title")
464
  gr.Markdown("Rate synthesised speech on 7 quality criteria, by language.", elem_id="app-sub")
@@ -613,10 +684,18 @@ with gr.Blocks(title="Plotweaver AI — TTS MOS Evaluation",
613
  # keep typed credentials so the user can correct them
614
  return (gr.update(), gr.update(), None, "❌ Invalid username or password.",
615
  gr.update(), gr.update(), gr.update(), gr.update(),
616
- gr.update(), gr.update())
 
617
  sess = user_session(user["id"])
618
  is_admin = sess["role"] == "admin"
619
  rl = reviewer_lang_choices(sess)
 
 
 
 
 
 
 
620
  return (
621
  gr.update(visible=False), # auth_col
622
  gr.update(visible=True), # app_col
@@ -628,18 +707,15 @@ with gr.Blocks(title="Plotweaver AI — TTS MOS Evaluation",
628
  gr.update(choices=rl, value=None), # rate_lang
629
  gr.update(value=""), # li_user (clear)
630
  gr.update(value=""), # li_pw (clear)
 
 
631
  )
632
 
633
- li_btn.click(
634
- do_login, [li_user, li_pw],
635
- [auth_col, app_col, session, li_msg, greeting, reviewer_tabs, admin_panel,
636
- rate_lang, li_user, li_pw],
637
- )
638
- li_pw.submit( # allow Enter-to-login
639
- do_login, [li_user, li_pw],
640
- [auth_col, app_col, session, li_msg, greeting, reviewer_tabs, admin_panel,
641
- rate_lang, li_user, li_pw],
642
- )
643
 
644
  def do_signup(username, email, password, lang_id, code):
645
  role = "admin" if (code and code == ADMIN_CODE) else "reviewer"
@@ -657,8 +733,8 @@ with gr.Blocks(title="Plotweaver AI — TTS MOS Evaluation",
657
  [su_msg, su_user, su_email, su_pw, su_lang, su_code])
658
 
659
  def do_logout():
660
- return (gr.update(visible=True), gr.update(visible=False), None, "")
661
- logout_btn.click(do_logout, None, [auth_col, app_col, session, greeting])
662
 
663
  # ---- Rating flow ----
664
  def load_samples_for_lang(sess, language_id):
@@ -843,6 +919,7 @@ with gr.Blocks(title="Plotweaver AI — TTS MOS Evaluation",
843
  with get_conn() as conn:
844
  conn.execute("UPDATE users SET role=?, is_active=? WHERE id=?",
845
  (role, 1 if active == "yes" else 0, int(uid)))
 
846
  return f"✅ Updated user {int(uid)}.", _users_table()
847
  update_user_btn.click(admin_update_user, [session, promote_id, role_choice, active_choice],
848
  [user_admin_msg, users_tbl])
@@ -863,12 +940,36 @@ with gr.Blocks(title="Plotweaver AI — TTS MOS Evaluation",
863
  return export_results(language_id)
864
  export_btn.click(admin_export, [session, res_lang], [res_file])
865
 
866
- # Populate admin tables + sample pickers right after an admin logs in.
867
- li_btn.click(lambda s: (_languages_table(), _samples_table(), _users_table(),
868
- gr.update(choices=_sample_delete_choices()),
869
- gr.update(choices=_sample_delete_choices()))
870
- if s and s["role"] == "admin" else ([], [], [], gr.update(choices=[]), gr.update(choices=[])),
871
- [session], [langs_tbl, samples_tbl, users_tbl, del_sample, tr_sample])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
872
 
873
  # Refresh language-dependent choices on every page load, so newly added
874
  # languages appear in the signup form (and admin dropdowns) without a restart.
 
28
 
29
  import os
30
  import re
31
+ import hmac
32
  import sqlite3
33
  import hashlib
34
  import secrets
35
+ import tempfile
36
  import datetime as dt
37
  import shutil
38
 
 
42
  # --------------------------------------------------------------------------- #
43
  # Configuration
44
  # --------------------------------------------------------------------------- #
45
+ # DATA_DIR is the *persistent* location (e.g. a Hugging Face Storage Bucket at
46
+ # /data). Audio files and the database BACKUP live here.
47
  DATA_DIR = os.environ.get("MOS_DATA_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "data"))
48
  AUDIO_DIR = os.path.join(DATA_DIR, "audio")
49
  EXPORT_DIR = os.path.join(DATA_DIR, "exports")
 
50
  ADMIN_CODE = os.environ.get("ADMIN_CODE", "plotweaver-admin")
51
 
52
+ # The LIVE database runs on fast LOCAL disk. SQLite needs real POSIX file
53
+ # locking, which FUSE/object-store mounts (like a bucket) do not provide
54
+ # reliably — running it directly on the bucket causes writes to silently not
55
+ # appear on later reads. So we keep the working DB local and atomically back it
56
+ # up to the bucket after every change (and restore it on startup).
57
+ LOCAL_DIR = os.environ.get("MOS_LOCAL_DIR") or os.path.join(tempfile.gettempdir(), "mos_live")
58
+ DB_PATH = os.path.join(LOCAL_DIR, "mos.db") # live (local disk)
59
+ DB_BACKUP = os.path.join(DATA_DIR, "mos.db") # persistent copy (bucket)
60
+
61
+ for d in (DATA_DIR, AUDIO_DIR, EXPORT_DIR, LOCAL_DIR):
62
  os.makedirs(d, exist_ok=True)
63
 
64
+ # Restore the live DB from the persistent backup on startup (first boot / after
65
+ # the container is recycled), so prior data survives restarts.
66
+ if os.path.abspath(DB_BACKUP) != os.path.abspath(DB_PATH) \
67
+ and os.path.exists(DB_BACKUP) and not os.path.exists(DB_PATH):
68
+ try:
69
+ shutil.copyfile(DB_BACKUP, DB_PATH)
70
+ except OSError:
71
+ pass
72
+
73
+ # Secret used to sign browser session tokens (keeps a refreshed page logged in).
74
+ SESSION_SECRET = os.environ.get("MOS_SECRET") or hashlib.sha256(
75
+ ("mos-session::" + ADMIN_CODE).encode()).hexdigest()
76
+
77
+
78
+ def backup_db():
79
+ """Atomically copy the live DB to the persistent backup on the bucket."""
80
+ if os.path.abspath(DB_BACKUP) == os.path.abspath(DB_PATH):
81
+ return
82
+ try:
83
+ src = sqlite3.connect(DB_PATH)
84
+ dst = sqlite3.connect(DB_BACKUP)
85
+ with dst:
86
+ src.backup(dst)
87
+ src.close()
88
+ dst.close()
89
+ except Exception: # noqa — best-effort backup; never break a write
90
+ pass
91
+
92
+
93
+ def make_token(user_id):
94
+ sig = hmac.new(SESSION_SECRET.encode(), str(user_id).encode(), hashlib.sha256).hexdigest()[:32]
95
+ return f"{user_id}.{sig}"
96
+
97
+
98
+ def verify_token(token):
99
+ """Return the user_id if the signed token is valid and the user is active."""
100
+ if not token or "." not in str(token):
101
+ return None
102
+ uid_str, sig = str(token).rsplit(".", 1)
103
+ try:
104
+ uid = int(uid_str)
105
+ except ValueError:
106
+ return None
107
+ good = hmac.new(SESSION_SECRET.encode(), str(uid).encode(), hashlib.sha256).hexdigest()[:32]
108
+ if not hmac.compare_digest(sig, good):
109
+ return None
110
+ with get_conn() as conn:
111
+ row = conn.execute("SELECT id, is_active FROM users WHERE id=?", (uid,)).fetchone()
112
+ return uid if (row and row["is_active"]) else None
113
+
114
  # The 7 MOS criteria, in the order they appear on the evaluation form.
115
  # (db_column, display_label, short_definition)
116
  CRITERIA = [
 
240
  uid = cur.lastrowid
241
  for lid in (language_ids or []):
242
  conn.execute("INSERT OR IGNORE INTO user_languages (user_id, language_id) VALUES (?,?)", (uid, lid))
243
+ backup_db()
244
  return uid
245
 
246
 
 
284
  conn.execute("INSERT INTO languages (code, name, created_at) VALUES (?,?,?)", (code, name, now_iso()))
285
  except sqlite3.IntegrityError:
286
  raise ValueError(f"Language code '{code}' already exists.")
287
+ backup_db()
288
 
289
 
290
  def list_languages():
 
297
  conn.execute("DELETE FROM user_languages WHERE user_id = ?", (uid,))
298
  for lid in language_ids:
299
  conn.execute("INSERT OR IGNORE INTO user_languages (user_id, language_id) VALUES (?,?)", (uid, lid))
300
+ backup_db()
301
 
302
 
303
  # --------------------------------------------------------------------------- #
 
321
  (language_id, sample_name, (model_name or "unspecified").strip(), dst,
322
  1 if is_reference else 0, (transcript or "").strip(), now_iso()),
323
  )
324
+ backup_db()
325
 
326
 
327
  def list_samples(language_id=None):
 
345
  except OSError:
346
  pass
347
  conn.execute("DELETE FROM samples WHERE id = ?", (sample_id,))
348
+ backup_db()
349
 
350
 
351
  def set_sample_transcript(sample_id, transcript):
352
  with get_conn() as conn:
353
  conn.execute("UPDATE samples SET transcript = ? WHERE id = ?",
354
  ((transcript or "").strip(), sample_id))
355
+ backup_db()
356
 
357
 
358
  def get_sample_transcript(sample_id):
 
377
  f"comments=excluded.comments, updated_at=excluded.updated_at",
378
  [user_id, sample_id, *vals, (comments or "").strip(), now_iso()],
379
  )
380
+ backup_db()
381
 
382
 
383
  def get_rating(user_id, sample_id):
 
487
  init_db()
488
  print("=" * 64)
489
  print("Plotweaver AI — TTS MOS Evaluation Platform")
490
+ print(f"Persistent dir : {DATA_DIR}")
491
+ print(f"Live database : {DB_PATH}")
492
+ print(f"DB backup : {DB_BACKUP}")
493
  print(f"Admin code : {ADMIN_CODE} (use on signup to create an admin)")
494
  print("=" * 64)
495
 
 
529
  css=CSS) as demo:
530
  session = gr.State(None) # logged-in user session dict
531
  current_sample = gr.State(None) # sample id currently being rated
532
+ auth_token = gr.BrowserState("") # signed token persisted in the browser (survives refresh)
533
 
534
  gr.Markdown("# 🎧 Plotweaver AI — TTS MOS Evaluation", elem_id="app-title")
535
  gr.Markdown("Rate synthesised speech on 7 quality criteria, by language.", elem_id="app-sub")
 
684
  # keep typed credentials so the user can correct them
685
  return (gr.update(), gr.update(), None, "❌ Invalid username or password.",
686
  gr.update(), gr.update(), gr.update(), gr.update(),
687
+ gr.update(), gr.update(), gr.update(),
688
+ gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
689
  sess = user_session(user["id"])
690
  is_admin = sess["role"] == "admin"
691
  rl = reviewer_lang_choices(sess)
692
+ if is_admin:
693
+ lt, st, ut = _languages_table(), _samples_table(), _users_table()
694
+ dch = _sample_delete_choices()
695
+ du, tu = gr.update(choices=dch), gr.update(choices=dch)
696
+ else:
697
+ lt, st, ut = [], [], []
698
+ du, tu = gr.update(), gr.update()
699
  return (
700
  gr.update(visible=False), # auth_col
701
  gr.update(visible=True), # app_col
 
707
  gr.update(choices=rl, value=None), # rate_lang
708
  gr.update(value=""), # li_user (clear)
709
  gr.update(value=""), # li_pw (clear)
710
+ make_token(user["id"]), # auth_token (persist login)
711
+ lt, st, ut, du, tu, # admin tables + pickers
712
  )
713
 
714
+ LOGIN_OUTPUTS = [auth_col, app_col, session, li_msg, greeting, reviewer_tabs, admin_panel,
715
+ rate_lang, li_user, li_pw, auth_token,
716
+ langs_tbl, samples_tbl, users_tbl, del_sample, tr_sample]
717
+ li_btn.click(do_login, [li_user, li_pw], LOGIN_OUTPUTS)
718
+ li_pw.submit(do_login, [li_user, li_pw], LOGIN_OUTPUTS) # Enter-to-login
 
 
 
 
 
719
 
720
  def do_signup(username, email, password, lang_id, code):
721
  role = "admin" if (code and code == ADMIN_CODE) else "reviewer"
 
733
  [su_msg, su_user, su_email, su_pw, su_lang, su_code])
734
 
735
  def do_logout():
736
+ return (gr.update(visible=True), gr.update(visible=False), None, "", "")
737
+ logout_btn.click(do_logout, None, [auth_col, app_col, session, greeting, auth_token])
738
 
739
  # ---- Rating flow ----
740
  def load_samples_for_lang(sess, language_id):
 
919
  with get_conn() as conn:
920
  conn.execute("UPDATE users SET role=?, is_active=? WHERE id=?",
921
  (role, 1 if active == "yes" else 0, int(uid)))
922
+ backup_db()
923
  return f"✅ Updated user {int(uid)}.", _users_table()
924
  update_user_btn.click(admin_update_user, [session, promote_id, role_choice, active_choice],
925
  [user_admin_msg, users_tbl])
 
940
  return export_results(language_id)
941
  export_btn.click(admin_export, [session, res_lang], [res_file])
942
 
943
+ # Restore the session on page load (so a refresh keeps you signed in).
944
+ def restore_session(token):
945
+ uid = verify_token(token)
946
+ if not uid:
947
+ # stay logged out; leave the UI at its default (auth visible)
948
+ return (gr.update(), gr.update(), None, gr.update(), gr.update(), gr.update(),
949
+ gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
950
+ sess = user_session(uid)
951
+ is_admin = sess["role"] == "admin"
952
+ rl = reviewer_lang_choices(sess)
953
+ if is_admin:
954
+ lt, st, ut = _languages_table(), _samples_table(), _users_table()
955
+ dch = _sample_delete_choices()
956
+ du, tu = gr.update(choices=dch), gr.update(choices=dch)
957
+ else:
958
+ lt, st, ut = [], [], []
959
+ du, tu = gr.update(), gr.update()
960
+ return (
961
+ gr.update(visible=False), # auth_col
962
+ gr.update(visible=True), # app_col
963
+ sess, # session
964
+ gr.update(value=f"Signed in as **{sess['username']}** · {sess['role']}"), # greeting
965
+ gr.update(visible=not is_admin), # reviewer_tabs
966
+ gr.update(visible=is_admin), # admin_panel
967
+ gr.update(choices=rl, value=None), # rate_lang
968
+ lt, st, ut, du, tu, # admin tables + pickers
969
+ )
970
+ demo.load(restore_session, [auth_token],
971
+ [auth_col, app_col, session, greeting, reviewer_tabs, admin_panel, rate_lang,
972
+ langs_tbl, samples_tbl, users_tbl, del_sample, tr_sample])
973
 
974
  # Refresh language-dependent choices on every page load, so newly added
975
  # languages appear in the signup form (and admin dropdowns) without a restart.