Phoe2004 commited on
Commit
8ce2809
·
verified ·
1 Parent(s): b6590ae

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +96 -14
  2. index.html +387 -42
app.py CHANGED
@@ -21,7 +21,7 @@ DEFAULT_VOICE = "my-MM-NilarNeural"
21
  TMP_DIR = os.path.join(tempfile.gettempdir(), "mm_tts_outputs")
22
  os.makedirs(TMP_DIR, exist_ok=True)
23
 
24
- MAX_CHARS = 3000
25
 
26
 
27
  def clamp_pct(value, lo=-50, hi=50):
@@ -41,6 +41,49 @@ def display_name(short_name):
41
  return part
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  @app.route("/")
45
  def index():
46
  return send_file(os.path.join(BASE_DIR, "index.html"))
@@ -48,13 +91,21 @@ def index():
48
 
49
  @app.route("/api/voices")
50
  def get_voices():
51
- """Return voices grouped by language for the UI dropdowns."""
 
52
  grouped = OrderedDict()
 
53
  for short_name, lang, gender in VOICE_LIST:
 
 
54
  grouped.setdefault(lang, []).append(
55
- {"id": short_name, "name": display_name(short_name), "gender": gender}
56
  )
57
- return jsonify(grouped)
 
 
 
 
58
 
59
 
60
  @app.route("/api/tts", methods=["POST"])
@@ -78,31 +129,62 @@ def tts():
78
  rate_str = f"{'+' if rate >= 0 else ''}{rate}%"
79
  pitch_str = f"{'+' if pitch >= 0 else ''}{pitch}Hz"
80
 
81
- filename = f"{uuid.uuid4().hex}.mp3"
82
- filepath = os.path.join(TMP_DIR, filename)
83
-
84
- async def generate():
85
- communicate = edge_tts.Communicate(text, voice, rate=rate_str, pitch=pitch_str)
86
- await communicate.save(filepath)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  try:
89
- asyncio.run(generate())
90
  except Exception as e:
 
 
 
91
  return jsonify({"error": str(e)}), 500
92
 
93
- if not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  return jsonify({"error": "အသံဖိုင် ထုတ်ယူခြင်း မအောင်မြင်ပါ"}), 500
95
 
96
  @after_this_request
97
  def cleanup(response):
98
  try:
99
- os.remove(filepath)
100
  except OSError:
101
  pass
102
  return response
103
 
