AhmedAshrafMarzouk commited on
Commit
96ecc92
·
1 Parent(s): 4a5fb32
Files changed (6) hide show
  1. .gitattributes +1 -0
  2. app.py +1193 -0
  3. requirements.txt +14 -0
  4. sentences_eg.json +0 -0
  5. sentences_ma.json +0 -0
  6. sentences_sa.json +3 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ sentences_sa.json filter=lfs diff=lfs merge=lfs -text
app.py ADDED
@@ -0,0 +1,1193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import uuid
4
+ import time
5
+ from pathlib import Path
6
+ import numpy as np
7
+ from datetime import datetime
8
+ import random
9
+ from dotenv import load_dotenv
10
+ import boto3
11
+ import gradio as gr
12
+ import soundfile as sf
13
+ from werkzeug.security import generate_password_hash, check_password_hash
14
+ from supabase import create_client, Client
15
+
16
+ # ===============================
17
+ # CONFIG & GLOBALS
18
+ # ===============================
19
+
20
+ load_dotenv()
21
+ BASE_DIR = Path(__file__).parent if "__file__" in globals() else Path(".").resolve()
22
+ DATA_DIR = Path.home() / ".tts_dataset_creator"
23
+ USERS_ROOT = DATA_DIR / "users"
24
+
25
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
26
+ USERS_ROOT.mkdir(parents=True, exist_ok=True)
27
+
28
+ AWS_ACCESS_KEY = os.environ.get("AWS_ACCESS_KEY", "")
29
+ AWS_SECRET_KEY = os.environ.get("AWS_SECRET_KEY", "")
30
+ S3_BUCKET = os.environ.get("S3_BUCKET", "voicer-storage")
31
+ AWS_REGION = os.environ.get("AWS_REGION", "me-south-1")
32
+ SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
33
+ SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
34
+
35
+ if not SUPABASE_URL or not SUPABASE_KEY:
36
+ print("⚠️ Supabase env vars not set")
37
+
38
+ supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) if SUPABASE_URL and SUPABASE_KEY else None
39
+
40
+
41
+ def _create_s3_client():
42
+ aws_access_key = os.environ.get("AWS_ACCESS_KEY", "")
43
+ aws_secret_key = os.environ.get("AWS_SECRET_KEY", "")
44
+ if not aws_access_key or not aws_secret_key:
45
+ print("Using IAM role or instance profile for S3")
46
+ return boto3.client("s3", region_name=AWS_REGION)
47
+ print("Using explicit access keys for S3")
48
+ return boto3.client(
49
+ "s3",
50
+ aws_access_key_id=aws_access_key,
51
+ aws_secret_access_key=aws_secret_key,
52
+ region_name=AWS_REGION,
53
+ )
54
+
55
+
56
+ S3_CLIENT = _create_s3_client()
57
+
58
+ # ===============================
59
+ # COUNTRIES & DIALECTS
60
+ # ===============================
61
+ AVAILABLE_COUNTRIES = [
62
+ "Egypt", "Saudi Arabia", "Morocco"
63
+ ]
64
+
65
+ COUNTRY_EMOJIS = {
66
+ "dz": "🇩🇿", # Algeria
67
+ "bh": "🇧🇭", # Bahrain
68
+ "eg": "🇪🇬", # Egypt
69
+ "iq": "🇮🇶", # Iraq
70
+ "jo": "🇯🇴", # Jordan
71
+ "kw": "🇰🇼", # Kuwait
72
+ "lb": "🇱🇧", # Lebanon
73
+ "ly": "🇱🇾", # Libya
74
+ "mr": "🇲🇷", # Mauritania
75
+ "ma": "🇲🇦", # Morocco
76
+ "om": "🇴🇲", # Oman
77
+ "ps": "🇵🇸", # Palestine
78
+ "qa": "🇶🇦", # Qatar
79
+ "sa": "🇸🇦", # Saudi Arabia
80
+ "so": "🇸🇴", # Somalia
81
+ "sd": "🇸🇩", # Sudan
82
+ "sy": "🇸🇾", # Syria
83
+ "tn": "🇹🇳", # Tunisia
84
+ "ae": "🇦🇪", # United Arab Emirates
85
+ "ye": "🇾🇪", # Yemen
86
+ }
87
+
88
+
89
+ RECORDING_TARGET_MINUTES = 30 # target total recording time per user
90
+ RECORDING_TARGET_SECONDS = RECORDING_TARGET_MINUTES * 60
91
+
92
+ COUNTRY_CODES = {
93
+ "Algeria": "dz",
94
+ "Bahrain": "bh",
95
+ "Egypt": "eg",
96
+ "Iraq": "iq",
97
+ "Jordan": "jo",
98
+ "Kuwait": "kw",
99
+ "Lebanon": "lb",
100
+ "Libya": "ly",
101
+ "Mauritania": "mr",
102
+ "Morocco": "ma",
103
+ "Oman": "om",
104
+ "Palestine": "ps",
105
+ "Qatar": "qa",
106
+ "Saudi Arabia": "sa",
107
+ "Somalia": "so",
108
+ "Sudan": "sd",
109
+ "Syria": "sy",
110
+ "Tunisia": "tn",
111
+ "United Arab Emirates": "ae",
112
+ "Yemen": "ye"
113
+ }
114
+
115
+ COUNTRY_DIALECTS = {
116
+ "Saudi Arabia": {
117
+ "حجازية": "hj",
118
+ "حجازية بدوية": "hj-bd",
119
+ "جنوبية": "jn",
120
+ "تهامية": "th",
121
+ "نجدية": "nj",
122
+ "نجدية بدوية": "nj-bd",
123
+ "قصيمية": "qm",
124
+ "الشمال": "sh",
125
+ "حساوية": "hs",
126
+ "قطيفية": "qt",
127
+ "سيهاتية": "sy",
128
+ "أخرى": "oth"
129
+ },
130
+ "Egypt": {
131
+ "قاهرية": "ca",
132
+ "إسكندرانية": "al",
133
+ "صعيدية": "sa",
134
+ "بورسعيدية": "si",
135
+ "نوبية": "nb",
136
+ "أخرى": "oth"
137
+ },
138
+ "Morocco": {
139
+ "فاسية": "fe",
140
+ "دار البيضاء": "ca",
141
+ "مراكشية": "ma",
142
+ "شمالية": "no",
143
+ "شرقية": "shar",
144
+ "أخرى": "oth"
145
+ },
146
+ "Iraq": {
147
+ "بغدادية": "ba",
148
+ "بصراوية": "bs",
149
+ "موصلية": "mo",
150
+ "كردية": "ku",
151
+ "جنوبية": "so",
152
+ "أخرى": "oth"
153
+ },
154
+ "Yemen": {
155
+ "صنعانية": "sa",
156
+ "عدنية": "ad",
157
+ "حضرمية": "ha",
158
+ "تهامية": "ti",
159
+ "أخرى": "oth"
160
+ },
161
+ "Jordan": {
162
+ "عمانية": "am",
163
+ "شمالية": "no",
164
+ "جنوبية": "so",
165
+ "بدوية": "be",
166
+ "أخرى": "oth"
167
+ },
168
+ "Lebanon": {
169
+ "بيروتية": "be",
170
+ "جبلية": "mo",
171
+ "جنوبية": "so",
172
+ "شمالية": "no",
173
+ "أخرى": "oth"
174
+ },
175
+ "Syria": {
176
+ "دمشقية": "da",
177
+ "حلبية": "al",
178
+ "حمصية": "ho",
179
+ "ساحلية": "co",
180
+ "أخرى": "oth"
181
+ },
182
+ "Palestine": {
183
+ "قدسية": "je",
184
+ "غزاوية": "ga",
185
+ "خليلية": "he",
186
+ "شمالية": "no",
187
+ "أخرى": "oth"
188
+ },
189
+ "United Arab Emirates": {
190
+ "إماراتية": "em",
191
+ "دبية": "du",
192
+ "أبوظبية": "ad",
193
+ "شارقية": "shr",
194
+ "أخرى": "oth"
195
+ },
196
+ "Kuwait": {
197
+ "كويتية": "ku",
198
+ "بدوية": "be",
199
+ "أخرى": "oth"
200
+ },
201
+ "Qatar": {
202
+ "قطرية": "qa",
203
+ "بدوية": "be",
204
+ "أخرى": "oth"
205
+ },
206
+ "Bahrain": {
207
+ "بحرينية": "ba",
208
+ "مدنية": "ur",
209
+ "أخرى": "oth"
210
+ },
211
+ "Oman": {
212
+ "عمانية": "om",
213
+ "ظفارية": "dh",
214
+ "داخلية": "in",
215
+ "أخرى": "oth"
216
+ },
217
+ "Algeria": {
218
+ "جزائرية": "al",
219
+ "قسنطينية": "co",
220
+ "وهرانية": "or",
221
+ "قبائلية": "ka",
222
+ "أخرى": "oth"
223
+ },
224
+ "Tunisia": {
225
+ "تونسية": "tu",
226
+ "صفاقسية": "sf",
227
+ "سوسية": "so",
228
+ "أخرى": "oth"
229
+ },
230
+ "Libya": {
231
+ "طرابلسية": "tr",
232
+ "بنغازية": "be",
233
+ "فزانية": "fe",
234
+ "أخرى": "oth"
235
+ },
236
+ "Sudan": {
237
+ "خرطومية": "kh",
238
+ "شمالية": "no",
239
+ "دارفورية": "da",
240
+ "أخرى": "oth"
241
+ },
242
+ "Somalia": {
243
+ "صومالية": "so",
244
+ "شمالية": "no",
245
+ "جنوبية": "so",
246
+ "أخرى": "oth"
247
+ },
248
+ "Mauritania": {
249
+ "موريتانية": "mr",
250
+ "حسانية": "ha",
251
+ "أخرى": "oth"
252
+ }
253
+ }
254
+
255
+ RECORDING_INSTRUCTIONS = """
256
+ <div dir="rtl" style="text-align: right">
257
+
258
+ ### تعليمات التسجيل
259
+ 1. **البيئة**: سجّل في مكان هادئ قد ما تقدر، وحاول ما يكون فيه ضوضاء أو أصوات في الخلفية.
260
+ 2. **الميكروفون**: يفضّل تستخدم مايك سماعة أو مايك خارجي، لأنه غالبًا بيكون أوضح بكثير من مايك اللابتوب. في حالة استخدام الجوال يمكن فقط التأكد من جودة التسجيل قبل الإكمال.
261
+ 3. **طريقة التحدث**: اقرأ الجملة بصوت واضح وطبيعي، وبلهجتك. لا تغيّر أو تستبدل أي كلمة أبدًا، إلا لو كان فيه اختلاف بالنطق مثل: "ثلاثة" و"تلاتة" — هذا عادي. إذا حسّيت إنك ما تبغى تسجل جملة معينة أو ما عرفت تنطقها، عادي اضغط "Skip".
262
+ 4. **التعديل**: تقدر تعدل الجملة قبل لا تسجل إذا ودك.
263
+ 5. **الحفظ**: بعد ما تسجل، اضغط "Save & Next" عشان تحفظ تسجيلك. إذا ودك تعيد، استخدم "Discard"، أو اضغط "Skip" عشان تروح للجملة اللي بعدها.
264
+ 6. **المدة**: حاول تسجل عدد كافي من الجمل، كل تسجيل يساعدنا أكثر! حاول يكون مجموع تسجيلاتك على الأقل 30 دقيقة، ونقدّر وقتك وجهدك
265
+
266
+ إذا عندك أي مشكلة أو استفسار، تواصل معي على الإيميل:
267
268
+ </div>
269
+ """
270
+
271
+ CONSENT_DETAILS = """
272
+ <section dir="rtl" lang="ar" style="text-align: right">
273
+ <h1>الموافقة على جمع واستخدام البيانات</h1>
274
+ <p>
275
+ هذه الاتفاقية بين <strong>المشارك </strong> وفريق البحث من
276
+ <strong>جامعة الملك فهد للبترول والمعادن</strong> و<strong>جامعة طيبة</strong>
277
+ (والتي سنشير إليها فيما يلي بـ "الجامعتين").
278
+ الهدف من الاتفاقية هو جمع واستخدام وتوزيع تسجيلات صوتية لدعم أبحاث كشف الأصوات المزيفة (Deepfake) وغيرها من الأبحاث غير التجارية.
279
+ </p>
280
+ <ol>
281
+ <li>
282
+ <strong>هدف جمع البيانات:</strong><br>
283
+ يقوم الفريق بجمع تسجيلات صوتية لإنشاء مجموعة بيانات (Dataset) خاصة بالكشف عن الأصوات المصنعة بالذكاء الاصطناعي
284
+ باستخدام تقنيات تحويل النص إلى صوت (TTS) أو تقليد الأصوات (Voice Conversion) وطرق أخرى.
285
+ ستُستخدم هذه البيانات في أبحاث علمية وأكاديمية لتطوير طرق أفضل لاكتشاف الأصوات المزيفة وغيرها من الأبحاث غير التجارية.
286
+ </li>
287
+ <li>
288
+ <strong>طبيعة البيانات التي سيتم جمعها:</strong><br>
289
+ يوافق المشارك على تقديم:
290
+ <ul>
291
+ <li>تسجيلات صوتية بصوته الطبيعي أو من خلال نصوص/جمل يطلب منه قراءتها.</li>
292
+ <li>بيانات اختي��رية مثل: النوع (ذكر/أنثى)، الفئة العمرية، اللهجة، وغيرها.</li>
293
+ <li>موافقة على إمكانية تعديل صوته أو تغييره باستخدام أساليب صناعية.</li>
294
+ </ul>
295
+ </li>
296
+ <li>
297
+ <strong>الحقوق الممنوحة:</strong><br>
298
+ يمنح المشارك الفريق الحق الكامل (بدون مقابل مالي أو قيود) في:
299
+ <ul>
300
+ <li>تسجيل ومعالجة واستخدام الصوت الطبيعي والنسخ المصنعة منه.</li>
301
+ <li>توزيع مجموعة البيانات (الطبيعية والمصنعة) للباحثين في المجتمع العلمي لأغراض بحثية غير تجارية فقط.</li>
302
+ <li>نشر عينات صوتية على منصات مهنية أو أكاديمية مثل LinkedIn، X/Twitter، YouTube لتعزيز الوعي بأبحاث الديب فيك أو للإعلان عن توفر البيانات.</li>
303
+ </ul>
304
+ </li>
305
+ <li>
306
+ <strong>إتاحة البيانات:</strong><br>
307
+ سيتم نشر المجموعة الصوتية (الطبيعية والمصنعة) بترخيص مفتوح
308
+ <em>(Creative Commons Attribution 4.0)</em>
309
+ مما يسمح لأي باحث باستخدامها ومشاركتها لأغراض أكاديمية غير تجارية.
310
+ </li>
311
+ <li>
312
+ <strong>الخصوصية والسرية:</strong><br>
313
+ <ul>
314
+ <li>لن يتم نشر اسم المشارك أو أي بيانات شخصية مباشرة إلا بموافقته المكتوبة.</li>
315
+ <li>سيكون للمشارك معرف (ID) مجهول داخل مجموعة البيانات.</li>
316
+ </ul>
317
+ </li>
318
+ <li>
319
+ <strong>المشاركة والانضمام:</strong><br>
320
+ <ul>
321
+ <li>المشاركة اختيارية 100٪.</li>
322
+ <li>للمشارك الحق في الانسحاب أو طلب حذف تسجيلاته قبل نشر مجموعة البيانات للعامة.</li>
323
+ <li>بعد النشر العام، سحب البيانات لن يكون ممكنًا بسبب طريقة توزيعها.</li>
324
+ </ul>
325
+ </li>
326
+ <li>
327
+ <strong>التعويض:</strong><br>
328
+ يدرك المشارك أن المشاركة لا تتضمن أي مقابل مادي، والمساهمة هنا لدعم وتطوير البحث العلمي فقط.
329
+ </li>
330
+ </ol>
331
+ </section>
332
+ """
333
+
334
+
335
+ AGES = [
336
+ "4–9", # baby
337
+ "10–14", # child
338
+ "15–19", # teen
339
+ "20–24", # young adult
340
+ "25–34", # adult
341
+ "35–44", # mid-age adult
342
+ "45–54", # older adult
343
+ "55–64", # senior
344
+ "65–74", # elderly
345
+ "75–84", # aged
346
+ "85+" # very aged
347
+ ]
348
+
349
+ GENDER = [
350
+ "ذكر",
351
+ "أنثى"
352
+ ]
353
+
354
+
355
+ def get_dialects_for_country(country: str):
356
+ dialects = list(COUNTRY_DIALECTS.get(country, {}).keys())
357
+ if not dialects:
358
+ return ["أخرى"]
359
+ return dialects
360
+
361
+
362
+ def split_dialect_code(dialect_code: str):
363
+ dialect_code = (dialect_code or "").strip().lower() or "unk-gen"
364
+ parts = dialect_code.split("-", 1)
365
+ if len(parts) == 2:
366
+ return parts[0], parts[1]
367
+ return parts[0], "gen"
368
+
369
+ # ===============================
370
+ # SENTENCES (per-country, cached)
371
+ # ===============================
372
+
373
+ SENTENCES_CACHE = {} # {country_code: [(id, text, [dialects]), ...]}
374
+
375
+
376
+ def get_sentences_file_for_country(country_code: str) -> Path:
377
+ """
378
+ Return the path to the sentences file for a given country code,
379
+ e.g. 'eg' -> BASE_DIR / 'sentences_eg.json'.
380
+ """
381
+ return BASE_DIR / f"sentences_{country_code}.json"
382
+
383
+
384
+ def load_sentences_for_country(country_code: str):
385
+ """
386
+ Load and cache all sentences for a given country code.
387
+
388
+ Expected JSON structure:
389
+ {
390
+ "sentences": [
391
+ {
392
+ "unique_id": "105130",
393
+ "text": "...",
394
+ "dialect": ["eg-ca", "eg-al", ...]
395
+ },
396
+ ...
397
+ ]
398
+ }
399
+ """
400
+ if country_code in SENTENCES_CACHE:
401
+ return SENTENCES_CACHE[country_code]
402
+
403
+ path = get_sentences_file_for_country(country_code)
404
+
405
+ # If missing, initialise an empty file (or you can raise an error if you prefer)
406
+ if not path.exists():
407
+ path.write_text(
408
+ json.dumps({"sentences": []}, ensure_ascii=False, indent=2),
409
+ encoding="utf-8"
410
+ )
411
+
412
+ data = json.loads(path.read_text(encoding="utf-8"))
413
+ raw_sentences = data.get("sentences", [])
414
+
415
+ SENTENCES_CACHE[country_code] = [
416
+ (s["unique_id"], s["text"], s.get("dialect", []))
417
+ for s in raw_sentences
418
+ ]
419
+ return SENTENCES_CACHE[country_code]
420
+
421
+
422
+
423
+ def filter_sentences(dialect_code: str, completed_ids):
424
+ """
425
+ Return all (sentence_id, text) pairs for a given dialect_code,
426
+ excluding any sentence IDs in completed_ids.
427
+
428
+ - dialect_code looks like 'sa-hj', 'eg-ca', etc.
429
+ - We infer the country_code ('sa', 'eg', ...) from dialect_code,
430
+ then load the corresponding sentences_{country_code}.json.
431
+ """
432
+ completed_set = set(completed_ids or [])
433
+
434
+ country_code, _ = split_dialect_code(dialect_code)
435
+ all_sentences = load_sentences_for_country(country_code)
436
+
437
+ return [
438
+ (sid, text)
439
+ for sid, text, dialects in all_sentences
440
+ if sid not in completed_set and dialect_code in dialects
441
+ ]
442
+
443
+ # ===============================
444
+ # AUTH / SUPABASE
445
+ # ===============================
446
+
447
+ def get_user_by_email(email: str):
448
+ if not supabase:
449
+ return None
450
+ try:
451
+ resp = supabase.table("users").select("*").eq("email", email.lower()).execute()
452
+ return resp.data[0] if resp.data else None
453
+ except Exception as e:
454
+ print("get_user_by_email error:", e)
455
+ return None
456
+
457
+
458
+ def get_user_by_username(username: str):
459
+ if not supabase:
460
+ return None
461
+ try:
462
+ resp = supabase.table("users").select("*").eq("username", username).execute()
463
+ return resp.data[0] if resp.data else None
464
+ except Exception as e:
465
+ print("get_user_by_username error:", e)
466
+ return None
467
+
468
+
469
+ def create_user(name: str, email: str, password: str, country: str, dialect_label: str, gender: str, age: str):
470
+ if not supabase:
471
+ return False, "Supabase not configured"
472
+
473
+ email = email.lower()
474
+ if get_user_by_email(email):
475
+ return False, "Email already registered"
476
+
477
+ base = name.strip().replace(" ", "_").lower() or "user"
478
+
479
+ country_code = COUNTRY_CODES.get(country, "unk")
480
+ dialect_map = COUNTRY_DIALECTS.get(country, {})
481
+ dialect_code_raw = dialect_map.get(dialect_label, "oth")
482
+ dialect_code = f"{country_code}-{dialect_code_raw}"
483
+ username = f"{base}_{uuid.uuid4().hex[:7]}_{dialect_code}_{'m' if gender == 'ذكر' else 'f'}"
484
+
485
+ hashed_pw = generate_password_hash(password)
486
+ payload = {
487
+ "username": username,
488
+ "name": name,
489
+ "email": email,
490
+ "password": hashed_pw,
491
+ "country": country,
492
+ "dialect_code": dialect_code,
493
+ "gender": gender,
494
+ "age": age,
495
+ "created_at": datetime.utcnow().isoformat(),
496
+ }
497
+
498
+ try:
499
+ resp = supabase.table("users").insert(payload).execute()
500
+ if resp.data:
501
+ supabase.table("sessions").insert({
502
+ "username": username,
503
+ "completed_sentences": [],
504
+ "total_recording_duration": 0.0,
505
+ "updated_at": datetime.utcnow().isoformat(),
506
+ }).execute()
507
+ return True, username
508
+ return False, "Failed to insert user"
509
+ except Exception as e:
510
+ print("create_user error:", e)
511
+ return False, f"Registration failed: {e}"
512
+
513
+
514
+ def authenticate(email: str, password: str):
515
+ if not supabase:
516
+ return False, "Supabase not configured"
517
+
518
+ user = get_user_by_email(email)
519
+ if not user or not check_password_hash(user.get("password", ""), password):
520
+ return False, "Invalid email or password"
521
+ return True, user["username"]
522
+
523
+
524
+ def create_password_reset_token(email: str):
525
+ if not supabase:
526
+ return False, "Supabase not configured"
527
+
528
+ user = get_user_by_email(email)
529
+ if not user:
530
+ return False, "Email not found"
531
+
532
+ token = uuid.uuid4().hex
533
+ payload = {
534
+ "email": email.lower(),
535
+ "token": token,
536
+ "created_at": datetime.utcnow().isoformat(),
537
+ }
538
+ try:
539
+ supabase.table("password_resets").insert(payload).execute()
540
+ return True, token
541
+ except Exception as e:
542
+ # nice clean message instead of raw dict
543
+ print("create_password_reset_token error:", e)
544
+ return False, "Password reset is not configured on the server (missing password_resets table)."
545
+
546
+
547
+ def reset_password_with_token(token: str, new_password: str):
548
+ if not supabase:
549
+ return False, "Supabase not configured"
550
+ try:
551
+ resp = supabase.table("password_resets").select("*").eq("token", token).execute()
552
+ rows = resp.data or []
553
+ if not rows:
554
+ return False, "Invalid or expired token"
555
+
556
+ row = rows[0]
557
+ email = row["email"]
558
+ user = get_user_by_email(email)
559
+ if not user:
560
+ return False, "User not found"
561
+
562
+ hashed_pw = generate_password_hash(new_password)
563
+ supabase.table("users").update({"password": hashed_pw}).eq("email", email).execute()
564
+ supabase.table("password_resets").delete().eq("token", token).execute()
565
+ return True, "Password updated successfully"
566
+ except Exception as e:
567
+ print("reset_password_with_token error:", e)
568
+ return False, "Password reset is not fully configured on the server."
569
+
570
+
571
+ def load_session(username: str):
572
+ if not supabase:
573
+ return {"completed_sentences": [], "total_recording_duration": 0.0}
574
+ try:
575
+ resp = supabase.table("sessions").select("*").eq("username", username).execute()
576
+ if resp.data:
577
+ row = resp.data[0]
578
+ return {
579
+ "completed_sentences": row.get("completed_sentences", []) or [],
580
+ "total_recording_duration": float(row.get("total_recording_duration", 0.0) or 0.0),
581
+ }
582
+ except Exception as e:
583
+ print("load_session error:", e)
584
+ return {"completed_sentences": [], "total_recording_duration": 0.0}
585
+
586
+
587
+ def save_session(username: str, completed_sentences, total_duration: float):
588
+ if not supabase:
589
+ return
590
+ try:
591
+ supabase.table("sessions").upsert({
592
+ "username": username,
593
+ "completed_sentences": completed_sentences,
594
+ "total_recording_duration": total_duration,
595
+ "updated_at": datetime.utcnow().isoformat(),
596
+ }).execute()
597
+ except Exception as e:
598
+ print("save_session error:", e)
599
+
600
+
601
+ # ===============================
602
+ # STORAGE / AUDIO
603
+ # ===============================
604
+
605
+ def ensure_user_dirs(username: str, dialect_code: str):
606
+ country_code, dialect = split_dialect_code(dialect_code)
607
+ user_dir = USERS_ROOT / country_code / dialect / username
608
+ (user_dir / "wavs").mkdir(parents=True, exist_ok=True)
609
+ (user_dir / "txt").mkdir(parents=True, exist_ok=True)
610
+ return user_dir
611
+
612
+
613
+ def validate_audio(audio_path: str):
614
+ try:
615
+ with sf.SoundFile(audio_path) as f:
616
+ duration = len(f) / f.samplerate
617
+ if f.samplerate < 16000:
618
+ return False, f"Sample rate too low: {f.samplerate} Hz", duration
619
+ if duration < 1.0:
620
+ return False, "Recording too short", duration
621
+ return True, "OK", duration
622
+ except Exception as e:
623
+ return False, f"Audio error: {e}", None
624
+
625
+
626
+ def upload_file_to_s3(local_path: Path, s3_key: str):
627
+ if not S3_CLIENT or not S3_BUCKET:
628
+ print("S3 not configured, skipping upload:", s3_key)
629
+ return False
630
+ try:
631
+ S3_CLIENT.upload_file(str(local_path), S3_BUCKET, s3_key)
632
+ return True
633
+ except Exception as e:
634
+ print("upload_file_to_s3 error:", e)
635
+ return False
636
+
637
+
638
+ def save_recording_and_upload(username: str, dialect_code: str, sentence_id: str, sentence_text: str, audio_path: str):
639
+ """
640
+ Local:
641
+ ~/.tts_dataset_creator/users/{country}/{dialect}/{username}/wavs/{country}_{dialect}_{username}_{sentence}.wav
642
+
643
+ S3 (country-level folder only):
644
+ {country_code}/{username}/wavs/{country}_{dialect}_{username}_{sentence}.wav
645
+ {country_code}/{username}/metadata.csv
646
+ """
647
+ user_dir = ensure_user_dirs(username, dialect_code)
648
+ wav_dir = user_dir / "wavs"
649
+ meta_file = user_dir / "metadata.csv"
650
+
651
+ if not meta_file.exists():
652
+ meta_file.write_text("audio_file|text\n", encoding="utf-8")
653
+
654
+ country_code, dialect = split_dialect_code(dialect_code)
655
+ filename = f"{username}_{sentence_id}.wav"
656
+ dest = wav_dir / filename
657
+
658
+ Path(audio_path).replace(dest)
659
+
660
+ try:
661
+ with sf.SoundFile(dest) as f:
662
+ duration = len(f) / f.samplerate
663
+ except Exception:
664
+ duration = 0.0
665
+
666
+ with meta_file.open("a", encoding="utf-8") as f:
667
+ f.write(f"{filename}|{sentence_text.strip()}\n")
668
+
669
+ base_prefix = f"{country_code}/{username}"
670
+ upload_file_to_s3(dest, f"{base_prefix}/wavs/{filename}")
671
+ upload_file_to_s3(meta_file, f"{base_prefix}/metadata.csv")
672
+
673
+ return duration
674
+
675
+ def make_progress_bar(current_seconds: float, target_seconds: float, bar_length: int = 20) -> str:
676
+ """
677
+ Text progress bar based on time.
678
+ Example: [████████░░░░░░░░░░] 40.0%
679
+ """
680
+ if target_seconds <= 0:
681
+ bar = "░" * bar_length
682
+ return f"[{bar}] 0.0%"
683
+
684
+ ratio = current_seconds / target_seconds
685
+ ratio = max(0.0, min(1.0, ratio)) # clamp 0–1
686
+
687
+ filled = int(bar_length * ratio)
688
+ bar = "█" * filled + "░" * (bar_length - filled)
689
+ return f"[{bar}] {ratio * 100:.1f}%"
690
+
691
+ def compute_progress(completed_count: int, total_duration: float):
692
+ """
693
+ Progress based on total recording time vs RECORDING_TARGET_SECONDS.
694
+ """
695
+ bar = make_progress_bar(total_duration, RECORDING_TARGET_SECONDS)
696
+
697
+ mins = int(total_duration // 60)
698
+ secs = int(total_duration % 60)
699
+ target_mins = int(RECORDING_TARGET_SECONDS // 60)
700
+
701
+ # Example:
702
+ # [██████░░░░░░░░░░░░] 30.0%
703
+ # 10m 43s / 30m target • 294 sentences
704
+ return f"{bar}\n{mins}m {secs}s / {target_mins}m target • {completed_count} sentences"
705
+
706
+
707
+ # ===============================
708
+ # GRADIO APP (3 PAGES)
709
+ # ===============================
710
+
711
+ def build_app():
712
+ with gr.Blocks(title="Arabic Speech Recorder") as demo:
713
+ state = gr.State({
714
+ "logged_in": False,
715
+ "username": None,
716
+ "dialect_code": None,
717
+ "completed_sentences": [],
718
+ "total_duration": 0.0,
719
+ "current_sentence_id": "",
720
+ "current_sentence_text": "",
721
+ })
722
+
723
+ gr.Markdown("""
724
+ <div style="text-align: center; padding: 20px 0;">
725
+ <h1 style="margin-bottom: 10px;"> 🗣️ Arabic Speech Dataset Recorder | مسجّل مجموعة البيانات الصوتية العربية 🎤</h1>
726
+ <p style="font-size: 1.1rem; color: #555;">
727
+ منصة لجمع تسجيلات صوتية من مختلف اللهجات العربية لدعم البحث العلمي في كشف الأصوات المزيفة وتقنيات الذكاء الاصطناعي الصوتية.
728
+ </p>
729
+ </div>
730
+ """)
731
+
732
+
733
+ # ---------- LOGIN PAGE ----------
734
+ with gr.Column(visible=True) as login_view:
735
+ gr.Markdown("### تسحيل الدخول")
736
+ login_email = gr.Textbox(label="Email")
737
+ login_pw = gr.Textbox(label="Password", type="password")
738
+ login_btn = gr.Button("تسجيل الدخول", variant="primary")
739
+ login_msg = gr.Markdown("")
740
+ goto_register_btn = gr.Button("إنشاء حساب جديد")
741
+ with gr.Accordion("Forgot password?", open=False, visible=False):
742
+ fp_email = gr.Textbox(label="Email")
743
+ fp_btn = gr.Button("Create reset token")
744
+ fp_output = gr.Markdown("")
745
+ rp_token = gr.Textbox(label="Reset token")
746
+ rp_new_pw = gr.Textbox(label="New password", type="password")
747
+ rp_btn = gr.Button("Reset password")
748
+ rp_output = gr.Markdown("")
749
+
750
+ # ---------- REGISTER PAGE ----------
751
+ with gr.Column(visible=False) as register_view:
752
+ gr.Markdown("### إنشاء حساب جديد")
753
+ reg_name = gr.Textbox(label="Name (Latin)")
754
+ reg_email = gr.Textbox(label="Email")
755
+ reg_pw = gr.Textbox(label="Password", type="password")
756
+ reg_country = gr.Dropdown(choices=AVAILABLE_COUNTRIES, value="Saudi Arabia", label="Country")
757
+ default_dialects = get_dialects_for_country("Saudi Arabia")
758
+ reg_dialect = gr.Dropdown(
759
+ choices=default_dialects,
760
+ value=None, # user must choose
761
+ label="Dialect"
762
+ )
763
+ reg_gender = gr.Dropdown(
764
+ choices=GENDER,
765
+ value=None, # user must choose
766
+ label="Gender"
767
+ )
768
+ reg_age = gr.Dropdown(
769
+ choices=AGES,
770
+ value=None, # user must choose
771
+ label="Age Group"
772
+ )
773
+ with gr.Accordion("إتفاقية التسجيل بالموقع واستخدام البيانات", open=True, visible=True):
774
+ inst_output = gr.Markdown(CONSENT_DETAILS)
775
+ reg_btn = gr.Button("إنشاء حساب", variant="primary")
776
+ reg_msg = gr.Markdown("")
777
+ back_to_login_btn = gr.Button("الرجوع لتسجيل الدخول")
778
+
779
+ # ---------- MAIN PAGE ----------
780
+ with gr.Column(visible=False) as main_view:
781
+ info = gr.Markdown("")
782
+ logout_btn = gr.Button("تسجيل الخروج")
783
+ with gr.Accordion("تعليمات مهمة للتسجيل", open=True, visible=True):
784
+ rec_inst_output = gr.Markdown(RECORDING_INSTRUCTIONS)
785
+ username_box = gr.Textbox(label="👤 Username", interactive=False, visible=False)
786
+ progress_box = gr.Textbox(label="📊 الإنجاز", interactive=False)
787
+ sentence_box = gr.Textbox(label="✍️الجملة (يمكنك تعديل الجملة)", interactive=True, lines=3)
788
+ sentence_id_box = gr.Textbox(label="Sentence ID", interactive=False, visible=False)
789
+ # 👇 give the audio component a stable DOM id
790
+ audio_rec = gr.Audio(
791
+ sources=["microphone"],
792
+ type="filepath",
793
+ label="Record",
794
+ format="wav",
795
+ )
796
+
797
+ temp_audio_path = gr.Textbox(label="Temp audio path", visible=False)
798
+
799
+ save_btn = gr.Button("Save & Next", variant="primary", interactive=False)
800
+ skip_btn = gr.Button("Skip")
801
+ msg_box = gr.Markdown("")
802
+
803
+ # ---------- Navigation helpers ----------
804
+
805
+ def show_register():
806
+ return (
807
+ gr.update(visible=False),
808
+ gr.update(visible=True),
809
+ gr.update(visible=False),
810
+ )
811
+
812
+ def show_login():
813
+ return (
814
+ gr.update(visible=True),
815
+ gr.update(visible=False),
816
+ gr.update(visible=False),
817
+ )
818
+
819
+ def show_main():
820
+ return (
821
+ gr.update(visible=False),
822
+ gr.update(visible=False),
823
+ gr.update(visible=True),
824
+ )
825
+
826
+ def on_start_recording():
827
+ """
828
+ Called when the user starts recording.
829
+ We can use this to clear any previous temp audio path.
830
+ """
831
+ return gr.update(interactive=False), gr.update(interactive=False)
832
+ audio_rec.start_recording(
833
+ fn=on_start_recording,
834
+ outputs=[save_btn, skip_btn],
835
+ )
836
+
837
+ def on_stop_recording(audio_path, st):
838
+ """
839
+ Called when the user stops recording.
840
+ For type="filepath", `audio_path` is a string path to the WAV on the server.
841
+ """
842
+ if not audio_path:
843
+ # nothing recorded
844
+ return st, "", gr.update(value=None), gr.update(interactive=True), gr.update(interactive=True)
845
+
846
+ # Store for later use if you want
847
+ st["last_temp_audio_path"] = audio_path
848
+ print("Stored temp audio at:", audio_path)
849
+
850
+ time.sleep(1) # simulate processing delay / UX
851
+ return (
852
+ st,
853
+ audio_path, # -> temp_audio_path Textbox
854
+ gr.update(value=audio_path), # set Audio value to that file (preview uses file)
855
+ gr.update(interactive=True), # re-enable Save
856
+ gr.update(interactive=True), # re-enable Skip
857
+ )
858
+
859
+ audio_rec.stop_recording(
860
+ fn=on_stop_recording,
861
+ inputs=[audio_rec, state],
862
+ outputs=[state, temp_audio_path, audio_rec, save_btn, skip_btn],
863
+ )
864
+
865
+ def on_clear():
866
+ """
867
+ Called when the user clears the recording.
868
+ We can use this to clear any previous temp audio path.
869
+ """
870
+ return gr.update(interactive=False)
871
+ audio_rec.clear(
872
+ fn=on_clear,
873
+ outputs=[save_btn],
874
+ )
875
+
876
+ goto_register_btn.click(
877
+ show_register,
878
+ inputs=[],
879
+ outputs=[login_view, register_view, main_view],
880
+ )
881
+
882
+ back_to_login_btn.click(
883
+ show_login,
884
+ inputs=[],
885
+ outputs=[login_view, register_view, main_view],
886
+ )
887
+
888
+ # ---------- Register callbacks ----------
889
+
890
+ def update_dialects(country):
891
+ dialects = get_dialects_for_country(country)
892
+ # IMPORTANT FIX: don't try to set a default value; let user choose
893
+ return gr.update(choices=dialects, value=None)
894
+
895
+ reg_country.change(
896
+ update_dialects,
897
+ inputs=reg_country,
898
+ outputs=reg_dialect
899
+ )
900
+
901
+ def do_register(name, email, pw, country, dialect_label, gender, age, st):
902
+ if not all([name, email, pw, country, dialect_label, gender, age]):
903
+ return (
904
+ st,
905
+ "❌ Please fill all fields",
906
+ gr.update(visible=False),
907
+ gr.update(visible=True),
908
+ gr.update(visible=False),
909
+ )
910
+
911
+ ok, result = create_user(name, email, pw, country, dialect_label, gender, age)
912
+ if not ok:
913
+ return (
914
+ st,
915
+ f"❌ {result}",
916
+ gr.update(visible=False),
917
+ gr.update(visible=True),
918
+ gr.update(visible=False),
919
+ )
920
+
921
+ return (
922
+ st,
923
+ "✅ Registered successfully. You can now login.",
924
+ gr.update(visible=True),
925
+ gr.update(visible=False),
926
+ gr.update(visible=False),
927
+ )
928
+
929
+ reg_btn.click(
930
+ do_register,
931
+ inputs=[reg_name, reg_email, reg_pw, reg_country, reg_dialect, reg_gender, reg_age, state],
932
+ outputs=[state, reg_msg, login_view, register_view, main_view],
933
+ )
934
+
935
+ # ---------- Login + password reset ----------
936
+
937
+ def do_login(email, pw, st):
938
+ ok, result = authenticate(email, pw)
939
+ if not ok:
940
+ return (
941
+ st,
942
+ f"❌ {result}",
943
+ "",
944
+ "",
945
+ "",
946
+ "",
947
+ "",
948
+ gr.update(visible=True),
949
+ gr.update(visible=False),
950
+ gr.update(visible=False),
951
+ )
952
+
953
+ username = result
954
+ user = get_user_by_username(username)
955
+ dialect_code = user.get("dialect_code", "sa-hj") if user else "sa-hj"
956
+
957
+ sess = load_session(username)
958
+ completed = sess["completed_sentences"]
959
+ total_dur = sess["total_recording_duration"]
960
+
961
+ available = filter_sentences(dialect_code, completed)
962
+ if not available:
963
+ sentence_id = ""
964
+ sentence_text = "No more sentences for your dialect."
965
+ else:
966
+ sentence_id, sentence_text = random.choice(available)
967
+
968
+ st.update({
969
+ "logged_in": True,
970
+ "username": username,
971
+ "dialect_code": dialect_code,
972
+ "completed_sentences": completed,
973
+ "total_duration": total_dur,
974
+ "current_sentence_id": sentence_id,
975
+ "current_sentence_text": sentence_text,
976
+ })
977
+
978
+ country = dialect_code.split("-", 1)[0]
979
+ progress = compute_progress(len(completed), total_dur)
980
+ username_show = " ".join(username.split("_")[:-3]).title()
981
+ info_text = f"## **{username_show}** ({COUNTRY_EMOJIS[country]} {COUNTRY_EMOJIS[country]}) "
982
+
983
+ return (
984
+ st,
985
+ "",
986
+ info_text,
987
+ username,
988
+ progress,
989
+ sentence_text,
990
+ sentence_id,
991
+ gr.update(visible=False),
992
+ gr.update(visible=False),
993
+ gr.update(visible=True),
994
+ )
995
+
996
+ login_btn.click(
997
+ do_login,
998
+ inputs=[login_email, login_pw, state],
999
+ outputs=[
1000
+ state,
1001
+ login_msg,
1002
+ info,
1003
+ username_box,
1004
+ progress_box,
1005
+ sentence_box,
1006
+ sentence_id_box,
1007
+ login_view,
1008
+ register_view,
1009
+ main_view,
1010
+ ],
1011
+ )
1012
+
1013
+ def do_forget_password(email):
1014
+ if not email:
1015
+ return "Please enter your email."
1016
+ ok, msg = create_password_reset_token(email)
1017
+ if not ok:
1018
+ return f"❌ {msg}"
1019
+ return f"✅ Reset token (dev mode): `{msg}`"
1020
+
1021
+ fp_btn.click(do_forget_password, inputs=[fp_email], outputs=[fp_output])
1022
+
1023
+ def do_reset_password(token, new_pw):
1024
+ if not token or not new_pw:
1025
+ return "Please provide token and new password."
1026
+ ok, msg = reset_password_with_token(token, new_pw)
1027
+ return ("✅ " if ok else "❌ ") + msg
1028
+
1029
+ rp_btn.click(do_reset_password, inputs=[rp_token, rp_new_pw], outputs=[rp_output])
1030
+
1031
+ # ---------- Main page logic ----------
1032
+
1033
+ def next_sentence_for_state(st):
1034
+ available = filter_sentences(st["dialect_code"], st["completed_sentences"])
1035
+ if not available:
1036
+ st["current_sentence_id"] = ""
1037
+ st["current_sentence_text"] = "No more sentences."
1038
+ else:
1039
+ sid, text = random.choice(available)
1040
+ st["current_sentence_id"] = sid
1041
+ st["current_sentence_text"] = text
1042
+
1043
+ def handle_save(audio_path, edited_sentence, temp_path, st):
1044
+ if not st.get("logged_in"):
1045
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1046
+ return st, "Please login first.", st["current_sentence_text"], st["current_sentence_id"], progress, gr.update(value=None), gr.update(interactive=True)
1047
+
1048
+ if not audio_path and not temp_path:
1049
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1050
+ return st, "⚠️ Record audio first.", st["current_sentence_text"], st["current_sentence_id"], progress, gr.update(value=None), gr.update(interactive=True)
1051
+
1052
+ sentence_text = (edited_sentence or st["current_sentence_text"]).strip()
1053
+ if not sentence_text:
1054
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1055
+ return st, "⚠️ Sentence text is empty.", st["current_sentence_text"], st["current_sentence_id"], progress, gr.update(value=None), gr.update(interactive=True)
1056
+
1057
+ sid = st["current_sentence_id"]
1058
+ if not sid:
1059
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1060
+ return st, "⚠️ No active sentence.", st["current_sentence_text"], st["current_sentence_id"], progress, gr.update(value=None), gr.update(interactive=True)
1061
+
1062
+ # Choose which filepath to use:
1063
+ # 1) Prefer current audio_rec value (audio_path)
1064
+ # 2) Fallback to temp_path from stop_recording
1065
+ tmp_path = audio_path or temp_path
1066
+
1067
+ if not tmp_path:
1068
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1069
+ return st, "❌ Could not find recorded audio.", st["current_sentence_text"], st["current_sentence_id"], progress, gr.update(value=None), gr.update(interactive=True)
1070
+
1071
+ ok, msg, _dur = validate_audio(tmp_path)
1072
+ if not ok:
1073
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1074
+ return st, f"❌ Audio error: {msg}", st["current_sentence_text"], st["current_sentence_id"], progress, gr.update(value=None), gr.update(interactive=True)
1075
+
1076
+ duration = save_recording_and_upload(
1077
+ st["username"],
1078
+ st["dialect_code"],
1079
+ sid,
1080
+ sentence_text,
1081
+ tmp_path,
1082
+ )
1083
+
1084
+ st["total_duration"] += duration
1085
+ if sid not in st["completed_sentences"]:
1086
+ st["completed_sentences"].append(sid)
1087
+
1088
+ save_session(st["username"], st["completed_sentences"], st["total_duration"])
1089
+
1090
+ next_sentence_for_state(st)
1091
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1092
+
1093
+ return (
1094
+ st,
1095
+ "✅ Saved",
1096
+ st["current_sentence_text"],
1097
+ st["current_sentence_id"],
1098
+ progress,
1099
+ gr.update(value=None), # clear audio UI if you want
1100
+ gr.update(interactive=True),
1101
+ )
1102
+
1103
+ def disable_skip():
1104
+ return gr.update(interactive=False)
1105
+
1106
+ save_btn.click(
1107
+ disable_skip,
1108
+ inputs=[],
1109
+ outputs=[skip_btn],
1110
+
1111
+ ).then(
1112
+ handle_save,
1113
+ inputs=[audio_rec, sentence_box, temp_audio_path, state],
1114
+ outputs=[state, msg_box, sentence_box, sentence_id_box, progress_box, audio_rec, skip_btn],
1115
+ )
1116
+
1117
+
1118
+ def handle_skip(st):
1119
+ if not st.get("logged_in"):
1120
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1121
+ return st, "Please login first.", st["current_sentence_text"], st["current_sentence_id"], progress, gr.update(value=None) , gr.update(interactive=True)
1122
+
1123
+ sid = st["current_sentence_id"]
1124
+ if sid and sid not in st["completed_sentences"]:
1125
+ st["completed_sentences"].append(sid)
1126
+ save_session(st["username"], st["completed_sentences"], st["total_duration"])
1127
+
1128
+ next_sentence_for_state(st)
1129
+ progress = compute_progress(len(st["completed_sentences"]), st["total_duration"])
1130
+ return st, "Skipped.", st["current_sentence_text"], st["current_sentence_id"], progress, gr.update(value=None), gr.update(interactive=True)
1131
+
1132
+ def disable_save():
1133
+ return gr.update(interactive=False)
1134
+
1135
+ skip_btn.click(
1136
+ disable_save,
1137
+ inputs=[],
1138
+ outputs=[save_btn],
1139
+ ).then(
1140
+ handle_skip,
1141
+ inputs=[state],
1142
+ outputs=[state, msg_box, sentence_box, sentence_id_box, progress_box, audio_rec, save_btn],
1143
+ )
1144
+
1145
+ def do_logout(st):
1146
+ st.update({
1147
+ "logged_in": False,
1148
+ "username": None,
1149
+ "dialect_code": None,
1150
+ "completed_sentences": [],
1151
+ "total_duration": 0.0,
1152
+ "current_sentence_id": "",
1153
+ "current_sentence_text": "",
1154
+ })
1155
+ return (
1156
+ st,
1157
+ "",
1158
+ "",
1159
+ "",
1160
+ "",
1161
+ gr.update(visible=True),
1162
+ gr.update(visible=False),
1163
+ gr.update(visible=False),
1164
+ )
1165
+
1166
+ logout_btn.click(
1167
+ do_logout,
1168
+ inputs=[state],
1169
+ outputs=[
1170
+ state,
1171
+ info,
1172
+ username_box,
1173
+ progress_box,
1174
+ msg_box,
1175
+ login_view,
1176
+ register_view,
1177
+ main_view,
1178
+ ],
1179
+ )
1180
+
1181
+ return demo
1182
+
1183
+
1184
+ if __name__ == "__main__":
1185
+ port = int(os.environ.get("GRADIO_SERVER_PORT", 7860))
1186
+ app = build_app()
1187
+ app.queue()
1188
+ app.launch(
1189
+ server_name="0.0.0.0",
1190
+ server_port=port,
1191
+ debug=False,
1192
+ )
1193
+ # ===============================
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==5.29.1
2
+ boto3==1.38.18
3
+ Werkzeug==3.1.3
4
+ soundfile==0.13.1
5
+ numpy==2.2.6
6
+ pandas==2.2.3
7
+ requests==2.32.3
8
+ python-multipart==0.0.20
9
+ aiofiles==24.1.0
10
+ uvicorn==0.34.2
11
+ supabase==2.24.0
12
+ supabase_auth==2.24.0
13
+ python-dotenv==1.0.1
14
+ matplotlib
sentences_eg.json ADDED
The diff for this file is too large to render. See raw diff
 
sentences_ma.json ADDED
The diff for this file is too large to render. See raw diff
 
sentences_sa.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e2037c8794c5ed10a0b44bff9b03f7a10ade5e6a549c65eb3f2531a386d8c34d
3
+ size 14362343