teamseven commited on
Commit
98e232e
·
verified ·
1 Parent(s): be62074

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +506 -933
app.py CHANGED
@@ -3,21 +3,16 @@ import re
3
  import asyncio
4
  import tempfile
5
  import logging
 
6
  import threading
7
- import time
8
- import shutil
9
 
10
  import gradio as gr
11
  import edge_tts
12
- from pydub import AudioSegment
13
 
14
-
15
- # ==================================================
16
- # CONFIGURATION
17
- # ==================================================
18
-
19
- logging.basicConfig(level=logging.INFO)
20
- logger = logging.getLogger("MyanmarEdgeTTS")
21
 
22
  APP_TITLE = "Myanmar Multilingual TTS Studio"
23
 
@@ -28,1227 +23,805 @@ MIN_AUDIO_BYTES = 100
28
 
29
  DEFAULT_VOICE_ID = "it-IT-GiuseppeMultilingualNeural"
30
 
31
- OUTPUT_DIR = os.path.join(
 
 
 
 
 
 
 
 
 
32
  tempfile.gettempdir(),
33
  "myanmar_multilingual_tts",
34
  )
35
 
36
- os.makedirs(OUTPUT_DIR, exist_ok=True)
37
-
38
 
39
- # ==================================================
40
- # VOICE DISPLAY NAMES
41
- # ==================================================
42
 
43
- PINNED_VOICES = {
44
- "my-MM-NilarNeural",
45
- "my-MM-ThihaNeural",
46
- "it-IT-GiuseppeMultilingualNeural",
47
- }
48
 
49
- VOICE_DISPLAY_NAMES = {
50
  "it-IT-GiuseppeMultilingualNeural": "နေတိုး",
51
  "my-MM-NilarNeural": "ဝတ်မှုန်ရွှေရည်",
52
  "my-MM-ThihaNeural": "ပြေတီဦး",
53
  }
54
 
55
- VOICE_ALIAS_NAMES = [
56
- "အကယ်ဒမီမင်းမော်ကွန်း",
57
- "ပိုင်တံခွန်",
58
- "ရဲတိုက်",
59
- "မြင့်မြတ်",
60
- "နေမင်း",
61
- "အောင်ရဲလင်း",
62
- "ကျော်ကျော်ဗို",
63
- "လွင်မိုး",
64
- "ပြေတီဦး",
65
- "နေတိုး",
66
- "ဝတ်မှုန်ရွှေရည်",
67
- "ဖွေးဖွေး",
68
- "အိန္ဒြာကျော်ဇင်",
69
- "သက်မွန်မြင့်",
70
- "မိုးဟေကို",
71
- "ရွှေမှုံရတီ",
72
- "သင်ဇာဝင့်ကျော်",
73
- "ပိုင်ဖြိုးသု",
74
- "ခင်ဝင့်ဝါ",
75
- "အေးမြတ်သူ",
76
- "အိချောပို",
77
- "နန်းဆုဦး",
78
- "အေးဝတ်ရည်သောင်း",
79
- "ဆုပန်ထွာ",
80
- "မေမြင့်မိုရ်",
81
- "ဝါဆိုမိုးဦး",
82
- "သံသာမိုးသိမ့်",
83
- "မြတ်နိုးအေး",
84
- "ခင်သဇင်",
85
- "ယွန်းယွန်း",
86
- "မင်းဦး",
87
- "ခန့်စည်သူ",
88
- "ထွန်းထွန်း",
89
- "ဒေါင်း",
90
- "အောင်လေး",
91
- "ဇေရဲထက်",
92
- "ဟိန်းဝေယံ",
93
- "မင်းရာဇာ",
94
- "ရဲအောင်",
95
- "ကျော်ဟိန်း",
96
- "ဇင်ဝိုင်း",
97
- "လူမင်း",
98
- "ခိုင်သင်းကြည်",
99
- "စိုးမြတ်သူဇာ",
100
- "ထက်ထက်မိုးဦး",
101
- "မေသန်းနု",
102
- "နန္ဒာလှိုင်",
103
- "စိုးပြည့်သဇင်",
104
- "ဝိုင်းစုခိုင်သိန်း",
105
- "ချစ်သုဝေ",
106
- "မေကဗျာ",
107
- "မိုးပြည့်ပြည့်မောင်",
108
- "ယုသန္တာတင်",
109
- "သဲစုညိမ်း",
110
- "မေသက်ခိုင်",
111
- "ရွှေသမီး",
112
- "စန္ဒီမြင့်လွင်",
113
- "နန်းမြတ်ဖြိုးသင်း",
114
- "အဆိုတော်အမျိုးသား",
115
- "အဆိုတော်အမျိုးသမီး",
116
- ]
117
-
118
-
119
- VOICE_CACHE = []
120
- VOICE_CACHE_TIME = 0
121
- VOICE_CACHE_TTL = 3600
122
-
123
- VOICE_LOCK = threading.Lock()
124
-
125
- JOB_SEMAPHORE = threading.Semaphore(
126
- MAX_CONCURRENT_JOBS
127
- )
128
-
129
-
130
- # ==================================================
131
- # VOICE MANAGEMENT
132
- # ==================================================
133
-
134
- async def fetch_voices_async():
135
- return await edge_tts.list_voices()
136
-
137
-
138
- def get_fallback_voices():
139
- return [
140
- {
141
- "ShortName": "my-MM-NilarNeural",
142
- "FriendlyName": "Nilar",
143
- "Gender": "Female",
144
- "Locale": "my-MM",
145
- },
146
- {
147
- "ShortName": "my-MM-ThihaNeural",
148
- "FriendlyName": "Thiha",
149
- "Gender": "Male",
150
- "Locale": "my-MM",
151
- },
152
- {
153
- "ShortName": "it-IT-GiuseppeMultilingualNeural",
154
- "FriendlyName": "Giuseppe Multilingual",
155
- "Gender": "Male",
156
- "Locale": "it-IT",
157
- },
158
- ]
159
-
160
-
161
- def get_voices(force_refresh=False):
162
- global VOICE_CACHE
163
- global VOICE_CACHE_TIME
164
-
165
- now = time.time()
166
-
167
- with VOICE_LOCK:
168
-
169
- if (
170
- VOICE_CACHE
171
- and not force_refresh
172
- and now - VOICE_CACHE_TIME < VOICE_CACHE_TTL
173
- ):
174
- return VOICE_CACHE
175
-
176
- try:
177
- voices = asyncio.run(
178
- fetch_voices_async()
179
- )
180
-
181
- selected = []
182
 
183
- for voice in voices:
184
 
185
- short_name = voice.get(
186
- "ShortName",
187
- "",
188
- )
189
-
190
- if not short_name:
191
- continue
192
-
193
- is_pinned = (
194
- short_name in PINNED_VOICES
195
- )
196
-
197
- is_multilingual = (
198
- "MultilingualNeural" in short_name
199
- )
200
-
201
- if is_pinned or is_multilingual:
202
- selected.append(voice)
203
-
204
- available_ids = {
205
- voice["ShortName"]
206
- for voice in selected
207
- }
208
-
209
- fallback_map = {
210
- voice["ShortName"]: voice
211
- for voice in get_fallback_voices()
212
- }
213
-
214
- for short_name in PINNED_VOICES:
215
-
216
- if short_name not in available_ids:
217
-
218
- selected.append(
219
- fallback_map[short_name]
220
- )
221
-
222
- # Default Voice ကို အပေါ်ဆုံးထားမယ်။
223
- selected.sort(
224
- key=lambda voice: (
225
- 0
226
- if voice["ShortName"] == DEFAULT_VOICE_ID
227
- else 1
228
- if voice["ShortName"] in PINNED_VOICES
229
- else 2,
230
- voice.get("Locale", ""),
231
- voice.get("ShortName", ""),
232
- )
233
- )
234
-
235
- VOICE_CACHE = selected
236
- VOICE_CACHE_TIME = now
237
 
