Files
2026-05-10 12:29:27 +02:00

420 lines
18 KiB
HTML

<a target="_blank" href="http://192.168.0.163:7860">VocalSynth</a> [<a target="_blank" href="https://alceawis.de/other/extra/scripts/ffmpeg/videoaudioannotate.html">AnnotationStudio</a>] <a target="_blank" href="http://192.168.0.163:1234/">PC File UpDownload</a> (<a target="_blank" href="srtparse.html">SrtParse</a>)
<hr><br>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Qwen Voice TTS Studio</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.content { padding: 30px; }
.status {
padding: 10px;
text-align: center;
font-weight: bold;
border-radius: 5px;
margin-bottom: 20px;
}
.status.connected { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status.disconnected { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; font-weight: bold; color: #333; }
textarea, input, select {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
textarea:focus, input:focus, select:focus { outline: none; border-color: #667eea; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
.button-group { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; }
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: transform 0.2s, opacity 0.2s;
}
button:hover:not(:disabled) { transform: translateY(-2px); }
button:active:not(:disabled) { transform: translateY(0); }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-primary { background: #28a745; color: white; }
.btn-secondary { background: #007bff; color: white; }
.btn-warning { background: #ffc107; color: #333; }
.example-buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.example-btn { padding: 5px 12px; font-size: 12px; background: #f0f0f0; color: #333; }
.audio-player {
margin-top: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
border: 2px solid #dee2e6;
text-align: center;
}
.audio-controls { display: flex; gap: 15px; justify-content: center; align-items: center; flex-wrap: wrap; }
.progress { padding: 10px; text-align: center; color: #666; font-style: italic; }
.loading {
display: inline-block;
width: 20px; height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
vertical-align: middle;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@media (max-width: 768px) {
.row { grid-template-columns: 1fr; }
.content { padding: 20px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎤 Qwen Voice TTS Studio</h1>
<p>Text-to-Speech with Emotional Control</p>
</div>
<div class="content">
<div id="status" class="status">Checking connection...</div>
<div class="form-group">
<label>📝 Text to Synthesize:</label>
<textarea id="textInput" rows="4" placeholder="Enter text here...">test</textarea>
</div>
<div class="row">
<div class="form-group">
<label>🌐 Language:</label>
<select id="language">
<option value="english">English</option>
<option value="german">German</option>
<option value="auto">Auto</option>
<option value="chinese">Chinese</option>
<option value="japanese">Japanese</option>
<option value="korean">Korean</option>
</select>
</div>
<div class="form-group">
<label>🎙️ Speaker:</label>
<select id="speaker">
<option value="CeaLP">CeaLP</option>
<option value="Vivian">Vivian</option>
<option value="Serena">Serena</option>
<option value="Ono Anna">Ono Anna</option>
<option value="Sohee">Sohee</option>
</select>
</div>
</div>
<div class="form-group">
<label>🎭 Vocal Instruction:</label>
<input type="text" id="instruct" placeholder="e.g. sound happy, whisper, sound sad">
<div class="example-buttons">
<button class="example-btn" onclick="setInstruct('sound happy')">😊 sound happy</button>
<button class="example-btn" onclick="setInstruct('sound sad')">😢 sound sad</button>
<button class="example-btn" onclick="setInstruct('sound angry')">😠 sound angry</button>
<button class="example-btn" onclick="setInstruct('whisper')">🤫 whisper</button>
<button class="example-btn" onclick="setInstruct('excited')">🎉 excited</button>
<button class="example-btn" onclick="setInstruct('calm')">😌 calm</button>
</div>
</div>
<div class="button-group">
<button class="btn-primary" id="generateBtn" onclick="generateTTS()">🎵 Generate Speech</button>
<button class="btn-secondary" onclick="callLambda()">⚡ Lambda_1</button>
<button class="btn-warning" onclick="refreshTiles()">🔄 Refresh Tiles</button>
</div>
<div id="progress" class="progress"></div>
<div id="audioPanel" class="audio-player" style="display:none;">
<h3>🎧 Generated Audio</h3>
<div id="audioControls" class="audio-controls"></div>
</div>
</div>
</div>
<script>
const SERVER_URL = "http://192.168.0.163:7860";
let sessionHash = newHash();
let activeSource = null; // current EventSource
// Read optional query params from the page URL (set once at load time)
const URL_PARAMS = new URLSearchParams(window.location.search);
const URL_TIMESTAMP = URL_PARAMS.get('timestamp') || null;
// Prefix a filename with the timestamp when one was provided
function withTimestamp(filename) {
return URL_TIMESTAMP ? `${URL_TIMESTAMP}_${filename}` : filename;
}
function newHash() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
function setInstruct(t) { document.getElementById('instruct').value = t; }
// ── Connection check ──────────────────────────────────────────────────────
async function testConnection() {
const el = document.getElementById('status');
try {
const r = await fetch(`${SERVER_URL}/gradio_api/info`);
if (r.ok) {
el.textContent = '✅ Connected to TTS Server';
el.className = 'status connected';
} else throw new Error();
} catch {
el.textContent = '❌ Not connected to TTS Server';
el.className = 'status disconnected';
}
}
// ── Main generate flow ────────────────────────────────────────────────────
async function generateTTS() {
const text = document.getElementById('textInput').value.trim();
if (!text) { alert('Please enter some text'); return; }
const btn = document.getElementById('generateBtn');
const progress = document.getElementById('progress');
btn.disabled = true;
btn.textContent = '⏳ Generating...';
progress.innerHTML = '<span class="loading"></span> Submitting to queue…';
// Close any leftover stream
if (activeSource) { activeSource.close(); activeSource = null; }
// Each generation needs a fresh session so results don't cross-contaminate
sessionHash = newHash();
try {
const payload = {
data: [text,
document.getElementById('language').value,
document.getElementById('speaker').value,
document.getElementById('instruct').value.trim(),
""],
fn_index: 3,
session_hash: sessionHash
};
const qr = await fetch(`${SERVER_URL}/gradio_api/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!qr.ok) throw new Error(`Queue join failed: HTTP ${qr.status}`);
progress.innerHTML = '<span class="loading"></span> Processing…';
// ── Use EventSource (SSE) to receive the result ───────────────────
const audioFile = await streamResult(sessionHash, progress);
if (!audioFile) throw new Error('No audio path received from server');
displayAudio(audioFile);
progress.innerHTML = '✅ Done!';
} catch (err) {
progress.innerHTML = `${err.message}`;
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = '🎵 Generate Speech';
}
}
// ── SSE helper: returns a Promise<string> with the audio path ─────────────
function streamResult(hash, progressEl) {
return new Promise((resolve, reject) => {
const url = `${SERVER_URL}/gradio_api/queue/data?session_hash=${hash}`;
const es = new EventSource(url);
activeSource = es;
const timeout = setTimeout(() => {
es.close();
reject(new Error('Timed out waiting for audio (60 s)'));
}, 60_000);
es.onmessage = (evt) => {
let msg;
try { msg = JSON.parse(evt.data); } catch { return; }
const event = msg.msg || msg.event || '';
if (event === 'process_starts') {
if (progressEl) progressEl.innerHTML = '<span class="loading"></span> Generating audio…';
}
if (event === 'process_completed' || event === 'complete') {
clearTimeout(timeout);
es.close();
activeSource = null;
const outputData = msg.output?.data ?? [];
let audioFile = outputData[0] ?? null;
if (audioFile && typeof audioFile === 'object') {
// Gradio ≥ 4 returns { path, url, orig_name, … }
audioFile = audioFile.url || audioFile.path || audioFile.name || null;
}
resolve(audioFile);
}
if (event === 'error' || msg.error) {
clearTimeout(timeout);
es.close();
activeSource = null;
reject(new Error(msg.error || 'Server returned an error event'));
}
};
es.onerror = (e) => {
// EventSource fires onerror on normal close too; only reject if
// the stream never delivered a complete event.
clearTimeout(timeout);
es.close();
activeSource = null;
reject(new Error('SSE connection error — is the server reachable?'));
};
});
}
// ── Display audio ─────────────────────────────────────────────────────────
function displayAudio(audioFile) {
const panel = document.getElementById('audioPanel');
const controls = document.getElementById('audioControls');
// Build the URL:
// • Gradio ≥ 4 may already return a full URL (audioFile.url above)
// • Older Gradio returns a server-relative path like /tmp/gradio/xyz.wav
// which is served by Gradio at /file=<path>
let audioUrl;
if (/^https?:\/\//.test(audioFile)) {
audioUrl = audioFile;
} else {
// Strip any accidental double-slash
const path = audioFile.startsWith('/') ? audioFile : `/${audioFile}`;
audioUrl = `${SERVER_URL}/file=${path}`;
}
const filename = withTimestamp(audioFile.split('/').pop() || `tts_${Date.now()}.wav`);
controls.innerHTML = `
<audio id="ttsAudio" controls autoplay style="width:100%;max-width:420px;">
<source src="${audioUrl}" type="audio/wav">
Your browser does not support the audio element.
</audio>
<a id="saveLink"
href="${audioUrl}"
download="${filename}"
style="padding:10px 20px;background:#28a745;color:white;
border-radius:8px;text-decoration:none;font-weight:bold;
display:inline-block;">
💾 Save As
</a>
<div style="margin-top:8px;font-size:12px;color:#666;">
File: ${filename}<br>
URL: <a href="${audioUrl}" target="_blank" style="color:#667eea;word-break:break-all;">${audioUrl}</a>
</div>
`;
panel.style.display = 'block';
window.currentAudioUrl = audioUrl;
// If the server is cross-origin, the <audio> src will work fine but
// <a download> may be blocked by the browser's same-origin download
// restriction. We add a fallback that fetches the blob first.
document.getElementById('saveLink').addEventListener('click', async (e) => {
e.preventDefault();
await saveAudio(audioUrl, filename);
});
}
// ── Save: fetch blob so the browser "Save As" dialog always appears ───────
async function saveAudio(url, filename = withTimestamp(`tts_audio_${Date.now()}.wav`)) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const blob = await r.blob();
const objUrl = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {
href: objUrl,
download: filename
});
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
} catch (err) {
// CORS fallback: open in new tab so the user can save manually
alert(`Direct download blocked (CORS). Opening audio in a new tab — use Ctrl+S / right-click → Save.\n\n${err.message}`);
window.open(url, '_blank');
}
}
// ── Utility buttons ───────────────────────────────────────────────────────
async function simpleQueueCall(fnIndex, label) {
const progress = document.getElementById('progress');
progress.innerHTML = `<span class="loading"></span> Calling ${label}`;
try {
const r = await fetch(`${SERVER_URL}/gradio_api/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: [], fn_index: fnIndex, session_hash: newHash() })
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
progress.innerHTML = `${label} executed!`;
} catch (err) {
progress.innerHTML = `${label} failed: ${err.message}`;
}
}
const callLambda = () => simpleQueueCall(4, 'lambda_1');
const refreshTiles = () => simpleQueueCall(25, 'Refresh Tiles');
// ── Init ──────────────────────────────────────────────────────────────────
testConnection();
// Prefill fields from URL params if provided
const _paramText = URL_PARAMS.get('text');
const _paramInstruct = URL_PARAMS.get('instruct');
if (_paramText) document.getElementById('textInput').value = _paramText;
if (_paramInstruct) document.getElementById('instruct').value = _paramInstruct;
setInterval(() => { sessionHash = newHash(); }, 3_600_000);
</script>
</body>
</html>