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; | |