Download index.html from Toowired/reader: direct link, hf CLI and curl.
- Browser
- Download file 26.8 kB
-
https://huggingface.co/spaces/Toowired/reader/resolve/main/index.html
- Command line
-
hf download hf://spaces/Toowired/reader/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Toowired/reader/resolve/main/index.html
26.8 kB
| <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> | |