// ==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.");
})();