238
- logger.info(
239
- "Loaded %d selected voices",
240
- len(VOICE_CACHE),
241
- )
242
-
243
- return VOICE_CACHE
244
 
245
- except Exception:
246
 
247
- logger.exception(
248
- "Unable to retrieve Edge TTS voices"
249
- )
 
 
 
 
 
250
 
251
- if VOICE_CACHE:
252
- return VOICE_CACHE
253
 
254
- VOICE_CACHE = get_fallback_voices()
255
- VOICE_CACHE_TIME = now
 
 
 
256
 
257
- return VOICE_CACHE
 
 
 
258
 
 
259
 
260
- def build_voice_display_map():
261
- """
262
- Create Myanmar display names for all selected voices.
263
- Original Voice IDs are not displayed in the UI.
264
- """
265
 
266
- voices = get_voices()
 
 
267
 
268
- result = {}
269
- used_names = set()
270
 
271
- alias_index = 0
272
 
273
  for voice in voices:
274
-
275
- short_name = voice.get(
276
- "ShortName",
277
- "",
278
- )
279
 
280
  if not short_name:
281
  continue
282
 
283
- if short_name in VOICE_DISPLAY_NAMES:
284
-
285
- label = VOICE_DISPLAY_NAMES[
286
- short_name
287
- ]
288
-
289
- else:
290
-
291
- label = None
292
-
293
- while alias_index < len(VOICE_ALIAS_NAMES):
294
-
295
- candidate = VOICE_ALIAS_NAMES[
296
- alias_index
297
- ]
298
-
299
- alias_index += 1
300
-
301
- if candidate not in used_names:
302
- label = candidate
303
- break
304
-
305
- if label is None:
306
-
307
- label = (
308
- f"အသံဇာတ်ကောင် "
309
- f"{len(result) + 1}"
310
- )
311
-
312
- base_label = label
313
- suffix = 2
314
-
315
- while label in used_names:
316
-
317
- label = f"{base_label} {suffix}"
318
- suffix += 1
319
-
320
- used_names.add(label)
321
-
322
- result[label] = short_name
323
-
324
- return result
325
-
326
-
327
- def voice_label(voice):
328
- short_name = voice.get(
329
- "ShortName",
330
- "",
331
- )
332
-
333
- return VOICE_DISPLAY_NAMES.get(
334
- short_name,
335
- "",
336
- )
337
-
338
-
339
- def get_voice_map():
340
- return build_voice_display_map()
341
 
 
 
 
342
 
343
- def get_voice_choices():
344
- return list(
345
- get_voice_map().keys()
346
- )
347
 
 
348
 
349
- def get_default_voice_label():
 
 
 
 
 
350
 
351
- voice_map = get_voice_map()
 
 
 
 
352
 
353
- for label, voice_id in voice_map.items():
354
 
355
- if voice_id == DEFAULT_VOICE_ID:
356
- return label
357
 
358
- return next(
359
- iter(voice_map),
360
- None,
361
- )
362
 
 
 
 
363
 
364
- def refresh_voices():
 
365
 
366
- get_voices(
367
- force_refresh=True
368
- )
369
 
370
- choices = get_voice_choices()
 
 
 
371
 
372
- default_label = get_default_voice_label()
 
373
 
374
- return (
375
- gr.update(
376
- choices=choices,
377
- value=default_label,
378
- ),
379
- (
380
- "Voice List ပြန်ရယူပြီးပါပြီ။ "
381
- f"Voice {len(choices)} ခု ရရှိထားသည်။"
382
- ),
383
- )
384
 
 
385
 
386
- # ==================================================
387
- # SCRIPT PROCESSING
388
- # ==================================================
389
 
390
- def normalize_script(text):
391
- """
392
- Normalize whitespace without changing word order.
393
- """
394
 
395
- text = (text or "").replace(
396
- "\r\n",
397
- "\n",
398
- ).replace(
399
- "\r",
400
- "\n",
401
- )
402
 
403
- lines = []
 
 
404
 
405
- for line in text.split("\n"):
 
 
406
 
407
- line = re.sub(
408
- r"[ \t]+",
409
- " ",
410
- line,
411
- ).strip()
412
 
413
- if line:
414
- lines.append(line)
415
 
416
- return "\n".join(lines)
 
417
 
 
 
 
 
 
418
 
419
- def split_into_sentences(text):
420
- """
421
- Split at sentence boundaries where possible.
422
- """
 
423
 
424
- parts = re.split(
425
- r"(?<=[.!?။!?၊])\s+",
426
- text,
427
- )
428
 
429
- return [
430
- part.strip()
431
- for part in parts
432
- if part.strip()
433
- ]
434
 
435
 
436
- def split_long_text(
437
- text,
438
- max_chars=MAX_CHUNK_CHARACTERS,
439
- ):
440
- """
441
- Safety fallback for a long section that has no
442
- usable Myanmar sentence-ending punctuation.
443
- Split at whitespace without dropping words.
444
- """
445
 
446
- words = text.split()
 
447
 
448
- chunks = []
449
- current_words = []
450
- current_length = 0
451
-
452
- for word in words:
453
 
454
- added_length = len(word)
 
455
 
456
- if current_words:
457
- added_length += 1
458
 
459
- if (
460
- current_words
461
- and current_length + added_length > max_chars
462
- ):
463
 
464
- chunks.append(
465
- " ".join(current_words)
466
- )
 
 
 
 
 
 
467
 
468
- current_words = [word]
469
- current_length = len(word)
470
 
471
- else:
 
 
472
 
473
- current_words.append(word)
474
- current_length += added_length
475
 
476
- if current_words:
 
 
477
 
478
- chunks.append(
479
- " ".join(current_words)
480
- )
481
 
482
- return chunks
 
483
 
 
484
 
485
- def split_for_tts(text):
486
- """
487
- Split TTS text around 1800 characters.
488
 
489
- Prefer Myanmar sentence-ending punctuation '။'.
490
- Preserve punctuation and text order.
491
 
492
- If a section has no '။' punctuation available,
493
- use whitespace as a safety fallback.
494
- """
495
 
496
- text = (text or "").strip()
 
497
 
498
  if not text:
499
  return []
500
 
501
- chunks = []
502
-
503
- # '။' နောက်မှာပဲ အဓိကခွဲမယ်။
504
- # Lookbehind သုံးထားတာကြောင့် '။' မပျောက်ပါ။
505
- sentences = re.split(
506
- r"(?<=။)",
507
  text,
508
  )
509
 
 
510
  current = ""
511
 
512
- for sentence in sentences:
 
513
 
514
- sentence = sentence.strip()
515
-
516
- if not sentence:
517
  continue
518
 
519
- candidate = (
520
- current + " " + sentence
521
- ).strip()
 
 
522
 
523
- if (
524
- current
525
- and len(candidate) > MAX_CHUNK_CHARACTERS
526
- ):
527
 
528
- chunks.append(current)
 
 
529
 
530
- current = sentence
531
 
 
 
532
  else:
533
-
534
- current = candidate
535
 
536
  if current:
537
  chunks.append(current)
538
 
539
- final_chunks = []
540
-
541
- for chunk in chunks:
542
-
543
- chunk = chunk.strip()
544
-
545
- if not chunk:
546
- continue
547
-
548
- # ပုဒ်ချမရှိဘဲ အပိုင်းအလွန်ရှည်နေရင်
549
- # Edge TTS error မဖြစ်အောင် whitespace ဖြင့်
550
- # safety fallback ခွဲမယ်။
551
- if len(chunk) > MAX_CHUNK_CHARACTERS:
552
-
553
- # ဒီအပိုင်းထဲမှာ ပုဒ်ချများစွာရှိပါက
554
- # ပုဒ်ချအလိုက် ထပ်ခွဲမယ်။
555
- parts = re.split(
556
- r"(?<=။)",
557
- chunk,
558
- )
559
-
560
- temp_chunk = ""
561
-
562
- for part in parts:
563
-
564
- part = part.strip()
565
-
566
- if not part:
567
- continue
568
-
569
- candidate = (
570
- temp_chunk + " " + part
571
- ).strip()
572
-
573
- if (
574
- temp_chunk
575
- and len(candidate) > MAX_CHUNK_CHARACTERS
576
- ):
577
-
578
- final_chunks.append(
579
- temp_chunk
580
- )
581
-
582
- temp_chunk = part
583
-
584
- else:
585
-
586
- temp_chunk = candidate
587
-
588
- if temp_chunk:
589
-
590
- if len(temp_chunk) > MAX_CHUNK_CHARACTERS:
591
-
592
- final_chunks.extend(
593
- split_long_text(
594
- temp_chunk,
595
- MAX_CHUNK_CHARACTERS,
596
- )
597
- )
598
-
599
- else:
600
-
601
- final_chunks.append(
602
- temp_chunk
603
- )
604
-
605
- else:
606
-
607
- final_chunks.append(chunk)
608
-
609
- # စာသားထဲရှိ စကားလုံးအစဉ်ကို စစ်မယ်။
610
- verify_text_integrity(
611
- text,
612
- final_chunks,
613
- )
614
-
615
- return final_chunks
616
-
617
-
618
- # ==================================================
619
- # SUBTITLE SEGMENTATION
620
- # ==================================================
621
-
622
- def split_for_srt(
623
- text,
624
- words_per_subtitle=WORDS_PER_SUBTITLE,
625
- ):
626
- """
627
- Split SRT text at whitespace boundaries.
628
-
629
- Target: approximately 20 whitespace-separated tokens
630
- per subtitle.
631
- """
632
-
633
- words = text.split()
634
-
635
- result = []
636
-
637
- for index in range(
638
- 0,
639
- len(words),
640
- words_per_subtitle,
641
- ):
642
-
643
- result.append(
644
- " ".join(
645
- words[
646
- index:index + words_per_subtitle
647
- ]
648
- )
649
- )
650
-
651
- verify_text_integrity(
652
- text,
653
- result,
654
- )
655
-
656
- return result
657
-
658
-
659
- def verify_text_integrity(
660
- original,
661
- subtitle_texts,
662
- ):
663
- """
664
- Verify every whitespace-separated token is preserved
665
- in the same order.
666
- """
667
-
668
- original_words = original.split()
669
-
670
- output_words = []
671
-
672
- for subtitle in subtitle_texts:
673
-
674
- output_words.extend(
675
- subtitle.split()
676
- )
677
-
678
- if original_words != output_words:
679
-
680
- raise RuntimeError(
681
- "စာသားစစ်ဆေးမှု မအောင်မြင်ပါ။ "
682
- "စာသားအစဉ် သို့မဟုတ် စကားလုံး ပျောက်နေသည်။"
683
- )
684
-
685
- return True
686
-
687
-
688
- # ==================================================
689
- # TTS SPEED
690
- # ==================================================
691
-
692
- def speed_to_rate(speed):
693
-
694
- speed = int(speed)
695
-
696
- speed = max(
697
- -50,
698
- min(100, speed),
699
- )
700
-
701
- if speed >= 0:
702
- return f"+{speed}%"
703
-
704
- return f"{speed}%"
705
-
706
 
707
- # ==================================================
708
- # AUDIO GENERATION
709
- # ==================================================
710
 
711
- async def synthesize_async(
712
- text,
713
- voice,
714
- rate,
715
- output_path,
716
- ):
717
 
 
718
  communicate = edge_tts.Communicate(
719
  text=text,
720
  voice=voice,
721
  rate=rate,
722
  )
723
 
724
- await communicate.save(
725
- output_path
726
- )
727
 
728
  if (
729
- not os.path.isfile(output_path)
730
  or os.path.getsize(output_path) < MIN_AUDIO_BYTES
731
  ):
 
732
 
733
- raise RuntimeError(
734
- "Edge TTS က အသံဖိုင်အလွတ် ပြန်ပေ���ထားသည်။"
735
- )
736
 
 
 
737
 
738
- def synthesize_chunk(
739
- text,
740
- voice,
741
- rate,
742
- output_path,
743
- ):
 
 
 
 
 
 
744
 
745
- asyncio.run(
746
- synthesize_async(
747
- text,
748
  voice,
749
  rate,
750
  output_path,
751
  )
752
- )
753
-
754
-
755
- # ==================================================
756
- # AUDIO JOINING
757
- # ==================================================
758
 
759
- def load_and_trim_audio_part(part_path):
760
- """
761
- Load an audio part without trimming its beginning
762
- or ending.
763
 
764
- No 150 ms trim is applied.
765
- """
766
-
767
- segment = AudioSegment.from_file(
768
- part_path,
769
- format="mp3",
770
- )
771
 
772
- return segment
773
 
 
 
 
 
 
774
 
775
- def combine_audio_parts(
776
- audio_parts,
777
- output_path,
778
- ):
779
- """
780
- Join audio parts in their original order.
781
- Preserve the beginning and ending of each part.
782
- """
783
 
784
- if not audio_parts:
785
 
 
786
  raise RuntimeError(
787
- "ပေါင်းစပ်ရန် Audio အပိုင်း မရှိပါ။"
788
  )
789
 
790
- combined_audio = AudioSegment.empty()
791
 
792
- for part_path in audio_parts:
793
 
794
- segment = load_and_trim_audio_part(
795
- part_path
796
- )
797
 
798
- combined_audio += segment
799
 
800
- if len(combined_audio) <= 0:
 
 
 
801
 
802
- raise RuntimeError(
803
- "ပေါင်းစပ်ထားသော Audio ဖိုင်တွင် အသံမရှိပါ။"
804
- )
 
805
 
806
- combined_audio.export(
 
 
 
 
 
 
 
 
 
 
 
807
  output_path,
808
- format="mp3",
809
- bitrate="192k",
 
 
 
 
 
 
 
 
810
  )
811
 
 
 
 
 
 
 
 
 
812
  if (
813
- not os.path.isfile(output_path)
814
  or os.path.getsize(output_path) < MIN_AUDIO_BYTES
815
  ):
 
816
 
817
- raise RuntimeError(
818
- "ပေါင်းစပ်ထားသော Audio ဖိုင်ကို ထုတ်မရပါ။"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
  )
820
 
821
- return len(combined_audio)
 
 
 
 
822
 
 
 
 
823
 
824
- # ==================================================
825
- # SRT TIMING
826
- # ==================================================
827
 
828
- def format_srt_time(milliseconds):
 
 
829
 
830
- milliseconds = max(
831
- 0,
832
- int(milliseconds),
833
- )
834
 
835
- hours = milliseconds // 3600000
 
 
836
 
837
- milliseconds %= 3600000
 
838
 
839
- minutes = milliseconds // 60000
840
 
841
- milliseconds %= 60000
 
842
 
843
- seconds = milliseconds // 1000
 
844
 
845
- milliseconds %= 1000
 
846
 
847
  return (
848
  f"{hours:02d}:"
849
  f"{minutes:02d}:"
850
- f"{seconds:02d},"
851
  f"{milliseconds:03d}"
852
  )
853
 
854
 
855
- def make_srt_entries(
856
- subtitle_texts,
857
- total_duration_ms,
858
- ):
859
- """
860
- Estimate timing proportionally by subtitle text length.
861
 
862
- This is not exact word-level audio alignment.
863
- """
864
-
865
- if not subtitle_texts:
866
  return []
867
 
