Spaces:
Running
Running
| // ===== KIMI VOICE MANAGEMENT MODULE ===== | |
| class KimiVoiceManager { | |
| constructor(database, memory) { | |
| this.db = database; | |
| this.memory = memory; | |
| this.isInitialized = false; | |
| // Voice properties | |
| this.speechSynthesis = window.speechSynthesis; | |
| this.currentVoice = null; | |
| this.availableVoices = []; | |
| // Speech recognition | |
| this.SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| this.recognition = null; | |
| this.isListening = false; | |
| this.isStoppingVolontaire = false; | |
| // DOM elements | |
| this.micButton = null; | |
| // Real-time transcript overlay elements (shows live speech transcription and AI responses) | |
| this.transcriptContainer = null; // Container for transcript overlay | |
| this.transcriptText = null; // Text element displaying current transcript | |
| // Callback for voice message analysis | |
| this.onSpeechAnalysis = null; | |
| // Reference to mic handler function for removal | |
| this.handleMicClick = null; | |
| this.transcriptHideTimeout = null; | |
| this.listeningTimeout = null; | |
| // Selected character for responses (will be updated from database) | |
| this.selectedCharacter = window.KIMI_CONFIG?.DEFAULTS?.SELECTED_CHARACTER || "Kimi"; | |
| // Speaking flag | |
| this.isSpeaking = false; | |
| // Auto-stop listening duration (in milliseconds) | |
| this.autoStopDuration = 15000; // 15 seconds | |
| // Silence timeout after final transcript (in milliseconds) | |
| this.silenceTimeout = 2200; // 2.2 seconds | |
| // Track if microphone permission has been granted | |
| this.micPermissionGranted = false; | |
| // Debounced microphone toggle (centralized utility) | |
| this._debouncedToggleMicrophone = window.KimiPerformanceUtils | |
| ? window.KimiPerformanceUtils.debounce(() => this._toggleMicrophoneCore(), 300, false, this) | |
| : null; | |
| // Browser detection | |
| this.browser = this._detectBrowser(); | |
| } | |
| // ===== INITIALIZATION ===== | |
| async init() { | |
| // Avoid double initialization | |
| if (this.isInitialized) { | |
| console.log("VoiceManager already initialized, ignored"); | |
| return true; | |
| } | |
| try { | |
| // Initialize DOM elements with verification | |
| this.micButton = document.getElementById("mic-button"); | |
| this.transcriptContainer = document.querySelector(".transcript-container"); | |
| this.transcriptText = document.getElementById("transcript"); | |
| if (!this.micButton) { | |
| if (window.KIMI_CONFIG?.DEBUG?.VOICE) { | |
| console.warn("Microphone button not found in DOM!"); | |
| } | |
| return false; | |
| } | |
| // Check transcript elements (non-critical, just warn) | |
| if (!this.transcriptContainer) { | |
| if (window.KIMI_CONFIG?.DEBUG?.VOICE) { | |
| console.warn("Transcript container not found in DOM - transcript feature will be disabled"); | |
| } | |
| } | |
| if (!this.transcriptText) { | |
| if (window.KIMI_CONFIG?.DEBUG?.VOICE) { | |
| console.warn("Transcript text element not found in DOM - transcript feature will be disabled"); | |
| } | |
| } | |
| // Initialize voice synthesis | |
| await this.initVoices(); | |
| // Only setup listener once during initialization | |
| if (!this._voicesListenerSetup) { | |
| this.setupVoicesChangedListener(); | |
| this._voicesListenerSetup = true; | |
| } | |
| this.setupLanguageSelector(); | |
| // Initialize speech recognition | |
| this.setupSpeechRecognition(); | |
| this.setupMicrophoneButton(); | |
| // Check current microphone permission status | |
| await this.checkMicrophonePermission(); | |
| // Initialize selected character with proper display name | |
| if (this.db && typeof this.db.getSelectedCharacter === "function") { | |
| const charKey = await this.db.getSelectedCharacter(); | |
| if (charKey && window.KIMI_CHARACTERS && window.KIMI_CHARACTERS[charKey]) { | |
| // Use the display name, not the key | |
| this.selectedCharacter = window.KIMI_CHARACTERS[charKey].name; | |
| } else if (charKey) { | |
| // Fallback to key if KIMI_CHARACTERS not available | |
| this.selectedCharacter = charKey; | |
| } | |
| } | |
| this.isInitialized = true; | |
| return true; | |
| } catch (error) { | |
| console.error("Error during VoiceManager initialization:", error); | |
| return false; | |
| } | |
| } | |
| _detectBrowser() { | |
| const ua = navigator.userAgent || ""; | |
| const isOpera = (!!window.opr && !!opr.addons) || ua.includes(" OPR/"); | |
| const isFirefox = typeof InstallTrigger !== "undefined" || ua.toLowerCase().includes("firefox"); | |
| const isSafari = /Safari\//.test(ua) && !/Chrom(e|ium)\//.test(ua) && !/Edg\//.test(ua); | |
| const isEdge = /Edg\//.test(ua); | |
| // Detect Brave explicitly: navigator.brave exists in many Brave builds, UA may also include 'Brave' | |
| const isBrave = | |
| (!!navigator.brave && typeof navigator.brave.isBrave === "function") || ua.toLowerCase().includes("brave"); | |
| const isChrome = /Chrome\//.test(ua) && !isEdge && !isOpera && !isBrave; | |
| if (isFirefox) return "firefox"; | |
| if (isOpera) return "opera"; | |
| if (isBrave) return "brave"; | |
| if (isSafari) return "safari"; | |
| if (isEdge) return "edge"; | |
| if (isChrome) return "chrome"; | |
| return "unknown"; | |
| } | |
| _getUnsupportedSRMessage() { | |
| // Build an i18n key by browser, then fallback to English if translation system isn't ready | |
| let key = "sr_not_supported_generic"; | |
| if (this.browser === "firefox") key = "sr_not_supported_firefox"; | |
| else if (this.browser === "opera") key = "sr_not_supported_opera"; | |
| else if (this.browser === "safari") key = "sr_not_supported_safari"; | |
| const translated = typeof window.kimiI18nManager?.t === "function" ? window.kimiI18nManager.t(key) : undefined; | |
| // Many i18n libs return the key itself if missing; detect that and fall back to English | |
| if (!translated || translated === key) { | |
| if (key === "sr_not_supported_firefox") { | |
| return "Speech recognition is not supported on Firefox. Please use Chrome, Edge, or Brave."; | |
| } | |
| if (key === "sr_not_supported_opera") { | |
| return "Speech recognition may not work on Opera. Please try Chrome, Edge, or Brave."; | |
| } | |
| if (key === "sr_not_supported_safari") { | |
| return "Speech recognition support varies on Safari. Prefer Chrome or Edge for best results."; | |
| } | |
| return "Speech recognition is not available in this browser."; | |
| } | |
| return translated; | |
| } | |
| // ===== MICROPHONE PERMISSION MANAGEMENT ===== | |
| async checkMicrophonePermission() { | |
| try { | |
| // Check if running on file:// protocol | |
| if (window.location.protocol === "file:") { | |
| if (window.KIMI_CONFIG?.DEBUG?.VOICE) { | |
| console.log("π€ Running on file:// protocol - microphone permissions will be requested each time"); | |
| } | |
| this.micPermissionGranted = false; | |
| return; | |
| } | |
| if (!navigator.permissions) { | |
| if (window.KIMI_CONFIG?.DEBUG?.VOICE) { | |
| console.log("π€ Permissions API not available"); | |
| } | |
| this.micPermissionGranted = false; // Set default state | |
| return; | |
| } | |
| const permissionStatus = await navigator.permissions.query({ name: "microphone" }); | |
| this.micPermissionGranted = permissionStatus.state === "granted"; | |
| // Listen for permission changes | |
| permissionStatus.addEventListener("change", () => { | |
| this.micPermissionGranted = permissionStatus.state === "granted"; | |
| }); | |
| } catch (error) { | |
| console.log("π€ Could not check microphone permission:", error); | |
| this.micPermissionGranted = false; | |
| } | |
| } | |
| // ===== VOICE SYNTHESIS ===== | |
| async initVoices() { | |
| // Prevent multiple simultaneous calls | |
| if (this._initializingVoices) { | |
| return; | |
| } | |
| this._initializingVoices = true; | |
| this.availableVoices = this.speechSynthesis.getVoices(); | |
| // Resolve selectedLanguage before any early return so SR uses correct language | |
| if (!this.selectedLanguage) { | |
| try { | |
| const selectedLanguage = await this.db?.getPreference("selectedLanguage", "en"); | |
| this.selectedLanguage = window.KimiLanguageUtils.normalizeLanguageCode(selectedLanguage || "en") || "en"; | |
| } catch (_) { | |
| this.selectedLanguage = "en"; | |
| } | |
| } | |
| if (this.availableVoices.length === 0) { | |
| this._initializingVoices = false; | |
| return; // onvoiceschanged will retry later | |
| } | |
| const effectiveLang = await this.getEffectiveLanguage(this.selectedLanguage); | |
| const savedVoice = await this.db?.getPreference("selectedVoice", "auto"); | |
| const filteredVoices = this.getVoicesForLanguage(effectiveLang); | |
| if (savedVoice && savedVoice !== "auto") { | |
| const foundVoice = filteredVoices.find(voice => voice.name === savedVoice); | |
| if (foundVoice) { | |
| this.currentVoice = foundVoice; | |
| console.log( | |
| `π€ Voice restored from cache: "${foundVoice.name}" (${foundVoice.lang}) for language "${effectiveLang}"` | |
| ); | |
| this.updateVoiceSelector(); | |
| this._initializingVoices = false; | |
| return; | |
| } else { | |
| console.log( | |
| `π€ Saved voice "${savedVoice}" not compatible with language "${effectiveLang}", using auto-selection` | |
| ); | |
| await this.db?.setPreference("selectedVoice", "auto"); | |
| } | |
| } | |
| // Prefer female voices if available in the language-compatible voices | |
| // Use real voice names since voice.gender is rarely provided by browsers | |
| const femaleVoice = filteredVoices.find(voice => { | |
| const name = voice.name.toLowerCase(); | |
| // Common female voice names across different platforms | |
| const femaleNames = [ | |
| // Microsoft voices | |
| "aria", | |
| "emma", | |
| "jenny", | |
| "michelle", | |
| "karen", | |
| "heather", | |
| "susan", | |
| "joanna", | |
| "salli", | |
| "kimberly", | |
| "kendra", | |
| "ivy", | |
| "rebecca", | |
| "zira", | |
| "eva", | |
| "linda", | |
| "denise", | |
| "elsa", | |
| "nathalie", | |
| "julie", | |
| "hortense", | |
| "marie", | |
| "pauline", | |
| "claudia", | |
| "lucia", | |
| "paola", | |
| "bianca", | |
| "cosima", | |
| "katja", | |
| "hedda", | |
| "helena", | |
| "naayf", | |
| "sabina", | |
| "naja", | |
| "sara", | |
| "amelie", | |
| "lea", | |
| "manon", | |
| // Google voices | |
| "wavenet-a", | |
| "wavenet-c", | |
| "wavenet-e", | |
| "wavenet-f", | |
| "wavenet-g", | |
| "standard-a", | |
| "standard-c", | |
| "standard-e", | |
| // Apple voices | |
| "allison", | |
| "ava", | |
| "samantha", | |
| "susan", | |
| "vicki", | |
| "victoria", | |
| "audrey", | |
| "aurelie", | |
| "marie", | |
| "thomas", | |
| "amelie", | |
| // General keywords | |
| "female", | |
| "woman", | |
| "girl", | |
| "lady" | |
| ]; | |
| // Check if voice name contains any female name | |
| return ( | |
| femaleNames.some(femaleName => name.includes(femaleName)) || | |
| (voice.gender && voice.gender.toLowerCase() === "female") | |
| ); | |
| }); | |
| // Debug: Voice analysis (debug mode only) | |
| if (window.KIMI_DEBUG_VOICE) { | |
| console.log(`π€ Female voice found: "${femaleVoice.name}" (${femaleVoice.lang})`); | |
| } else if (window.KIMI_DEBUG_VOICE) { | |
| console.log( | |
| `π€ No female voice found, using first available: "${filteredVoices[0]?.name}" (${filteredVoices[0]?.lang})` | |
| ); | |
| // Debug: Show what voices are available and why they don't match | |
| if (filteredVoices.length > 0 && filteredVoices.length <= 5) { | |
| console.log( | |
| `π€ Available voices for ${effectiveLang}:`, | |
| filteredVoices.map(v => ({ | |
| name: v.name, | |
| lang: v.lang, | |
| gender: v.gender || "undefined" | |
| })) | |
| ); | |
| } | |
| } | |
| // Use female voice if found, otherwise first compatible voice, with proper fallback | |
| // KEEP legacy auto-selection behavior only for Chrome/Edge where it was reliable. | |
| // For other browsers (Firefox/Brave/Opera), avoid auto-selecting to prevent wrong default (e.g., Hortense). | |
| const browser = this.browser || this._detectBrowser(); | |
| if (browser === "chrome" || browser === "edge") { | |
| this.currentVoice = femaleVoice || filteredVoices[0] || null; | |
| } else { | |
| // Do not auto-select on less predictable browsers | |
| this.currentVoice = femaleVoice && filteredVoices.length > 1 ? femaleVoice : null; | |
| } | |
| if (!this.currentVoice) { | |
| console.warn("π€ No voices available for speech synthesis - this may resolve automatically when voices load"); | |
| this._initializingVoices = false; | |
| // Don't return here - let the system continue, voices may load later via onvoiceschanged | |
| // The updateVoiceSelector will handle the empty state gracefully | |
| } else { | |
| // Log successful voice selection with language info | |
| console.log( | |
| `π€ Voice loaded: "${this.currentVoice.name}" (${this.currentVoice.lang}) for language "${effectiveLang}"` | |
| ); | |
| } | |
| // Do not overwrite "auto" preference here; only update if user selects a specific voice | |
| this.updateVoiceSelector(); | |
| this._initializingVoices = false; | |
| } | |
| updateVoiceSelector() { | |
| const voiceSelect = document.getElementById("voice-selection"); | |
| if (!voiceSelect) return; | |
| // Clear existing options | |
| while (voiceSelect.firstChild) { | |
| voiceSelect.removeChild(voiceSelect.firstChild); | |
| } | |
| // Add auto option | |
| const autoOption = document.createElement("option"); | |
| autoOption.value = "auto"; | |
| autoOption.textContent = "Automatic (Best voice for selected language)"; | |
| voiceSelect.appendChild(autoOption); | |
| const filteredVoices = this.getVoicesForLanguage(this.selectedLanguage); | |
| // If browser is not Chrome or Edge, do NOT expose voice options even when voices exist. | |
| // This avoids misleading users on Brave/Firefox/Opera/Safari who might think TTS is supported when it's not. | |
| const browser = this.browser || this._detectBrowser(); | |
| if ((browser !== "chrome" && browser !== "edge") || filteredVoices.length === 0) { | |
| const noVoicesOption = document.createElement("option"); | |
| noVoicesOption.value = "none"; | |
| noVoicesOption.textContent = "No voices available for this browser"; | |
| noVoicesOption.disabled = true; | |
| voiceSelect.appendChild(noVoicesOption); | |
| } else { | |
| filteredVoices.forEach(voice => { | |
| const option = document.createElement("option"); | |
| option.value = voice.name; | |
| option.textContent = `${voice.name} (${voice.lang})`; | |
| if (this.currentVoice && voice.name === this.currentVoice.name) { | |
| option.selected = true; | |
| } | |
| voiceSelect.appendChild(option); | |
| }); | |
| } | |
| // Remove existing handler before adding new one | |
| if (this.voiceChangeHandler) { | |
| voiceSelect.removeEventListener("change", this.voiceChangeHandler); | |
| } | |
| // Create and store the handler | |
| this.voiceChangeHandler = this.handleVoiceChange.bind(this); | |
| voiceSelect.addEventListener("change", this.voiceChangeHandler); | |
| } | |
| async handleVoiceChange(e) { | |
| if (e.target.value === "auto") { | |
| console.log(`π€ Voice set to automatic selection for language "${this.selectedLanguage}"`); | |
| await this.db?.setPreference("selectedVoice", "auto"); | |
| this.currentVoice = null; // clear immediate in-memory voice | |
| // Re-initialize voices synchronously so currentVoice is set before other code reacts | |
| try { | |
| await this.initVoices(); | |
| } catch (err) { | |
| // If init fails, leave currentVoice null but don't throw | |
| console.warn("π€ initVoices failed after setting auto:", err); | |
| } | |
| } else { | |
| this.currentVoice = this.availableVoices.find(voice => voice.name === e.target.value); | |
| console.log(`π€ Voice manually selected: "${this.currentVoice?.name}" (${this.currentVoice?.lang})`); | |
| await this.db?.setPreference("selectedVoice", e.target.value); | |
| } | |
| } | |
| setupVoicesChangedListener() { | |
| if (this.speechSynthesis.onvoiceschanged !== undefined) { | |
| // Prevent multiple event listeners | |
| this.speechSynthesis.onvoiceschanged = null; | |
| this.speechSynthesis.onvoiceschanged = async () => { | |
| // Only reinitialize if voices are actually available now | |
| if (this.speechSynthesis.getVoices().length > 0) { | |
| await this.initVoices(); | |
| } | |
| }; | |
| } | |
| // Fallback: Only use timeout if onvoiceschanged is not supported | |
| if (this.availableVoices.length === 0 && this.speechSynthesis.onvoiceschanged === undefined) { | |
| setTimeout(async () => { | |
| await this.initVoices(); | |
| }, 1000); | |
| } | |
| } | |
| // ===== LANGUAGE UTILITIES ===== | |
| getLanguageCode(langShort) { | |
| const languageMap = { | |
| en: "en-US", | |
| fr: "fr-FR", | |
| es: "es-ES", | |
| de: "de-DE", | |
| it: "it-IT", | |
| ja: "ja-JP", | |
| zh: "zh-CN" | |
| }; | |
| return languageMap[langShort] || langShort; | |
| } | |
| // language normalization handled by window.KimiLanguageUtils.normalizeLanguageCode | |
| getVoicesForLanguage(language) { | |
| const norm = window.KimiLanguageUtils.normalizeLanguageCode(language || ""); | |
| // First pass: voices whose lang primary subtag starts with normalized code | |
| let filteredVoices = this.availableVoices.filter(voice => { | |
| try { | |
| const vlang = String(voice.lang || "").toLowerCase(); | |
| return vlang.startsWith(norm); | |
| } catch (e) { | |
| return false; | |
| } | |
| }); | |
| // Second pass: voices that contain the code anywhere | |
| if (filteredVoices.length === 0 && norm) { | |
| filteredVoices = this.availableVoices.filter(voice => | |
| String(voice.lang || "") | |
| .toLowerCase() | |
| .includes(norm) | |
| ); | |
| } | |
| // Do not fall back to all voices: if none match, return empty array so UI shows "no voices available" | |
| if (filteredVoices.length === 0) return []; | |
| return filteredVoices; | |
| } | |
| // ===== VOICE PREFERENCE UTILITIES ===== | |
| getVoicePreference(paramType, options = {}) { | |
| // Hierarchy: options > memory.preferences > kimiMemory.preferences > DOM element > default | |
| const defaults = { | |
| rate: window.KIMI_CONFIG?.DEFAULTS?.VOICE_RATE || 1.1, | |
| pitch: window.KIMI_CONFIG?.DEFAULTS?.VOICE_PITCH || 1.1, | |
| volume: window.KIMI_CONFIG?.DEFAULTS?.VOICE_VOLUME || 0.8 | |
| }; | |
| const elementIds = { | |
| rate: "voice-rate", | |
| pitch: "voice-pitch", | |
| volume: "voice-volume" | |
| }; | |
| const memoryKeys = { | |
| rate: "voiceRate", | |
| pitch: "voicePitch", | |
| volume: "voiceVolume" | |
| }; | |
| // 1. Check options parameter | |
| if (options[paramType] !== undefined) { | |
| return parseFloat(options[paramType]); | |
| } | |
| // 2. Check local memory preferences | |
| if (this.memory?.preferences?.[memoryKeys[paramType]] !== undefined) { | |
| return parseFloat(this.memory.preferences[memoryKeys[paramType]]); | |
| } | |
| // 3. Check global memory preferences | |
| if (window.kimiMemory?.preferences?.[memoryKeys[paramType]] !== undefined) { | |
| return parseFloat(window.kimiMemory.preferences[memoryKeys[paramType]]); | |
| } | |
| // 4. Check DOM element | |
| const element = document.getElementById(elementIds[paramType]); | |
| if (element) { | |
| return parseFloat(element.value); | |
| } | |
| // 5. Return default value | |
| return defaults[paramType]; | |
| } | |
| // ===== CHAT MESSAGE UTILITIES ===== | |
| handleChatMessage(userMessage, kimiResponse) { | |
| // Always save to chat history, regardless of chat visibility | |
| const addMessageToChat = window.addMessageToChat || (typeof addMessageToChat !== "undefined" ? addMessageToChat : null); | |
| if (addMessageToChat) { | |
| // Save messages to history | |
| addMessageToChat("user", userMessage); | |
| addMessageToChat(this.selectedCharacter.toLowerCase(), kimiResponse); | |
| } else { | |
| // Fallback: only add to visible chat if available | |
| const chatContainer = document.getElementById("chat-container"); | |
| const chatMessages = document.getElementById("chat-messages"); | |
| if (chatContainer && chatContainer.classList.contains("visible") && chatMessages) { | |
| this.createChatMessage(chatMessages, "user", userMessage); | |
| this.createChatMessage(chatMessages, this.selectedCharacter.toLowerCase(), kimiResponse); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| } | |
| } | |
| createChatMessage(container, sender, text) { | |
| const messageDiv = document.createElement("div"); | |
| messageDiv.className = `message ${sender}`; | |
| const textDiv = document.createElement("div"); | |
| textDiv.textContent = text; | |
| const timeDiv = document.createElement("div"); | |
| timeDiv.className = "message-time"; | |
| timeDiv.textContent = new Date().toLocaleTimeString("en-US", { | |
| hour: "2-digit", | |
| minute: "2-digit" | |
| }); | |
| messageDiv.appendChild(textDiv); | |
| messageDiv.appendChild(timeDiv); | |
| container.appendChild(messageDiv); | |
| } | |
| async speak(text, options = {}) { | |
| // If no text or voice not ready, attempt short retries for voice initialization | |
| if (!text) { | |
| console.warn("Unable to speak: empty text"); | |
| return; | |
| } | |
| const maxRetries = 3; | |
| let attempt = 0; | |
| while (!this.currentVoice && attempt < maxRetries) { | |
| // Small jittered backoff | |
| const wait = 100 + Math.floor(Math.random() * 200); // 100-300ms | |
| await new Promise(r => setTimeout(r, wait)); | |
| // If voices available, try to init | |
| if (this.availableVoices.length > 0) { | |
| // attempt to pick a voice for the current language | |
| try { | |
| await this.initVoices(); | |
| } catch (e) { | |
| // ignore and retry | |
| } | |
| } | |
| attempt++; | |
| } | |
| if (!this.currentVoice) { | |
| console.warn("Unable to speak: voice not initialized after retries"); | |
| return; | |
| } | |
| this.clearTranscriptTimeout(); | |
| if (this.speechSynthesis.speaking) { | |
| this.speechSynthesis.cancel(); | |
| } | |
| // Clean text for better speech synthesis | |
| let processedText = this._normalizeForSpeech(text); | |
| // Get voice settings using centralized utility | |
| let customRate = this.getVoicePreference("rate", options); | |
| let customPitch = this.getVoicePreference("pitch", options); | |
| // Check for emotional indicators in original text (before processing) | |
| const lowerText = text.toLowerCase(); | |
| if ( | |
| lowerText.includes("β€οΈ") || | |
| lowerText.includes("π") || | |
| lowerText.includes("π") || | |
| lowerText.includes("amour") || | |
| lowerText.includes("love") || | |
| lowerText.includes("bisou") | |
| ) { | |
| // Tender loving content - slower and higher pitch | |
| customRate = Math.max(0.7, customRate - 0.2); | |
| customPitch = Math.min(1.3, customPitch + 0.1); | |
| } | |
| const utterance = new SpeechSynthesisUtterance(processedText); | |
| utterance.voice = this.currentVoice; | |
| utterance.rate = customRate; | |
| utterance.pitch = customPitch; | |
| // Get volume using centralized utility | |
| utterance.volume = this.getVoicePreference("volume", options); | |
| // Use centralized emotion system for consistency | |
| const emotionFromText = window.kimiEmotionSystem?.analyzeEmotionValidated(text) || "neutral"; | |
| // PRE-PREPARE speaking animation before TTS starts | |
| if (window.kimiVideo) { | |
| // Always prepare a speaking context based on detected emotion | |
| requestAnimationFrame(async () => { | |
| try { | |
| const traits = await this.db?.getAllPersonalityTraits( | |
| window.kimiMemory?.selectedCharacter || (await this.db.getSelectedCharacter()) | |
| ); | |
| const affection = traits ? traits.affection : 50; | |
| // Choose the appropriate speaking context | |
| if (emotionFromText === "negative") { | |
| window.kimiVideo.switchToContext("speakingNegative", "negative", null, traits || {}, affection); | |
| } else if (emotionFromText === "neutral") { | |
| // Even neutral text should use speaking context during TTS | |
| window.kimiVideo.switchToContext("speakingPositive", "neutral", null, traits || {}, affection); | |
| } else { | |
| // For positive and specific emotions | |
| const videoCategory = | |
| window.kimiEmotionSystem?.getVideoCategory(emotionFromText, traits) || "speakingPositive"; | |
| window.kimiVideo.switchToContext(videoCategory, emotionFromText, null, traits || {}, affection); | |
| } | |
| } catch (e) { | |
| console.warn("Failed to prepare speaking animation:", e); | |
| } | |
| }); | |
| } | |
| if (typeof window.updatePersonalityTraitsFromEmotion === "function") { | |
| window.updatePersonalityTraitsFromEmotion(emotionFromText, text); | |
| } | |
| this.showResponseWithPerfectTiming(text); | |
| utterance.onstart = async () => { | |
| this.isSpeaking = true; | |
| // IMMEDIATELY switch to appropriate speaking animation when TTS starts | |
| try { | |
| if (window.kimiVideo) { | |
| const traits = await this.db?.getAllPersonalityTraits( | |
| window.kimiMemory?.selectedCharacter || (await this.db.getSelectedCharacter()) | |
| ); | |
| const affection = traits ? traits.affection : 50; | |
| // Choose speaking context based on the detected emotion using centralized logic | |
| if (emotionFromText === "negative") { | |
| window.kimiVideo.switchToContext("speakingNegative", "negative", null, traits || {}, affection); | |
| } else if (emotionFromText === "neutral") { | |
| // Even for neutral speech, use speaking context during TTS | |
| window.kimiVideo.switchToContext("speakingPositive", "neutral", null, traits || {}, affection); | |
| } else { | |
| // For positive and specific emotions, use appropriate speaking context | |
| const videoCategory = | |
| window.kimiEmotionSystem?.getVideoCategory(emotionFromText, traits) || "speakingPositive"; | |
| window.kimiVideo.switchToContext(videoCategory, emotionFromText, null, traits || {}, affection); | |
| } | |
| } | |
| } catch (e) { | |
| console.warn("Failed to switch to speaking context:", e); | |
| } | |
| }; | |
| utterance.onend = () => { | |
| this.isSpeaking = false; | |
| // Hide transcript overlay when AI finishes speaking | |
| this.updateTranscriptVisibility(false); | |
| // Clear any pending hide timeout | |
| this.clearTranscriptTimeout(); | |
| // IMMEDIATELY return to neutral when TTS ends | |
| if (window.kimiVideo) { | |
| try { | |
| const info = window.kimiVideo.getCurrentVideoInfo ? window.kimiVideo.getCurrentVideoInfo() : null; | |
| // Only return to neutral if currently in a speaking context | |
| if (info && (info.context === "speakingPositive" || info.context === "speakingNegative")) { | |
| // Use async pattern to get traits for neutral transition | |
| (async () => { | |
| try { | |
| const traits = await this.db?.getAllPersonalityTraits( | |
| window.kimiMemory?.selectedCharacter || (await this.db.getSelectedCharacter()) | |
| ); | |
| window.kimiVideo.switchToContext( | |
| "neutral", | |
| "neutral", | |
| null, | |
| traits || {}, | |
| traits?.affection || 50 | |
| ); | |
| } catch (e) { | |
| // Fallback without traits | |
| window.kimiVideo.switchToContext("neutral", "neutral", null, {}, 50); | |
| } | |
| })(); | |
| } | |
| } catch (e) { | |
| console.warn("Failed to return to neutral after TTS:", e); | |
| } | |
| } | |
| }; | |
| utterance.onerror = e => { | |
| this.isSpeaking = false; | |
| this.updateTranscriptVisibility(false); | |
| this.clearTranscriptTimeout(); | |
| }; | |
| this.speechSynthesis.speak(utterance); | |
| } | |
| /** | |
| * Normalize raw model text into something natural for browser speech synthesis. | |
| * Goals: | |
| * - Remove emojis / pictographs (engines try to read them literally) | |
| * - Collapse excessive punctuation while preserving rhythm | |
| * - Convert ellipses to a Unicode ellipsis (β¦) | |
| * - Remove markdown / formatting artifacts (* _ ~ ` # [] <> etc.) | |
| * - Remove stray markup like **bold**, inline code, URLs parentheses clutter | |
| * - Keep meaningful punctuation (. , ! ? ; :) | |
| * - Avoid inserting artificial words (e.g., "pause") | |
| */ | |
| _normalizeForSpeech(raw) { | |
| if (!raw) return ""; | |
| let txt = raw; | |
| // Remove URLs completely (they sound awkward) β keep none. | |
| txt = txt.replace(/https?:\/\/\S+/gi, " "); | |
| // Remove markdown code blocks and inline code markers | |
| txt = txt.replace(/`{3}[\s\S]*?`{3}/g, " "); // fenced blocks | |
| txt = txt.replace(/`([^`]+)`/g, "$1"); // inline code unwrap | |
| // Remove emphasis markers (*, _, ~) while keeping inner text | |
| txt = txt.replace(/\*{1,3}([^*]+)\*{1,3}/g, "$1"); | |
| txt = txt.replace(/_{1,3}([^_]+)_{1,3}/g, "$1"); | |
| txt = txt.replace(/~{1,2}([^~]+)~{1,2}/g, "$1"); | |
| // Strip remaining markdown heading symbols at line starts | |
| txt = txt.replace(/^\s{0,3}#{1,6}\s+/gm, ""); | |
| // Remove HTML/XML tags | |
| txt = txt.replace(/<[^>]+>/g, " "); | |
| // Remove brackets content if it is link style [text](url) | |
| txt = txt.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); | |
| // Remove leftover standalone brackets | |
| txt = txt.replace(/[\[\]<>]/g, " "); | |
| // Remove emojis / pictographic chars | |
| txt = txt.replace(/[\p{Emoji}\p{Extended_Pictographic}]/gu, " "); | |
| // Normalize ellipses: sequences of 3+ dots -> single ellipsis surrounded by light spaces | |
| txt = txt.replace(/\.{3,}/g, " β¦ "); | |
| // Replace double dots with single period + space | |
| txt = txt.replace(/\.\./g, ". "); | |
| // Collapse multiple exclamation/question marks to single (keeps expressiveness but avoids stutter) | |
| txt = txt.replace(/!{2,}/g, "!"); | |
| txt = txt.replace(/\?{2,}/g, "?"); | |
| // Space after sentence punctuation if missing | |
| txt = txt.replace(/([.!?])([^\s\d])/g, "$1 $2"); | |
| // Replace underscores or asterisks still present with spaces | |
| txt = txt.replace(/[*_]{2,}/g, " "); | |
| // Remove stray backticks | |
| txt = txt.replace(/`+/g, " "); | |
| // Collapse mixed punctuation like ?!?! to a single terminal symbol keeping first char | |
| txt = txt.replace(/([!?]){2,}/g, "$1"); | |
| // Remove repeated commas / semicolons / colons | |
| txt = txt.replace(/,{2,}/g, ","); | |
| txt = txt.replace(/;{2,}/g, ";"); | |
| txt = txt.replace(/:{2,}/g, ":"); | |
| // Remove leading/trailing punctuation clusters | |
| txt = txt.replace(/^[\s.,;:!?]+/, "").replace(/[\s.,;:!?]+$/, ""); | |
| // Collapse whitespace | |
| txt = txt.replace(/\s+/g, " "); | |
| // Final trim | |
| txt = txt.trim(); | |
| return txt; | |
| } | |
| // Intelligently calculate synthesis duration | |
| calculateSpeechDuration(text, rate = 0.9) { | |
| const baseWordsPerMinute = 150; | |
| const adjustedWPM = baseWordsPerMinute * rate; | |
| const wordCount = text.split(/\s+/).length; | |
| const estimatedMinutes = wordCount / adjustedWPM; | |
| const estimatedMilliseconds = estimatedMinutes * 60 * 1000; | |
| const bufferTime = text.split(/[.!?]/).length * 500; | |
| return Math.max(estimatedMilliseconds + bufferTime, 2000); | |
| } | |
| // ===== REAL-TIME TRANSCRIPT DISPLAY ===== | |
| // Centralized transcript timeout management | |
| clearTranscriptTimeout() { | |
| if (this.transcriptHideTimeout) { | |
| clearTimeout(this.transcriptHideTimeout); | |
| this.transcriptHideTimeout = null; | |
| } | |
| } | |
| // Utility method to safely check transcript preference and control visibility | |
| async updateTranscriptVisibility(shouldShow, text = null) { | |
| if (!this.transcriptContainer || !this.transcriptText) return false; | |
| const showTranscript = await this.db?.getPreference( | |
| "showTranscript", | |
| window.KIMI_CONFIG?.DEFAULTS?.SHOW_TRANSCRIPT ?? true | |
| ); | |
| if (!showTranscript) { | |
| // If transcript is disabled, always hide | |
| this.transcriptContainer.classList.remove("visible"); | |
| return false; | |
| } | |
| if (shouldShow) { | |
| if (text) { | |
| // Show with text content | |
| this.transcriptText.textContent = text; | |
| this.transcriptContainer.classList.add("visible"); | |
| return true; | |
| } else { | |
| // Show but keep existing text (for cases where we just want to maintain visibility) | |
| this.transcriptContainer.classList.add("visible"); | |
| return true; | |
| } | |
| } else { | |
| // Hide transcript | |
| this.transcriptContainer.classList.remove("visible"); | |
| return false; | |
| } | |
| } | |
| // Show AI response text in real-time transcript overlay when AI is speaking | |
| async showResponseWithPerfectTiming(text) { | |
| const success = await this.updateTranscriptVisibility(true, `${this.selectedCharacter}: ${text}`); | |
| if (success) { | |
| this.clearTranscriptTimeout(); | |
| } | |
| } | |
| showResponse(text) { | |
| this.showResponseWithPerfectTiming(text); | |
| } | |
| // Show user voice input text in real-time transcript overlay during speech recognition | |
| async showUserMessage(text, duration = 3000) { | |
| const success = await this.updateTranscriptVisibility(true, text); | |
| if (success) { | |
| this.clearTranscriptTimeout(); | |
| // Auto-hide transcript after specified duration | |
| this.transcriptHideTimeout = setTimeout(async () => { | |
| await this.updateTranscriptVisibility(false); | |
| this.transcriptHideTimeout = null; | |
| }, duration); | |
| } | |
| } | |
| // ===== SPEECH RECOGNITION ===== | |
| async setupSpeechRecognition() { | |
| if (!this.SpeechRecognition) { | |
| // Do not show a UI message during initial load; only log. | |
| console.log("Your browser does not support speech recognition."); | |
| return; | |
| } | |
| // Always create a fresh instance (some browsers cache language at construction time) | |
| this.recognition = new this.SpeechRecognition(); | |
| this.recognition.continuous = true; | |
| // Resolve effective language (block invalid 'auto') | |
| const normalized = await this.getEffectiveLanguage(this.selectedLanguage); | |
| const langCode = this.getLanguageCode(normalized || "en"); | |
| try { | |
| this.recognition.lang = langCode; | |
| } catch (e) { | |
| console.warn("Could not set recognition.lang, fallback en-US", e); | |
| this.recognition.lang = "en-US"; | |
| } | |
| console.log(`π€ SpeechRecognition initialized (lang=${this.recognition.lang})`); | |
| this.recognition.interimResults = true; | |
| // Add onstart handler to confirm permission | |
| this.recognition.onstart = () => { | |
| if (!this.micPermissionGranted) { | |
| this.micPermissionGranted = true; | |
| console.log("π€ Microphone permission confirmed via onstart"); | |
| } | |
| }; | |
| this.recognition.onresult = async event => { | |
| // Mark permission as granted if we get results | |
| if (!this.micPermissionGranted) { | |
| this.micPermissionGranted = true; | |
| console.log("π€ Microphone permission confirmed via onresult"); | |
| } | |
| // Process speech recognition results into final and interim transcripts | |
| let final_transcript = ""; | |
| let interim_transcript = ""; | |
| for (let i = event.resultIndex; i < event.results.length; ++i) { | |
| if (event.results[i].isFinal) { | |
| final_transcript += event.results[i][0].transcript; | |
| } else { | |
| interim_transcript += event.results[i][0].transcript; | |
| } | |
| } | |
| // Display real-time speech transcription if enabled | |
| const transcriptText = final_transcript || interim_transcript; | |
| if (transcriptText) { | |
| await this.updateTranscriptVisibility(true, transcriptText); | |
| } | |
| if (final_transcript && this.onSpeechAnalysis) { | |
| try { | |
| // Show final user message in transcript before processing | |
| await this.showUserMessage(`You: ${final_transcript}`, 2000); | |
| // Auto-stop after silence timeout following final transcript | |
| setTimeout(() => { | |
| this.stopListening(); | |
| }, this.silenceTimeout); | |
| (async () => { | |
| let response; | |
| if (typeof window.analyzeAndReact === "function") { | |
| response = await window.analyzeAndReact(final_transcript); | |
| } else if (this.onSpeechAnalysis) { | |
| response = await this.onSpeechAnalysis(final_transcript); | |
| } | |
| if (response) { | |
| this.handleChatMessage(final_transcript, response); | |
| setTimeout(() => { | |
| this.speak(response); | |
| }, 500); | |
| } | |
| })(); | |
| } catch (error) { | |
| console.error("π€ Error during voice analysis:", error); | |
| } | |
| } | |
| }; | |
| this.recognition.onerror = event => { | |
| console.error("π€ Speech recognition error:", event.error); | |
| if (event.error === "not-allowed" || event.error === "service-not-allowed") { | |
| console.log("π€ Permission denied - stopping listening"); | |
| this.micPermissionGranted = false; | |
| this.stopListening(); | |
| const message = | |
| window.kimiI18nManager?.t("mic_permission_denied") || "Microphone permission denied. Click again to retry."; | |
| // Use promise-based approach for async operation | |
| this.updateTranscriptVisibility(true, message).then(() => { | |
| setTimeout(() => { | |
| this.updateTranscriptVisibility(false); | |
| }, 2000); | |
| }); | |
| } else { | |
| this.stopListening(); | |
| } | |
| }; | |
| this.recognition.onend = () => { | |
| console.log("π€ Speech recognition ended"); | |
| // Clear timeout if recognition ends naturally | |
| if (this.listeningTimeout) { | |
| clearTimeout(this.listeningTimeout); | |
| this.listeningTimeout = null; | |
| } | |
| // Always reset listening state when recognition ends | |
| this.isListening = false; | |
| if (this.isStoppingVolontaire) { | |
| console.log("Voluntary stop confirmed"); | |
| this.isStoppingVolontaire = false; | |
| if (this.micButton) { | |
| this.micButton.classList.remove("mic-pulse-active"); | |
| this.micButton.classList.remove("is-listening"); | |
| } | |
| return; | |
| } | |
| // User must click the mic button again to reactivate listening | |
| this.isListening = false; | |
| if (this.micButton) this.micButton.classList.remove("is-listening"); | |
| if (this.micButton) this.micButton.classList.remove("mic-pulse-active"); | |
| this.updateTranscriptVisibility(false); | |
| }; | |
| } | |
| setupMicrophoneButton() { | |
| if (!this.micButton) { | |
| console.error("setupMicrophoneButton: Mic button not found!"); | |
| return; | |
| } | |
| // Remove any existing event listener to prevent duplicates | |
| this.micButton.removeEventListener("click", this.handleMicClick); | |
| // Create the click handler function | |
| this.handleMicClick = () => { | |
| if (!this.SpeechRecognition) { | |
| console.warn("π€ Speech recognition not available"); | |
| let key = "sr_not_supported_generic"; | |
| if (this.browser === "firefox") key = "sr_not_supported_firefox"; | |
| else if (this.browser === "opera") key = "sr_not_supported_opera"; | |
| else if (this.browser === "safari") key = "sr_not_supported_safari"; | |
| const message = window.kimiI18nManager?.t(key) || "Speech recognition is not available in this browser."; | |
| this.updateTranscriptVisibility(true, message).then(() => { | |
| setTimeout(() => { | |
| this.updateTranscriptVisibility(false); | |
| }, 4000); | |
| }); | |
| return; | |
| } | |
| if (this.isListening) { | |
| console.log("π€ Stopping microphone via button click"); | |
| this.stopListening(); | |
| } else { | |
| console.log("π€ Starting microphone via button click"); | |
| this.startListening(); | |
| } | |
| }; | |
| // Add the event listener | |
| this.micButton.addEventListener("click", this.handleMicClick); | |
| } | |
| async startListening() { | |
| // Show helpful message if SR API is missing | |
| if (!this.SpeechRecognition) { | |
| let key = "sr_not_supported_generic"; | |
| if (this.browser === "firefox") key = "sr_not_supported_firefox"; | |
| else if (this.browser === "opera") key = "sr_not_supported_opera"; | |
| else if (this.browser === "safari") key = "sr_not_supported_safari"; | |
| const message = window.kimiI18nManager?.t(key) || "Speech recognition is not available in this browser."; | |
| this.updateTranscriptVisibility(true, message).then(() => { | |
| setTimeout(() => { | |
| this.updateTranscriptVisibility(false); | |
| }, 4000); | |
| }); | |
| return; | |
| } | |
| if (!this.recognition || this.isListening) return; | |
| // Check microphone API availability | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| console.warn("MediaDevices API not available"); | |
| const message = window.kimiI18nManager?.t("mic_not_supported") || "Microphone not supported in this browser."; | |
| this.updateTranscriptVisibility(true, message).then(() => { | |
| setTimeout(() => { | |
| this.updateTranscriptVisibility(false); | |
| }, 3000); | |
| }); | |
| return; | |
| } | |
| // If microphone permission already granted, start directly | |
| if (this.micPermissionGranted) { | |
| console.log("π€ Microphone permission already granted"); | |
| this.startRecognitionDirectly(); | |
| return; | |
| } | |
| // Check current permission status | |
| try { | |
| const permissionStatus = await navigator.permissions.query({ name: "microphone" }); | |
| console.log("π€ Current microphone permission status:", permissionStatus.state); | |
| if (permissionStatus.state === "granted") { | |
| this.micPermissionGranted = true; | |
| this.startRecognitionDirectly(); | |
| return; | |
| } else if (permissionStatus.state === "denied") { | |
| console.log("π€ Microphone permission denied"); | |
| const message = | |
| window.kimiI18nManager?.t("mic_permission_denied") || | |
| "Microphone permission denied. Please allow access in browser settings."; | |
| this.updateTranscriptVisibility(true, message).then(() => { | |
| setTimeout(() => { | |
| this.updateTranscriptVisibility(false); | |
| }, 4000); | |
| }); | |
| return; | |
| } | |
| } catch (error) { | |
| console.log("π€ Could not check permission status:", error); | |
| } | |
| // Permission is 'prompt' or unknown, proceed with recognition start (will trigger permission dialog) | |
| this.startRecognitionDirectly(); | |
| } | |
| startRecognitionDirectly() { | |
| // Prevent starting if already listening or if recognition is in an active state | |
| if (this.isListening) { | |
| console.log("π€ Already listening, ignoring start request"); | |
| return; | |
| } | |
| // Check if recognition is already in progress | |
| if (this.recognition && this.recognition.state && this.recognition.state !== "inactive") { | |
| console.log("π€ Recognition already active, stopping first"); | |
| try { | |
| this.recognition.stop(); | |
| } catch (e) { | |
| console.warn("π€ Error stopping existing recognition:", e); | |
| } | |
| // Wait a bit before trying to start again | |
| setTimeout(() => { | |
| this.startRecognitionDirectly(); | |
| }, 100); | |
| return; | |
| } | |
| this.isListening = true; | |
| this.isStoppingVolontaire = false; | |
| if (this.micButton) { | |
| this.micButton.classList.add("is-listening"); | |
| } else { | |
| console.error("Unable to add 'is-listening' - mic button not found"); | |
| } | |
| if (window.kimiVideo) { | |
| window.kimiVideo.startListening(); | |
| } | |
| // Set auto-stop timeout | |
| this.listeningTimeout = setTimeout(() => { | |
| console.log("π€ Auto-stopping listening after timeout"); | |
| this.stopListening(); | |
| }, this.autoStopDuration); | |
| try { | |
| this.recognition.start(); | |
| console.log("π€ Started listening with auto-stop timeout"); | |
| } catch (error) { | |
| console.error("Error starting listening:", error); | |
| this.isListening = false; // Reset state on error | |
| this.stopListening(); | |
| // Show user-friendly error message | |
| const message = | |
| window.kimiI18nManager?.t("mic_permission_denied") || "Microphone permission denied. Click again to retry."; | |
| this.updateTranscriptVisibility(true, message).then(() => { | |
| setTimeout(() => { | |
| this.updateTranscriptVisibility(false); | |
| }, 3000); | |
| }); | |
| } | |
| } | |
| stopListening() { | |
| if (!this.recognition || !this.isListening) return; | |
| // Clear auto-stop timeout if it exists | |
| if (this.listeningTimeout) { | |
| clearTimeout(this.listeningTimeout); | |
| this.listeningTimeout = null; | |
| } | |
| this.isListening = false; | |
| this.isStoppingVolontaire = true; | |
| if (this.micButton) { | |
| this.micButton.classList.remove("is-listening"); | |
| this.micButton.classList.add("mic-pulse-active"); | |
| } else { | |
| console.error("Unable to remove 'is-listening' - mic button not found"); | |
| } | |
| if (window.kimiVideo) { | |
| const currentInfo = window.kimiVideo.getCurrentVideoInfo ? window.kimiVideo.getCurrentVideoInfo() : null; | |
| if ( | |
| currentInfo && | |
| (currentInfo.context === "speakingPositive" || | |
| currentInfo.context === "speakingNegative" || | |
| currentInfo.context === "dancing") | |
| ) { | |
| // Let emotion video finish naturally | |
| } else if (this.isStoppingVolontaire) { | |
| // Use centralized video utility for neutral transition | |
| window.kimiVideo.returnToNeutral(); | |
| } | |
| } | |
| this.clearTranscriptTimeout(); | |
| if (!this.speechSynthesis.speaking) { | |
| // Hide transcript after delay if AI is not speaking | |
| this.transcriptHideTimeout = setTimeout(async () => { | |
| await this.updateTranscriptVisibility(false); | |
| this.transcriptHideTimeout = null; | |
| }, 2000); | |
| } | |
| try { | |
| this.recognition.stop(); | |
| console.log("π€ Stopped listening"); | |
| } catch (error) { | |
| console.error("Error stopping listening:", error); | |
| } | |
| } | |
| // ===== UTILITY METHODS ===== | |
| isVoiceAvailable() { | |
| return this.currentVoice !== null; | |
| } | |
| getCurrentVoice() { | |
| return this.currentVoice; | |
| } | |
| getAvailableVoices() { | |
| return this.availableVoices; | |
| } | |
| setOnSpeechAnalysis(callback) { | |
| this.onSpeechAnalysis = callback; | |
| } | |
| async testVoice() { | |
| const testMessages = [ | |
| window.kimiI18nManager?.t("test_voice_message_1") || "Hello my beloved! π", | |
| window.kimiI18nManager?.t("test_voice_message_2") || "I am Kimi, your virtual companion!", | |
| window.kimiI18nManager?.t("test_voice_message_3") || "How are you today, my love?" | |
| ]; | |
| } | |
| destroy() { | |
| // Clear all timeouts | |
| if (this.listeningTimeout) { | |
| clearTimeout(this.listeningTimeout); | |
| this.listeningTimeout = null; | |
| } | |
| this.clearTranscriptTimeout(); | |
| if (this.recognition) { | |
| this.recognition.stop(); | |
| this.recognition = null; | |
| } | |
| if (this.speechSynthesis.speaking) { | |
| this.speechSynthesis.cancel(); | |
| } | |
| // Clean up mic button event listener | |
| if (this.micButton && this.handleMicClick) { | |
| this.micButton.removeEventListener("click", this.handleMicClick); | |
| } | |
| // Clean up voice selector event listener | |
| if (this.voiceChangeHandler) { | |
| const voiceSelect = document.getElementById("voice-selection"); | |
| if (voiceSelect) { | |
| voiceSelect.removeEventListener("change", this.voiceChangeHandler); | |
| } | |
| this.voiceChangeHandler = null; | |
| } | |
| // Clean up language selector event listener | |
| if (this.languageChangeHandler) { | |
| const languageSelect = document.getElementById("language-selection"); | |
| if (languageSelect) { | |
| languageSelect.removeEventListener("change", this.languageChangeHandler); | |
| } | |
| this.languageChangeHandler = null; | |
| } | |
| // Reset state | |
| this.currentVoice = null; | |
| this.isInitialized = false; | |
| this.isListening = false; | |
| this.isStoppingVolontaire = false; | |
| this.handleMicClick = null; | |
| console.log("KimiVoiceManager destroyed and cleaned up"); | |
| } | |
| setupLanguageSelector() { | |
| const languageSelect = document.getElementById("language-selection"); | |
| if (!languageSelect) return; | |
| languageSelect.value = this.selectedLanguage || "en"; | |
| // Remove existing handler before adding new one | |
| if (this.languageChangeHandler) { | |
| languageSelect.removeEventListener("change", this.languageChangeHandler); | |
| } | |
| // Create and store the handler | |
| this.languageChangeHandler = this.handleLanguageChange.bind(this); | |
| languageSelect.addEventListener("change", this.languageChangeHandler); | |
| } | |
| async handleLanguageChange(e) { | |
| const rawLang = e.target.value; | |
| const newLang = window.KimiLanguageUtils?.normalizeLanguageCode | |
| ? window.KimiLanguageUtils.normalizeLanguageCode(rawLang) | |
| : rawLang; | |
| const oldLang = this.selectedLanguage; | |
| console.log(`π€ Language changing: "${oldLang}" β "${newLang}"`); | |
| this.selectedLanguage = newLang; | |
| await this.db?.setPreference("selectedLanguage", newLang); | |
| // Update i18n system for interface translations | |
| if (window.kimiI18nManager?.setLanguage) { | |
| await window.kimiI18nManager.setLanguage(newLang); | |
| } | |
| // Check saved voice compatibility: only reset to 'auto' if incompatible | |
| try { | |
| const currentVoicePref = await this.db?.getPreference("selectedVoice", "auto"); | |
| // Clear in-memory currentVoice to allow re-selection | |
| this.currentVoice = null; | |
| if (currentVoicePref && currentVoicePref !== "auto") { | |
| // If saved voice name exists, check if it's present among filtered voices for the new language | |
| const filtered = this.getVoicesForLanguage(newLang); | |
| const compatible = filtered.some(v => v.name === currentVoicePref); | |
| if (!compatible) { | |
| // Only write 'auto' when incompatible | |
| await this.db?.setPreference("selectedVoice", "auto"); | |
| } | |
| } | |
| // Re-init voices to pick a correct voice for the new language | |
| await this.initVoices(); | |
| // Ensure voice selector reflects new language even if no voice chosen | |
| this.updateVoiceSelector(); | |
| } catch (err) { | |
| // On error, fall back to safe behavior: init voices and set 'auto' | |
| try { | |
| await this.db?.setPreference("selectedVoice", "auto"); | |
| } catch {} | |
| await this.initVoices(); | |
| this.updateVoiceSelector(); | |
| } | |
| if (this.currentVoice) { | |
| console.log(`π€ Voice selected for "${newLang}": "${this.currentVoice.name}" (${this.currentVoice.lang})`); | |
| } else { | |
| console.warn(`π€ No voice found for language "${newLang}"`); | |
| } | |
| // Single clear path: recreate recognition instance with new language | |
| this._refreshRecognitionLanguage(newLang); | |
| } | |
| /** | |
| * Recreate speech recognition instance with a new language. | |
| * Some browsers (notably Chrome) may ignore lang changes mid-session; recreating ensures consistency. | |
| */ | |
| async _refreshRecognitionLanguage(newLang) { | |
| if (!this.SpeechRecognition) return; | |
| const wasListening = this.isListening; | |
| if (this.recognition) { | |
| try { | |
| if (this.isListening) this.recognition.stop(); | |
| } catch {} | |
| this.recognition.onresult = null; | |
| this.recognition.onstart = null; | |
| this.recognition.onend = null; | |
| this.recognition.onerror = null; | |
| this.recognition = null; | |
| } | |
| this.selectedLanguage = newLang; | |
| await this.setupSpeechRecognition(); | |
| console.log(`π€ Recognition language refreshed -> ${this.recognition?.lang}`); | |
| // Restart listening if it was active | |
| if (wasListening) { | |
| // Small delay to allow new instance to settle | |
| setTimeout(() => { | |
| this.startListening(); | |
| }, 150); | |
| } | |
| } | |
| // Return a normalized concrete language code (primary subtag) never 'auto' | |
| async getEffectiveLanguage(raw) { | |
| let base = raw || this.selectedLanguage || "en"; | |
| if (base === "auto") { | |
| try { | |
| if (window.KimiLanguageUtils?.getLanguage) { | |
| base = await window.KimiLanguageUtils.getLanguage(); | |
| } else { | |
| base = navigator.language?.split("-")[0] || "en"; | |
| } | |
| } catch { | |
| base = "en"; | |
| } | |
| } | |
| return window.KimiLanguageUtils?.normalizeLanguageCode | |
| ? window.KimiLanguageUtils.normalizeLanguageCode(base) | |
| : base || "en"; | |
| } | |
| async updateSelectedCharacter() { | |
| if (this.db && typeof this.db.getSelectedCharacter === "function") { | |
| const charKey = await this.db.getSelectedCharacter(); | |
| if (charKey && window.KIMI_CHARACTERS && window.KIMI_CHARACTERS[charKey]) { | |
| // Use the display name, not the key | |
| this.selectedCharacter = window.KIMI_CHARACTERS[charKey].name; | |
| } else if (charKey) { | |
| // Fallback to key if KIMI_CHARACTERS not available | |
| this.selectedCharacter = charKey; | |
| } | |
| } | |
| } | |
| // Public method for external microphone toggle (keyboard, etc.) | |
| toggleMicrophone() { | |
| if (this._debouncedToggleMicrophone) return this._debouncedToggleMicrophone(); | |
| return this._toggleMicrophoneCore(); | |
| } | |
| _toggleMicrophoneCore() { | |
| if (!this.SpeechRecognition) { | |
| console.warn("π€ Speech recognition not available"); | |
| return false; | |
| } | |
| // If Kimi is speaking, stop speech synthesis first | |
| if (this.isSpeaking && this.speechSynthesis.speaking) { | |
| console.log("π€ Interrupting speech to start listening"); | |
| this.speechSynthesis.cancel(); | |
| this.isSpeaking = false; | |
| this.updateTranscriptVisibility(false); | |
| } | |
| if (this.isListening) { | |
| console.log("π€ Stopping microphone via external trigger"); | |
| this.stopListening(); | |
| } else { | |
| console.log("π€ Starting microphone via external trigger"); | |
| this.startListening(); | |
| } | |
| return true; | |
| } | |
| // Configuration methods for timeout durations | |
| setSilenceTimeout(milliseconds) { | |
| if (typeof milliseconds === "number" && milliseconds > 0) { | |
| this.silenceTimeout = milliseconds; | |
| console.log(`π€ Silence timeout set to ${milliseconds}ms`); | |
| } else { | |
| console.warn("π€ Invalid silence timeout value"); | |
| } | |
| } | |
| setAutoStopDuration(milliseconds) { | |
| if (typeof milliseconds === "number" && milliseconds > 0) { | |
| this.autoStopDuration = milliseconds; | |
| console.log(`π€ Auto-stop duration set to ${milliseconds}ms`); | |
| } else { | |
| console.warn("π€ Invalid auto-stop duration value"); | |
| } | |
| } | |
| // Get current timeout configurations | |
| getTimeoutConfiguration() { | |
| return { | |
| silenceTimeout: this.silenceTimeout, | |
| autoStopDuration: this.autoStopDuration | |
| }; | |
| } | |
| // Cleanup method to prevent memory leaks | |
| cleanup() { | |
| // Clean up permission listener if it exists | |
| if (this.permissionStatus && this.permissionChangeHandler) { | |
| try { | |
| this.permissionStatus.removeEventListener("change", this.permissionChangeHandler); | |
| } catch (e) { | |
| // Silent cleanup - permission API may not support removal | |
| } | |
| } | |
| // Clean up timeouts | |
| if (this.listeningTimeout) { | |
| clearTimeout(this.listeningTimeout); | |
| this.listeningTimeout = null; | |
| } | |
| if (this.transcriptHideTimeout) { | |
| clearTimeout(this.transcriptHideTimeout); | |
| this.transcriptHideTimeout = null; | |
| } | |
| // Clean up recognition | |
| if (this.recognition) { | |
| try { | |
| this.recognition.abort(); | |
| } catch (e) { | |
| // Silent cleanup | |
| } | |
| } | |
| // Clean up mic button listener | |
| if (this.micButton && this.handleMicClick) { | |
| this.micButton.removeEventListener("click", this.handleMicClick); | |
| } | |
| // Clean up voice select listener | |
| const voiceSelect = document.getElementById("voice-select"); | |
| if (voiceSelect && this.voiceChangeHandler) { | |
| voiceSelect.removeEventListener("change", this.voiceChangeHandler); | |
| } | |
| } | |
| } | |
| // Export for usage | |
| window.KimiVoiceManager = KimiVoiceManager; | |