104
  return send_file(
105
- filepath,
106
  mimetype="audio/mpeg",
107
  as_attachment=False,
108
  download_name="voice.mp3",
 
21
  TMP_DIR = os.path.join(tempfile.gettempdir(), "mm_tts_outputs")
22
  os.makedirs(TMP_DIR, exist_ok=True)
23
 
24
+ MAX_CHARS = 10000
25
 
26
 
27
  def clamp_pct(value, lo=-50, hi=50):
 
41
  return part
42
 
43
 
44
+ GENDER_SYMBOL = {"Female": "\u2640", "Male": "\u2642"}
45
+
46
+
47
+ def region_code(short_name):
48
+ """Pull the ISO region code out of an edge-tts short name, e.g.
49
+ 'en-US-AvaNeural' -> 'US', 'iu-Cans-CA-SiqiniqNeural' -> 'CA',
50
+ 'zh-CN-liaoning-XiaobeiNeural' -> 'CN'."""
51
+ parts = short_name.split("-")
52
+ if len(parts) < 2:
53
+ return None
54
+ candidate = parts[1]
55
+ if len(candidate) == 4 and candidate.isalpha():
56
+ # script subtag (e.g. "Cans", "Latn") -> region is the next part
57
+ return parts[2] if len(parts) > 2 else None
58
+ if len(candidate) == 2 and candidate.isalpha():
59
+ return candidate
60
+ return None
61
+
62
+
63
+ def flag_emoji(region):
64
+ if not region or len(region) != 2:
65
+ return "\U0001F310" # globe fallback
66
+ base = 0x1F1E6
67
+ try:
68
+ return "".join(chr(base + (ord(c.upper()) - ord("A"))) for c in region)
69
+ except Exception:
70
+ return "\U0001F310"
71
+
72
+
73
+ import re
74
+
75
+ MAX_PARALLEL_CHUNKS = 6 # cap concurrent Edge TTS connections
76
+
77
+
78
+ def split_paragraphs(text):
79
+ """Split on blank lines into paragraphs. If there are no blank lines
80
+ (just one block of text), returns a single-item list — same as the
81
+ old single-request behavior."""
82
+ raw = re.split(r"\n\s*\n+", text.strip())
83
+ paragraphs = [p.strip() for p in raw if p.strip()]
84
+ return paragraphs or [text.strip()]
85
+
86
+
87
  @app.route("/")
88
  def index():
89
  return send_file(os.path.join(BASE_DIR, "index.html"))
 
91
 
92
  @app.route("/api/voices")
93
  def get_voices():
94
+ """Return voices grouped by language for the UI dropdowns, with a flag
95
+ per language group and a gender symbol baked into each voice name."""
96
  grouped = OrderedDict()
97
+ flags = {}
98
  for short_name, lang, gender in VOICE_LIST:
99
+ flags.setdefault(lang, flag_emoji(region_code(short_name)))
100
+ symbol = GENDER_SYMBOL.get(gender, "")
101
  grouped.setdefault(lang, []).append(
102
+ {"id": short_name, "name": display_name(short_name), "gender": gender, "symbol": symbol}
103
  )
104
+
105
+ result = OrderedDict()
106
+ for lang, voices in grouped.items():
107
+ result[lang] = {"flag": flags[lang], "voices": voices}
108
+ return jsonify(result)
109
 
110
 
111
  @app.route("/api/tts", methods=["POST"])
 
129
  rate_str = f"{'+' if rate >= 0 else ''}{rate}%"
130
  pitch_str = f"{'+' if pitch >= 0 else ''}{pitch}Hz"
131
 
132
+ paragraphs = split_paragraphs(text)
133
+ final_filename = f"{uuid.uuid4().hex}.mp3"
134
+ final_filepath = os.path.join(TMP_DIR, final_filename)
135
+ part_paths = [
136
+ os.path.join(TMP_DIR, f"{uuid.uuid4().hex}_part{i}.mp3")
137
+ for i in range(len(paragraphs))
138
+ ]
139
+
140
+ async def generate_one(idx, paragraph, sem):
141
+ async with sem:
142
+ communicate = edge_tts.Communicate(
143
+ paragraph, voice, rate=rate_str, pitch=pitch_str
144
+ )
145
+ await communicate.save(part_paths[idx])
146
+
147
+ async def generate_all():
148
+ sem = asyncio.Semaphore(MAX_PARALLEL_CHUNKS)
149
+ await asyncio.gather(
150
+ *[generate_one(i, p, sem) for i, p in enumerate(paragraphs)]
151
+ )
152
 
153
  try:
154
+ asyncio.run(generate_all())
155
  except Exception as e:
156
+ for p in part_paths:
157
+ if os.path.exists(p):
158
+ os.remove(p)
159
  return jsonify({"error": str(e)}), 500
160
 
161
+ # Stitch the per-paragraph mp3s back together in order. Edge TTS's mp3
162
+ # output is plain MPEG audio frames with no embedded ID3 tags, so a raw
163
+ # byte-for-byte concatenation plays back correctly — no ffmpeg needed.
164
+ try:
165
+ with open(final_filepath, "wb") as out:
166
+ for p in part_paths:
167
+ if os.path.exists(p) and os.path.getsize(p) > 0:
168
+ with open(p, "rb") as part:
169
+ out.write(part.read())
170
+ finally:
171
+ for p in part_paths:
172
+ if os.path.exists(p):
173
+ os.remove(p)
174
+
175
+ if not os.path.exists(final_filepath) or os.path.getsize(final_filepath) == 0:
176
  return jsonify({"error": "အသံဖိုင် ထုတ်ယူခြင်း မအောင်မြင်ပါ"}), 500
177
 
178
  @after_this_request
179
  def cleanup(response):
180
  try:
181
+ os.remove(final_filepath)
182
  except OSError:
183
  pass
184
  return response
185
 
186
  return send_file(
187
+ final_filepath,
188
  mimetype="audio/mpeg",
189
  as_attachment=False,
190
  download_name="voice.mp3",
index.html CHANGED
@@ -55,12 +55,6 @@
55
  color:var(--accent);
56
  margin:0 0 8px;
57
  }
58
- h1{
59
- font-family:var(--mm);
60
- font-weight:700;
61
- font-size:clamp(24px,6vw,32px);
62
- margin:0 0 8px;
63
- }
64
  .tagline{
65
  color:var(--ink-soft);
66
  font-size:14px;
@@ -279,58 +273,249 @@
279
  color:var(--ink-soft);
280
  font-family:var(--sans);
281
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  </style>
283
  </head>
284
  <body>
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  <div class="wrap">
287
  <header>
288
- <p class="eyebrow">Voice Studio · Free Text-to-Speech</p>
289
- <h1>အသံ Studio</h1>
290
- <p class="tagline">ဘာသာစကား 140+ မျိုးဖြင့် စာသားကို အသံအဖြစ် ပြေ��င်းလဲပါ</p>
291
  <div class="wave" id="wave"></div>
292
  </header>
293
 
294
  <!-- Text input -->
295
  <section class="panel">
296
- <h2>Script</h2>
297
- <textarea id="text" placeholder="စာသား ဒီနေရာတွင် ရိုက်ထည့်ပါ..." maxlength="3000"></textarea>
298
- <div class="char-count" id="charCount">0 / 3000</div>
299
  </section>
300
 
301
  <!-- Voice + settings -->
302
  <section class="panel">
303
- <h2>Voice</h2>
304
 
305
  <div class="field">
306
- <label>ဘာသာစကား ရှာရန် (Search language)</label>
307
- <input type="text" class="lang-search" id="langSearch" placeholder="e.g. Myanmar, English, Thai...">
308
  </div>
309
 
310
  <div class="row2">
311
  <div class="field">
312
- <label>ဘာသာစကား (Language)</label>
313
  <select id="langSelect"></select>
314
  </div>
315
  <div class="field">
316
- <label>အသံ (Voice)</label>
317
  <select id="voiceSelect"></select>
318
  </div>
319
  </div>
320
 
321
  <div class="controls">
322
  <div class="control">
323
- <label>အသံနှုန်း (Speed) <span class="val" id="rateVal">+0%</span></label>
324
  <input type="range" id="rate" min="-50" max="50" value="0" step="5">
325
  </div>
326
  <div class="control">
327
- <label>အသံအနိမ့်အမြင့် (Pitch) <span class="val" id="pitchVal">+0Hz</span></label>
328
  <input type="range" id="pitch" min="-50" max="50" value="0" step="5">
329
  </div>
330
  </div>
331
 
332
  <div class="actions">
333
- <button class="btn-primary" id="generateBtn">အသံ ထုတ်မည်</button>
334
  </div>
335
 
336
  <div class="status" id="status"></div>
@@ -338,17 +523,174 @@
338
  <div class="output" id="output">
339
  <audio id="player" controls></audio>
340
  <div class="actions">
341
- <a class="btn-secondary" id="downloadLink" download="voice.mp3" style="text-decoration:none; display:inline-block; text-align:center;">ဒေါင်းလုဒ် (mp3)</a>
 
 
 
 
 
 
342
  </div>
343
  </div>
344
  </section>
345
  </div>
346
 
347
- <footer>
348
  Powered by Edge TTS · Free &amp; Open Voice Studio
349
  </footer>
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  // ---- Build signature waveform ----
353
  const wave = document.getElementById('wave');
354
  const BAR_COUNT = 20;
@@ -402,8 +744,8 @@
402
  const charCount = document.getElementById('charCount');
403
  text.addEventListener('input', () => {
404
  const len = text.value.length;
405
- charCount.textContent = `${len} / 3000`;
406
- charCount.classList.toggle('over', len > 3000);
407
  });
408
 
409
  // ---- Voices: fetch grouped by language, populate selects ----
@@ -411,18 +753,20 @@
411
  const voiceSelect = document.getElementById('voiceSelect');
412
  const langSearch = document.getElementById('langSearch');
413
 
414
- let voiceData = {}; // { language: [ {id, name, gender}, ... ] }
415
- let languages = []; // sorted language names
416
  const DEFAULT_LANG = 'Myanmar (Burmese)';
417
  const DEFAULT_VOICE_ID = 'my-MM-NilarNeural';
418
 
419
  function populateVoicesFor(lang) {
420
  voiceSelect.innerHTML = '';
421
- const list = voiceData[lang] || [];
 
422
  list.forEach(v => {
423
  const opt = document.createElement('option');
424
  opt.value = v.id;
425
- opt.textContent = `${v.name} (${v.gender})`;
 
426
  voiceSelect.appendChild(opt);
427
  });
428
  }
@@ -434,7 +778,8 @@
434
  filtered.forEach(lang => {
435
  const opt = document.createElement('option');
436
  opt.value = lang;
437
- opt.textContent = lang;
 
438
  langSelect.appendChild(opt);
439
  });
440
  if (filtered.length) {
@@ -456,13 +801,13 @@
456
  languages = Object.keys(voiceData);
457
  populateLanguages('');
458
  // select default Myanmar voice if present
459
- if (voiceData[DEFAULT_LANG] && voiceData[DEFAULT_LANG].some(v => v.id === DEFAULT_VOICE_ID)) {
460
  langSelect.value = DEFAULT_LANG;
461
  populateVoicesFor(DEFAULT_LANG);
462
  voiceSelect.value = DEFAULT_VOICE_ID;
463
  }
464
  } catch (e) {
465
- status.textContent = 'ဘာသာစကား စာရင်း ရယူခြင်း မအောင်မြင်ပါ။';
466
  status.classList.add('error');
467
  }
468
  }
@@ -523,14 +868,14 @@
523
  generateBtn.addEventListener('click', async () => {
524
  const value = text.value.trim();
525
  if (!value) {
526
- status.textContent = 'စာသား ထည့်ပေးပါ။';
527
  status.classList.add('error');
528
  return;
529
  }
530
 
531
  const voice = voiceSelect.value;
532
  if (!voice) {
533
- status.textContent = 'အသံတစ်ခု ရွေးပေးပါ။';
534
  status.classList.add('error');
535
  return;
536
  }
@@ -540,17 +885,17 @@
540
 
541
  // Watch a short rewarded ad to unlock this free TTS generation (Android app only).
542
  if (inApp) {
543
- status.textContent = 'အကြောင်း — အခမဲ့ အသံထုတ်ရန် ကြော်ငြာ ပြသပါမည်...';
544
  }
545
  const okToGenerate = await requestRewardThen('generate');
546
  if (!okToGenerate) {
547
- status.textContent = 'အသံအခမဲ့ရယူရန် ကြော်ငြာကို အပြည့်အစုံ ကြည့်ပေးပါ။';
548
  status.classList.add('error');
549
  generateBtn.disabled = false;
550
  return;
551
  }
552
 
553
- status.textContent = 'အသံထုတ်နေပါသည်...';
554
  startWaveAnimation();
555
 
556
  try {
@@ -567,7 +912,7 @@
567
 
568
  if (!res.ok) {
569
  const err = await res.json().catch(() => ({}));
570
- throw new Error(err.error || 'အသံထုတ်ခြင်း မအောင်မြင်ပါ');
571
  }
572
 
573
  const blob = await res.blob();
@@ -578,7 +923,7 @@
578
  player.src = currentObjectUrl;
579
  downloadLink.href = currentObjectUrl;
580
  output.classList.add('visible');
581
- status.textContent = 'အသံ အောင်မြင်စွာ ထုတ်ပြီးပါပြီ။';
582
  player.play().catch(() => {});
583
  notifyInterstitialCheckpoint();
584
  } catch (e) {
@@ -597,10 +942,10 @@
597
  if (!currentBlob) return;
598
 
599
  status.classList.remove('error');
600
- status.textContent = 'ဒေါင်းလုဒ် အခမဲ့ ရယူရန် ကြော်ငြာ ပြသပါမည်...';
601
  const okToDownload = await requestRewardThen('download');
602
  if (!okToDownload) {
603
- status.textContent = 'ဒေါင်းလုဒ်ရန် ကြော်ငြာကို အပြည့်အစုံ ကြည့်ပေးပါ။';
604
  status.classList.add('error');
605
  return;
606
  }
@@ -608,9 +953,9 @@
608
  try {
609
  const base64 = await blobToBase64(currentBlob);
610
  window.AndroidBridge.saveBase64Mp3(base64, 'voice.mp3');
611
- status.textContent = 'Downloads ဖိုလ်ဒါသို့ သိမ်းပြီးပါပြီ။';
612
  } catch (err) {
613
- status.textContent = 'ဒေါင်းလုဒ် မအောင်မြင်ပါ။';
614
  status.classList.add('error');
615
  }
616
  });
 
55
  color:var(--accent);
56
  margin:0 0 8px;
57
  }
 
 
 
 
 
 
58
  .tagline{
59
  color:var(--ink-soft);
60
  font-size:14px;
 
273
  color:var(--ink-soft);
274
  font-family:var(--sans);
275
  }
276
+
277
+ /* ---------- Top app bar ---------- */
278
+ .app-bar{
279
+ position:sticky;
280
+ top:0;
281
+ z-index:20;
282
+ background:#fff;
283
+ border-bottom:1px solid var(--line);
284
+ display:flex;
285
+ align-items:center;
286
+ justify-content:space-between;
287
+ padding:12px 18px;
288
+ }
289
+ .app-bar-left{
290
+ display:flex;
291
+ align-items:center;
292
+ gap:10px;
293
+ }
294
+ .app-bar-logo{
295
+ width:32px;
296
+ height:32px;
297
+ border-radius:9px;
298
+ background:var(--accent);
299
+ display:flex;
300
+ align-items:center;
301
+ justify-content:center;
302
+ flex-shrink:0;
303
+ }
304
+ .app-bar-logo svg{ width:16px; height:16px; }
305
+ .app-bar-title{
306
+ font-family:var(--mm);
307
+ font-weight:700;
308
+ font-size:19px;
309
+ color:var(--ink);
310
+ }
311
+ .icon-btn{
312
+ width:38px;
313
+ height:38px;
314
+ border-radius:50%;
315
+ background:var(--surface-2);
316
+ border:1px solid var(--line);
317
+ display:flex;
318
+ align-items:center;
319
+ justify-content:center;
320
+ cursor:pointer;
321
+ padding:0;
322
+ flex-shrink:0;
323
+ }
324
+ .icon-btn:active{ transform:scale(.95); }
325
+ .icon-btn svg{ width:18px; height:18px; }
326
+
327
+ /* ---------- Header hero (below the app bar) ---------- */
328
+ header{ padding-top:24px; }
329
+
330
+ /* ---------- Download button (icon + label) ---------- */
331
+ .btn-download{
332
+ display:flex;
333
+ align-items:center;
334
+ justify-content:center;
335
+ gap:8px;
336
+ text-decoration:none;
337
+ flex:1;
338
+ }
339
+ .btn-download svg{ width:18px; height:18px; flex-shrink:0; }
340
+
341
+ /* ---------- About / menu modal ---------- */
342
+ .modal-overlay{
343
+ position:fixed;
344
+ inset:0;
345
+ background:rgba(15,17,21,.45);
346
+ display:none;
347
+ align-items:center;
348
+ justify-content:center;
349
+ padding:24px;
350
+ z-index:100;
351
+ }
352
+ .modal-overlay.visible{ display:flex; }
353
+
354
+ .modal-card{
355
+ background:#fff;
356
+ border-radius:18px;
357
+ width:100%;
358
+ max-width:360px;
359
+ padding:22px;
360
+ max-height:85vh;
361
+ overflow-y:auto;
362
+ }
363
+
364
+ .modal-header{
365
+ display:flex;
366
+ align-items:center;
367
+ gap:12px;
368
+ margin-bottom:18px;
369
+ }
370
+ .modal-logo{
371
+ width:46px;
372
+ height:46px;
373
+ border-radius:12px;
374
+ background:var(--accent);
375
+ display:flex;
376
+ align-items:center;
377
+ justify-content:center;
378
+ flex-shrink:0;
379
+ }
380
+ .modal-logo svg{ width:22px; height:22px; }
381
+ .modal-app-name{
382
+ font-family:var(--mm);
383
+ font-weight:700;
384
+ font-size:17px;
385
+ color:var(--ink);
386
+ }
387
+ .modal-version{
388
+ font-family:var(--sans);
389
+ font-size:12px;
390
+ color:var(--ink-soft);
391
+ margin-top:2px;
392
+ }
393
+
394
+ .modal-section{
395
+ border-top:1px solid var(--line);
396
+ padding:14px 0;
397
+ }
398
+ .modal-row{
399
+ display:flex;
400
+ align-items:center;
401
+ justify-content:space-between;
402
+ gap:10px;
403
+ padding:6px 0;
404
+ }
405
+ .modal-row-label{
406
+ font-family:var(--sans);
407
+ font-size:12px;
408
+ font-weight:600;
409
+ letter-spacing:.04em;
410
+ color:var(--ink-soft);
411
+ text-transform:uppercase;
412
+ }
413
+ .modal-row-value{
414
+ font-family:var(--sans);
415
+ font-size:14px;
416
+ color:var(--accent);
417
+ text-decoration:none;
418
+ font-weight:600;
419
+ }
420
+
421
+ .lang-toggle{
422
+ display:flex;
423
+ gap:8px;
424
+ margin-top:10px;
425
+ }
426
+ .lang-toggle-btn{
427
+ flex:1;
428
+ font-family:var(--mm);
429
+ font-size:14px;
430
+ font-weight:600;
431
+ padding:10px 12px;
432
+ border-radius:10px;
433
+ border:1px solid var(--line);
434
+ background:var(--surface-2);
435
+ color:var(--ink);
436
+ cursor:pointer;
437
+ }
438
+ .lang-toggle-btn.active{
439
+ background:var(--accent);
440
+ border-color:var(--accent);
441
+ color:#fff;
442
+ }
443
+
444
+ .modal-close{
445
+ width:100%;
446
+ margin-top:16px;
447
+ background:var(--surface-2);
448
+ color:var(--ink);
449
+ border:1px solid var(--line);
450
+ }
451
  </style>
452
  </head>
453
  <body>
454
 
455
+ <div class="app-bar">
456
+ <div class="app-bar-left">
457
+ <div class="app-bar-logo">
458
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
459
+ <path d="M4 10v4h3l4 4V6L7 10H4z" fill="#fff"/>
460
+ <path d="M16.5 9a4 4 0 010 6" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>
461
+ </svg>
462
+ </div>
463
+ <span class="app-bar-title" data-i18n="title">အသံ Studio</span>
464
+ </div>
465
+ <button class="icon-btn" id="menuBtn" aria-label="Menu">
466
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
467
+ <path d="M4 7h16M4 12h16M4 17h16" stroke="#16181D" stroke-width="1.8" stroke-linecap="round"/>
468
+ </svg>
469
+ </button>
470
+ </div>
471
+
472
  <div class="wrap">
473
  <header>
474
+ <p class="eyebrow" data-i18n="eyebrow">Voice Studio · Free Text-to-Speech</p>
475
+ <p class="tagline" data-i18n="tagline">ဘာသာစကား 140+ မျိုးဖြင့် စာသားကို အသံအဖြစ် ပြောင်းလဲပါ</p>
 
476
  <div class="wave" id="wave"></div>
477
  </header>
478
 
479
  <!-- Text input -->
480
  <section class="panel">
481
+ <h2 data-i18n="scriptLabel">Script</h2>
482
+ <textarea id="text" data-i18n-placeholder="textPlaceholder" placeholder="စာသား ဒီနေရာတွင် ရိုက်ထည့်ပါ..." maxlength="10000"></textarea>
483
+ <div class="char-count" id="charCount">0 / 10000</div>
484
  </section>
485
 
486
  <!-- Voice + settings -->
487
  <section class="panel">
488
+ <h2 data-i18n="voiceLabel">Voice</h2>
489
 
490
  <div class="field">
491
+ <label data-i18n="langSearchLabel">ဘာသာစကား ရှာရန် (Search language)</label>
492
+ <input type="text" class="lang-search" id="langSearch" data-i18n-placeholder="langSearchPlaceholder" placeholder="e.g. Myanmar, English, Thai...">
493
  </div>
494
 
495
  <div class="row2">
496
  <div class="field">
497
+ <label data-i18n="languageLabel">ဘာသာစကား (Language)</label>
498
  <select id="langSelect"></select>
499
  </div>
500
  <div class="field">
501
+ <label data-i18n="voiceField">အသံ (Voice)</label>
502
  <select id="voiceSelect"></select>
503
  </div>
504
  </div>
505
 
506
  <div class="controls">
507
  <div class="control">
508
+ <label><span data-i18n="speedLabel">အသံနှုန်း (Speed)</span> <span class="val" id="rateVal">+0%</span></label>
509
  <input type="range" id="rate" min="-50" max="50" value="0" step="5">
510
  </div>
511
  <div class="control">
512
+ <label><span data-i18n="pitchLabel">အသံအနိမ့်အမြင့် (Pitch)</span> <span class="val" id="pitchVal">+0Hz</span></label>
513
  <input type="range" id="pitch" min="-50" max="50" value="0" step="5">
514
  </div>
515
  </div>
516
 
517
  <div class="actions">
518
+ <button class="btn-primary" id="generateBtn" data-i18n="generateBtn">အသံ ထုတ်မည်</button>
519
  </div>
520
 
521
  <div class="status" id="status"></div>
 
523
  <div class="output" id="output">
524
  <audio id="player" controls></audio>
525
  <div class="actions">
526
+ <a class="btn-secondary btn-download" id="downloadLink" download="voice.mp3">
527
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
528
+ <path d="M12 3v12m0 0l-4.5-4.5M12 15l4.5-4.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
529
+ <path d="M5 19.5h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
530
+ </svg>
531
+ <span data-i18n="downloadBtn">mp3 ဒေါင်းလုဒ်</span>
532
+ </a>
533
  </div>
534
  </div>
535
  </section>
536
  </div>
537
 
538
+ <footer data-i18n="footer">
539
  Powered by Edge TTS · Free &amp; Open Voice Studio
540
  </footer>
541
 
542
+ <!-- About / developer-info menu -->
543
+ <div class="modal-overlay" id="modalOverlay">
544
+ <div class="modal-card">
545
+ <div class="modal-header">
546
+ <div class="modal-logo">
547
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
548
+ <path d="M4 10v4h3l4 4V6L7 10H4z" fill="#fff"/>
549
+ <path d="M16.5 9a4 4 0 010 6" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>
550
+ <path d="M19 7a7.5 7.5 0 010 10" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity=".6"/>
551
+ </svg>
552
+ </div>
553
+ <div>
554
+ <div class="modal-app-name" data-i18n="appNameFull">အသံ Studio</div>
555
+ <div class="modal-version"><span data-i18n="version">ဗားရှင်း</span> 1.0.0</div>
556
+ </div>
557
+ </div>
558
+
559
+ <div class="modal-section">
560
+ <div class="modal-row">
561
+ <span class="modal-row-label" data-i18n="developer">ဖန်တီးသူ</span>
562
+ <a href="mailto:sgyi9628@gmail.com" class="modal-row-value">sgyi9628@gmail.com</a>
563
+ </div>
564
+ <div class="modal-row">
565
+ <span class="modal-row-label" data-i18n="contact">ဆက်သွယ်ရန်</span>
566
+ <a href="tel:0951236012" class="modal-row-value">095 123 6012</a>
567
+ </div>
568
+ </div>
569
+
570
+ <div class="modal-section">
571
+ <div class="modal-row-label" data-i18n="uiLanguage">Interface ဘာသာစကား</div>
572
+ <div class="lang-toggle">
573
+ <button class="lang-toggle-btn" data-lang="my">မြန်မာ</button>
574
+ <button class="lang-toggle-btn" data-lang="en">English</button>
575
+ </div>
576
+ </div>
577
+
578
+ <button class="btn-primary modal-close" id="modalCloseBtn" data-i18n="close">ပိတ်မည်</button>
579
+ </div>
580
+ </div>
581
+
582
  <script>
583
+ // ---- i18n ----
584
+ const STR = {
585
+ my: {
586
+ eyebrow: 'Voice Studio · အခမဲ့ စာသားမှ အသံ',
587
+ title: 'အသံ Studio',
588
+ tagline: 'ဘာသာစကား 140+ မျိုးဖြင့် စာသားကို အသံအဖြစ် ပြောင်းလဲပါ',
589
+ scriptLabel: 'Script',
590
+ textPlaceholder: 'စာသား ဒီနေရာတွင် ရိုက်ထည့်ပါ...',
591
+ voiceLabel: 'Voice',
592
+ langSearchLabel: 'ဘာသာစကား ရှာရန် (Search language)',
593
+ langSearchPlaceholder: 'e.g. Myanmar, English, Thai...',
594
+ languageLabel: 'ဘာသာစကား (Language)',
595
+ voiceField: 'အသံ (Voice)',
596
+ speedLabel: 'အသံနှုန်း (Speed)',
597
+ pitchLabel: 'အသံအနိမ့်အမြင့် (Pitch)',
598
+ generateBtn: 'အသံ ထုတ်မည်',
599
+ downloadBtn: 'mp3 ဒေါင်းလုဒ်',
600
+ footer: 'Powered by Edge TTS · အခမဲ့ Voice Studio',
601
+ appNameFull: 'အသံ Studio',
602
+ version: 'ဗားရှင်း',
603
+ developer: 'ဖန်တီးသူ',
604
+ contact: 'ဆက်သွယ်ရန်',
605
+ close: 'ပိတ်မည်',
606
+ uiLanguage: 'Interface ဘာသာစကား',
607
+ errNeedText: 'စာသား ထည့်ပေးပါ။',
608
+ errNeedVoice: 'အသံတစ်ခု ရွေးပေးပါ။',
609
+ statusRewardGenerate: 'အခမဲ့ အသံထုတ်ရန် ကြော်ငြာ ပြသပါမည်...',
610
+ errSkippedGenerate: 'အသံအခမဲ့ရယူရန် ကြော်ငြာကို အပြည့်အစုံ ကြည့်ပေးပါ။',
611
+ statusGenerating: 'အသံထုတ်နေပါသည်...',
612
+ statusDone: 'အသံ အောင်မြင်စွာ ထုတ်ပြီးပါပြီ။',
613
+ errFetchVoices: 'ဘာသာစကား စာရင်း ရယူခြင်း မအောင်မြင်ပါ။',
614
+ statusRewardDownload: 'ဒေါင်းလုဒ် အခမဲ့ ရယူရန် ကြော်ငြာ ပြသပါမည်...',
615
+ errSkippedDownload: 'ဒေါင်းလုဒ်ရန် ကြော်ငြာကို အပြည့်အစုံ ကြည့်ပေးပါ။',
616
+ statusSaved: 'Downloads ဖိုလ်ဒါသို့ သိမ်းပြီးပါပြီ။',
617
+ errSaveFailed: 'ဒေါင်းလုဒ် မအောင်မြင်ပါ။'
618
+ },
619
+ en: {
620
+ eyebrow: 'Voice Studio · Free Text-to-Speech',
621
+ title: 'Voice Studio',
622
+ tagline: 'Turn text into natural speech in 140+ languages',
623
+ scriptLabel: 'Script',
624
+ textPlaceholder: 'Type your text here...',
625
+ voiceLabel: 'Voice',
626
+ langSearchLabel: 'Search language',
627
+ langSearchPlaceholder: 'e.g. Myanmar, English, Thai...',
628
+ languageLabel: 'Language',
629
+ voiceField: 'Voice',
630
+ speedLabel: 'Speed',
631
+ pitchLabel: 'Pitch',
632
+ generateBtn: 'Generate Voice',
633
+ downloadBtn: 'Download mp3',
634
+ footer: 'Powered by Edge TTS · Free & Open Voice Studio',
635
+ appNameFull: 'Voice Studio',
636
+ version: 'Version',
637
+ developer: 'Developer',
638
+ contact: 'Contact',
639
+ close: 'Close',
640
+ uiLanguage: 'Interface language',
641
+ errNeedText: 'Please enter some text.',
642
+ errNeedVoice: 'Please choose a voice.',
643
+ statusRewardGenerate: 'Showing a quick ad to unlock this free generation...',
644
+ errSkippedGenerate: 'Please watch the full ad to get this generation for free.',
645
+ statusGenerating: 'Generating audio...',
646
+ statusDone: 'Done! Your audio is ready.',
647
+ errFetchVoices: 'Could not load the language list.',
648
+ statusRewardDownload: 'Showing a quick ad to unlock the free download...',
649
+ errSkippedDownload: 'Please watch the full ad to download for free.',
650
+ statusSaved: 'Saved to your Downloads folder.',
651
+ errSaveFailed: 'Download failed.'
652
+ }
653
+ };
654
+
655
+ let uiLang = localStorage.getItem('vs_ui_lang') || 'my';
656
+
657
+ function t(key) {
658
+ return (STR[uiLang] && STR[uiLang][key]) || STR.my[key] || key;
659
+ }
660
+
661
+ function applyTranslations() {
662
+ document.documentElement.lang = uiLang;
663
+ document.querySelectorAll('[data-i18n]').forEach(el => {
664
+ el.textContent = t(el.getAttribute('data-i18n'));
665
+ });
666
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
667
+ el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
668
+ });
669
+ document.querySelectorAll('.lang-toggle-btn').forEach(btn => {
670
+ btn.classList.toggle('active', btn.dataset.lang === uiLang);
671
+ });
672
+ }
673
+
674
+ function setUiLang(lang) {
675
+ uiLang = lang;
676
+ localStorage.setItem('vs_ui_lang', lang);
677
+ applyTranslations();
678
+ }
679
+
680
+ applyTranslations();
681
+
682
+ // ---- Menu / About modal ----
683
+ const menuBtn = document.getElementById('menuBtn');
684
+ const modalOverlay = document.getElementById('modalOverlay');
685
+ menuBtn.addEventListener('click', () => modalOverlay.classList.add('visible'));
686
+ document.getElementById('modalCloseBtn').addEventListener('click', () => modalOverlay.classList.remove('visible'));
687
+ modalOverlay.addEventListener('click', (e) => {
688
+ if (e.target === modalOverlay) modalOverlay.classList.remove('visible');
689
+ });
690
+ document.querySelectorAll('.lang-toggle-btn').forEach(btn => {
691
+ btn.addEventListener('click', () => setUiLang(btn.dataset.lang));
692
+ });
693
+
694
  // ---- Build signature waveform ----