868
- weights = [
869
- max(
870
- 1,
871
- len(text.replace(" ", "")),
872
- )
873
- for text in subtitle_texts
874
- ]
875
-
876
- total_weight = sum(weights)
877
 
878
- if total_weight <= 0:
879
  return []
880
 
881
- entries = []
882
-
883
- elapsed = 0
884
-
885
- for index, (text, weight) in enumerate(
886
- zip(subtitle_texts, weights)
887
- ):
888
-
889
- start = elapsed
890
-
891
- if index == len(subtitle_texts) - 1:
892
-
893
- end = total_duration_ms
894
 
895
- else:
 
 
 
896
 
897
- duration = round(
898
- total_duration_ms
899
- * weight
900
- / total_weight
901
- )
902
 
903
- elapsed += duration
904
 
905
- end = elapsed
 
906
 
907
- entries.append(
908
- {
909
- "text": text,
910
- "start": start,
911
- "end": end,
912
- }
913
- )
914
 
915
- return entries
 
 
916
 
 
 
 
 
 
917
 
918
- def build_srt(entries):
919
 
920
- blocks = []
 
921
 
922
- for index, entry in enumerate(
923
- entries,
924
  start=1,
925
  ):
 
926
 
927
- start = format_srt_time(
928
- entry["start"]
929
- )
930
 
931
- end = format_srt_time(
932
- entry["end"]
933
- )
934
 
935
- blocks.append(
936
  f"{index}\n"
937
- f"{start} --> {end}\n"
938
- f"{entry['text']}"
 
939
  )
940
 
941
- return "\n\n".join(blocks) + "\n"
 
 
 
942
 
943
 
944
- # ==================================================
945
- # MAIN GENERATION
946
- # ==================================================
947
 
948
  def generate_audio_and_srt(
949
- script,
950
- selected_voice,
951
  speed,
952
  progress=gr.Progress(),
953
  ):
 
954
 
955
- script = normalize_script(script)
956
-
957
- if not script:
958
-
959
- raise gr.Error(
960
- "Script ထည့်ပေးပါ။"
961
- )
962
-
963
- voice_map = get_voice_map()
964
-
965
- voice = voice_map.get(
966
- selected_voice
967
- )
968
 
969
- if not voice:
970
 
 
971
  raise gr.Error(
972
- "Voice ကို ပြန်ရွေးပေးပါ။"
 
973
  )
974
 
975
- voice_display = selected_voice
976
-
977
- rate = speed_to_rate(speed)
978
-
979
- tts_chunks = split_for_tts(
980
- script
981
- )
982
 
983
- if not tts_chunks:
 
 
 
984
 
985
- raise gr.Error(
986
- "အသံထုတ်ရန် Script မရှိပါ။"
987
- )
988
 
989
- work_dir = tempfile.mkdtemp(
990
- prefix="job_",
991
- dir=OUTPUT_DIR,
992
- )
993
 
994
- audio_path = os.path.join(
995
- work_dir,
996
- "output.mp3",
997
  )
998
 
999
- srt_path = os.path.join(
1000
- work_dir,
1001
- "output.srt",
1002
- )
1003
 
1004
  try:
1005
-
1006
  with JOB_SEMAPHORE:
 
1007
 
1008
- audio_parts = []
1009
-
1010
- total_chunks = len(
1011
- tts_chunks
 
1012
  )
1013
 
1014
- for index, chunk in enumerate(
1015
- tts_chunks
1016
- ):
1017
-
1018
- progress(
1019
- 0.75
1020
- * index
1021
- / total_chunks,
1022
- desc=(
1023
- f"Audio ထုတ်နေသည် "
1024
- f"{index + 1}/{total_chunks}"
1025
- ),
1026
- )
1027
-
1028
- part_path = os.path.join(
1029
- work_dir,
1030
- f"part_{index:05d}.mp3",
1031
- )
1032
 
1033
- synthesize_chunk(
1034
- chunk,
1035
- voice,
 
1036
  rate,
1037
- part_path,
1038
- )
1039
-
1040
- audio_parts.append(
1041
- part_path
1042
  )
1043
-
1044
- progress(
1045
- 0.80,
1046
- desc="Audio ပေါင်းနေသည်...",
1047
  )
1048
 
1049
- duration_ms = combine_audio_parts(
1050
- audio_parts,
1051
- audio_path,
1052
- )
1053
 
1054
- progress(
1055
- 0.90,
1056
- desc="SRT ပြုလုပ်နေသည်...",
 
1057
  )
1058
 
1059
- subtitle_texts = split_for_srt(
1060
- script
1061
- )
1062
 
1063
- verify_text_integrity(
1064
- script,
1065
- subtitle_texts,
1066
- )
1067
 
1068
- entries = make_srt_entries(
1069
- subtitle_texts,
1070
- duration_ms,
 
1071
  )
1072
 
1073
- with open(
1074
- srt_path,
1075
- "w",
1076
- encoding="utf-8-sig",
1077
- newline="\n",
1078
- ) as file:
1079
 
1080
- file.write(
1081
- build_srt(entries)
1082
- )
 
 
 
 
1083
 
1084
- progress(
1085
- 1.0,
1086
- desc="ပြီးပါပြီ!",
1087
- )
1088
 
1089
- logger.info(
1090
- "Generation complete: voice=%s, "
1091
- "rate=%s, chars=%d, chunks=%d",
1092
- voice,
1093
- rate,
1094
- len(script),
1095
- total_chunks,
1096
- )
1097
-
1098
- return (
1099
- audio_path,
1100
- srt_path,
1101
- (
1102
- "အောင်မြင်ပါသည်!\n"
1103
- f"Voice Character: {voice_display}\n"
1104
- f"Speed: {rate}\n"
1105
- f"Script: {len(script)} characters\n"
1106
- f"Audio chunks: {total_chunks}\n"
1107
- f"SRT lines: {len(subtitle_texts)}"
1108
- ),
1109
- )
1110
 
1111
  except Exception as exc:
 
1112
 
1113
- logger.exception(
1114
- "Audio/SRT generation failed"
 
1115
  )
1116
 
1117
- shutil.rmtree(
1118
- work_dir,
1119
- ignore_errors=True,
1120
- )
1121
 
1122
- raise gr.Error(
1123
- f"Error: {type(exc).__name__}: "
1124
- f"{str(exc)[:400]}"
1125
- )
 
 
 
 
 
 
 
1126
 
 
 
 
 
 
1127
 
1128
- # ==================================================
1129
- # USER INTERFACE
1130
- # ==================================================
1131
 
1132
- initial_choices = get_voice_choices()
 
 
1133
 
1134
- initial_voice = get_default_voice_label()
1135
 
 
 
 
 
 
 
1136
 
1137
  with gr.Blocks(
1138
  title=APP_TITLE,
1139
- theme=gr.themes.Soft(),
1140
  ) as demo:
1141
 
1142
  gr.Markdown(
1143
  """
1144
- # 🎙️ Myanmar Multilingual TTS Studio
1145
 
1146
- မြန်မာအမည်များဖြင့် Voice ရွေးချယ်ပြီး
1147
- MP3 Audio နှင့် SRT Subtitle ကို တစ်ခါတည်း ထုတ်ယူပါ။
1148
  """
1149
  )
1150
 
1151
  with gr.Row():
1152
-
1153
- voice_input = gr.Dropdown(
1154
- choices=initial_choices,
1155
  value=initial_voice,
1156
- label="Voice Character",
1157
  interactive=True,
1158
  scale=5,
1159
  )
1160
 
1161
  refresh_button = gr.Button(
1162
- "🔄 Voice List ပြန်ယူမယ်",
1163
- scale=2,
1164
  )
1165
 
