USP / index.html
Phoe2004's picture
Upload index.html
84d1aad verified
Raw
History Blame Contribute Delete
38.9 kB
<!DOCTYPE html>
<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 &amp; 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>