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.kimiEnglishVoice = 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; | |
| this.transcriptContainer = null; | |
| this.transcriptText = null; | |
| // 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 | |
| this.selectedCharacter = "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) { | |
| console.warn("Microphone button not found in DOM!"); | |
| return false; | |
| } | |
| // Initialize voice synthesis | |
| await this.initVoices(); | |
| this.setupVoicesChangedListener(); | |
| this.setupLanguageSelector(); | |
| // Initialize speech recognition | |
| this.setupSpeechRecognition(); | |
| this.setupMicrophoneButton(); | |
| // Check current microphone permission status | |
| await this.checkMicrophonePermission(); | |
| // Initialize selected character | |
| if (this.db && typeof this.db.getSelectedCharacter === "function") { | |
| const char = await this.db.getSelectedCharacter(); | |
| if (char) this.selectedCharacter = char; | |
| } | |
| this.isInitialized = true; | |
| console.log("π€ VoiceManager initialized successfully"); | |
| 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); | |
| const isChrome = /Chrome\//.test(ua) && !isEdge && !isOpera; | |
| if (isFirefox) return "firefox"; | |
| if (isOpera) return "opera"; | |
| 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:") { | |
| console.log("π€ Running on file:// protocol - microphone permissions will be requested each time"); | |
| this.micPermissionGranted = false; | |
| return; | |
| } | |
| if (!navigator.permissions) { | |
| console.log("π€ Permissions API not available"); | |
| return; | |
| } | |
| const permissionStatus = await navigator.permissions.query({ name: "microphone" }); | |
| this.micPermissionGranted = permissionStatus.state === "granted"; | |
| console.log("π€ Initial microphone permission status:", permissionStatus.state); | |
| // Listen for permission changes | |
| permissionStatus.addEventListener("change", () => { | |
| this.micPermissionGranted = permissionStatus.state === "granted"; | |
| console.log("π€ Microphone permission changed to:", permissionStatus.state); | |
| }); | |
| } 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(); | |
| // Only get language from DB if not already set | |
| if (!this.selectedLanguage) { | |
| const selectedLanguage = await this.db?.getPreference("selectedLanguage", "en"); | |
| this.selectedLanguage = selectedLanguage || "en"; | |
| } | |
| const savedVoice = await this.db?.getPreference("selectedVoice", "auto"); | |
| let filteredVoices = this.availableVoices.filter(voice => voice.lang.toLowerCase().startsWith(this.selectedLanguage)); | |
| if (filteredVoices.length === 0) { | |
| filteredVoices = this.availableVoices.filter(voice => voice.lang.toLowerCase().includes(this.selectedLanguage)); | |
| } | |
| if (filteredVoices.length === 0) { | |
| // As a last resort, use any available voice rather than defaulting to English | |
| filteredVoices = this.availableVoices; | |
| } | |
| if (savedVoice && savedVoice !== "auto") { | |
| let foundVoice = filteredVoices.find(voice => voice.name === savedVoice); | |
| if (!foundVoice) { | |
| foundVoice = this.availableVoices.find(voice => voice.name === savedVoice); | |
| } | |
| if (foundVoice) { | |
| this.kimiEnglishVoice = foundVoice; | |
| this.updateVoiceSelector(); | |
| this._initializingVoices = false; | |
| return; | |
| } else if (filteredVoices.length > 0) { | |
| this.kimiEnglishVoice = filteredVoices[0]; | |
| await this.db?.setPreference("selectedVoice", this.kimiEnglishVoice.name); | |
| this.updateVoiceSelector(); | |
| this._initializingVoices = false; | |
| return; | |
| } | |
| } | |
| if (this.selectedLanguage && this.selectedLanguage.startsWith("fr")) { | |
| this.kimiEnglishVoice = | |
| filteredVoices.find(voice => voice.name.startsWith("Microsoft Eloise Online")) || | |
| filteredVoices.find(voice => voice.name.toLowerCase().includes("eloise")) || | |
| filteredVoices[0] || | |
| this.availableVoices[0]; | |
| } else { | |
| this.kimiEnglishVoice = | |
| filteredVoices.find(voice => voice.name.toLowerCase().includes("female")) || | |
| filteredVoices[0] || | |
| this.availableVoices[0]; | |
| } | |
| if (this.kimiEnglishVoice) { | |
| await this.db?.setPreference("selectedVoice", this.kimiEnglishVoice.name); | |
| } | |
| 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); | |
| let filteredVoices = this.availableVoices.filter(voice => voice.lang.toLowerCase().startsWith(this.selectedLanguage)); | |
| if (filteredVoices.length === 0) { | |
| filteredVoices = this.availableVoices.filter(voice => voice.lang.toLowerCase().includes(this.selectedLanguage)); | |
| } | |
| if (filteredVoices.length === 0) { | |
| // Show all voices if none match the selected language | |
| filteredVoices = this.availableVoices; | |
| } | |
| filteredVoices.forEach(voice => { | |
| const option = document.createElement("option"); | |
| option.value = voice.name; | |
| option.textContent = `${voice.name} (${voice.lang})`; | |
| if (this.kimiEnglishVoice && voice.name === this.kimiEnglishVoice.name) { | |
| option.selected = true; | |
| } | |
| voiceSelect.appendChild(option); | |
| }); | |
| voiceSelect.removeEventListener("change", this.handleVoiceChange); | |
| voiceSelect.addEventListener("change", this.handleVoiceChange.bind(this)); | |
| } | |
| async handleVoiceChange(e) { | |
| if (e.target.value === "auto") { | |
| await this.db?.setPreference("selectedVoice", "auto"); | |
| // Don't re-init voices when auto is selected to avoid loops | |
| this.kimiEnglishVoice = null; // Reset to trigger auto-selection on next speak | |
| } else { | |
| this.kimiEnglishVoice = this.availableVoices.find(voice => voice.name === e.target.value); | |
| await this.db?.setPreference("selectedVoice", e.target.value); | |
| // Reduced logging to prevent noise | |
| } | |
| } | |
| setupVoicesChangedListener() { | |
| if (this.speechSynthesis.onvoiceschanged !== undefined) { | |
| this.speechSynthesis.onvoiceschanged = async () => await this.initVoices(); | |
| } | |
| } | |
| async speak(text, options = {}) { | |
| if (!text || !this.kimiEnglishVoice) { | |
| console.warn("Unable to speak: empty text or voice not initialized"); | |
| return; | |
| } | |
| if (this.transcriptHideTimeout) { | |
| clearTimeout(this.transcriptHideTimeout); | |
| this.transcriptHideTimeout = null; | |
| } | |
| if (this.speechSynthesis.speaking) { | |
| this.speechSynthesis.cancel(); | |
| } | |
| // Clean text for better speech synthesis | |
| let processedText = text | |
| .replace(/([\p{Emoji}\p{Extended_Pictographic}])/gu, " ") | |
| .replace(/\.\.\./g, " pause ") | |
| .replace(/\!+/g, " ! ") | |
| .replace(/\?+/g, " ? ") | |
| .replace(/\.{2,}/g, " pause ") | |
| .replace(/[,;:]+/g, ", ") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| // Detect emotional content for voice adjustments | |
| let customRate = options.rate; | |
| if (customRate === undefined) { | |
| customRate = this.memory?.preferences?.voiceRate; | |
| } | |
| if (customRate === undefined) { | |
| customRate = window.kimiMemory?.preferences?.voiceRate; | |
| } | |
| if (customRate === undefined) { | |
| const rateSlider = document.getElementById("voice-rate"); | |
| customRate = rateSlider ? parseFloat(rateSlider.value) : 1.1; | |
| } | |
| let customPitch = options.pitch; | |
| if (customPitch === undefined) { | |
| customPitch = this.memory?.preferences?.voicePitch; | |
| } | |
| if (customPitch === undefined) { | |
| customPitch = window.kimiMemory?.preferences?.voicePitch; | |
| } | |
| if (customPitch === undefined) { | |
| const pitchSlider = document.getElementById("voice-pitch"); | |
| customPitch = pitchSlider ? parseFloat(pitchSlider.value) : 1.1; | |
| } | |
| // 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.kimiEnglishVoice; | |
| utterance.rate = customRate; | |
| utterance.pitch = customPitch; | |
| // Get volume from multiple sources with fallback hierarchy | |
| let volume = options.volume; | |
| if (volume === undefined) { | |
| // Try to get from memory preferences | |
| volume = this.memory?.preferences?.voiceVolume; | |
| } | |
| if (volume === undefined) { | |
| // Try to get from kimiMemory global | |
| volume = window.kimiMemory?.preferences?.voiceVolume; | |
| } | |
| if (volume === undefined) { | |
| // Try to get directly from slider | |
| const volumeSlider = document.getElementById("voice-volume"); | |
| volume = volumeSlider ? parseFloat(volumeSlider.value) : 0.8; | |
| } | |
| utterance.volume = volume; | |
| const emotionFromText = this.analyzeTextEmotion(text); | |
| if (window.kimiVideo && emotionFromText !== "neutral") { | |
| requestAnimationFrame(() => { | |
| window.kimiVideo.respondWithEmotion(emotionFromText); | |
| }); | |
| } | |
| if (typeof window.updatePersonalityTraitsFromEmotion === "function") { | |
| window.updatePersonalityTraitsFromEmotion(emotionFromText, text); | |
| } | |
| this.showResponseWithPerfectTiming(text); | |
| utterance.onstart = async () => { | |
| this.isSpeaking = true; | |
| const showTranscript = await this.db?.getPreference("showTranscript", true); | |
| if (showTranscript && this.transcriptContainer) { | |
| this.transcriptContainer.classList.add("visible"); | |
| } else if (this.transcriptContainer) { | |
| this.transcriptContainer.classList.remove("visible"); | |
| } | |
| // Ensure a speaking animation plays (avoid frozen neutral frame during TTS) | |
| try { | |
| if (window.kimiVideo && window.kimiVideo.getCurrentVideoInfo) { | |
| const info = window.kimiVideo.getCurrentVideoInfo(); | |
| if (info && !(info.context && info.context.startsWith("speaking"))) { | |
| // Use positive speaking as neutral fallback | |
| const traits = await this.db?.getAllPersonalityTraits( | |
| window.kimiMemory?.selectedCharacter || (await this.db.getSelectedCharacter()) | |
| ); | |
| const affection = traits ? traits.affection : 50; | |
| window.kimiVideo.switchToContext("speakingPositive", "positive", null, traits || {}, affection); | |
| } | |
| } | |
| } catch (e) { | |
| // Silent fallback | |
| } | |
| }; | |
| utterance.onend = () => { | |
| this.isSpeaking = false; | |
| if (this.transcriptContainer) { | |
| this.transcriptContainer.classList.remove("visible"); | |
| } | |
| this.transcriptHideTimeout = null; | |
| if (window.kimiVideo) { | |
| // Do not force neutral if an emotion clip is still playing (speaking/dancing) | |
| try { | |
| const info = window.kimiVideo.getCurrentVideoInfo ? window.kimiVideo.getCurrentVideoInfo() : null; | |
| const isEmotionClip = | |
| info && | |
| (info.context === "speakingPositive" || | |
| info.context === "speakingNegative" || | |
| info.context === "dancing"); | |
| if (!isEmotionClip) { | |
| requestAnimationFrame(() => { | |
| window.kimiVideo.returnToNeutral(); | |
| }); | |
| } | |
| } catch (_) { | |
| requestAnimationFrame(() => { | |
| window.kimiVideo.returnToNeutral(); | |
| }); | |
| } | |
| } | |
| }; | |
| utterance.onerror = e => { | |
| this.isSpeaking = false; | |
| if (this.transcriptContainer) { | |
| this.transcriptContainer.classList.remove("visible"); | |
| } | |
| this.transcriptHideTimeout = null; | |
| }; | |
| this.speechSynthesis.speak(utterance); | |
| } | |
| // 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); | |
| } | |
| async showResponseWithPerfectTiming(text) { | |
| if (!this.transcriptContainer || !this.transcriptText) return; | |
| const showTranscript = await this.db?.getPreference("showTranscript", true); | |
| if (!showTranscript) return; | |
| this.transcriptText.textContent = `${this.selectedCharacter}: ${text}`; | |
| this.transcriptContainer.classList.add("visible"); | |
| if (this.transcriptHideTimeout) { | |
| clearTimeout(this.transcriptHideTimeout); | |
| this.transcriptHideTimeout = null; | |
| } | |
| } | |
| showResponse(text) { | |
| this.showResponseWithPerfectTiming(text); | |
| } | |
| async showUserMessage(text, duration = 3000) { | |
| if (!this.transcriptContainer || !this.transcriptText) return; | |
| const showTranscript = await this.db?.getPreference("showTranscript", true); | |
| if (!showTranscript) return; | |
| if (this.transcriptHideTimeout) { | |
| clearTimeout(this.transcriptHideTimeout); | |
| this.transcriptHideTimeout = null; | |
| } | |
| this.transcriptText.textContent = text; | |
| this.transcriptContainer.classList.add("visible"); | |
| this.transcriptHideTimeout = setTimeout(() => { | |
| if (this.transcriptContainer) { | |
| this.transcriptContainer.classList.remove("visible"); | |
| } | |
| this.transcriptHideTimeout = null; | |
| }, duration); | |
| } | |
| // ===== SPEECH RECOGNITION ===== | |
| 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; | |
| } | |
| this.recognition = new this.SpeechRecognition(); | |
| this.recognition.continuous = true; | |
| let langCode = this.selectedLanguage || "en"; | |
| if (langCode === "fr") langCode = "fr-FR"; | |
| if (langCode === "en") langCode = "en-US"; | |
| this.recognition.lang = langCode; | |
| 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"); | |
| } | |
| 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; | |
| } | |
| } | |
| const showTranscript = await this.db?.getPreference("showTranscript", true); | |
| if (showTranscript && this.transcriptText) { | |
| this.transcriptText.textContent = final_transcript || interim_transcript; | |
| if (this.transcriptContainer && (final_transcript || interim_transcript)) { | |
| this.transcriptContainer.classList.add("visible"); | |
| } | |
| } else if (this.transcriptContainer) { | |
| this.transcriptContainer.classList.remove("visible"); | |
| } | |
| if (final_transcript && this.onSpeechAnalysis) { | |
| try { | |
| // Auto-stop after silence timeout following final transcript | |
| setTimeout(() => { | |
| this.stopListening(); | |
| }, this.silenceTimeout); | |
| (async () => { | |
| if (typeof window.analyzeAndReact === "function") { | |
| const response = await window.analyzeAndReact(final_transcript); | |
| if (response) { | |
| const chatContainer = document.getElementById("chat-container"); | |
| const chatMessages = document.getElementById("chat-messages"); | |
| if (chatContainer && chatContainer.classList.contains("visible") && chatMessages) { | |
| const addMessageToChat = | |
| window.addMessageToChat || | |
| (typeof addMessageToChat !== "undefined" ? addMessageToChat : null); | |
| if (addMessageToChat) { | |
| addMessageToChat("user", final_transcript); | |
| addMessageToChat("kimi", response); | |
| } else { | |
| const userDiv = document.createElement("div"); | |
| userDiv.className = "message user"; | |
| const userMessageDiv = document.createElement("div"); | |
| userMessageDiv.textContent = final_transcript; | |
| const userTimeDiv = document.createElement("div"); | |
| userTimeDiv.className = "message-time"; | |
| userTimeDiv.textContent = new Date().toLocaleTimeString("en-US", { | |
| hour: "2-digit", | |
| minute: "2-digit" | |
| }); | |
| userDiv.appendChild(userMessageDiv); | |
| userDiv.appendChild(userTimeDiv); | |
| chatMessages.appendChild(userDiv); | |
| const kimiDiv = document.createElement("div"); | |
| kimiDiv.className = "message kimi"; | |
| const kimiMessageDiv = document.createElement("div"); | |
| kimiMessageDiv.textContent = response; | |
| const kimiTimeDiv = document.createElement("div"); | |
| kimiTimeDiv.className = "message-time"; | |
| kimiTimeDiv.textContent = new Date().toLocaleTimeString("en-US", { | |
| hour: "2-digit", | |
| minute: "2-digit" | |
| }); | |
| kimiDiv.appendChild(kimiMessageDiv); | |
| kimiDiv.appendChild(kimiTimeDiv); | |
| chatMessages.appendChild(kimiDiv); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| } | |
| setTimeout(() => { | |
| this.speak(response); | |
| }, 500); | |
| } | |
| } else { | |
| const response = await this.onSpeechAnalysis(final_transcript); | |
| if (response) { | |
| const chatContainer = document.getElementById("chat-container"); | |
| const chatMessages = document.getElementById("chat-messages"); | |
| if (chatContainer && chatContainer.classList.contains("visible") && chatMessages) { | |
| const addMessageToChat = | |
| window.addMessageToChat || | |
| (typeof addMessageToChat !== "undefined" ? addMessageToChat : null); | |
| if (addMessageToChat) { | |
| addMessageToChat("user", final_transcript); | |
| addMessageToChat("kimi", response); | |
| } else { | |
| const userDiv = document.createElement("div"); | |
| userDiv.className = "message user"; | |
| const userMessageDiv = document.createElement("div"); | |
| userMessageDiv.textContent = final_transcript; | |
| const userTimeDiv = document.createElement("div"); | |
| userTimeDiv.className = "message-time"; | |
| userTimeDiv.textContent = new Date().toLocaleTimeString("en-US", { | |
| hour: "2-digit", | |
| minute: "2-digit" | |
| }); | |
| userDiv.appendChild(userMessageDiv); | |
| userDiv.appendChild(userTimeDiv); | |
| chatMessages.appendChild(userDiv); | |
| const kimiDiv = document.createElement("div"); | |
| kimiDiv.className = "message kimi"; | |
| const kimiMessageDiv = document.createElement("div"); | |
| kimiMessageDiv.textContent = response; | |
| const kimiTimeDiv = document.createElement("div"); | |
| kimiTimeDiv.className = "message-time"; | |
| kimiTimeDiv.textContent = new Date().toLocaleTimeString("en-US", { | |
| hour: "2-digit", | |
| minute: "2-digit" | |
| }); | |
| kimiDiv.appendChild(kimiMessageDiv); | |
| kimiDiv.appendChild(kimiTimeDiv); | |
| chatMessages.appendChild(kimiDiv); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| } | |
| 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(); | |
| if (this.transcriptText) { | |
| this.transcriptText.textContent = | |
| window.kimiI18nManager?.t("mic_permission_denied") || | |
| "Microphone permission denied. Click again to retry."; | |
| this.transcriptContainer?.classList.add("visible"); | |
| setTimeout(() => { | |
| this.transcriptContainer?.classList.remove("visible"); | |
| }, 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"); | |
| if (this.transcriptContainer) { | |
| this.transcriptContainer.classList.remove("visible"); | |
| } | |
| }; | |
| } | |
| 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."; | |
| if (this.transcriptText) { | |
| this.transcriptText.textContent = message; | |
| this.transcriptContainer?.classList.add("visible"); | |
| setTimeout(() => { | |
| this.transcriptContainer?.classList.remove("visible"); | |
| }, 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); | |
| console.log("π€ Microphone button event listener setup complete"); | |
| } | |
| 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."; | |
| if (this.transcriptText) { | |
| this.transcriptText.textContent = message; | |
| this.transcriptContainer?.classList.add("visible"); | |
| setTimeout(() => { | |
| this.transcriptContainer?.classList.remove("visible"); | |
| }, 4000); | |
| } | |
| return; | |
| } | |
| if (!this.recognition || this.isListening) return; | |
| // Check microphone API availability | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| console.warn("MediaDevices API not available"); | |
| if (this.transcriptText) { | |
| this.transcriptText.textContent = | |
| window.kimiI18nManager?.t("mic_not_supported") || "Microphone not supported in this browser."; | |
| this.transcriptContainer?.classList.add("visible"); | |
| setTimeout(() => { | |
| this.transcriptContainer?.classList.remove("visible"); | |
| }, 3000); | |
| } | |
| return; | |
| } | |
| // If permission was previously granted, start directly | |
| if (this.micPermissionGranted) { | |
| console.log("π€ Using previously granted microphone permission"); | |
| 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"); | |
| if (this.transcriptText) { | |
| this.transcriptText.textContent = | |
| window.kimiI18nManager?.t("mic_permission_denied") || | |
| "Microphone permission denied. Please allow access in browser settings."; | |
| this.transcriptContainer?.classList.add("visible"); | |
| setTimeout(() => { | |
| this.transcriptContainer?.classList.remove("visible"); | |
| }, 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 | |
| if (this.transcriptText) { | |
| this.transcriptText.textContent = | |
| window.kimiI18nManager?.t("mic_permission_denied") || "Microphone permission denied. Click again to retry."; | |
| this.transcriptContainer?.classList.add("visible"); | |
| setTimeout(() => { | |
| this.transcriptContainer?.classList.remove("visible"); | |
| }, 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(); | |
| } | |
| } | |
| if (this.transcriptHideTimeout) { | |
| clearTimeout(this.transcriptHideTimeout); | |
| this.transcriptHideTimeout = null; | |
| } | |
| if (!this.speechSynthesis.speaking) { | |
| this.transcriptHideTimeout = setTimeout(() => { | |
| if (this.transcriptContainer) { | |
| this.transcriptContainer.classList.remove("visible"); | |
| } | |
| 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.kimiFrenchVoice !== null; | |
| } | |
| getCurrentVoice() { | |
| return this.kimiFrenchVoice; | |
| } | |
| getAvailableVoices() { | |
| return this.availableVoices; | |
| } | |
| setOnSpeechAnalysis(callback) { | |
| this.onSpeechAnalysis = callback; | |
| } | |
| analyzeTextEmotion(text) { | |
| // Use unified emotion system | |
| if (window.kimiAnalyzeEmotion) { | |
| const emotion = window.kimiAnalyzeEmotion(text, "auto"); | |
| return this._modulateEmotionByPersonality(emotion); | |
| } | |
| return "neutral"; | |
| } // Helper to modulate emotion based on personality traits | |
| _modulateEmotionByPersonality(emotion) { | |
| try { | |
| let avg = 50; | |
| if (this.memory && typeof this.memory.affectionTrait === "number") { | |
| avg = this.memory.affectionTrait; | |
| } | |
| // Low affection makes emotions more subdued | |
| if (avg <= 20 && emotion !== "neutral") { | |
| return "shy"; | |
| } | |
| if (avg <= 40 && emotion === "positive") { | |
| return "shy"; | |
| } | |
| return emotion; | |
| } catch (e) { | |
| return emotion; | |
| } | |
| } | |
| 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?" | |
| ]; | |
| const randomMessage = testMessages[Math.floor(Math.random() * testMessages.length)]; | |
| await this.speak(randomMessage); | |
| } | |
| destroy() { | |
| // Clear all timeouts | |
| if (this.listeningTimeout) { | |
| clearTimeout(this.listeningTimeout); | |
| this.listeningTimeout = null; | |
| } | |
| if (this.transcriptHideTimeout) { | |
| clearTimeout(this.transcriptHideTimeout); | |
| this.transcriptHideTimeout = null; | |
| } | |
| if (this.recognition) { | |
| this.recognition.stop(); | |
| this.recognition = null; | |
| } | |
| if (this.speechSynthesis.speaking) { | |
| this.speechSynthesis.cancel(); | |
| } | |
| if (this.micButton && this.handleMicClick) { | |
| this.micButton.removeEventListener("click", this.handleMicClick); | |
| } | |
| 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"; | |
| } | |
| async handleLanguageChange(e) { | |
| const newLang = e.target.value; | |
| console.log(`π€ Language changing to: ${newLang}`); | |
| this.selectedLanguage = newLang; | |
| await this.db?.setPreference("selectedLanguage", newLang); | |
| // Force voice reset when changing language | |
| const currentVoicePref = await this.db?.getPreference("selectedVoice", "auto"); | |
| if (currentVoicePref === "auto") { | |
| // Reset voice selection to force auto-selection for new language | |
| this.kimiEnglishVoice = null; | |
| console.log(`π€ Voice reset for auto-selection in ${newLang}`); | |
| } | |
| await this.initVoices(); | |
| console.log( | |
| `π€ Voice initialized for ${newLang}, selected voice:`, | |
| this.kimiEnglishVoice?.name, | |
| this.kimiEnglishVoice?.lang | |
| ); | |
| if (this.recognition) { | |
| let langCode = newLang; | |
| if (langCode === "fr") langCode = "fr-FR"; | |
| else if (langCode === "en") langCode = "en-US"; | |
| else if (langCode === "es") langCode = "es-ES"; | |
| else if (langCode === "de") langCode = "de-DE"; | |
| else if (langCode === "it") langCode = "it-IT"; | |
| else if (langCode === "ja") langCode = "ja-JP"; | |
| else if (langCode === "zh") langCode = "zh-CN"; | |
| this.recognition.lang = langCode; | |
| } | |
| } | |
| async updateSelectedCharacter() { | |
| if (this.db && typeof this.db.getSelectedCharacter === "function") { | |
| const char = await this.db.getSelectedCharacter(); | |
| if (char) this.selectedCharacter = char; | |
| } | |
| } | |
| // 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; | |
| if (this.transcriptContainer) { | |
| this.transcriptContainer.classList.remove("visible"); | |
| } | |
| } | |
| 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 | |
| }; | |
| } | |
| } | |
| // Export for usage | |
| window.KimiVoiceManager = KimiVoiceManager; | |