1166
- voice_status = gr.Markdown(
1167
- "နေတိုးကို Default Voice အဖြစ် ရွေးထားသည်။"
1168
- )
1169
-
1170
  script_input = gr.Textbox(
1171
- label="Script",
1172
- placeholder=(
1173
- "အသံထုတ်လိုသော စာသားကို ဒီနေရာမှာ ထည့်ပါ..."
1174
- ),
1175
  lines=12,
1176
  max_lines=30,
1177
  )
1178
 
1179
- speed_input = gr.Slider(
1180
  minimum=-50,
1181
  maximum=100,
1182
  value=0,
1183
  step=5,
1184
- label="အသံ Speed (%)",
1185
- )
1186
-
1187
- gr.Markdown(
1188
- """
1189
- - Default Voice: နေတိုး
1190
- - SRT: Space ခြားထားသော စကားလုံး ၂၀ ဝန်းကျင်
1191
- - Speed: -50% မှ +100%
1192
- """
1193
  )
1194
 
1195
  generate_button = gr.Button(
1196
- "🎙️ Audio + SRT ထုတ်မယ်",
1197
  variant="primary",
1198
- size="lg",
1199
  )
1200
 
1201
  status_output = gr.Textbox(
1202
  label="အခြေအနေ",
1203
  interactive=False,
1204
- lines=5,
1205
  )
1206
 
1207
- audio_output = gr.Audio(
1208
- label="🎧 MP3 Audio",
1209
- type="filepath",
1210
- )
 
1211
 
1212
- srt_output = gr.File(
1213
- label="📝 SRT Subtitle Download",
1214
- )
1215
 
1216
  refresh_button.click(
1217
  fn=refresh_voices,
1218
  inputs=[],
1219
- outputs=[
1220
- voice_input,
1221
- voice_status,
1222
- ],
1223
  )
1224
 
1225
  generate_button.click(
1226
  fn=generate_audio_and_srt,
1227
  inputs=[
1228
  script_input,
1229
- voice_input,
1230
- speed_input,
1231
  ],
1232
  outputs=[
1233
  audio_output,
1234
  srt_output,
1235
  status_output,
1236
  ],
1237
- api_name="generate",
1238
  )
1239
 
 
 
 
 
 
 
 
 
1240
 
1241
- if __name__ == "__main__":
1242
 
 
 
 
 
 
1243
  demo.queue(
1244
- max_size=10,
1245
- default_concurrency_limit=2,
1246
- ).launch(
 
 
1247
  server_name="0.0.0.0",
1248
- server_port=int(
1249
- os.environ.get(
1250
- "PORT",
1251
- 7860,
1252
- )
1253
- ),
1254
  )
 
3
  import asyncio
4
  import tempfile
5
  import logging
6
+ import subprocess
7
  import threading
8
+ from pathlib import Path
 
9
 
10
  import gradio as gr
11
  import edge_tts
 
12
 
13
+ # =========================================================
14
+ # APP CONFIG
15
+ # =========================================================
 
 
 
 
16
 
17
  APP_TITLE = "Myanmar Multilingual TTS Studio"
18
 
 
23
 
24
  DEFAULT_VOICE_ID = "it-IT-GiuseppeMultilingualNeural"
25
 
26
+ logging.basicConfig(
27
+ level=logging.INFO,
28
+ format="%(asctime)s - %(levelname)s - %(message)s",
29
+ )
30
+
31
+ logger = logging.getLogger("MyanmarTTS")
32
+
33
+ JOB_SEMAPHORE = threading.Semaphore(MAX_CONCURRENT_JOBS)
34
+
35
+ WORK_DIR = os.path.join(
36
  tempfile.gettempdir(),
37
  "myanmar_multilingual_tts",
38
  )
39
 
40
+ os.makedirs(WORK_DIR, exist_ok=True)
 
41
 
 
 
 
42
 
43
+ # =========================================================
44
+ # VOICE ALIASES
45
+ # UI မှာ မူရင်း English Voice ID မပြဘဲ မြန်မာအမည်သာပြမည်။
46
+ # =========================================================
 
47
 
48
+ VOICE_ALIASES = {
49
  "it-IT-GiuseppeMultilingualNeural": "နေတိုး",
50
  "my-MM-NilarNeural": "ဝတ်မှုန်ရွှေရည်",
51
  "my-MM-ThihaNeural": "ပြေတီဦး",
52
  }
53
 
54
+ VOICE_LIST_CACHE = []
55
+ VOICE_ID_TO_LABEL = {}
56
+ VOICE_LABEL_TO_ID = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
 
58
 
59
+ # =========================================================
60
+ # TEXT CLEANING
61
+ # မူရင်းစာသားနှင့် မြန်မာစာလုံးများကို မဖျက်ပါ။
62
+ # Invisible/control characters များကိုသာ ရှင်းမည်။
63
+ # =========================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
+ def clean_text(text):
66
+ if not text:
67
+ return ""
 
 
 
68
 
69
+ text = str(text)
70
 
71
+ # Unicode normalization မလုပ်ဘဲ control characters သာဖယ်ရှား
72
+ text = text.replace("\ufeff", "")
73
+ text = text.replace("\u200b", "")
74
+ text = text.replace("\u200c", "")
75
+ text = text.replace("\u200d", "")
76
+ text = text.replace("\u2060", "")
77
+ text = text.replace("\r\n", "\n")
78
+ text = text.replace("\r", "\n")
79
 
80
+ # Tab များကို space အဖြစ်ပြောင်း
81
+ text = text.replace("\t", " ")
82
 
83
+ # Control characters များကို ဖယ်ရှားသော်လည်း newline ကိုထိန်းထား
84
+ text = "".join(
85
+ ch for ch in text
86
+ if ch == "\n" or ch == "\u200b" or ord(ch) >= 32
87
+ )
88
 
89
+ # အပို space များကိုရှင်း
90
+ text = re.sub(r"[ ]+", " ", text)
91
+ text = re.sub(r" *\n *", "\n", text)
92
+ text = re.sub(r"\n{3,}", "\n\n", text)
93
 
94
+ return text.strip()
95
 
 
 
 
 
 
96
 
97
+ # =========================================================
98
+ # VOICE LIST
99
+ # =========================================================
100
 
101
+ async def fetch_voices():
102
+ voices = await edge_tts.list_voices()
103
 
104
+ selected = []
105
 
106
  for voice in voices:
107
+ short_name = voice.get("ShortName", "")
 
 
 
 
108
 
109
  if not short_name:
110
  continue
111
 
112
+ # လိုအပ်သော မြန်မာအသံများကို အမြဲထည့်ထားမည်။
113
+ if short_name in VOICE_ALIASES:
114
+ selected.append(voice)
115
+ continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
+ # Multilingual Neural အသံများကိုလည်း ထည့်မည်။
118
+ if "MultilingualNeural" in short_name:
119
+ selected.append(voice)
120
 
121
+ # ShortName အလိုက် duplicate မဖြစ်စေရန်
122
+ unique = {}
123
+ for voice in selected:
124
+ unique[voice["ShortName"]] = voice
125
 
126
+ selected = list(unique.values())
127
 
128
+ # သတ်မှတ်ထားသော Voice များကို အပေါ်ဆုံးထားမည်။
129
+ pinned_order = [
130
+ "it-IT-GiuseppeMultilingualNeural",
131
+ "my-MM-NilarNeural",
132
+ "my-MM-ThihaNeural",
133
+ ]
134
 
135
+ def sort_key(voice):
136
+ name = voice.get("ShortName", "")
137
+ if name in pinned_order:
138
+ return (0, pinned_order.index(name))
139
+ return (1, name.lower())
140
 
141
+ selected.sort(key=sort_key)
142
 
143
+ return selected
 
144
 
 
 
 
 
145
 
146
+ def get_voice_label(voice_id, index=0):
147
+ if voice_id in VOICE_ALIASES:
148
+ return VOICE_ALIASES[voice_id]
149
 
