reader / index.html
Toowired's picture
Update index.html
b0a02f2 verified
Raw
History Blame Contribute Delete
26.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HD TTS Document Reader | Hugging Face Edition</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.waveform {
background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
border-radius: 12px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.waveform-bar {
width: 3px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 3px;
animation: equalize 1.5s infinite ease-in-out;
animation-play-state: paused;
height: 10px;
animation-delay: calc(var(--delay) * 0.1s);
}
@keyframes equalize {
0%, 100% { height: 10px; }
50% { height: 35px; }
}
.document-container {
max-height: 400px;
overflow-y: auto;
scrollbar-width: thin;
}
.document-container::-webkit-scrollbar { width: 8px; }
.document-container::-webkit-scrollbar-track { background: #f8fafc; border-radius: 10px; }
.document-container::-webkit-scrollbar-thumb { background: linear-gradient(145deg, #cbd5e1, #94a3b8); border-radius: 10px; }
.highlight {
background-color: #fef08a;
border-radius: 4px;
padding: 2px 0;
transition: background-color 0.3s ease;
}
.progress-bar {
height: 8px;
background-color: #e2e8f0;
border-radius: 10px;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
cursor: pointer;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
width: 0%;
transition: width 0.1s linear;
border-radius: 10px;
}
.glass-effect {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
transition: all 0.2s ease;
border-radius: 8px;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 10px 25px -5px rgba(59, 130, 246, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: rgba(107, 114, 128, 0.1);
backdrop-filter: blur(10px);
transition: all 0.2s ease;
border-radius: 8px;
}
.btn-secondary:hover:not(:disabled) {
background: rgba(107, 114, 128, 0.2);
transform: translateY(-1px);
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
#document-editor:empty::before {
content: attr(data-placeholder);
color: #9ca3af;
font-style: italic;
}
.card-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.1);
}
input[type="range"] {
-webkit-appearance: none; appearance: none;
background: transparent; cursor: pointer;
}
input[type="range"]::-webkit-slider-track { background: #e2e8f0; height: 8px; border-radius: 5px; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
height: 20px; width: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
cursor: pointer; border: 2px solid white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
input[type="range"]::-moz-range-track { background: #e2e8f0; height: 8px; border-radius: 5px; }
input[type="range"]::-moz-range-thumb {
height: 20px; width: 20px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
cursor: pointer; border: 2px solid white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.notification {
position: fixed;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-left: 4px solid #3b82f6;
padding: 16px 20px;
border-radius: 12px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
transform: translateX(120%);
opacity: 0;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
min-width: 300px;
}
.notification.show { transform: translateX(0); opacity: 1; }
.notification.success { border-left-color: #10b981; }
.notification.error { border-left-color: #ef4444; }
.notification.warning { border-left-color: #f59e0b; }
</style>
</head>
<body class="min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-7xl">
<header class="mb-8 text-center">
<h1 class="text-5xl font-bold text-gray-800 mb-4">HD TTS Document Reader</h1>
<p class="text-xl text-gray-600">Powered by the Hugging Face Inference API</p>
<div class="mt-6 flex justify-center gap-4 flex-wrap">
<span class="inline-flex items-center px-4 py-2 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
<i class="fas fa-microchip mr-2"></i> Hugging Face API
</span>
<span class="inline-flex items-center px-4 py-2 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
<i class="fas fa-microphone-alt mr-2"></i> High-Quality Voice
</span>
<span class="inline-flex items-center px-4 py-2 rounded-full text-sm font-medium bg-green-100 text-green-800">
<i class="fas fa-server mr-2"></i> Static Hosting Ready
</span>
</div>
</header>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Document Input Section -->
<div class="lg:col-span-2 glass-effect rounded-2xl shadow-xl overflow-hidden card-hover">
<div class="p-8">
<h2 class="text-3xl font-semibold text-gray-800 mb-6">Document Editor</h2>
<div class="mb-6">
<div class="document-container bg-gray-50 p-6 rounded-xl border border-gray-200">
<div id="document-editor" class="min-h-[250px] outline-none prose max-w-none" contenteditable="true" data-placeholder="Type or paste your text here..."></div>
</div>
</div>
<div class="flex justify-between items-center">
<div class="text-sm text-gray-500">
<span id="word-count">0</span> words • <span id="char-count">0</span> characters
</div>
<button id="clear-btn" class="text-gray-500 hover:text-red-600 font-medium transition-colors"><i class="fas fa-trash-alt mr-1"></i> Clear</button>
</div>
</div>
</div>
<!-- Voice Controls Section -->
<div class="glass-effect rounded-2xl shadow-xl overflow-hidden card-hover">
<div class="p-8">
<h2 class="text-3xl font-semibold text-gray-800 mb-6">Voice Settings</h2>
<div class="mb-6">
<label for="hf-token" class="block text-sm font-medium text-gray-700 mb-2">Hugging Face API Token</label>
<div class="flex gap-2">
<input type="password" id="hf-token" placeholder="Enter your HF Read Token" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button id="save-token-btn" class="btn-primary text-white px-4 py-2 rounded-lg" title="Save Token"><i class="fas fa-save"></i></button>
</div>
<a href="https://huggingface.co/settings/tokens" target="_blank" class="text-xs text-blue-600 hover:underline mt-1">Get your token here</a>
</div>
<div class="mb-8 p-4 bg-gray-100 rounded-xl border border-gray-200">
<p class="text-sm font-medium text-gray-700">Active Voice Model:</p>
<p id="model-name" class="font-semibold text-gray-900">espnet/kan-bayashi_ljspeech_vits</p>
</div>
<div class="mb-8">
<label for="voice-speed" class="block text-sm font-medium text-gray-700 mb-3">Playback Speed: <span id="speed-value" class="font-semibold">1.0</span>x</label>
<input type="range" id="voice-speed" min="0.5" max="2" step="0.1" value="1" class="w-full cursor-pointer">
</div>
<div class="flex flex-col gap-4 mb-6">
<button id="play-btn" class="btn-primary text-white font-semibold py-4 px-6 rounded-lg transition flex items-center justify-center text-lg shadow-lg">
<i id="play-icon" class="fas fa-play mr-3"></i> <span id="play-text">Play</span>
</button>
<div class="flex gap-4">
<button id="pause-btn" class="flex-1 btn-secondary text-gray-800 font-semibold py-3 px-4 rounded-lg transition flex items-center justify-center" disabled>
<i class="fas fa-pause mr-2"></i> Pause
</button>
<button id="stop-btn" class="flex-1 btn-secondary text-gray-800 font-semibold py-3 px-4 rounded-lg transition flex items-center justify-center" disabled>
<i class="fas fa-stop mr-2"></i> Stop
</button>
</div>
</div>
<div class="progress-bar mb-1" id="progress-bar-container">
<div id="progress-fill" class="progress-fill"></div>
</div>
<div class="flex justify-between text-xs text-gray-500">
<span id="current-time">0:00</span>
<span id="total-time">0:00</span>
</div>
<div class="waveform mt-6" id="waveform">
<!-- Waveform bars will be dynamically inserted here -->
</div>
</div>
</div>
</div>
</div>
<!-- Notification Container -->
<div id="notification-container" class="fixed top-5 right-5 z-[1001] w-80"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Element Cache ---
const dom = {
editor: document.getElementById('document-editor'),
wordCount: document.getElementById('word-count'),
charCount: document.getElementById('char-count'),
playBtn: document.getElementById('play-btn'),
playIcon: document.getElementById('play-icon'),
playText: document.getElementById('play-text'),
pauseBtn: document.getElementById('pause-btn'),
stopBtn: document.getElementById('stop-btn'),
clearBtn: document.getElementById('clear-btn'),
speedSlider: document.getElementById('voice-speed'),
speedValue: document.getElementById('speed-value'),
progressBarContainer: document.getElementById('progress-bar-container'),
progressFill: document.getElementById('progress-fill'),
currentTime: document.getElementById('current-time'),
totalTime: document.getElementById('total-time'),
waveform: document.getElementById('waveform'),
notificationContainer: document.getElementById('notification-container'),
hfTokenInput: document.getElementById('hf-token'),
saveTokenBtn: document.getElementById('save-token-btn'),
modelName: document.getElementById('model-name'),
};
// --- State Management ---
const state = {
isPlaying: false,
isPaused: false,
isLoading: false,
currentSentenceIndex: 0,
sentences: [],
audioPlayer: new Audio(),
hfToken: '',
apiModel: 'espnet/kan-bayashi_ljspeech_vits',
};
// --- Initialization ---
function init() {
loadToken();
updateCounts();
createWaveformBars();
setupEventListeners();
dom.modelName.textContent = state.apiModel;
}
function loadToken() {
const savedToken = localStorage.getItem('hf_tts_token');
if (savedToken) {
state.hfToken = savedToken;
dom.hfTokenInput.value = '********'; // Mask the token in UI
showNotification('Hugging Face token loaded from storage.', 'success');
}
}
function createWaveformBars() {
dom.waveform.innerHTML = '';
for (let i = 0; i < 40; i++) {
const bar = document.createElement('div');
bar.className = 'waveform-bar';
bar.style.setProperty('--delay', i);
dom.waveform.appendChild(bar);
}
}
// --- Event Listeners ---
function setupEventListeners() {
dom.editor.addEventListener('input', updateCounts);
dom.playBtn.addEventListener('click', handlePlayPause);
dom.pauseBtn.addEventListener('click', handlePause);
dom.stopBtn.addEventListener('click', handleStop);
dom.clearBtn.addEventListener('click', clearDocument);
dom.saveTokenBtn.addEventListener('click', saveToken);
dom.speedSlider.addEventListener('input', handleSpeedChange);
state.audioPlayer.addEventListener('timeupdate', updateProgress);
state.audioPlayer.addEventListener('ended', onAudioEnded);
}
// --- Core Application Logic ---
function handlePlayPause() {
if (state.isPlaying) {
handlePause();
} else {
handlePlay();
}
}
function handlePlay() {
if (state.isLoading) return;
if (state.isPaused) {
state.audioPlayer.play();
state.isPaused = false;
state.isPlaying = true;
updateUIForPlaying();
return;
}
if (!state.hfToken) {
showNotification('Please enter your Hugging Face API Token.', 'error');
dom.hfTokenInput.focus();
return;
}
const text = dom.editor.innerText.trim();
if (!text) {
showNotification('Editor is empty. Please enter some text.', 'warning');
return;
}
state.sentences = text.match(/[^.!?]+[.!?]*|[^.!?]+/g) || [];
state.currentSentenceIndex = 0;
if (state.sentences.length > 0) {
playSentence(state.currentSentenceIndex);
}
}
function handlePause() {
if (state.isPlaying && !state.isPaused) {
state.audioPlayer.pause();
state.isPaused = true;
state.isPlaying = false;
updateUIForPaused();
}
}
function handleStop() {
state.audioPlayer.pause();
state.audioPlayer.src = '';
resetState();
updateUIForStopped();
}
async function playSentence(index) {
if (index >= state.sentences.length) {
handleStop();
return;
}
const sentence = state.sentences[index].trim();
if (!sentence) {
// Skip empty sentences
state.currentSentenceIndex++;
playSentence(state.currentSentenceIndex);
return;
}
setLoading(true);
highlightSentence(index);
try {
const response = await fetch(`https://api-inference.huggingface.co/models/${state.apiModel}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${state.hfToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ inputs: sentence }),
});
if (!response.ok) {
let errorMessage = `API Error: ${response.status} ${response.statusText}`;
try {
const errorDetails = await response.json();
errorMessage = errorDetails.error || JSON.stringify(errorDetails);
} catch (e) {
// If JSON parsing fails, it's likely a plain text error (like 'Not Found')
errorMessage = await response.text();
}
throw new Error(errorMessage);
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
state.audioPlayer.src = audioUrl;
state.audioPlayer.playbackRate = parseFloat(dom.speedSlider.value);
state.audioPlayer.play();
state.isPlaying = true;
state.isPaused = false;
updateUIForPlaying();
} catch (error) {
console.error('TTS Error:', error);
showNotification(error.message, 'error');
handleStop();
} finally {
setLoading(false);
}
}
function onAudioEnded() {
state.currentSentenceIndex++;
playSentence(state.currentSentenceIndex);
}
// --- UI & Helper Functions ---
function updateUIForPlaying() {
dom.playBtn.disabled = true;
dom.pauseBtn.disabled = false;
dom.stopBtn.disabled = false;
dom.playText.textContent = 'Playing...';
dom.playIcon.className = 'fas fa-spinner fa-spin mr-3';
animateWaveform(true);
}
function updateUIForPaused() {
dom.playBtn.disabled = false;
dom.pauseBtn.disabled = true;
dom.stopBtn.disabled = false;
dom.playText.textContent = 'Resume';
dom.playIcon.className = 'fas fa-play mr-3';
animateWaveform(false);
}
function updateUIForStopped() {
dom.playBtn.disabled = false;
dom.pauseBtn.disabled = true;
dom.stopBtn.disabled = true;
dom.playText.textContent = 'Play';
dom.playIcon.className = 'fas fa-play mr-3';
animateWaveform(false);
clearHighlight();
dom.progressFill.style.width = '0%';
dom.currentTime.textContent = '0:00';
dom.totalTime.textContent = '0:00';
}
function setLoading(isLoading) {
state.isLoading = isLoading;
dom.playBtn.disabled = isLoading;
if (isLoading) {
dom.playIcon.className = 'fas fa-spinner fa-spin mr-3';
dom.playText.textContent = 'Loading...';
} else {
dom.playIcon.className = 'fas fa-play mr-3';
dom.playText.textContent = 'Play';
}
}
function resetState() {
state.isPlaying = false;
state.isPaused = false;
state.isLoading = false;
state.currentSentenceIndex = 0;
state.sentences = [];
}
function highlightSentence(index) {
clearHighlight();
let content = dom.editor.innerHTML;
const sentenceToHighlight = state.sentences[index];
// A simple way to highlight. More robust methods might be needed for complex HTML.
// This replaces only the first occurrence to avoid issues.
const highlightedContent = content.replace(sentenceToHighlight, `<span class="highlight">${sentenceToHighlight}</span>`);
dom.editor.innerHTML = highlightedContent;
}
function clearHighlight() {
const highlights = dom.editor.querySelectorAll('.highlight');
highlights.forEach(h => {
h.outerHTML = h.innerHTML; // Replace span with its content
});
}
function saveToken() {
const token = dom.hfTokenInput.value;
if (token && token !== '********') {
state.hfToken = token;
localStorage.setItem('hf_tts_token', token);
dom.hfTokenInput.value = '********';
showNotification('API Token saved successfully!', 'success');
} else if (!token) {
showNotification('Please enter a token to save.', 'warning');
}
}
function updateProgress() {
if (state.audioPlayer.duration) {
const progress = (state.audioPlayer.currentTime / state.audioPlayer.duration) * 100;
dom.progressFill.style.width = `${progress}%`;
dom.currentTime.textContent = formatTime(state.audioPlayer.currentTime);
dom.totalTime.textContent = formatTime(state.audioPlayer.duration);
}
}
function handleSpeedChange(e) {
const newSpeed = parseFloat(e.target.value);
dom.speedValue.textContent = newSpeed.toFixed(1);
if (state.isPlaying || state.isPaused) {
state.audioPlayer.playbackRate = newSpeed;
}
}
function clearDocument() {
handleStop();
dom.editor.innerHTML = '';
updateCounts();
showNotification('Document cleared.', 'success');
}
function updateCounts() {
const text = dom.editor.innerText;
const words = text.trim().split(/\s+/).filter(Boolean).length;
dom.charCount.textContent = text.length;
dom.wordCount.textContent = words;
}
function animateWaveform(isPlaying) {
const state = isPlaying ? 'running' : 'paused';
dom.waveform.querySelectorAll('.waveform-bar').forEach(bar => {
bar.style.animationPlayState = state;
});
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
const icons = { info: 'fa-info-circle', success: 'fa-check-circle', warning: 'fa-exclamation-triangle', error: 'fa-times-circle' };
notification.innerHTML = `<div class="flex items-center"><i class="fas ${icons[type]} text-lg mr-3"></i><span>${message}</span></div>`;
dom.notificationContainer.appendChild(notification);
setTimeout(() => notification.classList.add('show'), 100);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => notification.remove(), 500);
}, 5000);
}
function formatTime(seconds) {
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60).toString().padStart(2, '0');
return `${min}:${sec}`;
}
// --- App Entry Point ---
init();
});
</script>
</body>
</html>