695
  const wave = document.getElementById('wave');
696
  const BAR_COUNT = 20;
 
744
  const charCount = document.getElementById('charCount');
745
  text.addEventListener('input', () => {
746
  const len = text.value.length;
747
+ charCount.textContent = `${len} / 10000`;
748
+ charCount.classList.toggle('over', len > 10000);
749
  });
750
 
751
  // ---- Voices: fetch grouped by language, populate selects ----
 
753
  const voiceSelect = document.getElementById('voiceSelect');
754
  const langSearch = document.getElementById('langSearch');
755
 
756
+ let voiceData = {}; // { language: { flag, voices: [ {id, name, gender, symbol}, ... ] } }
757
+ let languages = []; // language names
758
  const DEFAULT_LANG = 'Myanmar (Burmese)';
759
  const DEFAULT_VOICE_ID = 'my-MM-NilarNeural';
760
 
761
  function populateVoicesFor(lang) {
762
  voiceSelect.innerHTML = '';
763
+ const entry = voiceData[lang];
764
+ const list = (entry && entry.voices) || [];
765
  list.forEach(v => {
766
  const opt = document.createElement('option');
767
  opt.value = v.id;
768
+ const symbol = v.symbol ? `${v.symbol} ` : '';
769
+ opt.textContent = `${symbol}${v.name} (${v.gender})`;
770
  voiceSelect.appendChild(opt);
771
  });
772
  }
 