150
+ # အခြား Voice များကိုလည်း English ID မပြဘဲ အမည်ပေးမည်။
151
+ return f"အခြားအသံ {index + 1:03d}"
152
 
 
 
 
153
 
154
+ def load_voice_choices():
155
+ global VOICE_LIST_CACHE
156
+ global VOICE_ID_TO_LABEL
157
+ global VOICE_LABEL_TO_ID
158
 
159
+ try:
160
+ voices = asyncio.run(fetch_voices())
161
 
162
+ VOICE_LIST_CACHE = voices
163
+ VOICE_ID_TO_LABEL = {}
164
+ VOICE_LABEL_TO_ID = {}
 
 
 
 
 
 
 
165
 
166
+ choices = []
167
 
168
+ for index, voice in enumerate(voices):
169
+ voice_id = voice.get("ShortName", "")
 
170
 
171
+ if not voice_id:
172
+ continue
 
 
173
 
174
+ label = get_voice_label(voice_id, index)
 
 
 
 
 
 
175
 
176
+ # Dropdown မှာ duplicate label မဖြစ်စေရန်
177
+ if label in VOICE_LABEL_TO_ID:
178
+ label = f"{label} ({index + 1})"
179
 
180
+ VOICE_ID_TO_LABEL[voice_id] = label
181
+ VOICE_LABEL_TO_ID[label] = voice_id
182
+ choices.append(label)
183
 
184
+ if not choices:
185
+ raise RuntimeError("Voice list is empty.")
 
 
 
186
 
187
+ return choices
 
188
 
189
+ except Exception as exc:
190
+ logger.exception("Voice list loading failed: %s", exc)
191
 
192
+ fallback = [
193
+ "နေတိုး",
194
+ "ဝတ်မှုန်ရွှေရည်",
195
+ "ပြေတီဦး",
196
+ ]
197
 
198
+ VOICE_LABEL_TO_ID = {
199
+ "နေတိုး": "it-IT-GiuseppeMultilingualNeural",
200
+ "ဝတ်မှုန်ရွှေရည်": "my-MM-NilarNeural",
201
+ "ပြေတီဦး": "my-MM-ThihaNeural",
202
+ }
203
 
204
+ VOICE_ID_TO_LABEL = {
205
+ value: key
206
+ for key, value in VOICE_LABEL_TO_ID.items()
207
+ }
208
 
209
+ return fallback
 
 
 
 
210
 
211
 
212
+ # =========================================================
213
+ # TEXT CHUNKING
214
+ # Myanmar sentence endings ကို ဦးစားပေးခွဲမည်။
215
+ # စာသားအစဉ်နှင့် စာသားအပြည့်အစုံကို ထိန်းထားမည်။
216
+ # =========================================================
 
 
 
 
217
 
218
+ def split_long_text(text, max_chars=MAX_CHUNK_CHARACTERS):
219
+ text = text.strip()
220
 
221
+ if not text:
222
+ return []
 
 
 
223
 
224
+ if len(text) <= max_chars:
225
+ return [text]
226
 
227
+ result = []
228
+ remaining = text
229
 
230
+ while len(remaining) > max_chars:
231
+ section = remaining[:max_chars]
 
 
232
 
233
+ # မြန်မာဝါကျအဆုံးကို ဦးစားပေးရှာ
234
+ candidates = [
235
+ section.rfind("။"),
236
+ section.rfind("!"),
237
+ section.rfind("?"),
238
+ section.rfind("၊"),
239
+ section.rfind("."),
240
+ section.rfind("\n"),
241
+ ]
242
 
243
+ split_at = max(candidates)
 
244
 
245
+ # အလွန်တိုသော chunk မဖြစ်စေရန်
246
+ if split_at < max_chars * 0.45:
247
+ split_at = section.rfind(" ")
248
 
249
+ if split_at < max_chars * 0.35:
250
+ split_at = max_chars
251
 
252
+ # Sentence punctuation ကို မဖယ်ရှားဘဲ ခွဲမည်
253
+ if split_at < len(remaining):
254
+ split_at += 1
255
 
256
+ part = remaining[:split_at].strip()
 
 
257
 
258
+ if part:
259
+ result.append(part)
260
 
261
+ remaining = remaining[split_at:].strip()
262
 
263
+ if remaining:
264
+ result.append(remaining)
 
265
 
266
+ return result
 
267
 
 
 
 
268
 
269
+ def split_text_for_tts(text):
270
+ text = clean_text(text)
271
 
272
  if not text:
273
  return []
274
 
275
+ # Myanmar sentence end၊ English punctuation နှင့် newline
276
+ # များကို အခြေခံပြီး စာသားအပိုင်းများ စုမည်။
277
+ units = re.split(
278
+ r"(?<=[။.!?၊,:;])\s+|\n+",
 
 
279
  text,
280
  )
281
 
282
+ chunks = []
283
  current = ""
284
 
285
+ for unit in units:
286
+ unit = unit.strip()
287
 
288
+ if not unit:
 
 
289
  continue
290
 
291
+ # ဝါကျတစ်ကြောင်းတည်းက limit ထက်ရှည်လျှင် ထပ်ခွဲမည်။
292
+ if len(unit) > MAX_CHUNK_CHARACTERS:
293
+ if current:
294
+ chunks.append(current)
295
+ current = ""
296
 
297
+ chunks.extend(
298
+ split_long_text(unit, MAX_CHUNK_CHARACTERS)
299
+ )
300
+ continue
301
 
302
+ if not current:
303
+ current = unit
304
+ continue
305
 
306
+ combined = current + " " + unit
307
 
308
+ if len(combined) <= MAX_CHUNK_CHARACTERS:
309
+ current = combined
310
  else:
311
+ chunks.append(current)
312
+ current = unit
313
 
314
  if current:
315
  chunks.append(current)
316
 
317
+ return chunks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
 
 
 
319
 
320
+ # =========================================================
321
+ # EDGE TTS GENERATION
322
+ # =========================================================
 
 
 
323
 
324
+ async def synthesize_one_chunk(text, voice, rate, output_path):
325
  communicate = edge_tts.Communicate(
326
  text=text,
327
  voice=voice,
328
  rate=rate,
329
  )
330
 
331
+ await communicate.save(output_path)
 
 
332
 
333
  if (
334
+ not os.path.exists(output_path)
335
  or os.path.getsize(output_path) < MIN_AUDIO_BYTES
336
  ):
337
+ raise RuntimeError("Edge TTS returned an empty audio chunk.")
338
 
 
 
 
339
 
340
+ async def synthesize_chunks(text_chunks, voice, rate, temp_dir):
341
+ chunk_files = []
342
 
343
+ for index, chunk in enumerate(text_chunks):
344
+ output_path = os.path.join(
345
+ temp_dir,
346
+ f"chunk_{index:05d}.mp3",
347
+ )
348
+
349
+ logger.info(
350
+ "Synthesizing chunk %s/%s (%s chars)",
351
+ index + 1,
352
+ len(text_chunks),
353
+ len(chunk),
354
+ )
355
 
356
+ await synthesize_one_chunk(
357
+ chunk,
 
358
  voice,
359
  rate,
360
  output_path,
361
  )
 
 
 
 
 
 
362
 
363
+ chunk_files.append(output_path)
 
 
 
364
 
365
+ return chunk_files
 
 
 
 
 
 
366
 
 
367
 
368
+ # =========================================================
369
+ # FFMPEG CONCAT
370
+ # All-in-One ပုံစံအတိုင်း MP3 stream copy ဖြင့်ပေါင်းမည်။
371
+ # Audio ကို trim မလုပ်ပါ။
372
+ # =========================================================
373
 
374
+ def find_ffmpeg():
375
+ import shutil
 
 
 
 
 
 
376
 
