Spaces:
Runtime error
Runtime error
| <html lang="my"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>အသံစာပေ — Voice Studio</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Myanmar:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --bg:#FFFFFF; | |
| --surface:#FFFFFF; | |
| --surface-2:#F5F6F8; | |
| --ink:#16181D; | |
| --ink-soft:#7B828F; | |
| --accent:#2563EB; | |
| --accent-soft:#EAF0FE; | |
| --line:#E7E9EE; | |
| --radius:14px; | |
| --sans:'Inter', sans-serif; | |
| --mm:'Noto Sans Myanmar', sans-serif; | |
| } | |
| *{ box-sizing:border-box; } | |
| body{ | |
| margin:0; | |
| background:var(--bg); | |
| color:var(--ink); | |
| font-family:var(--mm); | |
| line-height:1.6; | |
| -webkit-font-smoothing:antialiased; | |
| } | |
| .wrap{ | |
| max-width:640px; | |
| margin:0 auto; | |
| padding:0 18px 48px; | |
| } | |
| /* ---------- Header ---------- */ | |
| header{ | |
| padding:32px 0 20px; | |
| text-align:center; | |
| } | |
| .eyebrow{ | |
| font-family:var(--sans); | |
| font-weight:600; | |
| font-size:12px; | |
| letter-spacing:.14em; | |
| text-transform:uppercase; | |
| color:var(--accent); | |
| margin:0 0 8px; | |
| } | |
| .tagline{ | |
| color:var(--ink-soft); | |
| font-size:14px; | |
| margin:0; | |
| } | |
| .wave{ | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| gap:4px; | |
| height:28px; | |
| margin:18px auto 0; | |
| } | |
| .wave .bar{ | |
| width:4px; | |
| border-radius:3px; | |
| background:var(--accent); | |
| height:8px; | |
| opacity:.35; | |
| animation:breathe 2.6s ease-in-out infinite; | |
| transition:height .12s ease; | |
| } | |
| @keyframes breathe{ | |
| 0%, 100% { height:6px; opacity:.3; } | |
| 50% { height:22px; opacity:.7; } | |
| } | |
| .wave.is-active .bar{ animation:none; opacity:1; } | |
| /* ---------- Card ---------- */ | |
| .panel{ | |
| background:var(--surface); | |
| border:1px solid var(--line); | |
| border-radius:var(--radius); | |
| padding:18px; | |
| margin-top:16px; | |
| } | |
| .panel h2{ | |
| font-family:var(--sans); | |
| font-weight:600; | |
| font-size:12px; | |
| letter-spacing:.1em; | |
| text-transform:uppercase; | |
| color:var(--ink-soft); | |
| margin:0 0 12px; | |
| } | |
| textarea{ | |
| width:100%; | |
| min-height:140px; | |
| resize:vertical; | |
| border:1px solid var(--line); | |
| border-radius:10px; | |
| background:var(--surface-2); | |
| padding:14px 16px; | |
| font-family:var(--mm); | |
| font-size:16px; | |
| line-height:1.7; | |
| color:var(--ink); | |
| outline:none; | |
| } | |
| textarea:focus{ border-color:var(--accent); background:var(--surface); } | |
| textarea::placeholder{ color:var(--ink-soft); } | |
| .char-count{ | |
| text-align:right; | |
| font-size:12px; | |
| color:var(--ink-soft); | |
| margin-top:6px; | |
| font-family:var(--sans); | |
| } | |
| .char-count.over{ color:#D14343; } | |
| /* ---------- Selects ---------- */ | |
| .field{ margin-bottom:14px; } | |
| .field:last-child{ margin-bottom:0; } | |
| .field label{ | |
| display:block; | |
| font-family:var(--sans); | |
| font-size:12px; | |
| font-weight:600; | |
| letter-spacing:.04em; | |
| color:var(--ink-soft); | |
| margin-bottom:6px; | |
| } | |
| select{ | |
| width:100%; | |
| border:1px solid var(--line); | |
| border-radius:10px; | |
| background:var(--surface-2); | |
| padding:12px 14px; | |
| font-family:var(--mm); | |
| font-size:15px; | |
| color:var(--ink); | |
| outline:none; | |
| appearance:none; | |
| background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="14" height="9" viewBox="0 0 14 9"><path d="M1 1l6 6 6-6" stroke="%237B828F" stroke-width="1.6" fill="none" fill-rule="evenodd"/></svg>'); | |
| background-repeat:no-repeat; | |
| background-position:right 14px center; | |
| padding-right:34px; | |
| } | |
| select:focus{ border-color:var(--accent); background-color:var(--surface); } | |
| .lang-search{ | |
| width:100%; | |
| border:1px solid var(--line); | |
| border-radius:10px; | |
| background:var(--surface-2); | |
| padding:10px 14px; | |
| font-family:var(--sans); | |
| font-size:14px; | |
| color:var(--ink); | |
| outline:none; | |
| margin-bottom:8px; | |
| } | |
| .lang-search:focus{ border-color:var(--accent); background:var(--surface); } | |
| .row2{ | |
| display:grid; | |
| grid-template-columns:1fr 1fr; | |
| gap:12px; | |
| } | |
| @media (max-width:420px){ | |
| .row2{ grid-template-columns:1fr; } | |
| } | |
| /* ---------- Sliders ---------- */ | |
| .controls{ | |
| display:grid; | |
| grid-template-columns:1fr 1fr; | |
| gap:16px; | |
| margin-top:16px; | |
| } | |
| .control label{ | |
| display:flex; | |
| justify-content:space-between; | |
| font-size:12px; | |
| color:var(--ink-soft); | |
| margin-bottom:6px; | |
| font-family:var(--sans); | |
| font-weight:600; | |
| } | |
| .control label span.val{ | |
| font-family:var(--mm); | |
| color:var(--ink); | |
| font-weight:600; | |
| } | |
| input[type="range"]{ | |
| width:100%; | |
| accent-color:var(--accent); | |
| } | |
| @media (max-width:480px){ | |
| .controls{ grid-template-columns:1fr; } | |
| } | |
| /* ---------- Button ---------- */ | |
| .actions{ | |
| margin-top:18px; | |
| display:flex; | |
| gap:10px; | |
| flex-wrap:wrap; | |
| } | |
| button{ | |
| font-family:var(--mm); | |
| font-size:15px; | |
| font-weight:600; | |
| border:none; | |
| border-radius:10px; | |
| padding:13px 22px; | |
| cursor:pointer; | |
| transition:opacity .15s ease, transform .05s ease; | |
| } | |
| button:active{ transform:scale(.98); } | |
| button:disabled{ opacity:.5; cursor:wait; } | |
| .btn-primary{ | |
| background:var(--accent); | |
| color:#fff; | |
| flex:1; | |
| } | |
| .btn-primary:hover:not(:disabled){ opacity:.9; } | |
| .btn-secondary{ | |
| background:var(--surface-2); | |
| color:var(--ink); | |
| border:1px solid var(--line); | |
| } | |
| /* ---------- Output ---------- */ | |
| .output{ | |
| margin-top:16px; | |
| display:none; | |
| } | |
| .output.visible{ display:block; } | |
| .output audio{ | |
| width:100%; | |
| margin-top:12px; | |
| } | |
| .status{ | |
| font-family:var(--sans); | |
| font-size:13px; | |
| color:var(--ink-soft); | |
| margin-top:10px; | |
| min-height:18px; | |
| } | |
| .status.error{ color:#D14343; } | |
| footer{ | |
| text-align:center; | |
| margin-top:32px; | |
| font-size:12px; | |
| color:var(--ink-soft); | |
| font-family:var(--sans); | |
| } | |
| /* ---------- Top app bar ---------- */ | |
| .app-bar{ | |
| position:sticky; | |
| top:0; | |
| z-index:20; | |
| background:#fff; | |
| border-bottom:1px solid var(--line); | |
| display:flex; | |
| align-items:center; | |
| justify-content:space-between; | |
| padding:12px 18px; | |
| } | |
| .app-bar-left{ | |
| display:flex; | |
| align-items:center; | |
| gap:10px; | |
| } | |
| .app-bar-logo{ | |
| width:32px; | |
| height:32px; | |
| border-radius:9px; | |
| background:var(--accent); | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| flex-shrink:0; | |
| } | |
| .app-bar-logo svg{ width:16px; height:16px; } | |
| .app-bar-title{ | |
| font-family:var(--mm); | |
| font-weight:700; | |
| font-size:19px; | |
| color:var(--ink); | |
| } | |
| .icon-btn{ | |
| width:38px; | |
| height:38px; | |
| border-radius:50%; | |
| background:var(--surface-2); | |
| border:1px solid var(--line); | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| cursor:pointer; | |
| padding:0; | |
| flex-shrink:0; | |
| } | |
| .icon-btn:active{ transform:scale(.95); } | |
| .icon-btn svg{ width:18px; height:18px; } | |
| /* ---------- Header hero (below the app bar) ---------- */ | |
| header{ padding-top:24px; } | |
| /* ---------- Download button (icon + label) ---------- */ | |
| .btn-download{ | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| gap:8px; | |
| text-decoration:none; | |
| flex:1; | |
| } | |
| .btn-download svg{ width:18px; height:18px; flex-shrink:0; } | |
| /* ---------- About / menu modal ---------- */ | |
| .modal-overlay{ | |
| position:fixed; | |
| inset:0; | |
| background:rgba(15,17,21,.45); | |
| display:none; | |
| align-items:center; | |
| justify-content:center; | |
| padding:24px; | |
| z-index:100; | |
| } | |
| .modal-overlay.visible{ display:flex; } | |
| .modal-card{ | |
| background:#fff; | |
| border-radius:18px; | |
| width:100%; | |
| max-width:360px; | |
| padding:22px; | |
| max-height:85vh; | |
| overflow-y:auto; | |
| } | |
| .modal-header{ | |
| display:flex; | |
| align-items:center; | |
| gap:12px; | |
| margin-bottom:18px; | |
| } | |
| .modal-logo{ | |
| width:46px; | |
| height:46px; | |
| border-radius:12px; | |
| background:var(--accent); | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| flex-shrink:0; | |
| } | |
| .modal-logo svg{ width:22px; height:22px; } | |
| .modal-app-name{ | |
| font-family:var(--mm); | |
| font-weight:700; | |
| font-size:17px; | |
| color:var(--ink); | |
| } | |
| .modal-version{ | |
| font-family:var(--sans); | |
| font-size:12px; | |
| color:var(--ink-soft); | |
| margin-top:2px; | |
| } | |
| .modal-section{ | |
| border-top:1px solid var(--line); | |
| padding:14px 0; | |
| } | |
| .modal-row{ | |
| display:flex; | |
| align-items:center; | |
| justify-content:space-between; | |
| gap:10px; | |
| padding:6px 0; | |
| } | |
| .modal-row-label{ | |
| font-family:var(--sans); | |
| font-size:12px; | |
| font-weight:600; | |
| letter-spacing:.04em; | |
| color:var(--ink-soft); | |
| text-transform:uppercase; | |
| } | |
| .modal-row-value{ | |
| font-family:var(--sans); | |
| font-size:14px; | |
| color:var(--accent); | |
| text-decoration:none; | |
| font-weight:600; | |
| } | |
| .lang-toggle{ | |
| display:flex; | |
| gap:8px; | |
| margin-top:10px; | |
| } | |
| .lang-toggle-btn{ | |
| flex:1; | |
| font-family:var(--mm); | |
| font-size:14px; | |
| font-weight:600; | |
| padding:10px 12px; | |
| border-radius:10px; | |
| border:1px solid var(--line); | |
| background:var(--surface-2); | |
| color:var(--ink); | |
| cursor:pointer; | |
| } | |
| .lang-toggle-btn.active{ | |
| background:var(--accent); | |
| border-color:var(--accent); | |
| color:#fff; | |
| } | |
| .modal-close{ | |
| width:100%; | |
| margin-top:16px; | |
| background:var(--surface-2); | |
| color:var(--ink); | |
| border:1px solid var(--line); | |
| } | |
| /* ---------- Voice history (inside menu) ---------- */ | |
| .history-list{ | |
| display:flex; | |
| flex-direction:column; | |
| gap:8px; | |
| margin-top:10px; | |
| max-height:220px; | |
| overflow-y:auto; | |
| } | |
| .history-item{ | |
| display:flex; | |
| align-items:center; | |
| gap:10px; | |
| padding:8px; | |
| border:1px solid var(--line); | |
| border-radius:10px; | |
| background:var(--surface-2); | |
| } | |
| .history-play{ | |
| width:30px; | |
| height:30px; | |
| border-radius:50%; | |
| background:var(--accent); | |
| border:none; | |
| color:#fff; | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| flex-shrink:0; | |
| cursor:pointer; | |
| padding:0; | |
| } | |
| .history-play svg{ width:13px; height:13px; } | |
| .history-play[disabled]{ background:#C7CCD6; cursor:not-allowed; } | |
| .history-meta{ flex:1; min-width:0; } | |
| .history-text{ | |
| font-family:var(--mm); | |
| font-size:13px; | |
| color:var(--ink); | |
| white-space:nowrap; | |
| overflow:hidden; | |
| text-overflow:ellipsis; | |
| } | |
| .history-sub{ | |
| font-family:var(--sans); | |
| font-size:11px; | |
| color:var(--ink-soft); | |
| margin-top:2px; | |
| } | |
| .history-delete{ | |
| width:26px; | |
| height:26px; | |
| border-radius:50%; | |
| border:none; | |
| background:transparent; | |
| color:var(--ink-soft); | |
| font-size:17px; | |
| line-height:1; | |
| cursor:pointer; | |
| flex-shrink:0; | |
| } | |
| .history-empty{ | |
| font-family:var(--sans); | |
| font-size:13px; | |
| color:var(--ink-soft); | |
| padding:8px 0 0; | |
| } | |
| .history-clear{ | |
| margin-top:10px; | |
| font-family:var(--sans); | |
| font-size:12px; | |
| font-weight:600; | |
| color:#D14343; | |
| background:none; | |
| border:none; | |
| cursor:pointer; | |
| padding:0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app-bar"> | |
| <div class="app-bar-left"> | |
| <div class="app-bar-logo"> | |
| <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M4 10v4h3l4 4V6L7 10H4z" fill="#fff"/> | |
| <path d="M16.5 9a4 4 0 010 6" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/> | |
| </svg> | |
| </div> | |
| <span class="app-bar-title" data-i18n="title">အသံ Studio</span> | |
| </div> | |
| <button class="icon-btn" id="menuBtn" aria-label="Menu"> | |
| <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M4 7h16M4 12h16M4 17h16" stroke="#16181D" stroke-width="1.8" stroke-linecap="round"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="wrap"> | |
| <header> | |
| <p class="eyebrow" data-i18n="eyebrow">Voice Studio · Free Text-to-Speech</p> | |
| <p class="tagline" data-i18n="tagline">ဘာသာစကား 140+ မျိုးဖြင့် စာသားကို အသံအဖြစ် ပြောင်းလဲပါ</p> | |
| <div class="wave" id="wave"></div> | |
| </header> | |
| <!-- Text input --> | |
| <section class="panel"> | |
| <h2 data-i18n="scriptLabel">Script</h2> | |
| <textarea id="text" data-i18n-placeholder="textPlaceholder" placeholder="စာသား ဒီနေရာတွင် ရိုက်ထည့်ပါ..." maxlength="10000"></textarea> | |
| <div class="char-count" id="charCount">0 / 10000</div> | |
| </section> | |
| <!-- Voice + settings --> | |
| <section class="panel"> | |
| <h2 data-i18n="voiceLabel">Voice</h2> | |
| <div class="field"> | |
| <label data-i18n="langSearchLabel">ဘာသာစကား ရှာရန် (Search language)</label> | |
| <input type="text" class="lang-search" id="langSearch" data-i18n-placeholder="langSearchPlaceholder" placeholder="e.g. Myanmar, English, Thai..."> | |
| </div> | |
| <div class="row2"> | |
| <div class="field"> | |
| <label data-i18n="languageLabel">ဘာသာစကား (Language)</label> | |
| <select id="langSelect"></select> | |
| </div> | |
| <div class="field"> | |
| <label data-i18n="voiceField">အသံ (Voice)</label> | |
| <select id="voiceSelect"></select> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <div class="control"> | |
| <label><span data-i18n="speedLabel">အသံနှုန်း (Speed)</span> <span class="val" id="rateVal">+0%</span></label> | |
| <input type="range" id="rate" min="-50" max="50" value="0" step="5"> | |
| </div> | |
| <div class="control"> | |
| <label><span data-i18n="pitchLabel">အသံအနိမ့်အမြင့် (Pitch)</span> <span class="val" id="pitchVal">+0Hz</span></label> | |
| <input type="range" id="pitch" min="-50" max="50" value="0" step="5"> | |
| </div> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn-primary" id="generateBtn" data-i18n="generateBtn">အသံ ထုတ်မည်</button> | |
| </div> | |
| <div class="status" id="status"></div> | |
| <div class="output" id="output"> | |
| <audio id="player" controls></audio> | |
| <div class="actions"> | |
| <a class="btn-primary btn-download" id="downloadLink" download="voice.mp3"> | |
| <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <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"/> | |
| <path d="M5 19.5h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/> | |
| </svg> | |
| <span data-i18n="downloadBtn">mp3 ဒေါင်းလုဒ်</span> | |
| </a> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <footer data-i18n="footer"> | |
| Powered by Edge TTS · Free & Open Voice Studio | |
| </footer> | |
| <!-- About / developer-info menu --> | |
| <div class="modal-overlay" id="modalOverlay"> | |
| <div class="modal-card"> | |
| <div class="modal-header"> | |
| <div class="modal-logo"> | |
| <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M4 10v4h3l4 4V6L7 10H4z" fill="#fff"/> | |
| <path d="M16.5 9a4 4 0 010 6" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/> | |
| <path d="M19 7a7.5 7.5 0 010 10" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity=".6"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <div class="modal-app-name" data-i18n="appNameFull">အသံ Studio</div> | |
| <div class="modal-version"><span data-i18n="version">ဗားရှင်း</span> 1.0.0</div> | |
| </div> | |
| </div> | |
| <div class="modal-section"> | |
| <div class="modal-row-label" data-i18n="historyLabel">Voice History</div> | |
| <div id="historyList" class="history-list"></div> | |
| <div id="historyEmpty" class="history-empty" data-i18n="historyEmpty">မှတ်တမ်း မရှိသေးပါ။</div> | |
| <button id="clearHistoryBtn" class="history-clear" data-i18n="clearHistory">မှတ်တမ်း ရှင်းမည်</button> | |
| <audio id="historyAudio" style="display:none"></audio> | |
| </div> | |
| <div class="modal-section"> | |
| <div class="modal-row"> | |
| <span class="modal-row-label" data-i18n="developer">ဖန်တီးသူ</span> | |
| <a href="mailto:sgyi9628@gmail.com" class="modal-row-value">sgyi9628@gmail.com</a> | |
| </div> | |
| <div class="modal-row"> | |
| <span class="modal-row-label" data-i18n="contact">ဆက်သွယ်ရန်</span> | |
| <a href="tel:0951236012" class="modal-row-value">095 123 6012</a> | |
| </div> | |
| </div> | |
| <div class="modal-section"> | |
| <div class="modal-row-label" data-i18n="uiLanguage">Interface ဘာသာစကား</div> | |
| <div class="lang-toggle"> | |
| <button class="lang-toggle-btn" data-lang="my">မြန်မာ</button> | |
| <button class="lang-toggle-btn" data-lang="en">English</button> | |
| </div> | |
| </div> | |
| <button class="btn-primary modal-close" id="modalCloseBtn" data-i18n="close">ပိတ်မည်</button> | |
| </div> | |
| </div> | |
| <script> | |
| // ---- i18n ---- | |
| const STR = { | |
| my: { | |
| eyebrow: 'Voice Studio · အခမဲ့ စာသားမှ အသံ', | |
| title: 'အသံ Studio', | |
| tagline: 'ဘာသာစကား 140+ မျိုးဖြင့် စာသားကို အသံအဖြစ် ပြောင်းလဲပါ', | |
| scriptLabel: 'Script', | |
| textPlaceholder: 'စာသား ဒီနေရာတွင် ရိုက်ထည့်ပါ...', | |
| voiceLabel: 'Voice', | |
| langSearchLabel: 'ဘာသာစကား ရှာရန် (Search language)', | |
| langSearchPlaceholder: 'e.g. Myanmar, English, Thai...', | |
| languageLabel: 'ဘာသာစကား (Language)', | |
| voiceField: 'အသံ (Voice)', | |
| speedLabel: 'အသံနှုန်း (Speed)', | |
| pitchLabel: 'အသံအနိမ့်အမြင့် (Pitch)', | |
| generateBtn: 'အသံ ထုတ်မည်', | |
| downloadBtn: 'mp3 ဒေါင်းလုဒ်', | |
| footer: 'Powered by Edge TTS · အခမဲ့ Voice Studio', | |
| appNameFull: 'အသံ Studio', | |
| version: 'ဗားရှင်း', | |
| developer: 'ဖန်တီးသူ', | |
| contact: 'ဆက်သွယ်ရန်', | |
| close: 'ပိတ်မည်', | |
| uiLanguage: 'Interface ဘာသာစကား', | |
| errNeedText: 'စာသား ထည့်ပေးပါ။', | |
| errNeedVoice: 'အသံတစ်ခု ရွေးပေးပါ။', | |
| statusRewardGenerate: 'အခမဲ့ အသံထုတ်ရန် ကြော်ငြာ ပြသပါမည်...', | |
| errSkippedGenerate: 'အသံအခမဲ့ရယူရန် ကြော်ငြာကို အပြည့်အစုံ ကြည့်ပေးပါ။', | |
| statusGenerating: 'အသံထုတ်နေပါသည်...', | |
| statusDone: 'အသံ အောင်မြင်စွာ ထုတ်ပြီးပါပြီ။', | |
| errFetchVoices: 'ဘာသာစကား စာရင်း ရယူခြင်း မအောင်မြင်ပါ။', | |
| statusRewardDownload: 'ဒေါင်းလုဒ် အခမဲ့ ရယူရန် ကြော်ငြာ ပြသပါမည်...', | |
| errSkippedDownload: 'ဒေါင်းလုဒ်ရန် ကြော်ငြာကို အပြည့်အစုံ ကြည့်ပေးပါ။', | |
| statusSaved: 'Downloads ဖိုလ်ဒါသို့ သိမ်းပြီးပါပြီ။', | |
| errSaveFailed: 'ဒေါင်းလုဒ် မအောင်မြင်ပါ။', | |
| historyLabel: 'အသံ မှတ်တမ်း', | |
| historyEmpty: 'မှတ်တမ်း မရှိသေးပါ။', | |
| clearHistory: 'မှတ်တမ်း ရှင်းမည်', | |
| historyAudioUnavailable: 'ဖိုင်ကြီးလွန်းသောကြောင့် ပြန်နားထောင်ရန် မသိမ်းထားပါ' | |
| }, | |
| en: { | |
| eyebrow: 'Voice Studio · Free Text-to-Speech', | |
| title: 'Voice Studio', | |
| tagline: 'Turn text into natural speech in 140+ languages', | |
| scriptLabel: 'Script', | |
| textPlaceholder: 'Type your text here...', | |
| voiceLabel: 'Voice', | |
| langSearchLabel: 'Search language', | |
| langSearchPlaceholder: 'e.g. Myanmar, English, Thai...', | |
| languageLabel: 'Language', | |
| voiceField: 'Voice', | |
| speedLabel: 'Speed', | |
| pitchLabel: 'Pitch', | |
| generateBtn: 'Generate Voice', | |
| downloadBtn: 'Download mp3', | |
| footer: 'Powered by Edge TTS · Free & Open Voice Studio', | |
| appNameFull: 'Voice Studio', | |
| version: 'Version', | |
| developer: 'Developer', | |
| contact: 'Contact', | |
| close: 'Close', | |
| uiLanguage: 'Interface language', | |
| errNeedText: 'Please enter some text.', | |
| errNeedVoice: 'Please choose a voice.', | |
| statusRewardGenerate: 'Showing a quick ad to unlock this free generation...', | |
| errSkippedGenerate: 'Please watch the full ad to get this generation for free.', | |
| statusGenerating: 'Generating audio...', | |
| statusDone: 'Done! Your audio is ready.', | |
| errFetchVoices: 'Could not load the language list.', | |
| statusRewardDownload: 'Showing a quick ad to unlock the free download...', | |
| errSkippedDownload: 'Please watch the full ad to download for free.', | |
| statusSaved: 'Saved to your Downloads folder.', | |
| errSaveFailed: 'Download failed.', | |
| historyLabel: 'Voice History', | |
| historyEmpty: 'No history yet.', | |
| clearHistory: 'Clear history', | |
| historyAudioUnavailable: 'Too large to keep for replay' | |
| } | |
| }; | |
| let uiLang = localStorage.getItem('vs_ui_lang') || 'my'; | |
| function t(key) { | |
| return (STR[uiLang] && STR[uiLang][key]) || STR.my[key] || key; | |
| } | |
| function applyTranslations() { | |
| document.documentElement.lang = uiLang; | |
| document.querySelectorAll('[data-i18n]').forEach(el => { | |
| el.textContent = t(el.getAttribute('data-i18n')); | |
| }); | |
| document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { | |
| el.placeholder = t(el.getAttribute('data-i18n-placeholder')); | |
| }); | |
| document.querySelectorAll('.lang-toggle-btn').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.lang === uiLang); | |
| }); | |
| } | |
| function setUiLang(lang) { | |
| uiLang = lang; | |
| localStorage.setItem('vs_ui_lang', lang); | |
| applyTranslations(); | |
| renderHistory(); | |
| } | |
| applyTranslations(); | |
| // ---- Menu / About modal ---- | |
| const menuBtn = document.getElementById('menuBtn'); | |
| const modalOverlay = document.getElementById('modalOverlay'); | |
| menuBtn.addEventListener('click', () => modalOverlay.classList.add('visible')); | |
| document.getElementById('modalCloseBtn').addEventListener('click', () => modalOverlay.classList.remove('visible')); | |
| modalOverlay.addEventListener('click', (e) => { | |
| if (e.target === modalOverlay) modalOverlay.classList.remove('visible'); | |
| }); | |
| document.querySelectorAll('.lang-toggle-btn').forEach(btn => { | |
| btn.addEventListener('click', () => setUiLang(btn.dataset.lang)); | |
| }); | |
| // ---- Voice history (stored in localStorage, played back from the menu) ---- | |
| const HISTORY_KEY = 'vs_history_v1'; | |
| const MAX_HISTORY = 8; | |
| const MAX_HISTORY_AUDIO_BYTES = 1.5 * 1024 * 1024; // ~1.5MB cap per stored clip | |
| const TIME_STR = { | |
| my: { | |
| justNow: () => 'ခုလေးတင်', | |
| minAgo: (n) => `${n} မိနစ်အကြာ`, | |
| hourAgo: (n) => `${n} နာရီအကြာ`, | |
| dayAgo: (n) => `${n} ရက်အကြာ` | |
| }, | |
| en: { | |
| justNow: () => 'just now', | |
| minAgo: (n) => `${n}m ago`, | |
| hourAgo: (n) => `${n}h ago`, | |
| dayAgo: (n) => `${n}d ago` | |
| } | |
| }; | |
| function relativeTime(ts) { | |
| const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000)); | |
| const dict = TIME_STR[uiLang] || TIME_STR.my; | |
| if (diffSec < 60) return dict.justNow(); | |
| const mins = Math.floor(diffSec / 60); | |
| if (mins < 60) return dict.minAgo(mins); | |
| const hours = Math.floor(mins / 60); | |
| if (hours < 24) return dict.hourAgo(hours); | |
| return dict.dayAgo(Math.floor(hours / 24)); | |
| } | |
| function loadHistory() { | |
| try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } | |
| catch (e) { return []; } | |
| } | |
| function saveHistory(list) { | |
| try { | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(list)); | |
| } catch (e) { | |
| // Quota exceeded (likely too many large audio clips) — drop the oldest | |
| // half and retry once before giving up silently. | |
| try { | |
| const trimmed = list.slice(0, Math.ceil(list.length / 2)); | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed)); | |
| } catch (e2) { /* give up quietly, history just won't persist */ } | |
| } | |
| } | |
| function addHistoryEntry(entry) { | |
| const list = loadHistory(); | |
| list.unshift(entry); | |
| while (list.length > MAX_HISTORY) list.pop(); | |
| saveHistory(list); | |
| renderHistory(); | |
| } | |
| function deleteHistoryEntry(id) { | |
| const list = loadHistory().filter(e => e.id !== id); | |
| saveHistory(list); | |
| renderHistory(); | |
| } | |
| function clearHistory() { | |
| saveHistory([]); | |
| renderHistory(); | |
| } | |
| const historyListEl = document.getElementById('historyList'); | |
| const historyEmptyEl = document.getElementById('historyEmpty'); | |
| const historyAudio = document.getElementById('historyAudio'); | |
| let activeHistoryPlayBtn = null; | |
| function playIconSvg() { | |
| return '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5.5v13l11-6.5-11-6.5z"/></svg>'; | |
| } | |
| function pauseIconSvg() { | |
| return '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h3.5v14H7V5zm6.5 0H17v14h-3.5V5z"/></svg>'; | |
| } | |
| function resetActivePlayButton() { | |
| if (activeHistoryPlayBtn) { | |
| activeHistoryPlayBtn.innerHTML = playIconSvg(); | |
| activeHistoryPlayBtn = null; | |
| } | |
| } | |
| historyAudio.addEventListener('ended', resetActivePlayButton); | |
| historyAudio.addEventListener('pause', () => { | |
| if (activeHistoryPlayBtn) activeHistoryPlayBtn.innerHTML = playIconSvg(); | |
| }); | |
| function renderHistory() { | |
| const list = loadHistory(); | |
| historyListEl.innerHTML = ''; | |
| activeHistoryPlayBtn = null; | |
| if (!list.length) { | |
| historyEmptyEl.style.display = 'block'; | |
| return; | |
| } | |
| historyEmptyEl.style.display = 'none'; | |
| list.forEach(entry => { | |
| const row = document.createElement('div'); | |
| row.className = 'history-item'; | |
| const playBtn = document.createElement('button'); | |
| playBtn.className = 'history-play'; | |
| playBtn.innerHTML = playIconSvg(); | |
| if (!entry.audio) { | |
| playBtn.disabled = true; | |
| playBtn.title = t('historyAudioUnavailable'); | |
| } else { | |
| playBtn.addEventListener('click', () => { | |
| const isThisPlaying = activeHistoryPlayBtn === playBtn && !historyAudio.paused; | |
| resetActivePlayButton(); | |
| if (isThisPlaying) return; // was playing -> just stop | |
| historyAudio.src = entry.audio; | |
| historyAudio.play().catch(() => {}); | |
| playBtn.innerHTML = pauseIconSvg(); | |
| activeHistoryPlayBtn = playBtn; | |
| }); | |
| } | |
| const meta = document.createElement('div'); | |
| meta.className = 'history-meta'; | |
| const textEl = document.createElement('div'); | |
| textEl.className = 'history-text'; | |
| textEl.textContent = entry.textPreview; | |
| const subEl = document.createElement('div'); | |
| subEl.className = 'history-sub'; | |
| subEl.textContent = `${entry.langLabel || ''} · ${entry.voiceLabel || ''} · ${relativeTime(entry.ts)}`; | |
| meta.appendChild(textEl); | |
| meta.appendChild(subEl); | |
| const delBtn = document.createElement('button'); | |
| delBtn.className = 'history-delete'; | |
| delBtn.textContent = '\u00d7'; | |
| delBtn.addEventListener('click', () => { | |
| if (activeHistoryPlayBtn === playBtn) { | |
| historyAudio.pause(); | |
| } | |
| deleteHistoryEntry(entry.id); | |
| }); | |
| row.appendChild(playBtn); | |
| row.appendChild(meta); | |
| row.appendChild(delBtn); | |
| historyListEl.appendChild(row); | |
| }); | |
| } | |
| document.getElementById('clearHistoryBtn').addEventListener('click', () => { | |
| historyAudio.pause(); | |
| clearHistory(); | |
| }); | |
| renderHistory(); | |
| // ---- Build signature waveform ---- | |
| const wave = document.getElementById('wave'); | |
| const BAR_COUNT = 20; | |
| for (let i = 0; i < BAR_COUNT; i++) { | |
| const bar = document.createElement('div'); | |
| bar.className = 'bar'; | |
| bar.style.animationDelay = (i * 0.06) + 's'; | |
| wave.appendChild(bar); | |
| } | |
| const bars = wave.querySelectorAll('.bar'); | |
| function setWaveActive(active) { | |
| wave.classList.toggle('is-active', active); | |
| if (!active) { | |
| bars.forEach(b => b.style.height = ''); | |
| return; | |
| } | |
| } | |
| let waveInterval = null; | |
| function startWaveAnimation() { | |
| setWaveActive(true); | |
| waveInterval = setInterval(() => { | |
| bars.forEach(b => { | |
| const h = 6 + Math.random() * 22; | |
| b.style.height = h + 'px'; | |
| }); | |
| }, 110); | |
| } | |
| function stopWaveAnimation() { | |
| if (waveInterval) clearInterval(waveInterval); | |
| waveInterval = null; | |
| setWaveActive(false); | |
| } | |
| // ---- Sliders ---- | |
| const rate = document.getElementById('rate'); | |
| const pitch = document.getElementById('pitch'); | |
| const rateVal = document.getElementById('rateVal'); | |
| const pitchVal = document.getElementById('pitchVal'); | |
| function fmt(v, unit) { | |
| const n = Number(v); | |
| return (n >= 0 ? '+' : '') + n + unit; | |
| } | |
| rate.addEventListener('input', () => rateVal.textContent = fmt(rate.value, '%')); | |
| pitch.addEventListener('input', () => pitchVal.textContent = fmt(pitch.value, 'Hz')); | |
| // ---- Character counter ---- | |
| const text = document.getElementById('text'); | |
| const charCount = document.getElementById('charCount'); | |
| text.addEventListener('input', () => { | |
| const len = text.value.length; | |
| charCount.textContent = `${len} / 10000`; | |
| charCount.classList.toggle('over', len > 10000); | |
| }); | |
| // ---- Voices: fetch grouped by language, populate selects ---- | |
| const langSelect = document.getElementById('langSelect'); | |
| const voiceSelect = document.getElementById('voiceSelect'); | |
| const langSearch = document.getElementById('langSearch'); | |
| let voiceData = {}; // { language: { flag, voices: [ {id, name, gender, symbol}, ... ] } } | |
| let languages = []; // language names | |
| const DEFAULT_LANG = 'Myanmar (Burmese)'; | |
| const DEFAULT_VOICE_ID = 'my-MM-NilarNeural'; | |
| function populateVoicesFor(lang) { | |
| voiceSelect.innerHTML = ''; | |
| const entry = voiceData[lang]; | |
| const list = (entry && entry.voices) || []; | |
| list.forEach(v => { | |
| const opt = document.createElement('option'); | |
| opt.value = v.id; | |
| const symbol = v.symbol ? `${v.symbol} ` : ''; | |
| opt.textContent = `${symbol}${v.name} (${v.gender})`; | |
| voiceSelect.appendChild(opt); | |
| }); | |
| } | |
| function populateLanguages(filter) { | |
| langSelect.innerHTML = ''; | |
| const f = (filter || '').trim().toLowerCase(); | |
| const filtered = f ? languages.filter(l => l.toLowerCase().includes(f)) : languages; | |
| filtered.forEach(lang => { | |
| const opt = document.createElement('option'); | |
| opt.value = lang; | |
| const flag = (voiceData[lang] && voiceData[lang].flag) || ''; | |
| opt.textContent = `${flag} ${lang}`; | |
| langSelect.appendChild(opt); | |
| }); | |
| if (filtered.length) { | |
| const preferred = filtered.includes(DEFAULT_LANG) && !f ? DEFAULT_LANG : filtered[0]; | |
| langSelect.value = preferred; | |
| populateVoicesFor(preferred); | |
| } else { | |
| voiceSelect.innerHTML = ''; | |
| } | |
| } | |
| langSearch.addEventListener('input', () => populateLanguages(langSearch.value)); | |
| langSelect.addEventListener('change', () => populateVoicesFor(langSelect.value)); | |
| async function loadVoices() { | |
| try { | |
| const res = await fetch('/api/voices'); | |
| voiceData = await res.json(); | |
| languages = Object.keys(voiceData); | |
| populateLanguages(''); | |
| // select default Myanmar voice if present | |
| if (voiceData[DEFAULT_LANG] && voiceData[DEFAULT_LANG].voices.some(v => v.id === DEFAULT_VOICE_ID)) { | |
| langSelect.value = DEFAULT_LANG; | |
| populateVoicesFor(DEFAULT_LANG); | |
| voiceSelect.value = DEFAULT_VOICE_ID; | |
| } | |
| } catch (e) { | |
| status.textContent = t('errFetchVoices'); | |
| status.classList.add('error'); | |
| } | |
| } | |
| loadVoices(); | |
| // ---- Android app bridge (no-op / ignored on plain web) ---- | |
| // The Android WebView wrapper injects `window.AndroidBridge` with: | |
| // isAndroidApp(), requestReward(actionId), maybeShowInterstitial(), | |
| // saveBase64Mp3(base64Data, fileName) | |
| // On the web (no app), all of this safely falls back to "just do it". | |
| const inApp = !!(window.AndroidBridge && window.AndroidBridge.isAndroidApp && window.AndroidBridge.isAndroidApp()); | |
| function requestRewardThen(actionId) { | |
| // Resolves true when the action should proceed: | |
| // - reward actually earned, OR | |
| // - no ad inventory was available at all (fail-open, never block on AdMob fill issues) | |
| // Resolves false only when an ad WAS shown and the user skipped before earning it. | |
| // Always resolves true instantly when not running inside the Android app. | |
| return new Promise((resolve) => { | |
| if (!inApp) { resolve(true); return; } | |
| window.onAndroidRewardResult = (returnedActionId, granted, adWasAvailable) => { | |
| if (returnedActionId !== actionId) return; | |
| resolve(granted || !adWasAvailable); | |
| }; | |
| try { | |
| window.AndroidBridge.requestReward(actionId); | |
| } catch (e) { | |
| resolve(true); // bridge call itself failed — don't block the user | |
| } | |
| }); | |
| } | |
| function notifyInterstitialCheckpoint() { | |
| if (inApp && window.AndroidBridge.maybeShowInterstitial) { | |
| try { window.AndroidBridge.maybeShowInterstitial(); } catch (e) {} | |
| } | |
| } | |
| function blobToBase64(blob) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onloadend = () => resolve(reader.result); // "data:audio/mpeg;base64,...." | |
| reader.onerror = reject; | |
| reader.readAsDataURL(blob); | |
| }); | |
| } | |
| // ---- Generate ---- | |
| const generateBtn = document.getElementById('generateBtn'); | |
| const status = document.getElementById('status'); | |
| const output = document.getElementById('output'); | |
| const player = document.getElementById('player'); | |
| const downloadLink = document.getElementById('downloadLink'); | |
| let currentObjectUrl = null; | |
| let currentBlob = null; | |
| generateBtn.addEventListener('click', async () => { | |
| const value = text.value.trim(); | |
| if (!value) { | |
| status.textContent = t('errNeedText'); | |
| status.classList.add('error'); | |
| return; | |
| } | |
| const voice = voiceSelect.value; | |
| if (!voice) { | |
| status.textContent = t('errNeedVoice'); | |
| status.classList.add('error'); | |
| return; | |
| } | |
| status.classList.remove('error'); | |
| generateBtn.disabled = true; | |
| // Watch a short rewarded ad to unlock this free TTS generation (Android app only). | |
| if (inApp) { | |
| status.textContent = t('statusRewardGenerate'); | |
| } | |
| const okToGenerate = await requestRewardThen('generate'); | |
| if (!okToGenerate) { | |
| status.textContent = t('errSkippedGenerate'); | |
| status.classList.add('error'); | |
| generateBtn.disabled = false; | |
| return; | |
| } | |
| status.textContent = t('statusGenerating'); | |
| startWaveAnimation(); | |
| try { | |
| const res = await fetch('/api/tts', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| text: value, | |
| voice: voice, | |
| rate: Number(rate.value), | |
| pitch: Number(pitch.value) | |
| }) | |
| }); | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})); | |
| throw new Error(err.error || t('statusDone')); | |
| } | |
| const blob = await res.blob(); | |
| currentBlob = blob; | |
| if (currentObjectUrl) URL.revokeObjectURL(currentObjectUrl); | |
| currentObjectUrl = URL.createObjectURL(blob); | |
| player.src = currentObjectUrl; | |
| downloadLink.href = currentObjectUrl; | |
| output.classList.add('visible'); | |
| status.textContent = t('statusDone'); | |
| player.play().catch(() => {}); | |
| notifyInterstitialCheckpoint(); | |
| // Save to Voice History (skip storing audio itself if the clip is too | |
| // large, to keep localStorage usage reasonable). | |
| (async () => { | |
| let audioDataUrl = null; | |
| if (blob.size <= MAX_HISTORY_AUDIO_BYTES) { | |
| try { audioDataUrl = await blobToBase64(blob); } catch (e) { audioDataUrl = null; } | |
| } | |
| addHistoryEntry({ | |
| id: Date.now() + '_' + Math.random().toString(36).slice(2, 8), | |
| textPreview: value.length > 80 ? value.slice(0, 80) + '…' : value, | |
| voiceLabel: voiceSelect.options[voiceSelect.selectedIndex] | |
| ? voiceSelect.options[voiceSelect.selectedIndex].textContent.trim() | |
| : voice, | |
| langLabel: langSelect.options[langSelect.selectedIndex] | |
| ? langSelect.options[langSelect.selectedIndex].textContent.trim() | |
| : '', | |
| ts: Date.now(), | |
| audio: audioDataUrl | |
| }); | |
| })(); | |
| } catch (e) { | |
| status.textContent = e.message; | |
| status.classList.add('error'); | |
| } finally { | |
| generateBtn.disabled = false; | |
| stopWaveAnimation(); | |
| } | |
| }); | |
| // ---- Download (native save inside the Android app, normal <a download> on web) ---- | |
| downloadLink.addEventListener('click', async (e) => { | |
| if (!inApp) return; // plain web: let the default <a download> behavior run | |
| e.preventDefault(); | |
| if (!currentBlob) return; | |
| status.classList.remove('error'); | |
| status.textContent = t('statusRewardDownload'); | |
| const okToDownload = await requestRewardThen('download'); | |
| if (!okToDownload) { | |
| status.textContent = t('errSkippedDownload'); | |
| status.classList.add('error'); | |
| return; | |
| } | |
| try { | |
| const base64 = await blobToBase64(currentBlob); | |
| window.AndroidBridge.saveBase64Mp3(base64, 'voice.mp3'); | |
| status.textContent = t('statusSaved'); | |
| } catch (err) { | |
| status.textContent = t('errSaveFailed'); | |
| status.classList.add('error'); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |