Grok Message Text-to-Speech

Adds text-to-speech to Grok messages (English voice only).

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         Grok Message Text-to-Speech
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Adds text-to-speech to Grok messages (English voice only).
// @author       CHJ85
// @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("Grok TTS script started (Web Audio API version).");

    // Global state to manage the currently playing audio
    let activePlayback = {
        source: null, // The AudioBufferSourceNode
        context: null, // The AudioContext
        buffer: null, // The AudioBuffer (needed for resume)
        startTime: 0, // Position in buffer where playback started (used for resume offset)
        startContextTime: 0, // Context time when playback started (used to calculate current position)
        pausedTime: 0, // Position in buffer where playback was paused
        button: null, // Reference to the button element that started playback
        messageElement: null, // Reference to the message element containing the button
        isPlaying: false, // Simple state flag
        stopReason: null // Added flag: 'paused' or null (finished naturally/cancelled)
    };

    // Function to create the SVG icon for the audio (play) button
    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"); // Volume icon class

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

    // Function to create the SVG icon for the pause button
    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"); // Pause icon class

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

    // Helper function to swap the icon inside a button
    function replaceButtonIcon(buttonElement, newSvgElement) {
        const span = buttonElement.querySelector('span');
        if (span && span.firstElementChild) {
            span.replaceChild(newSvgElement, span.firstElementChild);
        }
    }

    // Function to create the audio button element
    function createAudioButton(messageTextElement, messageElement) {
        const button = document.createElement('button');

        // Copy classes for styling and visibility on hover/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', 'Listen to message');
        // Custom class for identification
        button.classList.add('grok-tts-button');

        const span = document.createElement('span');
        span.style.opacity = '1';
        span.style.transform = 'none';

        const svg = createAudioSVG(); // Start with the play icon
        span.appendChild(svg);
        button.appendChild(span);

        // Add the click event listener
        button.addEventListener('click', async () => {
            console.log("Grok TTS button clicked!");

            const clickedButton = button;
            const clickedMessageElement = messageElement; // Use the message element passed in

            // --- Handle Pause/Resume Logic ---
            // Check if this button's message is the one currently playing or paused
            if (activePlayback.messageElement === clickedMessageElement) {
                if (activePlayback.isPlaying) {
                    // This button is currently playing, so pause it
                    console.log("Pausing playback.");
                    const elapsedContextTime = activePlayback.context.currentTime - activePlayback.startContextTime;
                    activePlayback.pausedTime = activePlayback.startTime + elapsedContextTime; // Calculate pause time

                    console.log(`Paused at buffer time: ${activePlayback.pausedTime.toFixed(3)}s (elapsed context time: ${elapsedContextTime.toFixed(3)}s)`);

                    // Set stopReason BEFORE stopping
                    activePlayback.stopReason = 'paused';
                    activePlayback.source.stop(); // Stop the current source (triggers onended)
                    // isPlaying will be set to false in the onended handler if stopReason is 'paused'
                    // The icon will be changed in the onended handler if stopReason is 'paused'
                    return; // Stop here, don't proceed to start new playback
                } else if (activePlayback.pausedTime > 0) {
                    // This button was paused, resume playback from paused position
                    console.log("Resuming playback from", activePlayback.pausedTime.toFixed(3), "seconds.");
                    console.log("AudioContext state before resume:", activePlayback.context ? activePlayback.context.state : 'null');

                    // Ensure we have a context and buffer from the paused state
                    if (activePlayback.context && activePlayback.buffer) {
                        // Check context state and resume if necessary
                        if (activePlayback.context.state === 'suspended') {
                            console.log("AudioContext suspended, attempting to resume context.");
                            try {
                                await activePlayback.context.resume();
                                console.log("AudioContext resumed.");
                            } catch (e) {
                                console.error("Failed to resume AudioContext:", e);
                                // Cannot resume, reset state and icon
                                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; // Cannot proceed with resume
                            }
                        } else if (activePlayback.context.state !== 'running') {
                             // Handle other unexpected states if necessary, though 'suspended' is most common
                             console.warn("AudioContext in unexpected state:", activePlayback.context.state, "Resetting state.");
                             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;
                        }


                        // Proceed with creating and starting the source now that context is hopefully running
                        const newSource = activePlayback.context.createBufferSource();
                        newSource.buffer = activePlayback.buffer;
                        newSource.connect(activePlayback.context.destination);

                        // Update global state for resumed playback
                        activePlayback.source = newSource;
                        activePlayback.startTime = activePlayback.pausedTime; // Start from where it was paused
                        activePlayback.startContextTime = activePlayback.context.currentTime; // Record new start time
                        activePlayback.isPlaying = true;
                        activePlayback.pausedTime = 0; // Reset paused time as we are now resuming

                        // Set onended handler for the NEW source node
                        newSource.onended = () => {
                            console.log("Playback finished or stopped.");
                            // Only reset state and icon if this source is the one currently active
                            if (activePlayback.source === newSource) {
                                if (activePlayback.stopReason === 'paused') {
                                     console.log("Playback was paused, preserving state for resume.");
                                     activePlayback.stopReason = null; // Clear the reason
                                     activePlayback.source = null; // Nullify the finished source node
                                     activePlayback.isPlaying = false; // Ensure isPlaying is false after pause
                                     // Icon already changed on click
                                } else {
                                    console.log("Playback finished naturally, resetting state.");
                                    replaceButtonIcon(clickedButton, createAudioSVG());
                                    // Reset global state
                                    activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };
                                }
                            } else {
                                console.log("Ended event for a non-current source. Ignoring state reset.");
                            }
                        };

                        try {
                            console.log(`Attempting to resume start at offset: ${activePlayback.startTime.toFixed(3)}s`);
                            // start(when, offset, duration) - start immediately (0), from calculated offset
                            newSource.start(0, activePlayback.startTime);
                            replaceButtonIcon(clickedButton, createPauseSVG()); // Change icon to pause
                            console.log("Resumed playback started.");
                        } catch (e) {
                            console.error("Error starting resumed playback:", e);
                            replaceButtonIcon(clickedButton, createAudioSVG()); // Reset icon on error
                            // Reset global state on error
                            activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };
                        }
                    } else {
                       console.error("Could not resume: AudioContext or Buffer not available from previous state.");
                       // Reset state as we cannot resume
                       activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };
                       replaceButtonIcon(clickedButton, createAudioSVG()); // Reset icon on error
                    }
                    return; // Stop here, playback resumed
                }
            }


            // --- Handle Stop Current and Start New Playback ---
            // If we reach here, it means we need to start new playback (either first time, clicking a different button, or resume failed)

            // Stop any currently active playback first
            if (activePlayback.source) {
                console.log("Stopping current playback to start new one.");
                // Set stopReason to null before stopping, so onended knows it's not a pause
                activePlayback.stopReason = null;
                activePlayback.source.stop();
                 // The onended handler for the OLD source will now reset the state
                // The icon of the OLD button will be reset in the onended handler
            }

            // Reset the global state immediately before starting a NEW playback cycle
            // This ensures a clean slate if the previous state was messed up or from a different message
             activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };


            // Now, start the new playback for the clicked button
            console.log("Starting new playback.");

            // Disable the button while fetching/processing
            clickedButton.disabled = true;
             replaceButtonIcon(clickedButton, createPauseSVG()); // Assume success and show pause icon immediately


            const text = messageTextElement.textContent;
            const cleanedText = text.replace(/[\u200B-\u200D\uFEFF]/g, ''); // Remove zero-width spaces
            const encodedText = encodeURIComponent(cleanedText);
            const audioUrl = `https://texttospeech.responsivevoice.org/v1/text:synthesize?lang=en-GB&engine=g1&pitch=0.5&rate=0.5&volume=1&key=kvfbSITh&gender=male&text=${encodedText}`; // Note: Key might need proper handling if exposed/used publicly

            console.log("Generated audio URL for fetch:", 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; // Re-enable button after fetch attempt
                console.log("GM_xmlhttpRequest onload status:", response.status);

                if (response.status === 200) {
                    const audioData = response.response;
                    console.log("Received audio data (ArrayBuffer).");

                    try {
                        // Create a new AudioContext for this playback session if one doesn't exist or is closed
                        let audioContext = activePlayback.context;
                        if (!audioContext || audioContext.state === 'closed') {
                             audioContext = new (window.AudioContext || window.webkitAudioContext)();
                             console.log("New AudioContext created.");
                        }

                         // Ensure context is running (important for first playback)
                         if (audioContext.state === 'suspended') {
                             console.log("AudioContext suspended, attempting to resume context for initial play.");
                              try {
                                 await audioContext.resume();
                                 console.log("AudioContext resumed.");
                             } catch (e) {
                                console.error("Failed to resume AudioContext for initial play:", e);
                                 // Cannot proceed, reset icon and state
                                 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; // Stop execution
                             }
                         }


                        const audioBuffer = await audioContext.decodeAudioData(audioData);
                        console.log("Audio data decoded successfully.");

                        const source = audioContext.createBufferSource();
                        source.buffer = audioBuffer;
                        source.connect(audioContext.destination);

                        // Update global state BEFORE starting playback
                        activePlayback.source = source;
                        activePlayback.context = audioContext; // Store the context
                        activePlayback.buffer = audioBuffer; // Store buffer for potential resume
                        activePlayback.startTime = 0; // Starting from the beginning
                        activePlayback.startContextTime = audioContext.currentTime; // Record context time at start
                        activePlayback.button = clickedButton;
                        activePlayback.messageElement = clickedMessageElement;
                        activePlayback.isPlaying = true;
                        activePlayback.pausedTime = 0; // Ensure paused time is 0 for a new start
                        activePlayback.stopReason = null; // Ensure stop reason is null for a new start


                        // Set the onended handler for the newly created source
                        source.onended = () => {
                            console.log("Playback finished or stopped.");
                             // Only reset state and icon if this source is the one currently active
                             // and it wasn't a pause initiated stop
                            if (activePlayback.source === source) {
                                if (activePlayback.stopReason === 'paused') {
                                    console.log("Playback was paused, preserving state for resume.");
                                    activePlayback.stopReason = null; // Clear the reason
                                    activePlayback.source = null; // Nullify the finished source node
                                    activePlayback.isPlaying = false; // Ensure isPlaying is false after pause
                                    // Icon already changed on click
                                } else {
                                    console.log("Playback finished naturally, resetting state.");
                                    replaceButtonIcon(clickedButton, createAudioSVG());
                                    // Reset global state
                                    activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };
                                }
                            } else {
                                console.log("Ended event for a non-current source. Ignoring state reset.");
                            }
                        };

                        console.log("Attempting to play audio via Web Audio API...");
                        source.start(0); // Play immediately from the beginning
                        // replaceButtonIcon(clickedButton, createPauseSVG()); // Icon changed earlier, move back here if you want to wait for success

                    } catch (audioError) {
                        console.error("Error decoding or playing audio with Web Audio API:", audioError);
                        clickedButton.disabled = false; // Re-enable button on error
                        replaceButtonIcon(clickedButton, createAudioSVG()); // Reset icon on error
                        // Reset global state on error
                        activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };
                    }

                } else {
                    console.error(`Failed to fetch audio data: HTTP status ${response.status}`);
                    console.error("Response details:", response);
                    clickedButton.disabled = false; // Re-enable button on error
                    replaceButtonIcon(clickedButton, createAudioSVG()); // Reset icon on error
                    // Reset global state on error
                    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("GM_xmlhttpRequest network or timeout error:", gmError);
                clickedButton.disabled = false; // Re-enable button on error
                replaceButtonIcon(clickedButton, createAudioSVG()); // Reset icon on error
                // Reset global state on error
                activePlayback = { source: null, context: null, buffer: null, startTime: 0, startContextTime: 0, pausedTime: 0, button: null, messageElement: null, isPlaying: false, stopReason: null };
            }
        });

        return button;
    }

    // Function to process a single message container element
    function processMessage(messageElement) {
        // Check if we've already added the button to this message
        if (messageElement.classList.contains('grok-audio-added')) {
            return;
        }

        // Find the message text element (the <p> tag inside the message bubble)
        const messageTextElement = messageElement.querySelector('.message-bubble p');
        // Find the container where the action buttons are
        const actionsContainer = messageElement.querySelector('.flex.flex-row.flex-wrap > .flex.items-center.gap-\\[2px\\]');

        if (messageTextElement && actionsContainer) {
            console.log("Found message and actions, adding TTS button.");
            // Create the audio button and pass the text and message element references
            const audioButton = createAudioButton(messageTextElement, messageElement);

            if (audioButton) {
                // Append the new button to the actions container
                actionsContainer.appendChild(audioButton);

                // Mark this message element so we don't process it again
                messageElement.classList.add('grok-audio-added');
                console.log("Audio button added and message marked.");
            } else {
                console.error("Failed to create audio button element.");
            }

        } else {
             // console.log("Could not find necessary elements for message processing on a potential message element.");
        }
    }

    // Set up a MutationObserver to watch for new nodes being added to the 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) {
                        // Check if the added node itself is a message container
                        if (node.matches('.relative.group.flex.flex-col')) {
                            processMessage(node);
                        }
                        // Also check if the added node contains message containers
                        node.querySelectorAll('.relative.group.flex.flex-col').forEach(processMessage);
                    }
                });
            }
        });
    });

    // Start observing the body for changes
    console.log("Starting MutationObserver on document.body");
    observer.observe(document.body, { childList: true, subtree: true });
    console.log("MutationObserver started.");


    // Also process any messages that might already be present when the script runs
    console.log("Processing existing messages on page load.");
    document.querySelectorAll('.relative.group.flex.flex-col').forEach(processMessage);
    console.log("Finished processing existing messages.");


})();