377
+ ffmpeg_path = shutil.which("ffmpeg")
378
 
379
+ if not ffmpeg_path:
380
  raise RuntimeError(
381
+ "FFmpeg မတွေ့ပါ။ Environment ထဲမှာ FFmpeg ထည့်ထားပါ။"
382
  )
383
 
384
+ return ffmpeg_path
385
 
 
386
 
387
+ def concatenate_mp3_files(chunk_files, output_path, temp_dir):
388
+ if not chunk_files:
389
+ raise RuntimeError("Audio chunks မရှိပါ။")
390
 
391
+ ffmpeg = find_ffmpeg()
392
 
393
+ concat_list = os.path.join(
394
+ temp_dir,
395
+ "concat_list.txt",
396
+ )
397
 
398
+ # FFmpeg concat demuxer အတွက် file list ရေးမည်။
399
+ with open(concat_list, "w", encoding="utf-8") as file:
400
+ for chunk_path in chunk_files:
401
+ absolute_path = os.path.abspath(chunk_path)
402
 
403
+ # FFmpeg concat list အတွက် quote escaping
404
+ escaped_path = absolute_path.replace("'", "'\\''")
405
+
406
+ file.write(f"file '{escaped_path}'\n")
407
+
408
+ command = [
409
+ ffmpeg,
410
+ "-y",
411
+ "-f", "concat",
412
+ "-safe", "0",
413
+ "-i", concat_list,
414
+ "-c", "copy",
415
  output_path,
416
+ ]
417
+
418
+ logger.info("Joining audio chunks with FFmpeg stream copy.")
419
+
420
+ result = subprocess.run(
421
+ command,
422
+ stdout=subprocess.PIPE,
423
+ stderr=subprocess.PIPE,
424
+ text=True,
425
+ check=False,
426
  )
427
 
428
+ if result.returncode != 0:
429
+ logger.error("FFmpeg concat failed: %s", result.stderr[-4000:])
430
+
431
+ raise RuntimeError(
432
+ "Audio ပေါင်းရာတွင် FFmpeg error ဖြစ်သွားပါတယ်။ "
433
+ "Logs ကိုစစ်ပါ။"
434
+ )
435
+
436
  if (
437
+ not os.path.exists(output_path)
438
  or os.path.getsize(output_path) < MIN_AUDIO_BYTES
439
  ):
440
+ raise RuntimeError("Final audio ဖိုင် မထွက်လာပါ။")
441
 
442
+
443
+ # =========================================================
444
+ # AUDIO DURATION
445
+ # =========================================================
446
+
447
+ def get_audio_duration(audio_path):
448
+ try:
449
+ import subprocess
450
+
451
+ result = subprocess.run(
452
+ [
453
+ find_ffmpeg().replace("ffmpeg", "ffprobe"),
454
+ "-v", "error",
455
+ "-show_entries", "format=duration",
456
+ "-of", "default=noprint_wrappers=1:nokey=1",
457
+ audio_path,
458
+ ],
459
+ stdout=subprocess.PIPE,
460
+ stderr=subprocess.PIPE,
461
+ text=True,
462
+ check=False,
463
  )
464
 
465
+ if result.returncode == 0:
466
+ return max(0.0, float(result.stdout.strip()))
467
+
468
+ except Exception:
469
+ logger.exception("ffprobe duration check failed.")
470
 
471
+ # ffprobe မရလျှင် pydub ဖြင့်စမ်းမည်။
472
+ try:
473
+ from pydub import AudioSegment
474
 
475
+ audio = AudioSegment.from_file(audio_path)
476
+ return len(audio) / 1000.0
 
477
 
478
+ except Exception:
479
+ logger.exception("Audio duration detection failed.")
480
+ return 0.0
481
 
 
 
 
 
482
 
483
+ # =========================================================
484
+ # SRT GENERATION
485
+ # =========================================================
486
 
487
+ def srt_timestamp(seconds):
488
+ seconds = max(0.0, float(seconds))
489
 
490
+ total_ms = int(round(seconds * 1000))
491
 
492
+ hours = total_ms // 3_600_000
493
+ total_ms %= 3_600_000
494
 
495
+ minutes = total_ms // 60_000
496
+ total_ms %= 60_000
497
 
498
+ secs = total_ms // 1000
499
+ milliseconds = total_ms % 1000
500
 
501
  return (
502
  f"{hours:02d}:"
503
  f"{minutes:02d}:"
504
+ f"{secs:02d},"
505
  f"{milliseconds:03d}"
506
  )
507
 
508
 
509
+ def split_text_for_srt(text, words_per_subtitle=WORDS_PER_SUBTITLE):
510
+ text = clean_text(text)
 
 
 
 
511
 
512
+ if not text:
 
 
 
513
  return []
514
 
515
+ # မြန်မာစာတွင် space မရှိသည့် စာသားများကို မခွဲမပစ်ဘဲ
516
+ # ရှိပြီးသား space များအလိုက် အုပ်စုဖွဲ့မည်။
517
+ words = text.split()
 
 
 
 
 
 
518
 
519
+ if not words:
520
  return []
521
 
522
+ result = []
 
 
 
 
 
 
 
 
 
 
 
 
523
 
524
+ for start in range(0, len(words), words_per_subtitle):
525
+ result.append(
526
+ " ".join(words[start:start + words_per_subtitle])
527
+ )
528
 
529
+ return result
 
 
 
 
530
 
 
531
 
532
+ def create_srt(text, duration, output_path):
533
+ subtitle_lines = split_text_for_srt(text)
534
 
535
+ if not subtitle_lines:
536
+ raise RuntimeError("SRT ထုတ်ရန် စာသားမရှိပါ။")
 
 
 
 
 
537
 
538
+ if duration <= 0:
539
+ # Duration မရသည့်အခါ fallback
540
+ duration = max(1.0, len(text) / 8.0)
541
 
542
+ # စာသားအရှည်အချိုးအစားအလိုက် အချိန်ခွဲမည်။
543
+ weights = [
544
+ max(1, len(line.strip()))
545
+ for line in subtitle_lines
546
+ ]
547
 
548
+ total_weight = sum(weights)
549
 
550
+ current_time = 0.0
551
+ entries = []
552
 
553
+ for index, (line, weight) in enumerate(
554
+ zip(subtitle_lines, weights),
555
  start=1,
556
  ):
557
+ segment_duration = duration * weight / total_weight
558
 
559
+ start_time = current_time
560
+ end_time = current_time + segment_duration
 
561
 
562
+ # နောက်ဆုံး subtitle ကို audio duration နှင့်ညှိ
563
+ if index == len(subtitle_lines):
564
+ end_time = duration
565
 
566
+ entries.append(
567
  f"{index}\n"
568
+ f"{srt_timestamp(start_time)} --> "
569
+ f"{srt_timestamp(end_time)}\n"
570
+ f"{line}\n"
571
  )
572
 
573
+ current_time = end_time
574
+
575
+ with open(output_path, "w", encoding="utf-8-sig") as file:
576
+ file.write("\n".join(entries))
577
 
578
 
579
+ # =========================================================
580
+ # MAIN GENERATION FUNCTION
581
+ # =========================================================
582
 
583
  def generate_audio_and_srt(
584
+ text,
585
+ voice_label,
586
  speed,
587
  progress=gr.Progress(),
588
  ):
589
+ text = clean_text(text)
590
 
591
+ if not text:
592
+ raise gr.Error("စာသားထည့်ပေးပါ။")
 
 
 
 
 
 
 
 
 
 
 
593
 
594
+ voice_id = VOICE_LABEL_TO_ID.get(voice_label)
595
 
596
+ if not voice_id:
597
  raise gr.Error(
598
+ "ရွေးထားတဲ့ Voice ကိုမတွေ့ပါ။ "
599
+ "Voice list ကို Refresh လုပ်ပြီး ထပ်ရွေးပါ။"
600
  )
