Tusharz commited on
Commit
f7515a6
Β·
1 Parent(s): 677d00f

feat: add export TXT/SRT/PDF, language detection, translate, shimmer animation

Browse files
backend/app.py CHANGED
@@ -2,33 +2,30 @@
2
  # ---------------------------------------------------------
3
  # THE SERVER β€” manages all API routes
4
  #
5
- # Changes for HF Spaces deployment:
6
- # - Port changed from 5000 to 7860 (HF requirement)
7
- # - HOST set to 0.0.0.0 (accepts external connections)
8
- # - Added /health endpoint for HF to check app is alive
9
- # - Serves frontend files directly from Flask
10
- # (no separate frontend server needed on HF)
11
  # ---------------------------------------------------------
12
 
13
  import os
14
  from flask import Flask, request, jsonify, send_from_directory
15
  from flask_cors import CORS
16
  from transcriber import transcribe_audio
 
17
 
18
  app = Flask(__name__)
19
  CORS(app)
20
 
21
- # Paths
22
- BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
23
  UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
24
  FRONTEND_DIR = os.path.join(BASE_DIR, "frontend")
25
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
26
 
27
 
28
- # ── Serve frontend files ───────────────────────────────────
29
- # On HF Spaces, Flask serves both the API AND the frontend HTML
30
- # So visiting the Space URL shows the VoiceScript UI directly
31
-
32
  @app.route("/")
33
  def index():
34
  return send_from_directory(FRONTEND_DIR, "index.html")
@@ -38,31 +35,93 @@ def frontend_files(filename):
38
  return send_from_directory(FRONTEND_DIR, filename)
39
 
40
 
41
- # ── Health check β€” HF uses this to verify app is running ──
42
  @app.route("/health")
43
  def health():
44
  return jsonify({"status": "ok", "app": "VoiceScript"})
45
 
46
 
47
- # ── Main transcription endpoint ────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
48
  @app.route("/transcribe", methods=["POST"])
49
  def transcribe():
50
  if "audio" not in request.files:
51
  return jsonify({"success": False, "error": "No audio file received."}), 400
52
 
53
  audio_file = request.files["audio"]
54
-
55
  if audio_file.filename == "":
56
  return jsonify({"success": False, "error": "Empty filename."}), 400
57
 
 
 
 
 
 
58
  file_path = os.path.join(UPLOAD_FOLDER, "temp_audio")
59
  audio_file.save(file_path)
60
 
61
- result = transcribe_audio(file_path)
62
 
63
  if os.path.exists(file_path):
64
  os.remove(file_path)
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  return jsonify(result)
67
 
68
 
 
2
  # ---------------------------------------------------------
3
  # THE SERVER β€” manages all API routes
4
  #
5
+ # Routes:
6
+ # GET / β†’ serves frontend index.html
7
+ # GET /health β†’ health check
8
+ # POST /transcribe β†’ audio β†’ text (any language)
9
+ # POST /translate β†’ text β†’ translated text
10
+ # GET /languages β†’ returns all supported languages
11
  # ---------------------------------------------------------
12
 
13
  import os
14
  from flask import Flask, request, jsonify, send_from_directory
15
  from flask_cors import CORS
16
  from transcriber import transcribe_audio
17
+ from translator import translate_text, get_supported_languages
18
 
19
  app = Flask(__name__)
20
  CORS(app)
21
 
22
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
23
  UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
24
  FRONTEND_DIR = os.path.join(BASE_DIR, "frontend")
25
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
26
 
27
 
28
+ # ── Serve frontend ─────────────────────────────────────────
 
 
 
29
  @app.route("/")
30
  def index():
31
  return send_from_directory(FRONTEND_DIR, "index.html")
 
35
  return send_from_directory(FRONTEND_DIR, filename)
36
 
37
 
38
+ # ── Health check ───────────────────────────────────────────
39
  @app.route("/health")
40
  def health():
41
  return jsonify({"status": "ok", "app": "VoiceScript"})
42
 
43
 
44
+ # ── Get supported languages ────────────────────────────────
45
+ # Frontend calls this on load to populate the language dropdown
46
+ @app.route("/languages", methods=["GET"])
47
+ def languages():
48
+ return jsonify({
49
+ "success": True,
50
+ "languages": get_supported_languages()
51
+ })
52
+
53
+
54
+ # ── Transcribe audio ───────────────────────────────────────
55
+ # mode = "transcribe" β†’ transcribe in original language
56
+ # mode = "translate" β†’ transcribe ANY language β†’ English
57
  @app.route("/transcribe", methods=["POST"])
58
  def transcribe():
59
  if "audio" not in request.files:
60
  return jsonify({"success": False, "error": "No audio file received."}), 400
61
 
62
  audio_file = request.files["audio"]
 
63
  if audio_file.filename == "":
64
  return jsonify({"success": False, "error": "Empty filename."}), 400
65
 
66
+ # Get mode from form data β€” default is "transcribe"
67
+ # "translate_to_english" mode tells Whisper to output English
68
+ # regardless of what language was spoken
69
+ mode = request.form.get("mode", "transcribe")
70
+
71
  file_path = os.path.join(UPLOAD_FOLDER, "temp_audio")
72
  audio_file.save(file_path)
73
 
74
+ result = transcribe_audio(file_path, mode=mode)
75
 
76
  if os.path.exists(file_path):
77
  os.remove(file_path)
78
 
79
+ # Format segments into clean timestamped lines for frontend
80
+ # Each segment: { start, end, text }
81
+ # We convert seconds β†’ [MM:SS] format here in backend
82
+ if result.get("success") and result.get("segments"):
83
+ timestamped = []
84
+ for seg in result["segments"]:
85
+ start_sec = int(seg.get("start", 0))
86
+ end_sec = int(seg.get("end", 0))
87
+ text = seg.get("text", "").strip()
88
+ if text:
89
+ start_fmt = f"{start_sec // 60:02d}:{start_sec % 60:02d}"
90
+ end_fmt = f"{end_sec // 60:02d}:{end_sec % 60:02d}"
91
+ timestamped.append({
92
+ "start" : start_sec,
93
+ "end" : end_sec,
94
+ "start_fmt" : start_fmt,
95
+ "end_fmt" : end_fmt,
96
+ "text" : text
97
+ })
98
+ result["timestamped"] = timestamped
99
+ # Remove raw segments from response (too heavy)
100
+ del result["segments"]
101
+
102
+ return jsonify(result)
103
+
104
+
105
+ # ── Translate transcript ───────────────────────────────────
106
+ # Takes existing transcript text + target language code
107
+ # Returns translated text
108
+ @app.route("/translate", methods=["POST"])
109
+ def translate():
110
+ data = request.get_json()
111
+
112
+ if not data:
113
+ return jsonify({"success": False, "error": "No data received."}), 400
114
+
115
+ text = data.get("text", "").strip()
116
+ target_lang = data.get("target_language", "")
117
+
118
+ if not text:
119
+ return jsonify({"success": False, "error": "No text to translate."}), 400
120
+
121
+ if not target_lang:
122
+ return jsonify({"success": False, "error": "No target language specified."}), 400
123
+
124
+ result = translate_text(text, target_lang)
125
  return jsonify(result)
126
 
127
 
backend/transcriber.py CHANGED
@@ -185,7 +185,7 @@ def convert_to_whisper_wav(input_path, output_path):
185
  # STEP 3 β€” WHISPER TRANSCRIPTION (medium model, all settings maxed)
186
  # ═══════════════════════════════════════════════════════════
187
 
188
- def transcribe_with_whisper(wav_path, duration_secs):
189
  """
190
  Runs Whisper medium with every accuracy setting maximized.
191
 
@@ -232,20 +232,51 @@ def transcribe_with_whisper(wav_path, duration_secs):
232
  print(f"[INFO] ESTIMATED TIME : ~{est_time}s")
233
  print(f"[INFO] ─────────────────────────────────────────")
234
 
235
- result = model.transcribe(
236
- wav_path,
237
- language = "en",
238
- fp16 = False,
239
- task = "transcribe",
240
- beam_size = 5,
241
- best_of = 5,
242
- temperature = 0,
243
- patience = 2,
244
- condition_on_previous_text = True,
245
- no_speech_threshold = 0.25,
246
- compression_ratio_threshold= 2.6,
247
- word_timestamps = True,
248
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
  # Build clean transcript from segments
251
  segments = result.get("segments", [])
@@ -259,8 +290,30 @@ def transcribe_with_whisper(wav_path, duration_secs):
259
  full_text = " ".join(lines).strip()
260
  word_count = len(full_text.split())
261
 
 
 
262
  print(f"[INFO] Transcription complete: {word_count} words from {len(segments)} segments")
263
- return full_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
 
266
  # ═══════════════════════════════════════��═══════════════════
@@ -314,12 +367,15 @@ def _cleanup(paths):
314
  # MAIN β€” called by app.py for every transcription request
315
  # ═══════════════════════════════════════════════════════════
316
 
317
- def transcribe_audio(file_path):
318
  """
319
  Full beast mode pipeline:
320
  1. Preprocess (convert + normalize)
321
  2. Vocal isolation via demucs (if available)
322
  3. Whisper medium with max accuracy settings
 
 
 
323
  """
324
 
325
  uploads_dir = os.path.dirname(file_path)
@@ -360,14 +416,19 @@ def transcribe_audio(file_path):
360
 
361
  # ── Step 3: Transcribe ────────────────────────
362
  if WHISPER_AVAILABLE:
363
- text = transcribe_with_whisper(audio_for_whisper, duration_secs)
364
- engine = f"Whisper {WHISPER_MODEL_SIZE}"
 
 
 
365
  if is_demucs_available():
366
  engine += " + Demucs vocal isolation"
367
  else:
368
  print("[INFO] Whisper not installed β€” using Google fallback")
369
- text = transcribe_with_google_fallback(audio_for_whisper, duration_ms)
370
- engine = "Google Speech Recognition"
 
 
371
 
372
  print("[INFO] === PIPELINE COMPLETE ===")
373
 
@@ -385,10 +446,46 @@ def transcribe_audio(file_path):
385
 
386
  word_count = len(text.split())
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  return {
389
- "success" : True,
390
- "transcript": text,
391
- "duration" : round(duration_secs, 1),
392
- "word_count": word_count,
393
- "engine" : engine
 
 
 
394
  }
 
185
  # STEP 3 β€” WHISPER TRANSCRIPTION (medium model, all settings maxed)
186
  # ═══════════════════════════════════════════════════════════
187
 
188
+ def transcribe_with_whisper(wav_path, duration_secs, mode="transcribe"):
189
  """
190
  Runs Whisper medium with every accuracy setting maximized.
191
 
 
232
  print(f"[INFO] ESTIMATED TIME : ~{est_time}s")
233
  print(f"[INFO] ─────────────────────────────────────────")
234
 
