Synthèse vocale des messages Grok

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é.");
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。