您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Ajoute la synthèse vocale aux messages Grok (voix en français uniquement).
// ==UserScript== // @name Synthèse vocale des messages Grok // @namespace http://tampermonkey.net/ // @version 1.0 // @description Ajoute la synthèse vocale aux messages Grok (voix en français uniquement). // @author Jonathan Laurendeau // @match https://grok.com/* // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @grant GM_xmlhttpRequest // @connect texttospeech.responsivevoice.org // @run-at document-start // ==/UserScript== (function() { 'use strict'; console.log("Script TTS Grok démarré (version Web Audio API)."); // État global pour gérer la lecture audio en cours let activePlayback = { source: null, // Le nœud AudioBufferSourceNode context: null, // Le contexte AudioContext buffer: null, // Le buffer AudioBuffer (nécessaire pour reprendre) startTime: 0, // Position dans le buffer où la lecture a commencé (pour offset de reprise) startContextTime: 0, // Temps du contexte au début de la lecture (pour calculer la position actuelle) pausedTime: 0, // Position dans le buffer où la lecture a été mise en pause button: null, // Référence au bouton ayant déclenché la lecture messageElement: null, // Référence à l'élément du message contenant le bouton isPlaying: false, // Drapeau d'état simple stopReason: null // Drapeau ajouté : 'paused' ou null (terminé naturellement/annulé) }; // Fonction pour créer l'icône SVG du bouton audio (lecture) function createAudioSVG() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("width", "16"); svg.setAttribute("height", "16"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.classList.add("lucide", "lucide-volume-2", "size-4"); // Classe pour l'icône de volume const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); polygon.setAttribute("points", "11 5 6 9 2 9 2 15 6 15 11 19 11 5"); svg.appendChild(polygon); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"); svg.appendChild(path); return svg; } // Fonction pour créer l'icône SVG du bouton de pause function createPauseSVG() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("width", "16"); svg.setAttribute("height", "16"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.classList.add("lucide", "lucide-pause", "size-4"); // Classe pour l'icône de pause const rect1 = document.createElementNS("http://www.w3.org/2000/svg", "rect"); rect1.setAttribute("x", "6"); rect1.setAttribute("y", "4"); rect1.setAttribute("width", "4"); rect1.setAttribute("height", "16"); svg.appendChild(rect1); const rect2 = document.createElementNS("http://www.w3.org/2000/svg", "rect"); rect2.setAttribute("x", "14"); rect2.setAttribute("y", "4"); rect2.setAttribute("width", "4"); rect2.setAttribute("height", "16"); svg.appendChild(rect2); return svg; } // Fonction auxiliaire pour remplacer l'icône à l'intérieur d'un bouton function replaceButtonIcon(buttonElement, newSvgElement) { const span = buttonElement.querySelector('span'); if (span && span.firstElementChild) { span.replaceChild(newSvgElement, span.firstElementChild); } } // Fonction pour créer l'élément du bouton audio function createAudioButton(messageTextElement, messageElement) { const button = document.createElement('button'); // Copie des classes pour le style et la visibilité au survol/focus button.classList.add( 'inline-flex', 'items-center', 'justify-center', 'gap-2', 'whitespace-nowrap', 'text-sm', 'font-medium', 'leading-[normal]', 'cursor-pointer', 'focus-visible:outline-none', 'focus-visible:ring-1', 'focus-visible:ring-ring', 'disabled:cursor-not-allowed', 'transition-colors', 'duration-100', '[&_svg]:duration-100', '[&_svg]:pointer-events-none', '[&_svg]:shrink-0', '[&_svg]:-mx-0.5', 'select-none', 'text-fg-secondary', 'hover:text-fg-primary', 'hover:bg-button-ghost-hover', 'disabled:hover:text-fg-secondary', 'disabled:hover:bg-transparent', '[&_svg]:hover:text-fg-primary', 'h-8', 'w-8', 'rounded-full', 'opacity-0', 'group-focus-within:opacity-100', 'group-hover:opacity-100', '[.last-response_&]:opacity-100', 'disabled:opacity-0', 'group-focus-within:disabled:opacity-60', 'group-hover:disabled:opacity-60', '[.last-response_&]:disabled:opacity-60' ); button.setAttribute('type', 'button'); button.setAttribute('aria-label', 'Écouter le message'); // Classe personnalisée pour l'identification button.classList.add('grok-tts-button'); const span = document.createElement('span'); span.style.opacity = '1'; span.style.transform = 'none'; const svg = createAudioSVG(); // Commence avec l'icône de lecture span.appendChild(svg); button.appendChild(span); // Ajoute l'écouteur d'événements pour le clic button.addEventListener('click', async () => { console.log("Bouton TTS Grok cliqué !"); const clickedButton = button; const clickedMessageElement = messageElement; // Utilise l'élément de message passé // --- Gestion de la logique Pause/Reprise --- // Vérifie si le message de ce bouton est celui en cours de lecture ou en pause if (activePlayback.messageElement === clickedMessageElement) { if (activePlayback.isPlaying) { // Ce bouton est en lecture, donc le mettre en pause console.log("Mise en pause de la lecture."); const elapsedContextTime = activePlayback.context.currentTime - activePlayback.startContextTime; activePlayback.pausedTime = activePlayback.startTime + elapsedContextTime; // Calcule le temps de pause console.log(`Pause à la position du buffer : ${activePlayback.pausedTime.toFixed(3)}s (temps écoulé du contexte : ${elapsedContextTime.toFixed(3)}s)`); // Définir stopReason AVANT d'arrêter activePlayback.stopReason = 'paused'; activePlayback.source.stop(); // Arrête la source actuelle (déclenche onended) // isPlaying sera défini à false dans le gestionnaire onended si stopReason est 'paused' // L'icône sera changée dans le gestionnaire onended si stopReason est 'paused' return; // Arrête ici, ne passe pas à une nouvelle lecture } else if (activePlayback.pausedTime > 0) { // Ce bouton était en pause, reprendre la lecture à partir de la position de pause console.log("Reprise de la lecture à partir de", activePlayback.pausedTime.toFixed(3), "secondes."); console.log("État de l'AudioContext avant reprise :", activePlayback.context ? activePlayback.context.state : 'null'); // Vérifie si le contexte et le buffer sont disponibles depuis l'état de pause if (activePlayback.context && activePlayback.buffer) { // Vérifie l'état du contexte et le reprend si nécessaire if (activePlayback.context.state === 'suspended') { console.log("AudioContext suspendu, tentative de reprise du contexte."); try { await activePlayback.context.resume(); console.log("AudioContext repris."); } catch (e) { console.error("Échec de la reprise de l'AudioContext :", e); // Impossible de reprendre, réinitialise l'état et l'icône activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; replaceButtonIcon(clickedButton, createAudioSVG()); return; // Impossible de continuer avec la reprise } } else if (activePlayback.context.state !== 'running') { // Gérer d'autres états inattendus si nécessaire, bien que 'suspended' soit le plus courant console.warn("AudioContext dans un état inattendu :", activePlayback.context.state, "Réinitialisation de l'état."); activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; replaceButtonIcon(clickedButton, createAudioSVG()); return; } // Poursuit avec la création et le démarrage de la source maintenant que le contexte est probablement en cours d'exécution const newSource = activePlayback.context.createBufferSource(); newSource.buffer = activePlayback.buffer; newSource.connect(activePlayback.context.destination); // Met à jour l'état global pour la lecture reprise activePlayback.source = newSource; activePlayback.startTime = activePlayback.pausedTime; // Commence à l'endroit où il était en pause activePlayback.startContextTime = activePlayback.context.currentTime; // Enregistre le nouveau temps de démarrage activePlayback.isPlaying = true; activePlayback.pausedTime = 0; // Réinitialise le temps de pause car nous reprenons // Définit le gestionnaire onended pour le NOUVEAU nœud source newSource.onended = () => { console.log("Lecture terminée ou arrêtée."); // Réinitialise l'état et l'icône uniquement si cette source est celle actuellement active if (activePlayback.source === newSource) { if (activePlayback.stopReason === 'paused') { console.log("Lecture mise en pause, préservation de l'état pour reprise."); activePlayback.stopReason = null; // Efface la raison activePlayback.source = null; // Annule le nœud source terminé activePlayback.isPlaying = false; // Assure que isPlaying est false après pause // L'icône a déjà été changée lors du clic } else { console.log("Lecture terminée naturellement, réinitialisation de l'état."); replaceButtonIcon(clickedButton, createAudioSVG()); // Réinitialise l'état global activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; } } else { console.log("Événement de fin pour une source non actuelle. Ignorer la réinitialisation de l'état."); } }; try { console.log(`Tentative de reprise à l'offset : ${activePlayback.startTime.toFixed(3)}s`); // start(when, offset, duration) - démarre immédiatement (0), à partir de l'offset calculé newSource.start(0, activePlayback.startTime); replaceButtonIcon(clickedButton, createPauseSVG()); // Change l'icône pour pause console.log("Lecture reprise démarrée."); } catch (e) { console.error("Erreur lors du démarrage de la lecture reprise :", e); replaceButtonIcon(clickedButton, createAudioSVG()); // Réinitialise l'icône en cas d'erreur // Réinitialise l'état global en cas d'erreur activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; } } else { console.error("Impossible de reprendre : AudioContext ou Buffer non disponible depuis l'état précédent."); // Réinitialise l'état car nous ne pouvons pas reprendre activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; replaceButtonIcon(clickedButton, createAudioSVG()); // Réinitialise l'icône en cas d'erreur } return; // Arrête ici, lecture reprise } } // --- Gestion de l'arrêt de la lecture actuelle et démarrage d'une nouvelle --- // Si nous arrivons ici, cela signifie qu'il faut démarrer une nouvelle lecture (première fois, clic sur un autre bouton, ou reprise échouée) // Arrête d'abord toute lecture active en cours if (activePlayback.source) { console.log("Arrêt de la lecture actuelle pour démarrer une nouvelle."); // Définit stopReason à null avant d'arrêter, pour que onended sache que ce n'est pas une pause activePlayback.stopReason = null; activePlayback.source.stop(); // Le gestionnaire onended de l'ANCIENNE source réinitialisera l'état // L'icône de l'ANCIEN bouton sera réinitialisée dans le gestionnaire onended } // Réinitialise immédiatement l'état global avant de démarrer un NOUVEAU cycle de lecture // Cela garantit un état propre si l'état précédent était corrompu ou provenait d'un autre message activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; // Maintenant, démarre la nouvelle lecture pour le bouton cliqué console.log("Démarrage d'une nouvelle lecture."); // Désactive le bouton pendant la récupération/traitement clickedButton.disabled = true; replaceButtonIcon(clickedButton, createPauseSVG()); // Suppose le succès et affiche l'icône de pause immédiatement const text = messageTextElement.textContent; const cleanedText = text.replace(/[\u200B-\u200D\uFEFF]/g, ''); // Supprime les espaces de largeur nulle const encodedText = encodeURIComponent(cleanedText); const audioUrl = `https://texttospeech.responsivevoice.org/v1/text:synthesize?lang=fr-FR&engine=g1&pitch=0.5&rate=0.5&volume=1&key=kvfbSITh&gender=male&text=${encodedText}`; // Note : La clé doit être gérée correctement si exposée/publiquement utilisée console.log("URL audio générée pour récupération :", audioUrl.substring(0, 200) + (audioUrl.length > 200 ? '...' : '')); try { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: audioUrl, responseType: "arraybuffer", onload: resolve, onerror: reject, ontimeout: reject }); }); clickedButton.disabled = false; // Réactive le bouton après la tentative de récupération console.log("Statut de GM_xmlhttpRequest onload :", response.status); if (response.status === 200) { const audioData = response.response; console.log("Données audio reçues (ArrayBuffer)."); try { // Crée un nouveau AudioContext pour cette session de lecture si aucun n'existe ou s'il est fermé let audioContext = activePlayback.context; if (!audioContext || audioContext.state === 'closed') { audioContext = new (window.AudioContext || window.webkitAudioContext)(); console.log("Nouveau AudioContext créé."); } // S'assure que le contexte est en cours d'exécution (important pour la première lecture) if (audioContext.state === 'suspended') { console.log("AudioContext suspendu, tentative de reprise du contexte pour la lecture initiale."); try { await audioContext.resume(); console.log("AudioContext repris."); } catch (e) { console.error("Échec de la reprise de l'AudioContext pour la lecture initiale :", e); // Impossible de continuer, réinitialise l'icône et l'état replaceButtonIcon(clickedButton, createAudioSVG()); activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; return; // Arrête l'exécution } } const audioBuffer = await audioContext.decodeAudioData(audioData); console.log("Données audio décodées avec succès."); const source = audioContext.createBufferSource(); source.buffer = audioBuffer; source.connect(audioContext.destination); // Met à jour l'état global AVANT de démarrer la lecture activePlayback.source = source; activePlayback.context = audioContext; // Stocke le contexte activePlayback.buffer = audioBuffer; // Stocke le buffer pour une éventuelle reprise activePlayback.startTime = 0; // Commence depuis le début activePlayback.startContextTime = audioContext.currentTime; // Enregistre le temps du contexte au démarrage activePlayback.button = clickedButton; activePlayback.messageElement = clickedMessageElement; activePlayback.isPlaying = true; activePlayback.pausedTime = 0; // S'assure que le temps de pause est à 0 pour un nouveau démarrage activePlayback.stopReason = null; // S'assure que la raison d'arrêt est null pour un nouveau démarrage // Définit le gestionnaire onended pour la source nouvellement créée source.onended = () => { console.log("Lecture terminée ou arrêtée."); // Réinitialise l'état et l'icône uniquement si cette source est celle actuellement active // et que ce n'était pas un arrêt initié par une pause if (activePlayback.source === source) { if (activePlayback.stopReason === 'paused') { console.log("Lecture mise en pause, préservation de l'état pour reprise."); activePlayback.stopReason = null; // Efface la raison activePlayback.source = null; // Annule le nœud source terminé activePlayback.isPlaying = false; // Assure que isPlaying est false après pause // L'icône a déjà été changée lors du clic } else { console.log("Lecture terminée naturellement, réinitialisation de l'état."); replaceButtonIcon(clickedButton, createAudioSVG()); // Réinitialise l'état global activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; } } else { console.log("Événement de fin pour une source non actuelle. Ignorer la réinitialisation de l'état."); } }; console.log("Tentative de lecture audio via Web Audio API..."); source.start(0); // Joue immédiatement depuis le début // replaceButtonIcon(clickedButton, createPauseSVG()); // Icône changée plus tôt, remettre ici si vous voulez attendre le succès } catch (audioError) { console.error("Erreur lors du décodage ou de la lecture audio avec Web Audio API :", audioError); clickedButton.disabled = false; // Réactive le bouton en cas d'erreur replaceButtonIcon(clickedButton, createAudioSVG()); // Réinitialise l'icône en cas d'erreur // Réinitialise l'état global en cas d'erreur activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; } } else { console.error(`Échec de la récupération des données audio : Statut HTTP ${response.status}`); console.error("Détails de la réponse :", response); clickedButton.disabled = false; // Réactive le bouton en cas d'erreur replaceButtonIcon(clickedButton, createAudioSVG()); // Réinitialise l'icône en cas d'erreur // Réinitialise l'état global en cas d'erreur activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; } } catch (gmError) { console.error("Erreur réseau ou de délai d'attente GM_xmlhttpRequest :", gmError); clickedButton.disabled = false; // Réactive le bouton en cas d'erreur replaceButtonIcon(clickedButton, createAudioSVG()); // Réinitialise l'icône en cas d'erreur // Réinitialise l'état global en cas d'erreur activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null }; } }); return button; } // Fonction pour traiter un conteneur de message unique function processMessage(messageElement) { // Vérifie si nous avons déjà ajouté le bouton à ce message if (messageElement.classList.contains('grok-audio-added')) { return; } // Trouve l'élément de texte du message (la balise <p> à l'intérieur de la bulle de message) const messageTextElement = messageElement.querySelector('.message-bubble p'); // Trouve le conteneur où se trouvent les boutons d'action const actionsContainer = messageElement.querySelector('.flex.flex-row.flex-wrap > .flex.items-center.gap-\\[2px\\]'); if (messageTextElement && actionsContainer) { console.log("Message et actions trouvés, ajout du bouton TTS."); // Crée le bouton audio et passe les références du texte et de l'élément de message const audioButton = createAudioButton(messageTextElement, messageElement); if (audioButton) { // Ajoute le nouveau bouton au conteneur des actions actionsContainer.appendChild(audioButton); // Marque cet élément de message pour ne pas le traiter à nouveau messageElement.classList.add('grok-audio-added'); console.log("Bouton audio ajouté et message marqué."); } else { console.error("Échec de la création de l'élément du bouton audio."); } } else { // console.log("Impossible de trouver les éléments nécessaires pour le traitement du message sur un élément de message potentiel."); } } // Configure un MutationObserver pour surveiller les nouveaux nœuds ajoutés au DOM const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Vérifie si le nœud ajouté est lui-même un conteneur de message if (node.matches('.relative.group.flex.flex-col')) { processMessage(node); } // Vérifie également si le nœud ajouté contient des conteneurs de messages node.querySelectorAll('.relative.group.flex.flex-col').forEach(processMessage); } }); } }); }); // Commence à observer le corps pour les changements console.log("Démarrage de MutationObserver sur document.body"); observer.observe(document.body, { childList: true, subtree: true }); console.log("MutationObserver démarré."); // Traite également les messages qui pourraient déjà être présents lorsque le script s'exécute console.log("Traitement des messages existants au chargement de la page."); document.querySelectorAll('.relative.group.flex.flex-col').forEach(processMessage); console.log("Traitement des messages existants terminé."); })();