235
+ # mode="translate_to_english" β†’ Whisper auto-detects language, outputs English
236
+ # mode="transcribe" β†’ Standard English transcription
237
+ whisper_task = "translate" if mode == "translate_to_english" else "transcribe"
238
+ whisper_lang = None if mode == "translate_to_english" else "en"
239
+
240
+ if mode == "translate_to_english":
241
+ print(f"[INFO] MODE: Multilingual β†’ English translation")
242
+ print(f"[INFO] Whisper will auto-detect language and output English")
243
+ else:
244
+ print(f"[INFO] MODE: Standard English transcription")
245
+
246
+ # Translation mode needs different settings than transcription mode.
247
+ # The tensor size mismatch error happens because patience=2 and best_of=5
248
+ # conflict with Whisper's internal beam search in translation mode.
249
+ # Fix: use safer/simpler settings for translation, full settings for transcription.
250
+ if mode == "translate_to_english":
251
+ result = model.transcribe(
252
+ wav_path,
253
+ language = whisper_lang, # None = auto-detect
254
+ fp16 = False,
255
+ task = "translate", # translate any language β†’ English
256
+ beam_size = 5,
257
+ temperature = 0,
258
+ condition_on_previous_text = True,
259
+ no_speech_threshold = 0.25,
260
+ compression_ratio_threshold= 2.6,
261
+ word_timestamps = True,
262
+ # NOTE: patience and best_of are intentionally excluded in translate mode
263
+ # They cause "Sizes of tensors must match" error with task="translate"
264
+ )
265
+ else:
266
+ result = model.transcribe(
267
+ wav_path,
268
+ language = "en",
269
+ fp16 = False,
270
+ task = "transcribe",
271
+ beam_size = 5,
272
+ best_of = 5,
273
+ temperature = 0,
274
+ patience = 2,
275
+ condition_on_previous_text = True,
276
+ no_speech_threshold = 0.25,
277
+ compression_ratio_threshold= 2.6,
278
+ word_timestamps = True,
279
+ )
280
 
281
  # Build clean transcript from segments
282
  segments = result.get("segments", [])
 
290
  full_text = " ".join(lines).strip()
291
  word_count = len(full_text.split())
292
 
293
+ # Extract detected language from Whisper result
294
+ detected_lang = result.get("language", "unknown")
295
  print(f"[INFO] Transcription complete: {word_count} words from {len(segments)} segments")
296
+ print(f"[INFO] Detected language: {detected_lang}")
297
+
298
+ # Format segments with timestamps right here
299
+ # Each segment gets start_fmt and end_fmt so frontend can use immediately
300
+ formatted = []
301
+ for seg in segments:
302
+ start_sec = int(seg.get("start", 0))
303
+ end_sec = int(seg.get("end", 0))
304
+ seg_text = seg.get("text", "").strip()
305
+ if not seg_text:
306
+ continue
307
+ formatted.append({
308
+ "start" : start_sec,
309
+ "end" : end_sec,
310
+ "start_fmt": f"{start_sec // 60:02d}:{start_sec % 60:02d}",
311
+ "end_fmt" : f"{end_sec // 60:02d}:{end_sec % 60:02d}",
312
+ "text" : seg_text
313
+ })
314
+
315
+ print(f"[INFO] {len(formatted)} formatted segments ready for frontend")
316
+ return {"text": full_text, "detected_language": detected_lang, "segments": formatted}
317
 
318
 
319
  # ═══════════════════════════════════════��═══════════════════
 
367
  # MAIN β€” called by app.py for every transcription request
368
  # ═══════════════════════════════════════════════════════════
369
 
370
+ def transcribe_audio(file_path, mode="transcribe"):
371
  """
372
  Full beast mode pipeline:
373
  1. Preprocess (convert + normalize)
374
  2. Vocal isolation via demucs (if available)
375
  3. Whisper medium with max accuracy settings
376
+
377
+ mode = "transcribe" β†’ standard English transcription
378
+ mode = "translate_to_english" β†’ any language audio β†’ English text
379
  """
380
 
381
  uploads_dir = os.path.dirname(file_path)
 
416
 
417
  # ── Step 3: Transcribe ────────────────────────
418
  if WHISPER_AVAILABLE:
419
+ whisper_result = transcribe_with_whisper(audio_for_whisper, duration_secs, mode=mode)
420
+ text = whisper_result["text"]
421
+ detected_lang = whisper_result["detected_language"]
422
+ segments = whisper_result["segments"]
423
+ engine = f"Whisper {WHISPER_MODEL_SIZE}" + (" (Multilingual→EN)" if mode == "translate_to_english" else "")
424
  if is_demucs_available():
425
  engine += " + Demucs vocal isolation"
426
  else:
427
  print("[INFO] Whisper not installed β€” using Google fallback")
428
+ text = transcribe_with_google_fallback(audio_for_whisper, duration_ms)
429
+ detected_lang = "unknown"
430
+ segments = []
431
+ engine = "Google Speech Recognition"
432
 
433
  print("[INFO] === PIPELINE COMPLETE ===")
434
 
 
446
 
447
  word_count = len(text.split())
448
 
449
+ # Language code β†’ full name mapping for display
450
+ lang_names = {
451
+ "en": "English", "hi": "Hindi", "de": "German", "fr": "French",
452
+ "es": "Spanish", "it": "Italian", "pt": "Portuguese", "ru": "Russian",
453
+ "ja": "Japanese", "ko": "Korean", "zh": "Chinese", "ar": "Arabic",
454
+ "nl": "Dutch", "pl": "Polish", "tr": "Turkish", "sv": "Swedish",
455
+ "da": "Danish", "fi": "Finnish", "nb": "Norwegian", "uk": "Ukrainian",
456
+ "cs": "Czech", "ro": "Romanian", "hu": "Hungarian", "el": "Greek",
457
+ "he": "Hebrew", "th": "Thai", "vi": "Vietnamese", "id": "Indonesian",
458
+ "ms": "Malay", "bn": "Bengali", "ur": "Urdu", "fa": "Persian",
459
+ "ta": "Tamil", "te": "Telugu", "ml": "Malayalam", "kn": "Kannada",
460
+ }
461
+ detected_lang_name = lang_names.get(detected_lang, detected_lang.upper() if detected_lang != "unknown" else "Unknown")
462
+
463
+ # Format segments here β€” convert raw Whisper segments to clean
464
+ # { start_fmt, end_fmt, text } objects for the frontend
465
+ formatted_segments = []
466
+ for seg in segments:
467
+ start_sec = int(seg.get("start", 0))
468
+ end_sec = int(seg.get("end", 0))
469
+ seg_text = seg.get("text", "").strip()
470
+ if not seg_text:
471
+ continue
472
+ formatted_segments.append({
473
+ "start" : start_sec,
474
+ "end" : end_sec,
475
+ "start_fmt": f"{start_sec // 60:02d}:{start_sec % 60:02d}",
476
+ "end_fmt" : f"{end_sec // 60:02d}:{end_sec % 60:02d}",
477
+ "text" : seg_text
478
+ })
479
+
480
+ print(f"[INFO] Returning {len(formatted_segments)} formatted segments to frontend")
481
+
482
  return {
483
+ "success" : True,
484
+ "transcript" : text,
485
+ "duration" : round(duration_secs, 1),
486
+ "word_count" : word_count,
487
+ "engine" : engine,
488
+ "detected_language" : detected_lang,
489
+ "detected_language_name": detected_lang_name,
490
+ "segments" : formatted_segments
491
  }
backend/translator.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # translator.py
2
+ # ---------------------------------------------------------
3
+ # THIS FILE HAS ONE JOB:
4
+ # Take a text + target language β†’ return translated text
5
+ #
6
+ # We use deep-translator library which is:
7
+ # - 100% free, no API key needed
8
+ # - Supports 100+ languages
9
+ # - Uses Google Translate engine under the hood
10
+ # - Simple and reliable
11
+ # ---------------------------------------------------------
12
+
13
+ from deep_translator import GoogleTranslator
14
+
15
+ # All supported languages with their display names and codes
16
+ # Code is what Google Translate uses internally
17
+ SUPPORTED_LANGUAGES = {
18
+ "af": "Afrikaans",
19
+ "sq": "Albanian",
20
+ "ar": "Arabic",
21
+ "bn": "Bengali",
22
+ "bs": "Bosnian",
23
+ "bg": "Bulgarian",
24
+ "zh-CN": "Chinese (Simplified)",
25
+ "zh-TW": "Chinese (Traditional)",
26
+ "hr": "Croatian",
27
+ "cs": "Czech",
28
+ "da": "Danish",
29
+ "nl": "Dutch",
30
+ "en": "English",
31
+ "et": "Estonian",
32
+ "fi": "Finnish",
33
+ "fr": "French",
34
+ "de": "German",
35
+ "el": "Greek",
36
+ "gu": "Gujarati",
37
+ "hi": "Hindi",
38
+ "hu": "Hungarian",
39
+ "id": "Indonesian",
40
+ "it": "Italian",
41
+ "ja": "Japanese",
42
+ "kn": "Kannada",
43
+ "ko": "Korean",
44
+ "lv": "Latvian",
45
+ "lt": "Lithuanian",
46
+ "ms": "Malay",
47
+ "ml": "Malayalam",
48
+ "mr": "Marathi",
49
+ "ne": "Nepali",
50
+ "no": "Norwegian",
51
+ "fa": "Persian",
52
+ "pl": "Polish",
53
+ "pt": "Portuguese",
54
+ "pa": "Punjabi",
55
+ "ro": "Romanian",
56
+ "ru": "Russian",
57
+ "sr": "Serbian",
58
+ "si": "Sinhala",
59
+ "sk": "Slovak",
60
+ "sl": "Slovenian",
61
+ "es": "Spanish",
62
+ "sw": "Swahili",
63
+ "sv": "Swedish",
64
+ "tl": "Filipino",
65
+ "ta": "Tamil",
66
+ "te": "Telugu",
67
+ "th": "Thai",
68
+ "tr": "Turkish",
69
+ "uk": "Ukrainian",
70
+ "ur": "Urdu",
71
+ "vi": "Vietnamese",
72
+ "cy": "Welsh",
73
+ }
74
+
75
+
76
+ def translate_text(text, target_language_code):
77
+ """
78
+ Translates text to the target language.
79
+
80
+ text β€” the transcript string to translate
81
+ target_language_code β€” e.g. "hi" for Hindi, "fr" for French
82
+
83
+ Returns a dict with success status and translated text.
84
+ """
85
+
86
+ # Validate the language code
87
+ if target_language_code not in SUPPORTED_LANGUAGES:
88
+ return {
89
+ "success": False,
90
+ "error": f"Unsupported language code: {target_language_code}"
91
+ }
92
+
93
+ try:
94
+ print(f"[INFO] Translating to {SUPPORTED_LANGUAGES[target_language_code]}...")
95
+
96
+ # GoogleTranslator takes source and target language
97
+ # source="auto" means it detects the input language automatically
98
+ translator = GoogleTranslator(
99
+ source="auto",
100
+ target=target_language_code
101
+ )
102
+
103
+ # Google Translate has a ~5000 character limit per request
104
+ # For long transcripts we split into chunks and translate each
105
+ if len(text) <= 4500:
106
+ translated = translator.translate(text)
107
+ else:
108
+ translated = translate_long_text(text, target_language_code)
109
+
110
+ print(f"[INFO] Translation complete!")
111
+
112
+ return {
113
+ "success": True,
114
+ "translated": translated,
115
+ "language": SUPPORTED_LANGUAGES[target_language_code],
116
+ "language_code": target_language_code
117
+ }
118
+
119
+ except Exception as e:
120
+ return {
121
+ "success": False,
122
+ "error": f"Translation failed: {str(e)}"
123
+ }
124
+
125
+
126
+ def translate_long_text(text, target_language_code):
127
+ """
128
+ Splits long text into chunks of ~4500 chars,
129
+ translates each chunk, then joins them back together.
130
+ """
131
+ # Split by sentences (periods) to avoid cutting mid-sentence
132
+ sentences = text.replace(". ", ".|").split("|")
133
+ chunks = []
134
+ current_chunk = ""
135
+
136
+ for sentence in sentences:
137
+ if len(current_chunk) + len(sentence) < 4500:
138
+ current_chunk += sentence + " "
139
+ else:
140
+ chunks.append(current_chunk.strip())
141
+ current_chunk = sentence + " "
142
+
143
+ if current_chunk:
144
+ chunks.append(current_chunk.strip())
145
+
146
+ # Translate each chunk
147
+ translator = GoogleTranslator(source="auto", target=target_language_code)
148
+ translated_chunks = []
149
+
150
+ for i, chunk in enumerate(chunks):
151
+ print(f"[INFO] Translating chunk {i+1}/{len(chunks)}...")
152
+ translated_chunks.append(translator.translate(chunk))
153
+
154
+ return " ".join(translated_chunks)
155
+
156
+
157
+ def get_supported_languages():
158
+ """Returns the full list of supported languages for the frontend."""
159
+ return SUPPORTED_LANGUAGES
frontend/app.js CHANGED
@@ -1,30 +1,71 @@
1
  /*
2
- app.js
3
- ---------------------------------------------------------
4
- UPDATED FOR HUGGING FACE DEPLOYMENT:
5
-
6
- When running locally: API_URL = "http://localhost:7860"
7
- When running on HF: API_URL = "" (empty = same server)
8
-
9
- Because on HF Spaces, Flask serves BOTH the frontend HTML
10
- AND the /transcribe API from the same server. So we use
11
- a relative URL β€” no need to hardcode any domain.
12
- ---------------------------------------------------------
13
  */
