/** * Talking Snake - Main Application Script * Handles file upload, URL submission, and audio streaming */ // DOM Elements const dropZone = document.getElementById("dropZone"); const fileInput = document.getElementById("fileInput"); const urlInput = document.getElementById("urlInput"); const urlSubmit = document.getElementById("urlSubmit"); const textInput = document.getElementById("textInput"); const textSubmit = document.getElementById("textSubmit"); const status = document.getElementById("status"); const player = document.getElementById("player"); const audio = document.getElementById("audio"); const filename = document.getElementById("filename"); const tabs = document.querySelectorAll(".tab"); const tabContents = document.querySelectorAll(".tab-content"); const inputSection = document.getElementById("inputSection"); const processingSection = document.getElementById("processingSection"); const stopBtn = document.getElementById("stopBtn"); const pauseBtn = document.getElementById("pauseBtn"); const deviceInfo = document.getElementById("deviceInfo"); const docInfo = document.getElementById("docInfo"); const languageButtons = document.querySelectorAll("#languageButtons .style-btn"); const processingProgressBar = document.getElementById("processingProgressBar"); const streamPlayBtn = document.getElementById("streamPlayBtn"); // Custom player elements const playerPlayBtn = document.getElementById("playerPlayBtn"); const progressBar = document.getElementById("progressBar"); const progressSlider = document.getElementById("progressSlider"); const timeDisplay = document.getElementById("timeDisplay"); const downloadBtn = document.getElementById("downloadBtn"); const deleteBtn = document.getElementById("deleteBtn"); // Constants const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB // State let currentAbortController = null; let selectedLanguage = "english"; let selectedStyle = "technical"; let isPaused = false; let estimatedDuration = 0; // Estimated total duration from server let currentDocName = ""; // Store document name for download filename let playbackStartTime = 0; // When playback started (for tracking real elapsed time) let playbackElapsed = 0; // Total elapsed playback time /** * Format time in seconds to MM:SS */ function formatTime(seconds) { if (!isFinite(seconds) || seconds < 0) { return "0:00"; } const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, "0")}`; } /** * Format a number in human-readable form (1.2K, 3.4M, etc.) */ function formatNumber(num) { if (num >= 1000000) { return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M"; } if (num >= 1000) { return (num / 1000).toFixed(1).replace(/\.0$/, "") + "K"; } return num.toString(); } /** * Get icon for document type */ function getDocTypeIcon(docType) { switch (docType) { case "pdf": return "fa-file-pdf"; case "url": return "fa-link"; case "text": return "fa-file-lines"; default: return "fa-file"; } } /** * Update the document info display */ function updateDocInfo(data) { const icon = getDocTypeIcon(data.doc_type); const docName = data.doc_name || "Document"; const pageInfo = data.page_count ? ` ${data.page_count}p` : ""; const charInfo = data.total_chars ? ` ${formatNumber(data.total_chars)}` : ""; // Style icons mapping const styleIcons = { technical: "fa-microchip", narrative: "fa-book-open", child_narrative: "fa-child", news: "fa-newspaper", academic: "fa-graduation-cap" }; // Language flags mapping const langFlags = { english: "🇬🇧", chinese: "🇨🇳", japanese: "🇯🇵", korean: "🇰🇷" }; const styleIcon = styleIcons[selectedStyle] || "fa-microchip"; const langFlag = langFlags[selectedLanguage] || "🇬🇧"; docInfo.innerHTML = ` ${docName} ${pageInfo} ${charInfo} ${langFlag} `; } /** * Update the custom player progress bar and time display */ function updatePlayerProgress() { // For streaming WAV, browser's duration/currentTime are unreliable // Track real playback time ourselves let currentTime; if (playbackStartTime > 0 && !audio.paused) { currentTime = playbackElapsed + (Date.now() - playbackStartTime) / 1000; } else { currentTime = playbackElapsed; } // Use our estimated duration, update it if playback exceeds estimate let duration = estimatedDuration; if (currentTime > duration) { estimatedDuration = currentTime + 10; // Extend estimate duration = estimatedDuration; } // Ensure we have reasonable values if (duration <= 0) { duration = 60; // Fallback } const progress = duration > 0 ? (currentTime / duration) * 100 : 0; progressBar.style.width = `${Math.min(progress, 100)}%`; progressSlider.value = Math.min(progress, 100); timeDisplay.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; } /** * Handle seeking via the progress slider */ function handleSeek(e) { const percent = parseFloat(e.target.value); const duration = estimatedDuration || 60; const seekTime = (percent / 100) * duration; // Set our playback tracker playbackElapsed = seekTime; playbackStartTime = audio.paused ? 0 : Date.now(); // Try to seek the audio (may not work well with streaming) try { audio.currentTime = seekTime; } catch { // Seeking may fail with streaming audio } updatePlayerProgress(); } /** * Toggle play/pause for custom player */ function togglePlayerPlay() { if (audio.paused) { audio.play().catch(() => {}); } else { audio.pause(); } } /** * Update play button icon */ function updatePlayButton() { const icon = playerPlayBtn.querySelector("i"); if (audio.paused) { icon.className = "fa-solid fa-play"; } else { icon.className = "fa-solid fa-pause"; } } /** * Get HTML for model state indicator * @param {string} state - Model state: loaded, loading, unloaded, unloading * @returns {string} HTML string for the model state indicator */ function getModelStateHtml(state) { const stateConfig = { loaded: { icon: "fa-circle-check", class: "model-loaded", text: "Model loaded", tooltip: "TTS model is loaded in memory and ready for inference" }, loading: { icon: "fa-spinner fa-spin", class: "model-loading", text: "Loading...", tooltip: "TTS model is being loaded into memory" }, unloaded: { icon: "fa-circle-xmark", class: "model-unloaded", text: "Model unloaded", tooltip: "TTS model is not loaded (will load on first request)" }, unloading: { icon: "fa-spinner fa-spin", class: "model-unloading", text: "Unloading...", tooltip: "TTS model is being unloaded from memory" } }; const config = stateConfig[state] || stateConfig.unloaded; return ` ${config.text}`; } /** * Update device info display from SSE data * @param {Object} info - Device info object */ function updateDeviceInfo(info) { const icon = info.device === "cuda" ? "fa-microchip" : "fa-server"; const deviceTooltip = info.device === "cuda" ? "GPU accelerated inference for faster audio generation" : "CPU-based inference (slower than GPU)"; const gpuMemoryInfo = info.device === "cuda" ? ` GPU: ${info.memory_used_gb}/${info.memory_total_gb}GB` : ""; const ramInfo = ` RAM: ${info.ram_used_gb}/${info.ram_total_gb}GB`; // Show timing stats if available const timingInfo = info.seconds_per_char !== undefined ? ` ${info.seconds_per_char.toFixed(4)}s/char` : ""; // Show model state const modelStateInfo = getModelStateHtml(info.model_state); deviceInfo.innerHTML = ` ${info.device_name} ${modelStateInfo} ${gpuMemoryInfo} ${ramInfo} ${timingInfo} No files stored `; deviceInfo.classList.add("visible"); } /** * Initialize device info SSE stream */ function initDeviceInfoStream() { const eventSource = new EventSource("/api/device-info-stream"); eventSource.onmessage = (event) => { try { const info = JSON.parse(event.data); updateDeviceInfo(info); } catch { // Silently fail - device info is optional } }; eventSource.onerror = () => { // On error, close and try to reconnect after a delay eventSource.close(); setTimeout(initDeviceInfoStream, 5000); }; } // Start device info SSE stream initDeviceInfoStream(); // Custom player event listeners playerPlayBtn.addEventListener("click", togglePlayerPlay); progressSlider.addEventListener("input", handleSeek); audio.addEventListener("play", () => { // Start tracking real playback time playbackStartTime = Date.now(); updatePlayButton(); }); audio.addEventListener("pause", () => { // Save elapsed time when pausing if (playbackStartTime > 0) { playbackElapsed += (Date.now() - playbackStartTime) / 1000; playbackStartTime = 0; } updatePlayButton(); }); audio.addEventListener("timeupdate", updatePlayerProgress); audio.addEventListener("ended", () => { // Update elapsed to match duration on completion if (playbackStartTime > 0) { playbackElapsed += (Date.now() - playbackStartTime) / 1000; playbackStartTime = 0; } // Ensure we show completion if (estimatedDuration > 0 && playbackElapsed < estimatedDuration) { playbackElapsed = estimatedDuration; } updatePlayButton(); progressBar.style.width = "100%"; timeDisplay.textContent = `${formatTime(estimatedDuration)} / ${formatTime(estimatedDuration)}`; }); // Update duration when metadata is available audio.addEventListener("loadedmetadata", () => { // If browser has a valid duration, use it instead of estimate if (isFinite(audio.duration) && audio.duration > 0 && audio.duration < 36000) { estimatedDuration = audio.duration; } updatePlayerProgress(); }); // Also check duration changes (for streaming audio) audio.addEventListener("durationchange", () => { if (isFinite(audio.duration) && audio.duration > 0 && audio.duration < 36000) { estimatedDuration = audio.duration; } updatePlayerProgress(); }); // Log audio errors for debugging audio.addEventListener("error", () => { console.error("Audio error:", audio.error?.message || "Unknown error"); }); // Show pause button when audio actually starts playing audio.addEventListener("playing", () => { streamPlayBtn.classList.add("hidden"); pauseBtn.classList.remove("hidden"); }); // Show stream play button when audio has enough data to start playing audio.addEventListener("canplay", () => { // Only show if processing is still in progress (player not visible yet) // and audio is paused (not already playing) and pause button isn't showing if (!player.classList.contains("visible") && audio.paused && pauseBtn.classList.contains("hidden")) { streamPlayBtn.classList.remove("hidden"); } }); /** * Start streaming audio playback and enable download from cache * @param {string} jobId - The job ID for the audio */ async function startAudioStream(jobId) { const audioUrl = `/api/audio/${jobId}`; // Reset playback tracking for new stream playbackStartTime = 0; playbackElapsed = 0; // Set up audio source for streaming (user can click play) audio.src = audioUrl; audio.load(); // Store job ID for download - will fetch from cache audio.dataset.jobId = jobId; // Play button will be shown by the canplay event handler } /** * Download the current audio as a WAV file */ function downloadAudio() { const jobId = audio.dataset.jobId; if (!jobId) { return; } // Create filename from document name let filename = currentDocName || "audio"; filename = filename.replace(/\.[^.]+$/, "") + ".wav"; // Use download endpoint which returns proper WAV file const a = document.createElement("a"); a.href = `/api/download/${jobId}?filename=${encodeURIComponent(filename)}`; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); } /** * Delete the current audio and reset the player */ function deleteAudio() { // Stop audio immediately audio.pause(); // Add deleting animation player.classList.add("deleting"); // Wait for animation to complete setTimeout(() => { // Reset audio audio.src = ""; audio.currentTime = 0; // Clear state currentDocName = ""; estimatedDuration = 0; // Hide player and buttons player.classList.remove("visible", "deleting"); downloadBtn.classList.add("hidden"); deleteBtn.classList.add("hidden"); // Reset progress progressBar.style.width = "0%"; progressSlider.value = 0; timeDisplay.textContent = "0:00 / 0:00"; updatePlayButton(); // Show input section again inputSection.classList.remove("hidden"); }, 300); } /** * Get the currently selected language * @returns {string} The selected language name */ function getSelectedLanguage() { return selectedLanguage; } /** * Detect language from text based on character scripts. * @param {string} text - The text to analyze * @returns {string|null} Detected language or null if mostly ASCII/Latin */ function detectLanguage(text) { if (!text || text.length < 5) { return null; } let chinese = 0; let japanese = 0; // Hiragana + Katakana let korean = 0; let latin = 0; for (const char of text) { const code = char.charCodeAt(0); // CJK Unified Ideographs (shared by Chinese/Japanese) if (code >= 0x4e00 && code <= 0x9fff) { chinese++; } // Hiragana else if (code >= 0x3040 && code <= 0x309f) { japanese++; } // Katakana else if (code >= 0x30a0 && code <= 0x30ff) { japanese++; } // Hangul Syllables else if (code >= 0xac00 && code <= 0xd7af) { korean++; } // Hangul Jamo else if (code >= 0x1100 && code <= 0x11ff) { korean++; } // Basic Latin letters else if ( (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) ) { latin++; } } const total = chinese + japanese + korean + latin; if (total === 0) { return null; } // Japanese uses kanji (chinese chars) + kana, so check for kana first if (japanese > 0 && (japanese + chinese) / total > 0.3) { return "japanese"; } // Korean if (korean / total > 0.3) { return "korean"; } // Chinese (CJK without kana) if (chinese / total > 0.3) { return "chinese"; } // Default to English for Latin text if (latin / total > 0.5) { return "english"; } return null; } /** * Set the selected language, optionally marking it as auto-detected. * @param {string} lang - Language to select * @param {boolean} isAuto - Whether this was auto-detected */ function setLanguage(lang, isAuto = false) { const btn = document.querySelector( `#languageButtons .style-btn[data-language="${lang}"]` ); if (!btn || selectedLanguage === lang) { return; } // Update selection state languageButtons.forEach((b) => { b.classList.remove("active", "auto-detected"); }); btn.classList.add("active"); selectedLanguage = lang; // Visual feedback for auto-detection if (isAuto) { btn.classList.add("auto-detected"); // Remove animation class after it completes setTimeout(() => btn.classList.remove("auto-detected"), 1500); } } /** * Get the currently selected style * @returns {string} The selected style ID */ function getSelectedStyle() { return selectedStyle; } /** * Show the input section and hide processing section */ function showInputSection() { inputSection.classList.remove("hidden"); processingSection.classList.remove("visible"); } /** * Show the processing section and hide input section */ function showProcessingSection() { inputSection.classList.add("hidden"); processingSection.classList.add("visible"); // Reset progress bar and hide buttons processingProgressBar.style.width = "0%"; pauseBtn.classList.add("hidden"); streamPlayBtn.classList.add("hidden"); } /** * Show a status message to the user * @param {string} message - HTML message to display * @param {string} type - Status type: 'loading', 'error', or 'success' */ function showStatus(message, type) { status.innerHTML = message; status.className = `status visible ${type}`; } /** * Stop the current generation and audio playback */ function stopGeneration() { // Stop the fetch request if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } // Stop audio playback and clear source audio.pause(); audio.currentTime = 0; audio.src = ""; audio.load(); // Force release of audio resources // Reset pause state isPaused = false; updatePauseButton(); // Hide download button, pause button, and stream play button downloadBtn.classList.add("hidden"); pauseBtn.classList.add("hidden"); streamPlayBtn.classList.add("hidden"); // Reset progress bar processingProgressBar.style.width = "0%"; showStatus(' Generation stopped', "error"); showInputSection(); } // Stop audio when page is closed or navigated away window.addEventListener("beforeunload", () => { audio.pause(); audio.src = ""; }); // Also handle page hide (works better on mobile and for navigation) window.addEventListener("pagehide", () => { audio.pause(); audio.src = ""; }); /** * Toggle pause/play state */ function togglePause() { if (audio.paused) { audio.play().catch(() => {}); isPaused = false; } else { audio.pause(); isPaused = true; } updatePauseButton(); } /** * Update pause button icon based on state */ function updatePauseButton() { const icon = pauseBtn.querySelector("i"); if (isPaused || audio.paused) { icon.className = "fa-solid fa-play"; pauseBtn.title = "Resume"; } else { icon.className = "fa-solid fa-pause"; pauseBtn.title = "Pause"; } } /** * Get icon class for source type * @param {string} sourceType - The source type ("pdf", "url", "text") * @returns {string} Font Awesome icon class */ function getSourceIcon(sourceType) { switch (sourceType) { case "pdf": return "fa-file-pdf"; case "url": return "fa-link"; case "text": default: return "fa-keyboard"; } } /** * Process SSE stream for progress updates * Sets up audio stream once job_id is received * @param {Response} response - Fetch response with SSE stream * @param {string} docName - Document name for display * @param {string} sourceType - Source type ("pdf", "url", "text") * @returns {Promise} * @throws {Error} If stream contains an error event or fails */ async function processStream(response, docName, sourceType = "text") { const reader = response.body.getReader(); const decoder = new TextDecoder(); let lastStatus = ""; let audioJobId = null; // Reset estimated duration estimatedDuration = 0; try { while (true) { const { done, value } = await reader.read(); if (done) { break; } const text = decoder.decode(value, { stream: true }); const lines = text.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { try { const data = JSON.parse(line.slice(6)); if (data.type === "error") { throw new Error(data.message || "TTS generation failed"); } else if (data.type === "start" && data.job_id) { // Got job ID - start audio stream immediately const jobId = data.job_id; // Estimate audio duration from character count // Typical speech is ~14 chars/sec (150 wpm, 5 chars/word) if (data.total_chars) { estimatedDuration = data.total_chars / 14; } // Display document info updateDocInfo(data); if (!audioJobId) { audioJobId = jobId; // Start streaming playback immediately startAudioStream(jobId); } // Show generating status showStatus( ' Generating...', "loading" ); // Update progress bar processingProgressBar.style.width = "5%"; } else if (data.type === "progress") { lastStatus = data.status; // Show progress percentage showStatus( ` ${data.percent}%`, "loading" ); // Update progress bar processingProgressBar.style.width = `${data.percent}%`; } else if (data.type === "complete") { // Generation complete - show player // Use actual audio duration from server if available if (data.audio_duration && data.audio_duration > 0) { estimatedDuration = data.audio_duration; } // Build filename with style and language indicators const styleIcons = { technical: "fa-microchip", conversational: "fa-comments", storytelling: "fa-book-open", child_narrative: "fa-child", news: "fa-newspaper", academic: "fa-graduation-cap" }; const langFlags = { english: "🇬🇧", chinese: "🇨🇳", japanese: "🇯🇵", korean: "🇰🇷" }; const usedStyle = getSelectedStyle(); const usedLang = getSelectedLanguage(); const styleIcon = styleIcons[usedStyle] || "fa-microchip"; const langFlag = langFlags[usedLang] || "🇬🇧"; filename.innerHTML = ` ${docName} ${langFlag}`; currentDocName = docName; // Hide stream buttons, show full player with download streamPlayBtn.classList.add("hidden"); downloadBtn.classList.remove("hidden"); deleteBtn.classList.remove("hidden"); player.classList.add("visible"); // Set progress to 100% processingProgressBar.style.width = "100%"; showInputSection(); showStatus( ` Done in ${data.total_time}s`, "success" ); updatePlayerProgress(); } } catch (parseError) { // Check if it's our thrown error or a JSON parse error if (parseError.message && !parseError.message.includes("JSON")) { throw parseError; } // Ignore JSON parse errors for partial data } } } } } catch (streamError) { // Re-throw with more context and preserve the original cause const context = lastStatus ? ` (during: ${lastStatus})` : ""; throw new Error(`Stream error${context}: ${streamError.message}`, { cause: streamError }); } } /** * Handle file upload and TTS conversion * @param {File} file - The uploaded file */ async function handleFile(file) { // Validate file type if (!file.name.toLowerCase().endsWith(".pdf")) { showStatus(' Please select a PDF file', "error"); return; } // Validate file size if (file.size > MAX_FILE_SIZE) { showStatus(' File too large. Maximum size is 50MB.', "error"); return; } showProcessingSection(); showStatus(' Extracting text...', "loading"); player.classList.remove("visible"); downloadBtn.classList.add("hidden"); const formData = new FormData(); formData.append("file", file); formData.append("language", getSelectedLanguage()); formData.append("style", getSelectedStyle()); // Create abort controller for this request currentAbortController = new AbortController(); try { const response = await fetch("/api/read-stream", { method: "POST", body: formData, signal: currentAbortController.signal, }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Failed to process document"); } // Process stream handles both progress SSE and starting audio playback await processStream(response, file.name, "pdf"); } catch (error) { if (error.name === "AbortError") { // User cancelled - already handled in stopGeneration return; } showStatus(` ${error.message}`, "error"); showInputSection(); } finally { currentAbortController = null; } } /** * Handle URL submission and TTS conversion * @param {string} url - The URL to process */ async function handleUrl(url) { url = url.trim(); if (!url) { showStatus(' Please enter a URL', "error"); return; } // Validate URL format try { new URL(url); } catch { showStatus(' Please enter a valid URL', "error"); return; } showProcessingSection(); showStatus(' Fetching content...', "loading"); player.classList.remove("visible"); downloadBtn.classList.add("hidden"); urlSubmit.disabled = true; // Create abort controller for this request currentAbortController = new AbortController(); try { const response = await fetch("/api/read-url-stream", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ url, language: getSelectedLanguage(), style: getSelectedStyle() }), signal: currentAbortController.signal, }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Failed to process document"); } // Extract filename from URL const urlPath = new URL(url).pathname; const docName = urlPath.split("/").pop() || "document"; // Process stream handles both progress SSE and starting audio playback await processStream(response, docName, "url"); } catch (error) { if (error.name === "AbortError") { // User cancelled - already handled in stopGeneration return; } showStatus(` ${error.message}`, "error"); showInputSection(); } finally { urlSubmit.disabled = false; currentAbortController = null; } } /** * Handle text submission and TTS conversion * @param {string} text - The text to process */ async function handleText(text) { text = text.trim(); if (!text) { showStatus(' Please enter some text', "error"); return; } if (text.length > 500000) { showStatus(' Text too long (max 500,000 characters)', "error"); return; } showProcessingSection(); showStatus(' Processing text...', "loading"); player.classList.remove("visible"); downloadBtn.classList.add("hidden"); textSubmit.disabled = true; // Create abort controller for this request currentAbortController = new AbortController(); try { const response = await fetch("/api/read-text-stream", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ text, language: getSelectedLanguage(), style: getSelectedStyle() }), signal: currentAbortController.signal, }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || "Failed to process text"); } // Generate document name from first few words const words = text.trim().split(/\s+/).slice(0, 5).join(" "); const docName = words.length > 30 ? words.slice(0, 30) + "..." : words; // Process stream handles both progress SSE and starting audio playback await processStream(response, docName, "text"); } catch (error) { if (error.name === "AbortError") { // User cancelled - already handled in stopGeneration return; } showStatus(` ${error.message}`, "error"); showInputSection(); } finally { textSubmit.disabled = false; currentAbortController = null; } } // Tab switching tabs.forEach((tab) => { tab.addEventListener("click", () => { const isAlreadyActive = tab.classList.contains("active"); const isUploadTab = tab.dataset.tab === "upload"; // If clicking on already-active upload tab, open file picker if (isAlreadyActive && isUploadTab) { fileInput.click(); return; } tabs.forEach((t) => t.classList.remove("active")); tabContents.forEach((tc) => tc.classList.remove("active")); tab.classList.add("active"); document.getElementById(`${tab.dataset.tab}-tab`).classList.add("active"); }); }); // Drag and drop handlers dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); }); dropZone.addEventListener("dragleave", () => { dropZone.classList.remove("dragover"); }); dropZone.addEventListener("drop", (e) => { e.preventDefault(); dropZone.classList.remove("dragover"); const files = e.dataTransfer.files; if (files.length > 0) { handleFile(files[0]); } }); // Click to select file dropZone.addEventListener("click", (e) => { if (e.target !== fileInput) { fileInput.click(); } }); fileInput.addEventListener("change", () => { if (fileInput.files.length > 0) { handleFile(fileInput.files[0]); } }); // URL submission urlSubmit.addEventListener("click", () => { handleUrl(urlInput.value); }); urlInput.addEventListener("keypress", (e) => { if (e.key === "Enter") { handleUrl(urlInput.value); } }); // Text submission textSubmit.addEventListener("click", () => { handleText(textInput.value); }); // Allow Ctrl+Enter to submit text textInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { handleText(textInput.value); } }); // Auto-detect language from text input textInput.addEventListener("input", () => { const detected = detectLanguage(textInput.value); if (detected) { setLanguage(detected, true); } }); // Stop button stopBtn.addEventListener("click", stopGeneration); // Stream play button (during processing) streamPlayBtn.addEventListener("click", () => { audio.play().catch(() => {}); // Hide stream play button and show pause button streamPlayBtn.classList.add("hidden"); pauseBtn.classList.remove("hidden"); }); // Pause button pauseBtn.addEventListener("click", togglePause); // Download button downloadBtn.addEventListener("click", downloadAudio); // Delete button deleteBtn.addEventListener("click", deleteAudio); // Update pause button when audio state changes audio.addEventListener("play", updatePauseButton); audio.addEventListener("pause", updatePauseButton); audio.addEventListener("ended", () => { isPaused = false; updatePauseButton(); }); // Language selection languageButtons.forEach((btn) => { btn.addEventListener("click", () => { setLanguage(btn.dataset.language, false); }); }); // Style selection const styleButtons = document.querySelectorAll("#styleButtons .style-btn"); styleButtons.forEach((btn) => { btn.addEventListener("click", () => { styleButtons.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); selectedStyle = btn.dataset.style; }); });