778
  filtered.forEach(lang => {
779
  const opt = document.createElement('option');
780
  opt.value = lang;
781
+ const flag = (voiceData[lang] && voiceData[lang].flag) || '';
782
+ opt.textContent = `${flag} ${lang}`;
783
  langSelect.appendChild(opt);
784
  });
785
  if (filtered.length) {
 
801
  languages = Object.keys(voiceData);
802
  populateLanguages('');
803
  // select default Myanmar voice if present
804
+ if (voiceData[DEFAULT_LANG] && voiceData[DEFAULT_LANG].voices.some(v => v.id === DEFAULT_VOICE_ID)) {
805
  langSelect.value = DEFAULT_LANG;
806
  populateVoicesFor(DEFAULT_LANG);
807
  voiceSelect.value = DEFAULT_VOICE_ID;
808
  }
809
  } catch (e) {
810
+ status.textContent = t('errFetchVoices');
811
  status.classList.add('error');
812
  }
813
  }
 
868
  generateBtn.addEventListener('click', async () => {
869
  const value = text.value.trim();
870
  if (!value) {
871
+ status.textContent = t('errNeedText');
872
  status.classList.add('error');
873
  return;
874
  }
875
 
876
  const voice = voiceSelect.value;
877
  if (!voice) {
878
+ status.textContent = t('errNeedVoice');
879
  status.classList.add('error');
880
  return;
881
  }
 
885
 
886
  // Watch a short rewarded ad to unlock this free TTS generation (Android app only).
887
  if (inApp) {
888
+ status.textContent = t('statusRewardGenerate');
889
  }
890
  const okToGenerate = await requestRewardThen('generate');
891
  if (!okToGenerate) {
892
+ status.textContent = t('errSkippedGenerate');
893
  status.classList.add('error');
894
  generateBtn.disabled = false;
895
  return;
896
  }
897
 
898
+ status.textContent = t('statusGenerating');
899
  startWaveAnimation();
900
 
901
  try {
 
912
 
913
  if (!res.ok) {
914
  const err = await res.json().catch(() => ({}));
915
+ throw new Error(err.error || t('statusDone'));
916
  }
917
 
918
  const blob = await res.blob();
 
923
  player.src = currentObjectUrl;
924
  downloadLink.href = currentObjectUrl;
925
  output.classList.add('visible');
926
+ status.textContent = t('statusDone');
927
  player.play().catch(() => {});
928
  notifyInterstitialCheckpoint();
929
  } catch (e) {
 
942
  if (!currentBlob) return;
943
 
944
  status.classList.remove('error');
945
+ status.textContent = t('statusRewardDownload');
946
  const okToDownload = await requestRewardThen('download');
947
  if (!okToDownload) {
948
+ status.textContent = t('errSkippedDownload');
949
  status.classList.add('error');
950
  return;
951
  }
 
953
  try {
954
  const base64 = await blobToBase64(currentBlob);
955
  window.AndroidBridge.saveBase64Mp3(base64, 'voice.mp3');
956
+ status.textContent = t('statusSaved');
957
  } catch (err) {
958
+ status.textContent = t('errSaveFailed');
959
  status.classList.add('error');
960
  }
961
  });