601
 
602
+ # Edge TTS rate format
603
+ speed = int(speed)
 
 
 
 
 
604
 
605
+ if speed > 0:
606
+ rate = f"+{speed}%"
607
+ else:
608
+ rate = f"{speed}%"
609
 
610
+ text_chunks = split_text_for_tts(text)
 
 
611
 
612
+ if not text_chunks:
613
+ raise gr.Error("အသံထုတ်ရန် စာသားမရှိပါ။")
 
 
614
 
615
+ job_dir = tempfile.mkdtemp(
616
+ prefix="tts_job_",
617
+ dir=WORK_DIR,
618
  )
619
 
620
+ output_audio = os.path.join(job_dir, "output.mp3")
621
+ output_srt = os.path.join(job_dir, "subtitles.srt")
 
 
622
 
623
  try:
 
624
  with JOB_SEMAPHORE:
625
+ progress(0.05, desc="စာသားပြင်ဆင်နေသည်...")
626
 
627
+ logger.info(
628
+ "Starting job: voice=%s, chunks=%s, chars=%s",
629
+ voice_id,
630
+ len(text_chunks),
631
+ len(text),
632
  )
633
 
634
+ progress(0.10, desc="Edge TTS အသံထုတ်နေသည်...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
 
636
+ chunk_files = asyncio.run(
637
+ synthesize_chunks(
638
+ text_chunks,
639
+ voice_id,
640
  rate,
641
+ job_dir,
 
 
 
 
642
  )
 
 
 
 
643
  )
644
 
645
+ progress(0.80, desc="အသံအပိုင်းများကို ပေါင်းနေသည်...")
 
 
 
646
 
647
+ concatenate_mp3_files(
648
+ chunk_files,
649
+ output_audio,
650
+ job_dir,
651
  )
652
 
653
+ progress(0.92, desc="SRT ဖိုင်ထုတ်နေသည်...")
 
 
654
 
655
+ duration = get_audio_duration(output_audio)
 
 
 
656
 
657
+ create_srt(
658
+ text,
659
+ duration,
660
+ output_srt,
661
  )
662
 
663
+ progress(1.0, desc="ပြီးပါပြီ။")
 
 
 
 
 
664
 
665
+ status = (
666
+ "ပြီးပါပြီ။\n"
667
+ f"Voice: {voice_label}\n"
668
+ f"စာလုံးရေ: {len(text)}\n"
669
+ f"Audio chunks: {len(text_chunks)}\n"
670
+ f"Audio ကြာချိန်: {duration:.2f} seconds"
671
+ )
672
 
673
+ return output_audio, output_srt, status
 
 
 
674
 
675
+ except gr.Error:
676
+ raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
 
678
  except Exception as exc:
679
+ logger.exception("TTS generation failed.")
680
 
681
+ raise gr.Error(
682
+ "အသံထုတ်ရာတွင် Error ဖြစ်သွားပါတယ်။ "
683
+ f"အကြောင်းရင်း: {str(exc)}"
684
  )
685
 
 
 
 
 
686
 
687
+ # =========================================================
688
+ # REFRESH VOICES
689
+ # =========================================================
690
+
691
+ def refresh_voices():
692
+ choices = load_voice_choices()
693
+
694
+ default_label = VOICE_ID_TO_LABEL.get(
695
+ DEFAULT_VOICE_ID,
696
+ choices[0] if choices else None,
697
+ )
698
 
699
+ return gr.Dropdown(
700
+ choices=choices,
701
+ value=default_label,
702
+ label="အသံရွေးချယ်ပါ",
703
+ )
704
 
 
 
 
705
 
706
+ # =========================================================
707
+ # UI
708
+ # =========================================================
709
 
710
+ initial_voice_choices = load_voice_choices()
711
 
712
+ initial_voice = VOICE_ID_TO_LABEL.get(
713
+ DEFAULT_VOICE_ID,
714
+ initial_voice_choices[0] if initial_voice_choices else None,
715
+ )
716
+
717
+ theme = gr.themes.Soft()
718
 
719
  with gr.Blocks(
720
  title=APP_TITLE,
721
+ theme=theme,
722
  ) as demo:
723
 
724
  gr.Markdown(
725
  """
726
+ # Myanmar Multilingual TTS Studio
727
 
728
+ မြန်မာစာနှင့် Multilingual Neural Voice များကို
729
+ အသုံးပြုပြီး အသံနှင့် SRT ဖိုင် ထုတ်နိုင်ပါသည်။
730
  """
731
  )
732
 
733
  with gr.Row():
734
+ voice_dropdown = gr.Dropdown(
735
+ choices=initial_voice_choices,
 
736
  value=initial_voice,
737
+ label="အသံရွေးချယ်ပါ",
738
  interactive=True,
739
  scale=5,
740
  )
741
 
742
  refresh_button = gr.Button(
743
+ "အသံစာရင်း Refresh",
744
+ scale=1,
745
  )
746
 
 
 
 
 
747
  script_input = gr.Textbox(
748
+ label="အသံထုတ်မည့် စာသား",
749
+ placeholder="ဒီနေရာမှာ စာသားထည့်ပါ...",
 
 
750
  lines=12,
751
  max_lines=30,
752
  )
753
 
754
+ speed_slider = gr.Slider(
755
  minimum=-50,
756
  maximum=100,
757
  value=0,
758
  step=5,
759
+ label="အသံအမြန်နှုန်း",
760
+ info="0% = ပုံမှန်၊ အနှုတ်တန်ဖိုး = နှေး၊ အပေါင်းတန်ဖိုး = မြန်",
 
 
 
 
 
 
 
761
  )
762
 
763
  generate_button = gr.Button(
764
+ "အသံနှင့် SRT ထုတ်မည်",
765
  variant="primary",
 
766
  )
767
 
768
  status_output = gr.Textbox(
769
  label="အခြေအနေ",
770
  interactive=False,
771
+ lines=4,
772
  )
773
 
774
+ with gr.Row():
775
+ audio_output = gr.Audio(
776
+ label="ထုတ်ထားသော အသံ",
777
+ type="filepath",
778
+ )
779
 
780
+ srt_output = gr.File(
781
+ label="SRT ဖိုင်",
782
+ )
783
 
784
  refresh_button.click(
785
  fn=refresh_voices,
786
  inputs=[],
787
+ outputs=[voice_dropdown],
 
 
 
788
  )
789
 
790
  generate_button.click(
791
  fn=generate_audio_and_srt,
792
  inputs=[
793
  script_input,
794
+ voice_dropdown,
795
+ speed_slider,
796
  ],
797
  outputs=[
798
  audio_output,
799
  srt_output,
800
  status_output,
801
  ],
 
802
  )
803
 
804
+ gr.Markdown(
805
+ """
806
+ ---
807
+ **မှတ်ချက်:** အသံထုတ်ချိန်တွင် စာသားကို အပိုင်းငယ်များခွဲပြီး
808
+ နောက်ဆုံးတွင် FFmpeg ဖြင့် ပေါင်းစပ်ပေးပါသည်။
809
+ မူရင်းအသံအပိုင်းများကို ဖြတ်တောက်ခြင်း မပြုလုပ်ပါ။
810
+ """
811
+ )
812
 
 
813
 
814
+ # =========================================================
815
+ # START SERVER
816
+ # =========================================================
817
+
818
+ if __name__ == "__main__":
819
  demo.queue(
820
+ max_size=5,
821
+ default_concurrency_limit=MAX_CONCURRENT_JOBS,
822
+ )
823
+
824
+ demo.launch(
825
  server_name="0.0.0.0",
826
+ server_port=int(os.environ.get("PORT", 7860)),
 
 
 
 
 
827
  )