14
 
15
- // Empty string "" means "same server this page is served from"
16
- // Works both locally (localhost:7860) and on HF Spaces URL
17
  const API_URL = "";
18
 
19
- // ── Variables to track recording state ────────────────────
20
- let mediaRecorder = null;
21
- let audioChunks = [];
22
- let timerInterval = null;
23
- let secondsElapsed = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  // ═══════════════════════════════════════════════════════════
27
- // SECTION 1: FILE UPLOAD
 
 
 
 
 
 
 
 
 
 
 
28
  // ═══════════════════════════════════════════════════════════
29
 
30
  document.getElementById("audio-file-input").addEventListener("change", function () {
@@ -37,7 +78,7 @@ document.getElementById("audio-file-input").addEventListener("change", function
37
 
38
  async function uploadAndTranscribe() {
39
  const fileInput = document.getElementById("audio-file-input");
40
- const file = fileInput.files[0];
41
 
42
  if (!file) {
43
  alert("Please select an audio file first!");
@@ -46,18 +87,17 @@ async function uploadAndTranscribe() {
46
 
47
  const formData = new FormData();
48
  formData.append("audio", file);
 
49
 
50
  showLoading();
51
 
52
  try {
53
  const response = await fetch(`${API_URL}/transcribe`, {
54
  method: "POST",
55
- body: formData,
56
  });
57
-
58
  const result = await response.json();
59
  handleResult(result);
60
-
61
  } catch (error) {
62
  showError("Could not connect to backend. Is the server running? (python backend/app.py)");
63
  }
@@ -65,19 +105,15 @@ async function uploadAndTranscribe() {
65
 
66
 
67
  // ═══════════════════════════════════════════════════════════
68
- // SECTION 2: MICROPHONE RECORDING
69
  // ═══════════════════════════════════════════════════════════
70
 
71
  async function startRecording() {
72
  try {
73
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
74
-
75
- const mimeType = MediaRecorder.isTypeSupported("audio/webm")
76
- ? "audio/webm"
77
- : "audio/ogg";
78
- mediaRecorder = new MediaRecorder(stream, { mimeType });
79
-
80
- audioChunks = [];
81
 
82
  mediaRecorder.ondataavailable = (event) => {
83
  if (event.data.size > 0) audioChunks.push(event.data);
@@ -90,12 +126,10 @@ async function startRecording() {
90
  };
91
 
92
  mediaRecorder.start();
93
-
94
  document.body.classList.add("recording");
95
  document.getElementById("record-status").textContent = "Recording...";
96
  document.getElementById("start-btn").disabled = true;
97
- document.getElementById("stop-btn").disabled = false;
98
-
99
  startTimer();
100
 
101
  } catch (error) {
@@ -107,25 +141,24 @@ function stopRecording() {
107
  if (mediaRecorder && mediaRecorder.state !== "inactive") {
108
  mediaRecorder.stop();
109
  }
110
-
111
  document.body.classList.remove("recording");
112
  document.getElementById("record-status").textContent = "Processing...";
113
  document.getElementById("start-btn").disabled = false;
114
- document.getElementById("stop-btn").disabled = true;
115
-
116
  stopTimer();
117
  showLoading();
118
  }
119
 
120
  async function sendAudioToBackend(audioBlob) {
121
  const formData = new FormData();
122
- const ext = audioBlob.type.includes("ogg") ? "ogg" : "webm";
123
  formData.append("audio", audioBlob, `recording.${ext}`);
 
124
 
125
  try {
126
  const response = await fetch(`${API_URL}/transcribe`, {
127
  method: "POST",
128
- body: formData,
129
  });
130
  const result = await response.json();
131
  handleResult(result);
@@ -138,72 +171,99 @@ async function sendAudioToBackend(audioBlob) {
138
 
139
 
140
  // ═══════════════════════════════════════════════════════════
141
- // SECTION 3: SHOWING RESULTS
142
  // ═══════════════════════════════════════════════════════════
143
 
144
  function handleResult(result) {
145
  hideLoading();
146
  if (result.success) {
147
- showTranscript(result.transcript, result.duration, result.chunks);
 
 
 
 
 
 
148
  } else {
149
  showError(result.error);
150
  }
151
  }
152
 
153
- function showTranscript(text, duration, chunks) {
 
154
  document.getElementById("results-section").classList.add("visible");
155
  document.getElementById("result-box").classList.add("visible");
156
  document.getElementById("error-box").classList.remove("visible");
 
 
157
  document.getElementById("transcript-text").textContent = text;
 
158
 
159
- const wordCount = text.trim().split(/\s+/).length;
160
- let meta = `${wordCount} words`;
 
 
 
 
161
  if (duration) meta += ` Β· ${duration}s audio`;
162
- if (chunks && chunks > 1) meta += ` Β· ${chunks} chunks processed`;
163
  document.getElementById("word-count").textContent = meta;
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  document.getElementById("results-section").scrollIntoView({ behavior: "smooth" });
166
  }
167
 
168
  function showError(message) {
169
  document.getElementById("results-section").classList.add("visible");
170
  document.getElementById("result-box").classList.remove("visible");
171
- const errorBox = document.getElementById("error-box");
172
  document.getElementById("error-text").textContent = message;
173
- errorBox.classList.add("visible");
174
  document.getElementById("results-section").scrollIntoView({ behavior: "smooth" });
175
  }
176
 
177
- // Rotating messages so user knows pipeline is actively working
178
- const loadingMessages = [
179
- 'Starting beast mode pipeline...',
180
- 'Converting audio format...',
181
- 'Demucs isolating vocals from background...',
182
- 'Whisper AI transcribing clean vocals...',
183
- 'Almost there β€” processing final segments...',
184
- 'Finalizing transcript...'
185
- ];
186
- let loadingMsgInterval = null;
187
-
188
  function showLoading() {
 
 
 
 
 
189
  let msgIndex = 0;
190
- const msgEl = document.getElementById("loading-msg");
191
  if (msgEl) msgEl.textContent = loadingMessages[0];
192
  loadingMsgInterval = setInterval(() => {
193
  msgIndex = (msgIndex + 1) % loadingMessages.length;
194
  if (msgEl) msgEl.textContent = loadingMessages[msgIndex];
195
  }, 4000);
196
-
197
- document.getElementById("results-section").classList.add("visible");
198
- document.getElementById("loading-state").classList.add("visible");
199
- document.getElementById("result-box").classList.remove("visible");
200
- document.getElementById("error-box").classList.remove("visible");
201
  }
202
 
203
  function hideLoading() {
204
- if (loadingMsgInterval) { clearInterval(loadingMsgInterval); loadingMsgInterval = null; }
205
-
206
  document.getElementById("loading-state").classList.remove("visible");
 
 
 
 
207
  }
208
 
209
  function clearResults() {
@@ -212,13 +272,206 @@ function clearResults() {
212
  document.getElementById("error-box").classList.remove("visible");
213
  document.getElementById("loading-state").classList.remove("visible");
214
  document.getElementById("transcript-text").textContent = "";
 
 
 
 
 
 
215
  }
216
 
217
 
218
  // ═══════════════════════════════════════════════════════════
219
- // SECTION 4: UTILITIES
220
  // ═══════════════════════════════════════════════════════════
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  function copyTranscript() {
223
  const text = document.getElementById("transcript-text").textContent;
224
  navigator.clipboard.writeText(text).then(() => {
@@ -230,7 +483,7 @@ function copyTranscript() {
230
 
231
  function startTimer() {
232
  secondsElapsed = 0;
233
- timerInterval = setInterval(() => {
234
  secondsElapsed++;
235
  const mins = String(Math.floor(secondsElapsed / 60)).padStart(2, "0");
236
  const secs = String(secondsElapsed % 60).padStart(2, "0");
@@ -244,26 +497,23 @@ function stopTimer() {
244
  secondsElapsed = 0;
245
  }
246
 
247
- // Drag and drop support
248
  const dropZone = document.getElementById("drop-zone");
249
-
250
  dropZone.addEventListener("dragover", (e) => {
251
  e.preventDefault();
252
  dropZone.style.borderColor = "#6366f1";
253
  });
254
-
255
  dropZone.addEventListener("dragleave", () => {
256
  dropZone.style.borderColor = "";
257
  });
258
-
259
  dropZone.addEventListener("drop", (e) => {
260
  e.preventDefault();
261
  dropZone.style.borderColor = "";
262
  const file = e.dataTransfer.files[0];
263
  if (file) {
264
- const dataTransfer = new DataTransfer();
265
- dataTransfer.items.add(file);
266
- document.getElementById("audio-file-input").files = dataTransfer.files;
267
  document.getElementById("file-name-display").textContent = file.name;
268
  document.getElementById("drop-text").textContent = "File selected:";
269
  }
 
1
  /*
2
+ app.js β€” VoiceScript
3
+ Full frontend logic: upload, record, transcribe, translate, timestamps
 
 
 
 
 
 
 
 
 
4
  */
5
 
 
 
6
  const API_URL = "";
7
 
8
+ // ── Global state ───────────────────────────────────────────
9
+ let mediaRecorder = null;
10
+ let audioChunks = [];
11
+ let timerInterval = null;
12
+ let secondsElapsed = 0;
13
+ let currentMode = "transcribe";
14
+ let lastTranscript = "";
15
+ let lastSegments = [];
16
+ let timestampsShowing = false;
17
+
18
+ // Loading messages that rotate while processing
19
+ const loadingMessages = [
20
+ "Starting beast mode pipeline...",
21
+ "Converting audio format...",
22
+ "Demucs isolating vocals from background...",
23
+ "Whisper AI transcribing clean vocals...",
24
+ "Almost there β€” processing final segments...",
25
+ "Finalizing transcript..."
26
+ ];
27
+ let loadingMsgInterval = null;
28
+
29
+
30
+ // ═══════════════════════════════════════════════════════════
31
+ // INIT β€” runs on page load
32
+ // ═══════════════════════════════════════════════════════════
33
+
34
+ async function loadLanguages() {
35
+ try {
36
+ const response = await fetch(`${API_URL}/languages`);
37
+ const data = await response.json();
38
+ if (data.success) {
39
+ const select = document.getElementById("lang-select");
40
+ const sorted = Object.entries(data.languages).sort((a, b) => a[1].localeCompare(b[1]));
41
+ sorted.forEach(([code, name]) => {
42
+ const opt = document.createElement("option");
43
+ opt.value = code;
44
+ opt.textContent = name;
45
+ select.appendChild(opt);
46
+ });
47
+ }
48
+ } catch (e) {
49
+ console.log("Languages not loaded yet β€” server may be starting");
50
+ }
51
+ }
52
+
53
+ loadLanguages();
54
 
55
 
56
  // ═══════════════════════════════════════════════════════════
57
+ // MODE TOGGLE (Transcribe / Any Language β†’ English)
58
+ // ═══════════════════════════════════════════════════════════
59
+
60
+ function setMode(mode) {
61
+ currentMode = mode;
62
+ document.getElementById("mode-transcribe").classList.toggle("active", mode === "transcribe");
63
+ document.getElementById("mode-translate").classList.toggle("active", mode === "translate_to_english");
64
+ }
65
+
66
+
67
+ // ═══════════════════════════════════════════════════════════
68
+ // FILE UPLOAD
69
  // ═══════════════════════════════════════════════════════════
70
 
71
  document.getElementById("audio-file-input").addEventListener("change", function () {
 
78
 
79
  async function uploadAndTranscribe() {
80
  const fileInput = document.getElementById("audio-file-input");
81
+ const file = fileInput.files[0];
82
 
83
  if (!file) {
84
  alert("Please select an audio file first!");
 
87
 
88
  const formData = new FormData();
89
  formData.append("audio", file);
90
+ formData.append("mode", currentMode);
91
 
92
  showLoading();
93
 
94
  try {
95
  const response = await fetch(`${API_URL}/transcribe`, {
96
  method: "POST",
97
+ body: formData,
98
  });
 
99
  const result = await response.json();
100
  handleResult(result);
 
101
  } catch (error) {
102
  showError("Could not connect to backend. Is the server running? (python backend/app.py)");
103
  }
 
105
 
106
 
107
  // ═══════════════════════════════════════════════════════════
108
+ // MICROPHONE RECORDING
109
  // ═══════════════════════════════════════════════════════════
110
 
111
  async function startRecording() {
112
  try {
113
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
114
+ const mimeType = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/ogg";
115
+ mediaRecorder = new MediaRecorder(stream, { mimeType });
116
+ audioChunks = [];
 
 
 
 
117
 
118
  mediaRecorder.ondataavailable = (event) => {
119
  if (event.data.size > 0) audioChunks.push(event.data);
 
126
  };
127
 
128
  mediaRecorder.start();
 
129
  document.body.classList.add("recording");
130
  document.getElementById("record-status").textContent = "Recording...";
131
  document.getElementById("start-btn").disabled = true;
132
+ document.getElementById("stop-btn").disabled = false;
 
133
  startTimer();
134
 
135
  } catch (error) {
 
141
  if (mediaRecorder && mediaRecorder.state !== "inactive") {
142
  mediaRecorder.stop();
143
  }
 
144
  document.body.classList.remove("recording");
145
  document.getElementById("record-status").textContent = "Processing...";
146
  document.getElementById("start-btn").disabled = false;
147
+ document.getElementById("stop-btn").disabled = true;
 
148
  stopTimer();
149
  showLoading();
150
  }
151
 
152
  async function sendAudioToBackend(audioBlob) {
153
  const formData = new FormData();
154
+ const ext = audioBlob.type.includes("ogg") ? "ogg" : "webm";
155
  formData.append("audio", audioBlob, `recording.${ext}`);
156
+ formData.append("mode", currentMode);
157
 
158
  try {
159
  const response = await fetch(`${API_URL}/transcribe`, {
160
  method: "POST",
161
+ body: formData,
162
  });
163
  const result = await response.json();
164
  handleResult(result);
 
171
 
172
 
173
  // ═══════════════════════════════════════════════════════════
174
+ // RESULT HANDLING
175
  // ═══════════════════════════════════════════════════════════
176
 
177
  function handleResult(result) {
178
  hideLoading();
179
  if (result.success) {
180
+ lastSegments = result.segments || [];
181
+ showTranscript(
182
+ result.transcript,
183
+ result.duration,
184
+ result.word_count,
185
+ result.detected_language_name
186
+ );
187
  } else {
188
  showError(result.error);
189
  }
190
  }
191
 
192
+ function showTranscript(text, duration, wordCount, detectedLang) {
193
+ // Show result section
194
  document.getElementById("results-section").classList.add("visible");
195
  document.getElementById("result-box").classList.add("visible");
196
  document.getElementById("error-box").classList.remove("visible");
197
+
198
+ // Set plain transcript text
199
  document.getElementById("transcript-text").textContent = text;
200
+ document.getElementById("transcript-text").style.display = "block";
201
 
202
+ // Reset timestamps view
203
+ document.getElementById("timestamps-view").style.display = "none";
204
+ timestampsShowing = false;
205
+
206
+ // Word count + duration meta
207
+ let meta = `${wordCount || text.trim().split(/\s+/).length} words`;
208
  if (duration) meta += ` Β· ${duration}s audio`;
 
209
  document.getElementById("word-count").textContent = meta;
210
 
211
+ // Language badge
212
+ const langBadge = document.getElementById("lang-badge");
213
+ if (detectedLang && detectedLang !== "Unknown") {
214
+ langBadge.textContent = "🌐 " + detectedLang + " detected";
215
+ langBadge.style.display = "inline-block";
216
+ } else {
217
+ langBadge.style.display = "none";
218
+ }
219
+
220
+ // Timestamps button β€” show only if we have segments
221
+ const tsBtn = document.getElementById("timestamps-btn");
222
+ if (lastSegments.length > 0) {
223
+ tsBtn.style.display = "inline-block";
224
+ tsBtn.textContent = "Show Timestamps";
225
+ tsBtn.classList.remove("active");
226
+ } else {
227
+ tsBtn.style.display = "none";
228
+ }
229
+
230
+ // Store for translation
231
+ lastTranscript = text;
232
+ document.getElementById("translated-box").classList.remove("visible");
233
+ document.getElementById("translate-panel").classList.add("visible");
234
+
235
  document.getElementById("results-section").scrollIntoView({ behavior: "smooth" });
236
  }
237
 
238
  function showError(message) {
239
  document.getElementById("results-section").classList.add("visible");
240
  document.getElementById("result-box").classList.remove("visible");
 
241
  document.getElementById("error-text").textContent = message;
242
+ document.getElementById("error-box").classList.add("visible");
243
  document.getElementById("results-section").scrollIntoView({ behavior: "smooth" });
244
  }
245
 
 
 
 
 
 
 
 
 
 
 
 
246
  function showLoading() {
247
+ document.getElementById("results-section").classList.add("visible");
248
+ document.getElementById("loading-state").classList.add("visible");
249
+ document.getElementById("result-box").classList.remove("visible");
250
+ document.getElementById("error-box").classList.remove("visible");
251
+
252
  let msgIndex = 0;
253
+ const msgEl = document.getElementById("loading-msg");
254
  if (msgEl) msgEl.textContent = loadingMessages[0];
255
  loadingMsgInterval = setInterval(() => {
256
  msgIndex = (msgIndex + 1) % loadingMessages.length;
257
  if (msgEl) msgEl.textContent = loadingMessages[msgIndex];
258
  }, 4000);
 
 
 
 
 
259
  }
260
 
261
  function hideLoading() {
 
 
262
  document.getElementById("loading-state").classList.remove("visible");
263
+ if (loadingMsgInterval) {
264
+ clearInterval(loadingMsgInterval);
265
+ loadingMsgInterval = null;
266
+ }
267
  }
268
 
269
  function clearResults() {
 
272
  document.getElementById("error-box").classList.remove("visible");
273
  document.getElementById("loading-state").classList.remove("visible");
274
  document.getElementById("transcript-text").textContent = "";
275
+ document.getElementById("translate-panel").classList.remove("visible");
276
+ document.getElementById("translated-box").classList.remove("visible");
277
+ document.getElementById("timestamps-view").innerHTML = "";
278
+ lastTranscript = "";
279
+ lastSegments = [];
280
+ timestampsShowing = false;
281
  }
282
 
283
 
284
  // ═══════════════════════════════════════════════════════════
285
+ // TIMESTAMPS
286
  // ═══════════════════════════════════════════════════════════
287
 
288
+ function toggleTimestamps() {
289
+ timestampsShowing = !timestampsShowing;
290
+ const btn = document.getElementById("timestamps-btn");
291
+ const plainView = document.getElementById("transcript-text");
292
+ const tsView = document.getElementById("timestamps-view");
293
+
294
+ if (timestampsShowing) {
295
+ // Build the timestamped lines
296
+ tsView.innerHTML = "";
297
+ lastSegments.forEach(seg => {
298
+ const line = document.createElement("div");
299
+ line.className = "ts-line";
300
+ line.innerHTML = `<span class="ts-badge">${seg.start_fmt}</span><span class="ts-text">${seg.text}</span>`;
301
+ tsView.appendChild(line);
302
+ });
303
+
304
+ plainView.style.display = "none";
305
+ tsView.style.display = "flex";
306
+ btn.textContent = "Hide Timestamps";
307
+ btn.classList.add("active");
308
+ } else {
309
+ plainView.style.display = "block";
310
+ tsView.style.display = "none";
311
+ btn.textContent = "Show Timestamps";
312
+ btn.classList.remove("active");
313
+ }
314
+ }
315
+
316
+
317
+ // ═══════════════════════════════════════════════════════════
318
+ // TRANSLATION
319
+ // ═══════════════════════════════════════════════════════════
320
+
321
+ async function translateTranscript() {
322
+ const langSelect = document.getElementById("lang-select");
323
+ const targetLang = langSelect.value;
324
+
325
+ if (!targetLang) {
326
+ alert("Please select a language first!");
327
+ return;
328
+ }
329
+ if (!lastTranscript) {
330
+ alert("No transcript to translate. Please transcribe audio first.");
331
+ return;
332
+ }
333
+
334
+ const btn = document.getElementById("translate-btn");
335
+ btn.textContent = "Translating...";
336
+ btn.disabled = true;
337
+
338
+ try {
339
+ const response = await fetch(`${API_URL}/translate`, {
340
+ method: "POST",
341
+ headers: { "Content-Type": "application/json" },
342
+ body: JSON.stringify({ text: lastTranscript, target_language: targetLang })
343
+ });
344
+ const result = await response.json();
345
+
346
+ if (result.success) {
347
+ document.getElementById("translated-text").textContent = result.translated;
348
+ document.getElementById("translated-lang-label").textContent = "Translated to " + result.language;
349
+ document.getElementById("translated-box").classList.add("visible");
350
+ document.getElementById("translated-box").scrollIntoView({ behavior: "smooth" });
351
+ } else {
352
+ alert("Translation failed: " + result.error);
353
+ }
354
+ } catch (error) {
355
+ alert("Could not connect to translation service. Is the server running?");
356
+ }
357
+
358
+ btn.textContent = "Translate";
359
+ btn.disabled = false;
360
+ }
361
+
362
+ function copyTranslation() {
363
+ const text = document.getElementById("translated-text").textContent;
364
+ navigator.clipboard.writeText(text).then(() => {
365
+ const btns = document.querySelectorAll(".btn-copy");
366
+ if (btns[1]) {
367
+ btns[1].textContent = "Copied!";
368
+ setTimeout(() => btns[1].textContent = "Copy", 2000);
369
+ }
370
+ });
371
+ }
372
+
373
+
374
+ // ═══════════════════════════════════════════════════════════
375
+ // UTILITIES
376
+ // ═══════════════════════════════════════════════════════════
377
+
378
+ // ═══════════════════════════════════════════════════════════
379
+ // EXPORT FUNCTIONS β€” TXT, SRT, PDF
380
+ // ═══════════════════════════════════════════════════════════
381
+
382
+ function downloadFile(filename, content, mimeType) {
383
+ const blob = new Blob([content], { type: mimeType });
384
+ const url = URL.createObjectURL(blob);
385
+ const a = document.createElement("a");
386
+ a.href = url;
387
+ a.download = filename;
388
+ document.body.appendChild(a);
389
+ a.click();
390
+ document.body.removeChild(a);
391
+ URL.revokeObjectURL(url);
392
+ }
393
+
394
+ function exportTXT() {
395
+ if (!lastTranscript) { alert("No transcript to export!"); return; }
396
+ const now = new Date();
397
+ const dateStr = now.toLocaleDateString("en-IN", { day:"2-digit", month:"short", year:"numeric" });
398
+ const words = lastTranscript.trim().split(/\s+/).length;
399
+ const content = [
400
+ "VoiceScript β€” Transcript Export",
401
+ "================================",
402
+ "Date : " + dateStr,
403
+ "Words : " + words,
404
+ "",
405
+ "TRANSCRIPT",
406
+ "----------",
407
+ lastTranscript,
408
+ "",
409
+ "--------------------------------",
410
+ "Exported by VoiceScript Β· AI Speech Recognition",
411
+ "https://huggingface.co/spaces/Tusharz/VoiceScript"
412
+ ].join("\n");
413
+ downloadFile("VoiceScript_Transcript.txt", content, "text/plain");
414
+ }
415
+
416
+ function secondsToSRT(totalSeconds) {
417
+ const h = Math.floor(totalSeconds / 3600);
418
+ const m = Math.floor((totalSeconds % 3600) / 60);
419
+ const s = Math.floor(totalSeconds % 60);
420
+ return String(h).padStart(2,"0") + ":" + String(m).padStart(2,"0") + ":" + String(s).padStart(2,"0") + ",000";
421
+ }
422
+
423
+ function exportSRT() {
424
+ if (!lastTranscript) { alert("No transcript to export!"); return; }
425
+ let srtContent = "";
426
+
427
+ if (lastSegments && lastSegments.length > 0) {
428
+ lastSegments.forEach(function(seg, i) {
429
+ srtContent += (i + 1) + "\n" + secondsToSRT(seg.start) + " --> " + secondsToSRT(seg.end) + "\n" + seg.text + "\n\n";
430
+ });
431
+ } else {
432
+ const words = lastTranscript.trim().split(/\s+/);
433
+ const size = 10;
434
+ let idx = 1;
435
+ for (let i = 0; i < words.length; i += size) {
436
+ const chunk = words.slice(i, i + size).join(" ");
437
+ srtContent += idx + "\n" + secondsToSRT((idx-1)*5) + " --> " + secondsToSRT(idx*5) + "\n" + chunk + "\n\n";
438
+ idx++;
439
+ }
440
+ }
441
+ downloadFile("VoiceScript_Subtitles.srt", srtContent, "text/plain");
442
+ }
443
+
444
+ function exportPDF() {
445
+ if (!lastTranscript) { alert("No transcript to export!"); return; }
446
+ const now = new Date();
447
+ const dateStr = now.toLocaleDateString("en-IN", { day:"2-digit", month:"long", year:"numeric" });
448
+ const words = lastTranscript.trim().split(/\s+/).length;
449
+
450
+ const printHTML = "<!DOCTYPE html><html><head><meta charset='UTF-8'/><title>VoiceScript Transcript</title>"
451
+ + "<style>"
452
+ + "body{font-family:Georgia,serif;color:#1a1a2e;padding:60px;max-width:800px;margin:0 auto;line-height:1.8}"
453
+ + ".brand{font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#6366f1;letter-spacing:.1em;text-transform:uppercase;margin-bottom:8px}"
454
+ + "h1{font-size:28px;color:#1a1a2e;margin-bottom:8px}"
455
+ + ".header{border-bottom:3px solid #6366f1;padding-bottom:20px;margin-bottom:32px}"
456
+ + ".meta{font-size:13px;color:#666;font-family:Arial,sans-serif}"
457
+ + ".body{font-size:16px;line-height:2;text-align:justify;margin-bottom:40px}"
458
+ + ".footer{border-top:1px solid #ddd;padding-top:16px;font-size:12px;color:#999;font-family:Arial,sans-serif;text-align:center}"
459
+ + "</style></head><body>"
460
+ + "<div class='header'><div class='brand'>VoiceScript β€” AI Speech Recognition</div>"
461
+ + "<h1>Transcript</h1>"
462
+ + "<div class='meta'>Generated on " + dateStr + " &nbsp;Β·&nbsp; " + words + " words</div></div>"
463
+ + "<div class='body'>" + lastTranscript.replace(/\n/g, "<br/>") + "</div>"
464
+ + "<div class='footer'>Transcribed by VoiceScript Β· Powered by OpenAI Whisper + Facebook Demucs<br/>"
465
+ + "huggingface.co/spaces/Tusharz/VoiceScript</div>"
466
+ + "</body></html>";
467
+
468
+ const win = window.open("", "_blank");
469
+ win.document.write(printHTML);
470
+ win.document.close();
471
+ win.focus();
472
+ setTimeout(function() { win.print(); }, 600);
473
+ }
474
+
475
  function copyTranscript() {
476
  const text = document.getElementById("transcript-text").textContent;
477
  navigator.clipboard.writeText(text).then(() => {
 
483
 
484
  function startTimer() {
485
  secondsElapsed = 0;
486
+ timerInterval = setInterval(() => {
487
  secondsElapsed++;
488
  const mins = String(Math.floor(secondsElapsed / 60)).padStart(2, "0");
489
  const secs = String(secondsElapsed % 60).padStart(2, "0");
 
497
  secondsElapsed = 0;
498
  }
499
 
500
+ // Drag and drop
501
  const dropZone = document.getElementById("drop-zone");
 
502
  dropZone.addEventListener("dragover", (e) => {
503
  e.preventDefault();
504
  dropZone.style.borderColor = "#6366f1";
505
  });
 
506
  dropZone.addEventListener("dragleave", () => {
507
  dropZone.style.borderColor = "";
508
  });
 
509
  dropZone.addEventListener("drop", (e) => {
510
  e.preventDefault();
511
  dropZone.style.borderColor = "";
512
  const file = e.dataTransfer.files[0];
513
  if (file) {
514
+ const dt = new DataTransfer();
515
+ dt.items.add(file);
516
+ document.getElementById("audio-file-input").files = dt.files;
517
  document.getElementById("file-name-display").textContent = file.name;
518
  document.getElementById("drop-text").textContent = "File selected:";
519
  }
frontend/index.html CHANGED
@@ -30,33 +30,62 @@
30
  <!-- ── HEADER ───────────────────────────────────────── -->
31
  <header class="header">
32
  <div class="header-inner">
 
33
  <div class="logo">
34
- <!-- SVG waveform icon β€” represents audio/sound -->
35
- <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
36
- <rect width="32" height="32" rx="8" fill="#6366f1"/>
37
- <rect x="6" y="12" width="3" height="8" rx="1.5" fill="white"/>
38
- <rect x="11" y="8" width="3" height="16" rx="1.5" fill="white"/>
39
- <rect x="16" y="5" width="3" height="22" rx="1.5" fill="white"/>
40
- <rect x="21" y="9" width="3" height="14" rx="1.5" fill="white"/>
41
- <rect x="26" y="13" width="3" height="6" rx="1.5" fill="white"/>
42
- </svg>
43
- <span>VoiceScript</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  </div>
45
- <div class="header-badge">AI Powered</div>
46
  </div>
47
  </header>
48
 
49
  <!-- ── MAIN CONTENT ─────────────────────────────────── -->
50
  <main class="main">
51
 
52
- <!-- Hero section β€” the big intro text -->
53
  <section class="hero">
 
 
 
 
54
  <h1 class="hero-title">
55
- Turn your voice<br/>into <span class="accent">text instantly</span>
56
  </h1>
57
- <p class="hero-sub">
58
  Upload an audio file or record live β€” our AI transcribes it in seconds.
59
  </p>
 
 
 
 
 
 
 
 
 
60
  </section>
61
 
62
  <!-- ── TWO CARDS: Upload + Record ───────────────── -->
@@ -84,6 +113,16 @@
84
  <span id="file-name-display" class="file-name-display"></span>
85
  </div>
86
 
 
 
 
 
 
 
 
 
 
 
87
  <!-- Button to send the file to backend -->
88
  <button class="btn btn-primary" id="upload-btn" onclick="uploadAndTranscribe()">
89
  Transcribe File
@@ -143,19 +182,96 @@
143
  <div class="result-box" id="result-box">
144
  <div class="result-header">
145
  <h3>Transcript</h3>
146
- <!-- Copy to clipboard button -->
147
- <button class="btn-copy" id="copy-btn" onclick="copyTranscript()">
148
- Copy Text
149
- </button>
 
 
 
 
 
 
 
 
150
  </div>
151
- <!-- The actual transcript text appears here -->
 
152
  <p class="transcript-text" id="transcript-text"></p>
153
 
 
 
 
154
  <!-- Word count info -->
155
  <div class="result-meta">
156
  <span id="word-count"></span>
157
  <button class="btn-clear" onclick="clearResults()">Clear</button>
158
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </div>
160
 
161
  <!-- Error box β€” shown if something goes wrong -->
@@ -169,24 +285,61 @@
169
 
170
  <!-- ── FOOTER ────────────────────────────────────────── -->
171
  <footer class="footer">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
- <!-- Tech stack badges -->
174
- <div class="footer-stack">
175
- <span class="stack-badge badge-python">Python 3</span>
176
- <span class="stack-badge badge-flask">Flask</span>
177
- <span class="stack-badge badge-js">JavaScript</span>
178
- <span class="stack-badge badge-whisper">Whisper AI</span>
179
- <span class="stack-badge badge-demucs">Demucs</span>
180
- <span class="stack-badge badge-pydub">pydub</span>
181
- <span class="stack-badge badge-ffmpeg">ffmpeg</span>
182
- </div>
183
-
184
- <p class="footer-desc">
185
- Speech-to-text powered by OpenAI Whisper Β· Vocal isolation by Facebook Demucs Β· Audio processing by pydub + ffmpeg
186
- </p>
187
 
188
- <p class="footer-by">By Tushar ❀️</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
 
 
 
 
 
190
  </footer>
191
 
192
 
 
30
  <!-- ── HEADER ───────────────────────────────────────── -->
31
  <header class="header">
32
  <div class="header-inner">
33
+ <!-- Logo -->
34
  <div class="logo">
35
+ <div class="logo-icon">
36
+ <svg width="20" height="20" viewBox="0 0 32 32" fill="none">
37
+ <rect x="6" y="12" width="3" height="8" rx="1.5" fill="white" opacity="0.7"/>
38
+ <rect x="11" y="8" width="3" height="16" rx="1.5" fill="white"/>
39
+ <rect x="16" y="5" width="3" height="22" rx="1.5" fill="white"/>
40
+ <rect x="21" y="9" width="3" height="14" rx="1.5" fill="white" opacity="0.9"/>
41
+ <rect x="26" y="13" width="3" height="6" rx="1.5" fill="white" opacity="0.6"/>
42
+ </svg>
43
+ </div>
44
+ <span class="logo-text">VoiceScript</span>
45
+ <span class="logo-version">v2.0</span>
46
+ </div>
47
+
48
+ <!-- Nav right -->
49
+ <div class="header-right">
50
+ <div class="header-status">
51
+ <span class="status-dot"></span>
52
+ <span class="status-text">Model ready</span>
53
+ </div>
54
+ <a href="https://github.com/TUSHARTAMRAKAR/VoiceScript" target="_blank" class="header-link">
55
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
56
+ GitHub
57
+ </a>
58
+ <a href="https://huggingface.co/spaces/Tusharz/VoiceScript" target="_blank" class="header-btn-live">
59
+ πŸ€— Live Demo
60
+ </a>
61
  </div>
 
62
  </div>
63
  </header>
64
 
65
  <!-- ── MAIN CONTENT ─────────────────────────────────── -->
66
  <main class="main">
67
 
68
+ <!-- Hero section -->
69
  <section class="hero">
70
+ <div class="hero-eyebrow">
71
+ <span class="eyebrow-dot"></span>
72
+ <span>Powered by OpenAI Whisper + Facebook Demucs</span>
73
+ </div>
74
  <h1 class="hero-title">
75
+ Turn your voice<br/>into <span class="accent" id="hero-accent">text instantly</span>
76
  </h1>
77
+ <p class="hero-sub" id="hero-sub">
78
  Upload an audio file or record live β€” our AI transcribes it in seconds.
79
  </p>
80
+ <div class="hero-stats">
81
+ <div class="stat"><span class="stat-num">99+</span><span class="stat-label">Languages</span></div>
82
+ <div class="stat-divider"></div>
83
+ <div class="stat"><span class="stat-num">∞</span><span class="stat-label">Audio Length</span></div>
84
+ <div class="stat-divider"></div>
85
+ <div class="stat"><span class="stat-num">100%</span><span class="stat-label">Private</span></div>
86
+ <div class="stat-divider"></div>
87
+ <div class="stat"><span class="stat-num">Free</span><span class="stat-label">Forever</span></div>
88
+ </div>
89
  </section>
90
 
91
  <!-- ── TWO CARDS: Upload + Record ───────────────── -->
 
113
  <span id="file-name-display" class="file-name-display"></span>
114
  </div>
115
 
116
+ <!-- Mode toggle: Transcribe vs Translate to English -->
117
+ <div class="mode-toggle" id="mode-toggle">
118
+ <button class="mode-btn active" id="mode-transcribe" onclick="setMode('transcribe')">
119
+ Transcribe
120
+ </button>
121
+ <button class="mode-btn" id="mode-translate" onclick="setMode('translate_to_english')">
122
+ Any Language β†’ English
123
+ </button>
124
+ </div>
125
+
126
  <!-- Button to send the file to backend -->
127
  <button class="btn btn-primary" id="upload-btn" onclick="uploadAndTranscribe()">
128
  Transcribe File
 
182
  <div class="result-box" id="result-box">
183
  <div class="result-header">
184
  <h3>Transcript</h3>
185
+ <div class="result-header-right">
186
+ <!-- Detected language badge -->
187
+ <span class="lang-badge" id="lang-badge" style="display:none;"></span>
188
+ <!-- Timestamps toggle -->
189
+ <button class="btn-timestamps" id="timestamps-btn" onclick="toggleTimestamps()" style="display:none;">
190
+ Show Timestamps
191
+ </button>
192
+ <!-- Copy to clipboard button -->
193
+ <button class="btn-copy" id="copy-btn" onclick="copyTranscript()">
194
+ Copy Text
195
+ </button>
196
+ </div>
197
  </div>
198
+
199
+ <!-- Plain transcript view (default) -->
200
  <p class="transcript-text" id="transcript-text"></p>
201
 
202
+ <!-- Timestamped transcript view (shown when timestamps toggled on) -->
203
+ <div class="timestamps-view" id="timestamps-view" style="display:none;"></div>
204
+
205
  <!-- Word count info -->
206
  <div class="result-meta">
207
  <span id="word-count"></span>
208
  <button class="btn-clear" onclick="clearResults()">Clear</button>
209
  </div>
210
+
211
+ <!-- Export section -->
212
+ <div class="export-section">
213
+ <div class="export-header">
214
+ <span class="export-label">⬇ Download Transcript</span>
215
+ <span class="export-hint">Choose your format</span>
216
+ </div>
217
+ <div class="export-btns">
218
+
219
+ <button class="btn-export btn-export-txt" onclick="exportTXT()">
220
+ <div class="export-icon">
221
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
222
+ </div>
223
+ <div class="export-info">
224
+ <span class="export-name">Plain Text</span>
225
+ <span class="export-ext">.txt</span>
226
+ </div>
227
+ </button>
228
+
229
+ <button class="btn-export btn-export-srt" onclick="exportSRT()">
230
+ <div class="export-icon">
231
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="3"/><path d="M7 8h10M7 12h10M7 16h6"/></svg>
232
+ </div>
233
+ <div class="export-info">
234
+ <span class="export-name">Subtitles</span>
235
+ <span class="export-ext">.srt</span>
236
+ </div>
237
+ </button>
238
+
239
+ <button class="btn-export btn-export-pdf" onclick="exportPDF()">
240
+ <div class="export-icon">
241
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13h1a1 1 0 010 2H9v-3m5 0h1.5a1.5 1.5 0 010 3H14v-3m4 0v4"/></svg>
242
+ </div>
243
+ <div class="export-info">
244
+ <span class="export-name">Document</span>
245
+ <span class="export-ext">.pdf</span>
246
+ </div>
247
+ </button>
248
+
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ <!-- Translate panel β€” shown after transcription -->
254
+ <div class="translate-panel" id="translate-panel">
255
+ <div class="translate-header">
256
+ <span class="translate-title">Translate Transcript</span>
257
+ <span class="translate-hint">Powered by Google Translate</span>
258
+ </div>
259
+ <div class="translate-controls">
260
+ <select class="lang-select" id="lang-select">
261
+ <option value="">Select language...</option>
262
+ </select>
263
+ <button class="btn btn-translate" id="translate-btn" onclick="translateTranscript()">
264
+ Translate
265
+ </button>
266
+ </div>
267
+ <!-- Translated result -->
268
+ <div class="translated-box" id="translated-box">
269
+ <div class="translated-header">
270
+ <span id="translated-lang-label">Translation</span>
271
+ <button class="btn-copy" onclick="copyTranslation()">Copy</button>
272
+ </div>
273
+ <p class="transcript-text" id="translated-text"></p>
274
+ </div>
275
  </div>
276
 
277
  <!-- Error box β€” shown if something goes wrong -->
 
285
 
286
  <!-- ── FOOTER ────────────────────────────────────────── -->
287
  <footer class="footer">
288
+ <div class="footer-inner">
289
+
290
+ <!-- Left: branding -->
291
+ <div class="footer-brand">
292
+ <div class="footer-logo">
293
+ <div class="logo-icon" style="width:28px;height:28px;">
294
+ <svg width="16" height="16" viewBox="0 0 32 32" fill="none">
295
+ <rect x="6" y="12" width="3" height="8" rx="1.5" fill="white" opacity="0.7"/>
296
+ <rect x="11" y="8" width="3" height="16" rx="1.5" fill="white"/>
297
+ <rect x="16" y="5" width="3" height="22" rx="1.5" fill="white"/>
298
+ <rect x="21" y="9" width="3" height="14" rx="1.5" fill="white" opacity="0.9"/>
299
+ <rect x="26" y="13" width="3" height="6" rx="1.5" fill="white" opacity="0.6"/>
300
+ </svg>
301
+ </div>
302
+ <span class="logo-text" style="font-size:16px;">VoiceScript</span>
303
+ </div>
304
+ <p class="footer-tagline">AI-powered speech recognition,<br/>built by Tushar Tamrakar.</p>
305
+ <p class="footer-by">Made with ❀️ · <a href="https://github.com/TUSHARTAMRAKAR" target="_blank" class="footer-a">@TUSHARTAMRAKAR</a></p>
306
+ </div>
307
 
308
+ <!-- Center: stack -->
309
+ <div class="footer-center">
310
+ <p class="footer-stack-label">Built with</p>
311
+ <div class="footer-stack">
312
+ <span class="stack-badge badge-python">Python 3</span>
313
+ <span class="stack-badge badge-flask">Flask</span>
314
+ <span class="stack-badge badge-js">JavaScript</span>
315
+ <span class="stack-badge badge-whisper">Whisper AI</span>
316
+ <span class="stack-badge badge-demucs">Demucs</span>
317
+ <span class="stack-badge badge-pydub">pydub</span>
318
+ <span class="stack-badge badge-ffmpeg">ffmpeg</span>
319
+ </div>
320
+ </div>
 
321
 
322
+ <!-- Right: links -->
323
+ <div class="footer-links">
324
+ <p class="footer-stack-label">Links</p>
325
+ <a href="https://github.com/TUSHARTAMRAKAR/VoiceScript" target="_blank" class="footer-link-item">
326
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
327
+ GitHub Repository
328
+ </a>
329
+ <a href="https://huggingface.co/spaces/Tusharz/VoiceScript" target="_blank" class="footer-link-item">
330
+ πŸ€— Live on Hugging Face
331
+ </a>
332
+ <a href="mailto:tushartamrakar2003@gmail.com" class="footer-link-item">
333
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
334
+ Contact
335
+ </a>
336
+ </div>
337
 
338
+ </div>
339
+ <div class="footer-bottom">
340
+ <span>Β© 2026 VoiceScript Β· MIT License</span>
341
+ <span>Speech-to-text powered by OpenAI Whisper Β· Vocal isolation by Facebook Demucs</span>
342
+ </div>
343
  </footer>
344
 
345
 
frontend/style.css CHANGED
@@ -113,10 +113,27 @@ body {
113
  margin-bottom: 16px;
114
  }
115
 
116
- /* The "text instantly" part β€” glowing accent color */
117
  .accent {
118
- color: var(--accent-light);
119
  position: relative;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  }
121
 
122
  .hero-sub {
@@ -469,3 +486,591 @@ body.recording .record-timer { color: var(--text-1); }
469
  letter-spacing: 0.01em;
470
  }
471
  .footer-by .heart { color: #f472b6; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  margin-bottom: 16px;
114
  }
115
 
116
+ /* The "text instantly" part β€” infinite shimmer animation */
117
  .accent {
 
118
  position: relative;
119
+ background: linear-gradient(
120
+ 90deg,
121
+ #818cf8 0%,
122
+ #c084fc 25%,
123
+ #f472b6 50%,
124
+ #c084fc 75%,
125
+ #818cf8 100%
126
+ );
127
+ background-size: 200% auto;
128
+ -webkit-background-clip: text;
129
+ -webkit-text-fill-color: transparent;
130
+ background-clip: text;
131
+ animation: shimmer 3s linear infinite;
132
+ }
133
+
134
+ @keyframes shimmer {
135
+ 0% { background-position: 0% center; }
136
+ 100% { background-position: 200% center; }
137
  }
138
 
139
  .hero-sub {
 
486
  letter-spacing: 0.01em;
487
  }
488
  .footer-by .heart { color: #f472b6; }
489
+
490
+ /* ── MODE TOGGLE ──────────────────────────────────────────*/
491
+ .mode-toggle {
492
+ display: flex;
493
+ background: var(--bg-card-2);
494
+ border: 1px solid var(--border);
495
+ border-radius: 10px;
496
+ padding: 4px;
497
+ gap: 4px;
498
+ }
499
+
500
+ .mode-btn {
501
+ flex: 1;
502
+ padding: 8px 10px;
503
+ border: none;
504
+ border-radius: 7px;
505
+ font-size: 12px;
506
+ font-weight: 500;
507
+ cursor: pointer;
508
+ background: transparent;
509
+ color: var(--text-3);
510
+ transition: all 0.2s;
511
+ font-family: 'Inter', sans-serif;
512
+ }
513
+
514
+ .mode-btn.active {
515
+ background: var(--accent);
516
+ color: white;
517
+ }
518
+
519
+ .mode-btn:hover:not(.active) {
520
+ color: var(--text-2);
521
+ background: rgba(255,255,255,0.05);
522
+ }
523
+
524
+ /* ── TRANSLATE PANEL ──────────────────────────────────────*/
525
+ .translate-panel {
526
+ display: none;
527
+ background: var(--bg-card);
528
+ border: 1px solid var(--border);
529
+ border-radius: var(--radius);
530
+ padding: 24px;
531
+ margin-top: 16px;
532
+ }
533
+
534
+ .translate-panel.visible { display: block; }
535
+
536
+ .translate-header {
537
+ display: flex;
538
+ justify-content: space-between;
539
+ align-items: center;
540
+ margin-bottom: 16px;
541
+ }
542
+
543
+ .translate-title {
544
+ font-family: 'Space Grotesk', sans-serif;
545
+ font-size: 16px;
546
+ font-weight: 600;
547
+ color: var(--text-1);
548
+ }
549
+
550
+ .translate-hint {
551
+ font-size: 11px;
552
+ color: var(--text-3);
553
+ background: var(--bg-card-2);
554
+ padding: 3px 10px;
555
+ border-radius: 20px;
556
+ border: 1px solid var(--border);
557
+ }
558
+
559
+ .translate-controls {
560
+ display: flex;
561
+ gap: 10px;
562
+ margin-bottom: 16px;
563
+ }
564
+
565
+ .lang-select {
566
+ flex: 1;
567
+ padding: 10px 14px;
568
+ background: var(--bg-card-2);
569
+ border: 1px solid var(--border);
570
+ border-radius: 10px;
571
+ color: var(--text-1);
572
+ font-size: 14px;
573
+ font-family: 'Inter', sans-serif;
574
+ cursor: pointer;
575
+ outline: none;
576
+ transition: border-color 0.2s;
577
+ }
578
+
579
+ .lang-select:focus { border-color: var(--accent); }
580
+ .lang-select option { background: var(--bg-card); }
581
+
582
+ .btn-translate {
583
+ padding: 10px 20px;
584
+ background: linear-gradient(135deg, var(--accent), var(--accent-light));
585
+ color: white;
586
+ border: none;
587
+ border-radius: 10px;
588
+ font-size: 14px;
589
+ font-weight: 600;
590
+ cursor: pointer;
591
+ transition: opacity 0.2s, transform 0.2s;
592
+ font-family: 'Inter', sans-serif;
593
+ white-space: nowrap;
594
+ }
595
+
596
+ .btn-translate:hover { opacity: 0.9; transform: translateY(-1px); }
597
+ .btn-translate:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
598
+
599
+ .translated-box {
600
+ display: none;
601
+ background: var(--bg-card-2);
602
+ border: 1px solid rgba(99,102,241,0.25);
603
+ border-radius: 10px;
604
+ padding: 20px;
605
+ }
606
+
607
+ .translated-box.visible { display: block; }
608
+
609
+ .translated-header {
610
+ display: flex;
611
+ justify-content: space-between;
612
+ align-items: center;
613
+ margin-bottom: 12px;
614
+ padding-bottom: 12px;
615
+ border-bottom: 1px solid var(--border);
616
+ }
617
+
618
+ #translated-lang-label {
619
+ font-size: 13px;
620
+ font-weight: 600;
621
+ color: var(--accent-light);
622
+ }
623
+
624
+ /* ── LANGUAGE BADGE ───────────────────────────────────────*/
625
+ .result-header-right {
626
+ display: flex;
627
+ align-items: center;
628
+ gap: 10px;
629
+ }
630
+
631
+ .lang-badge {
632
+ font-size: 12px;
633
+ font-weight: 600;
634
+ padding: 4px 12px;
635
+ border-radius: 20px;
636
+ background: rgba(99,102,241,0.15);
637
+ color: var(--accent-light);
638
+ border: 1px solid rgba(99,102,241,0.3);
639
+ letter-spacing: 0.02em;
640
+ }
641
+
642
+ /* ── TIMESTAMPS BUTTON ────────────────────────────────────*/
643
+ .btn-timestamps {
644
+ padding: 5px 12px;
645
+ border: 1px solid var(--border);
646
+ border-radius: 8px;
647
+ background: transparent;
648
+ color: var(--text-2);
649
+ font-size: 12px;
650
+ font-weight: 500;
651
+ cursor: pointer;
652
+ transition: all 0.2s;
653
+ font-family: 'Inter', sans-serif;
654
+ }
655
+ .btn-timestamps:hover { border-color: var(--accent); color: var(--accent-light); }
656
+ .btn-timestamps.active { background: var(--accent-glow); border-color: var(--accent); color: var(--accent-light); }
657
+
658
+ /* ── TIMESTAMPS VIEW ────────────────────────��─────────────*/
659
+ .timestamps-view {
660
+ display: flex;
661
+ flex-direction: column;
662
+ gap: 2px;
663
+ }
664
+
665
+ .ts-line {
666
+ display: flex;
667
+ gap: 12px;
668
+ align-items: flex-start;
669
+ padding: 8px 10px;
670
+ border-radius: 8px;
671
+ cursor: pointer;
672
+ transition: background 0.15s;
673
+ }
674
+
675
+ .ts-line:hover { background: var(--bg-card-2); }
676
+
677
+ .ts-badge {
678
+ font-family: 'Space Grotesk', monospace;
679
+ font-size: 11px;
680
+ font-weight: 600;
681
+ color: var(--accent-light);
682
+ background: var(--accent-glow);
683
+ border: 1px solid rgba(99,102,241,0.2);
684
+ padding: 2px 8px;
685
+ border-radius: 6px;
686
+ white-space: nowrap;
687
+ flex-shrink: 0;
688
+ margin-top: 2px;
689
+ letter-spacing: 0.03em;
690
+ }
691
+
692
+ .ts-text {
693
+ font-size: 15px;
694
+ color: var(--text-1);
695
+ line-height: 1.6;
696
+ }
697
+
698
+ /* ── TIMESTAMPS ───────────────────────────────────────────*/
699
+ .btn-timestamps {
700
+ padding: 5px 12px;
701
+ border: 1px solid var(--border);
702
+ border-radius: 8px;
703
+ background: transparent;
704
+ color: var(--text-2);
705
+ font-size: 12px;
706
+ font-weight: 500;
707
+ cursor: pointer;
708
+ transition: all 0.2s;
709
+ font-family: 'Inter', sans-serif;
710
+ white-space: nowrap;
711
+ }
712
+ .btn-timestamps:hover { border-color: var(--accent); color: var(--accent-light); }
713
+ .btn-timestamps.active { background: var(--accent-glow); border-color: var(--accent); color: var(--accent-light); }
714
+
715
+ .timestamps-view {
716
+ display: flex;
717
+ flex-direction: column;
718
+ gap: 2px;
719
+ }
720
+
721
+ .ts-line {
722
+ display: flex;
723
+ gap: 14px;
724
+ align-items: flex-start;
725
+ padding: 8px 10px;
726
+ border-radius: 8px;
727
+ transition: background 0.15s;
728
+ cursor: default;
729
+ }
730
+ .ts-line:hover { background: var(--bg-card-2); }
731
+
732
+ .ts-badge {
733
+ font-family: 'Space Grotesk', monospace;
734
+ font-size: 11px;
735
+ font-weight: 600;
736
+ color: var(--accent-light);
737
+ background: var(--accent-glow);
738
+ border: 1px solid rgba(99,102,241,0.25);
739
+ border-radius: 6px;
740
+ padding: 3px 8px;
741
+ white-space: nowrap;
742
+ flex-shrink: 0;
743
+ margin-top: 2px;
744
+ min-width: 52px;
745
+ text-align: center;
746
+ }
747
+
748
+ .ts-text {
749
+ font-size: 15px;
750
+ color: var(--text-1);
751
+ line-height: 1.6;
752
+ }
753
+
754
+ /* ═══════════════════════════════════════════════════════════
755
+ PREMIUM UI UPGRADE
756
+ ═══════════════════════════════════════════════════════════ */
757
+
758
+ /* ── HEADER UPGRADE ─────────────────────────────────────── */
759
+ .header-right {
760
+ display: flex;
761
+ align-items: center;
762
+ gap: 12px;
763
+ }
764
+
765
+ .header-status {
766
+ display: flex;
767
+ align-items: center;
768
+ gap: 6px;
769
+ font-size: 12px;
770
+ color: var(--text-3);
771
+ }
772
+
773
+ .status-dot {
774
+ width: 7px;
775
+ height: 7px;
776
+ border-radius: 50%;
777
+ background: var(--success);
778
+ box-shadow: 0 0 6px var(--success);
779
+ animation: pulse-dot 2s ease-in-out infinite;
780
+ }
781
+
782
+ @keyframes pulse-dot {
783
+ 0%, 100% { opacity: 1; transform: scale(1); }
784
+ 50% { opacity: 0.6; transform: scale(0.85); }
785
+ }
786
+
787
+ .status-text { color: var(--text-3); font-size: 12px; }
788
+
789
+ .header-link {
790
+ display: flex;
791
+ align-items: center;
792
+ gap: 6px;
793
+ font-size: 13px;
794
+ color: var(--text-2);
795
+ text-decoration: none;
796
+ padding: 6px 12px;
797
+ border-radius: 8px;
798
+ border: 1px solid var(--border);
799
+ transition: all 0.2s;
800
+ }
801
+ .header-link:hover { color: var(--text-1); border-color: var(--text-3); }
802
+
803
+ .header-btn-live {
804
+ display: flex;
805
+ align-items: center;
806
+ gap: 6px;
807
+ font-size: 13px;
808
+ font-weight: 600;
809
+ color: white;
810
+ text-decoration: none;
811
+ padding: 7px 14px;
812
+ border-radius: 8px;
813
+ background: linear-gradient(135deg, #6366f1, #818cf8);
814
+ transition: all 0.2s;
815
+ box-shadow: 0 2px 12px rgba(99,102,241,0.3);
816
+ }
817
+ .header-btn-live:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(99,102,241,0.45); }
818
+
819
+ .logo-icon {
820
+ width: 34px;
821
+ height: 34px;
822
+ background: linear-gradient(135deg, #6366f1, #4f46e5);
823
+ border-radius: 9px;
824
+ display: flex;
825
+ align-items: center;
826
+ justify-content: center;
827
+ box-shadow: 0 2px 10px rgba(99,102,241,0.35);
828
+ }
829
+
830
+ .logo-text { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 700; color: var(--text-1); }
831
+ .logo-version { font-size: 10px; font-weight: 600; color: var(--accent-light); background: var(--accent-glow); border: 1px solid rgba(99,102,241,0.3); padding: 1px 6px; border-radius: 20px; align-self: flex-start; margin-top: 4px; }
832
+
833
+ /* ── HERO UPGRADE ─────────────────────────────────────── */
834
+ .hero-eyebrow {
835
+ display: inline-flex;
836
+ align-items: center;
837
+ gap: 8px;
838
+ font-size: 12px;
839
+ font-weight: 500;
840
+ color: var(--accent-light);
841
+ background: var(--accent-glow);
842
+ border: 1px solid rgba(99,102,241,0.25);
843
+ padding: 6px 14px;
844
+ border-radius: 20px;
845
+ margin-bottom: 24px;
846
+ letter-spacing: 0.02em;
847
+ }
848
+
849
+ .eyebrow-dot {
850
+ width: 6px;
851
+ height: 6px;
852
+ background: var(--accent-light);
853
+ border-radius: 50%;
854
+ animation: pulse-dot 2s ease-in-out infinite;
855
+ }
856
+
857
+ /* Animated typewriter subtitle */
858
+ .hero-sub {
859
+ overflow: hidden;
860
+ border-right: 2px solid var(--accent-light);
861
+ white-space: nowrap;
862
+ animation: typing 3.5s steps(60, end), blink-caret 0.75s step-end infinite;
863
+ animation-fill-mode: forwards;
864
+ max-width: 520px;
865
+ margin: 0 auto 32px;
866
+ }
867
+
868
+ @keyframes typing {
869
+ from { width: 0; }
870
+ to { width: 100%; }
871
+ }
872
+ @keyframes blink-caret {
873
+ from, to { border-color: transparent; }
874
+ 50% { border-color: var(--accent-light); }
875
+ }
876
+
877
+ /* Stats row */
878
+ .hero-stats {
879
+ display: flex;
880
+ align-items: center;
881
+ justify-content: center;
882
+ gap: 0;
883
+ margin-top: 8px;
884
+ background: var(--bg-card);
885
+ border: 1px solid var(--border);
886
+ border-radius: 14px;
887
+ padding: 16px 32px;
888
+ width: fit-content;
889
+ margin-left: auto;
890
+ margin-right: auto;
891
+ }
892
+
893
+ .stat {
894
+ display: flex;
895
+ flex-direction: column;
896
+ align-items: center;
897
+ gap: 3px;
898
+ padding: 0 24px;
899
+ }
900
+
901
+ .stat-num {
902
+ font-family: 'Space Grotesk', sans-serif;
903
+ font-size: 22px;
904
+ font-weight: 700;
905
+ color: var(--text-1);
906
+ background: linear-gradient(135deg, var(--accent-light), #c7d2fe);
907
+ -webkit-background-clip: text;
908
+ -webkit-text-fill-color: transparent;
909
+ background-clip: text;
910
+ }
911
+
912
+ .stat-label { font-size: 11px; color: var(--text-3); font-weight: 500; letter-spacing: 0.04em; }
913
+ .stat-divider { width: 1px; height: 32px; background: var(--border); }
914
+
915
+ /* ── EXPORT BUTTONS UPGRADE ──────────────────────────── */
916
+ .export-section {
917
+ margin-top: 20px;
918
+ padding-top: 20px;
919
+ border-top: 1px solid var(--border);
920
+ }
921
+
922
+ .export-header {
923
+ display: flex;
924
+ justify-content: space-between;
925
+ align-items: center;
926
+ margin-bottom: 12px;
927
+ }
928
+
929
+ .export-label {
930
+ font-size: 13px;
931
+ font-weight: 600;
932
+ color: var(--text-2);
933
+ letter-spacing: 0.02em;
934
+ }
935
+
936
+ .export-hint { font-size: 11px; color: var(--text-3); }
937
+
938
+ .export-btns {
939
+ display: flex;
940
+ gap: 10px;
941
+ flex-wrap: wrap;
942
+ }
943
+
944
+ .btn-export {
945
+ display: flex;
946
+ align-items: center;
947
+ gap: 12px;
948
+ padding: 12px 20px;
949
+ border-radius: 12px;
950
+ font-size: 13px;
951
+ cursor: pointer;
952
+ border: 1px solid var(--border);
953
+ background: var(--bg-card-2);
954
+ transition: all 0.2s;
955
+ font-family: 'Inter', sans-serif;
956
+ flex: 1;
957
+ min-width: 120px;
958
+ }
959
+
960
+ .export-icon {
961
+ width: 36px;
962
+ height: 36px;
963
+ border-radius: 8px;
964
+ display: flex;
965
+ align-items: center;
966
+ justify-content: center;
967
+ flex-shrink: 0;
968
+ transition: transform 0.2s;
969
+ }
970
+
971
+ .btn-export:hover .export-icon { transform: scale(1.1); }
972
+ .btn-export:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
973
+
974
+ .export-info { display: flex; flex-direction: column; gap: 2px; }
975
+ .export-name { font-size: 13px; font-weight: 600; color: var(--text-1); }
976
+ .export-ext { font-size: 11px; color: var(--text-3); font-family: var(--font-mono, monospace); }
977
+
978
+ .btn-export-txt { border-color: rgba(99,102,241,0.25); }
979
+ .btn-export-txt .export-icon { background: rgba(99,102,241,0.12); color: #a5b4fc; }
980
+ .btn-export-txt:hover { border-color: #6366f1; background: rgba(99,102,241,0.06); }
981
+ .btn-export-txt .export-name { color: #a5b4fc; }
982
+
983
+ .btn-export-srt { border-color: rgba(16,185,129,0.25); }
984
+ .btn-export-srt .export-icon { background: rgba(16,185,129,0.12); color: #6ee7b7; }
985
+ .btn-export-srt:hover { border-color: #10b981; background: rgba(16,185,129,0.06); }
986
+ .btn-export-srt .export-name { color: #6ee7b7; }
987
+
988
+ .btn-export-pdf { border-color: rgba(239,68,68,0.25); }
989
+ .btn-export-pdf .export-icon { background: rgba(239,68,68,0.12); color: #fca5a5; }
990
+ .btn-export-pdf:hover { border-color: #ef4444; background: rgba(239,68,68,0.06); }
991
+ .btn-export-pdf .export-name { color: #fca5a5; }
992
+
993
+ /* ── FOOTER UPGRADE ──────────────────────────────────── */
994
+ .footer {
995
+ border-top: 1px solid var(--border);
996
+ padding: 0;
997
+ background: var(--bg-card);
998
+ }
999
+
1000
+ .footer-inner {
1001
+ max-width: 960px;
1002
+ margin: 0 auto;
1003
+ padding: 48px 24px 40px;
1004
+ display: grid;
1005
+ grid-template-columns: 1.5fr 1fr 1fr;
1006
+ gap: 40px;
1007
+ }
1008
+
1009
+ @media (max-width: 640px) {
1010
+ .footer-inner { grid-template-columns: 1fr; gap: 28px; }
1011
+ .hero-stats { flex-wrap: wrap; padding: 14px 16px; }
1012
+ .stat { padding: 0 12px; }
1013
+ .header-link { display: none; }
1014
+ }
1015
+
1016
+ .footer-brand { display: flex; flex-direction: column; gap: 12px; }
1017
+ .footer-logo { display: flex; align-items: center; gap: 10px; }
1018
+
1019
+ .footer-tagline {
1020
+ font-size: 13px;
1021
+ color: var(--text-3);
1022
+ line-height: 1.7;
1023
+ }
1024
+
1025
+ .footer-by {
1026
+ font-size: 13px;
1027
+ color: var(--text-3);
1028
+ }
1029
+
1030
+ .footer-a {
1031
+ color: var(--accent-light);
1032
+ text-decoration: none;
1033
+ }
1034
+ .footer-a:hover { text-decoration: underline; }
1035
+
1036
+ .footer-stack-label {
1037
+ font-size: 11px;
1038
+ font-weight: 600;
1039
+ text-transform: uppercase;
1040
+ letter-spacing: 0.08em;
1041
+ color: var(--text-3);
1042
+ margin-bottom: 12px;
1043
+ }
1044
+
1045
+ .footer-stack {
1046
+ display: flex;
1047
+ flex-wrap: wrap;
1048
+ gap: 6px;
1049
+ }
1050
+
1051
+ .footer-links { display: flex; flex-direction: column; gap: 10px; }
1052
+
1053
+ .footer-link-item {
1054
+ display: flex;
1055
+ align-items: center;
1056
+ gap: 8px;
1057
+ font-size: 13px;
1058
+ color: var(--text-2);
1059
+ text-decoration: none;
1060
+ transition: color 0.2s;
1061
+ }
1062
+ .footer-link-item:hover { color: var(--accent-light); }
1063
+
1064
+ .footer-bottom {
1065
+ border-top: 1px solid var(--border);
1066
+ padding: 16px 24px;
1067
+ max-width: 960px;
1068
+ margin: 0 auto;
1069
+ display: flex;
1070
+ justify-content: space-between;
1071
+ align-items: center;
1072
+ font-size: 11px;
1073
+ color: var(--text-3);
1074
+ flex-wrap: wrap;
1075
+ gap: 8px;
1076
+ }
requirements.txt CHANGED
@@ -1,6 +1,3 @@
1
- # requirements.txt
2
- # Install with: pip install -r requirements.txt
3
-
4
  flask==3.0.3
5
  flask-cors==4.0.1
6
  SpeechRecognition==3.11.0
@@ -8,3 +5,4 @@ pydub==0.25.1
8
  openai-whisper
9
  numpy
10
  demucs
 
 
 
 
 
1
  flask==3.0.3
2
  flask-cors==4.0.1
3
  SpeechRecognition==3.11.0
 
5
  openai-whisper
6
  numpy
7
  demucs
8
+ deep-translator==1.11.4