Read Aloud Speedster

Set playback speed for Read Aloud on ChatGPT.com. Clicking the speed display opens a popup to save the default playback speed and toggle the square design. Also adds color-coded icons for copy, thumbs up, thumbs down, read aloud, and stop buttons. Highlight color for strong text is green in dark mode and violet in light mode.

// ==UserScript==
// @name         Read Aloud Speedster
// @description  Set playback speed for Read Aloud on ChatGPT.com. Clicking the speed display opens a popup to save the default playback speed and toggle the square design. Also adds color-coded icons for copy, thumbs up, thumbs down, read aloud, and stop buttons. Highlight color for strong text is green in dark mode and violet in light mode.
// @author       Tim Macy
// @license      GNU AFFERO GENERAL PUBLIC LICENSE-3.0
// @version      3.0.3
// @namespace    TimMacy.ReadAloudSpeedster
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @match        https://*.chatgpt.com/*
// @grant        GM.setValue
// @grant        GM.getValue
// @run-at       document-start
// @homepageURL  https://github.com/TimMacy/ReadAloudSpeedster
// @supportURL   https://github.com/TimMacy/ReadAloudSpeedster/issues
// ==/UserScript==

/************************************************************************
*                                                                       *
*                    Copyright © 2025 Tim Macy                          *
*                    GNU Affero General Public License v3.0             *
*                    Version: 3.0.3 - Read Aloud Speedster              *
*                    All Rights Reserved.                               *
*                                                                       *
*             Visit: https://github.com/TimMacy                         *
*                                                                       *
************************************************************************/

(function() {
    'use strict';
    const className = "sm:mt-5";
    const escapedClassName = CSS.escape(className);
    const styleSheet = document.createElement('style');
    styleSheet.textContent = `

        /**************************************
                     user settings
        **************************************/

        /* chatbox - reduced vertical margin */
        .${escapedClassName} {
            margin-top: .5rem !important;
            margin-bottom: .25rem !important;
        }

        /* chatbox - fade effect for content */
        main form > div > .w-full {
            box-shadow: 0 -10px 10px 0px var(--main-surface-primary);
        }

        /* copy icon */
        button[aria-label="Copy"] .icon-md-heavy {
            color: darkorange !important;
            opacity: .8;
        }

        /* thumbs up icon */
        button[aria-label="Good response"] .icon-md-heavy {
            color: forestgreen !important;
        }

        /* thumbs down icon */
        button[aria-label="Bad response"] .icon-md-heavy {
            color: crimson !important;
            opacity: .8;
        }

        /* read aloud and stop icon */
        button[aria-label="Read aloud"] .icon-md-heavy,
        button[aria-label="Stop"] .icon-md-heavy {
            color: deepskyblue !important;
        }

        /* highlight color - dark mode */
        .markdown strong {
            color: springgreen !important;
        }

        /* highlight color - light mode */
        .light .markdown strong {
            color: darkviolet !important;
        }

        /* hide 'view plans' banner
        .h-full.w-\\[260px\\] .dark\\:border-white\\/20:is(.dark *) {
            display: none;
        } */

        /* hide 'chat can make mistakes' text
        .relative.flex.min-h-8.w-full.items-center.justify-center.p-2.text-center.text-xs.text-token-text-secondary.md\\:px-\\[60px\\] {
            display: none;
        } */

        /* add space below chatbox
        .xl\\:px-5 {
            padding-bottom: 1rem;
        } */

        /* disable voice mode button
        button[aria-label="Start voice mode"] {
            pointer-events: none;
            opacity: 0.5;
        } */

        /* select color */
        ::selection {
            /*background: #00519d;*/
            background-color: var(--text-primary);
            color: var(--main-surface-tertiary);
        }

        /* right button height
        .w-7 {
            width: 2.25rem !important
        }

        .h-7 {
            height: 2.25rem !important
        }

        [data-testid="composer-speech-button"].w-7.\\!h-7,
        button[data-testid="composer-speech-button"] {
            width: 2.25rem !important;
            height: 2.25rem !important;
        } */

        /* darker bg for chatbox and header
        main form .bg-token-main-surface-primary {
            background-color: #141414 !important;;
        }

        .h-header-height {
            background: #171717 !important;
        } */

        /**************************************
                 Read Aloud Speedster
        **************************************/

        .speed-control-container {
            position: relative;
            display: flex;
            align-items: center;
        }

        .speed-btn {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 36px;
            min-width: 36px;
            border: 1px solid var(--border-light);
            font-size: .75rem;
            line-height: 1rem;
            font-weight: 600;
            background: transparent;
            color: var(--text-secondary);
            cursor: pointer;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }

        .speed-btn.minus {
            border-radius: 100% 0 0 100%;
            border-right: none;
        }

        .speed-btn.plus {
            border-radius: 0 100% 100% 0;
            border-left: none;
        }

        .speed-btn:hover,
        .speed-control-config-popup button:hover {
            background-color: var(--main-surface-secondary);
        }

        .speed-btn:active,
        .speed-control-config-popup button:active {
            background-color: oklab(0 0 0/.1);
        }

        .speed-display {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 36px;
            min-width: 36px;
            padding: .5rem;
            border: 1px solid var(--border-light);
            font-size: .75rem;
            line-height: 1rem;
            font-weight: 600;
            background: transparent;
            color: var(--text-secondary);
            cursor: default;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }

        .speed-control-config-popup {
            position: absolute;
            bottom: 100%;
            left: 50%;
            transform: translateX(-50%);
            background: var(--main-surface-primary);
            border: 1px solid var(--border-light);
            border-radius: 3px;
            padding: 8px;
            margin-bottom: 4px;
            z-index: 2077;
            display: none;
            flex-direction: row;
            gap: 10px;
        }

        .speed-control-config-popup.show {
            display: flex;
        }

        .speed-control-config-popup input[type="number"] {
            width: 6ch;
            border: 1px solid var(--border-light);
            border-radius: 3px;
            background: transparent;
            color: var(--text-primary);
            text-align: center;
            margin-right: 10px;
        }

        .speed-control-config-popup button {
            padding: 4px 8px;
            border: 1px solid var(--border-light);
            border-radius: 3px;
            background: transparent;
            color: var(--text-secondary);
            cursor: pointer;
        }

        .speed-control-config-popup .toggle-container {
            display: flex;
            align-items: center;
            text-wrap: nowrap;
            gap: 5px;
        }
    `; document.head.appendChild(styleSheet);

    let squareDesignEnabled = false;
    let squareStyleSheet = null;
    let audioListeners = new Map();
    let controlsContainer = null;
    let configPopup = null;
    let observer = null;
    let playbackSpeed = 1;
    let ignoreRateChange = false;
    let lastUserRate = playbackSpeed;
    let savedSpeed;

    const MIN_SPEED = 1;
    const MAX_SPEED = 17;
    const DELTA = 0.25;

    // toggle square design
    function applySquareDesign(enabled) {
        if (enabled) {
            if (!squareStyleSheet) {
                squareStyleSheet = document.createElement('style');
                squareStyleSheet.textContent = `
                    /* button 'send prompt' radius */
                    button[aria-label="Send prompt"], button[aria-label="Stop streaming"], button[aria-label="Start voice mode"] {
                        border-radius: 4px !important;
                    }

                    /* button radii */
                    .btn, .rounded-full {
                        border-radius: 2px !important;
                    }

                    /* button minus radius */
                    .speed-btn.minus {
                        border-radius: 2px 0 0 2px;
                    }

                    /* button plus radius */
                    .speed-btn.plus {
                        border-radius: 0 2px 2px 0;
                    }

                    /* chatbox - radius */
                    .rounded-3xl,
                    .rounded-b-3xl,
                    .rounded-t-3xl,
                    .rounded-\\[28px\\] {
                        border-radius: .25em !important;
                    }

                    /* popup radii */
                    .rounded-lg, .rounded-2xl {
                        border-radius: .25rem !important;
                    }

                    /* reply radii */
                    .rounded-b-lg,
                    .rounded-t-\\[20px\\] {
                        border-radius: 0 !important;
                    }
                `;
                document.head.appendChild(squareStyleSheet);
            }
        } else {
            if (squareStyleSheet) {
                squareStyleSheet.remove();
                squareStyleSheet = null;
            }
        }
    }

    // load playback speed and square design values from config or use default
    async function initializeSpeed() {
        savedSpeed = await GM.getValue('defaultSpeed');
        if (savedSpeed !== undefined) {
            playbackSpeed = savedSpeed;
            lastUserRate = playbackSpeed;
        }

        // load square design setting
        squareDesignEnabled = await GM.getValue('squareDesign');
        if (squareDesignEnabled === undefined) {
            squareDesignEnabled = false;
        }

        applySquareDesign(squareDesignEnabled);
        updateSpeedDisplay();
        setPlaybackSpeed();
    }

    // set playback speed and manage listeners
    function setPlaybackSpeed() {
        const audioElements = document.querySelectorAll('audio');
        audioElements.forEach(audio => {
            if (audioListeners.has(audio)) {
                const { playListener, rateListener } = audioListeners.get(audio);
                audio.removeEventListener('play', playListener);
                audio.removeEventListener('ratechange', rateListener);
                audioListeners.delete(audio);
            }

            audio.playbackRate = playbackSpeed;
            const playListener = () => audio.playbackRate = playbackSpeed;

            const rateListener = () => {
                if (ignoreRateChange) { ignoreRateChange = false; return; }
                audio.playbackRate = lastUserRate;
            };

            audio.addEventListener('play', playListener);
            audio.addEventListener('ratechange', rateListener);
            audioListeners.set(audio, { playListener, rateListener });
        });
    }

    // config popup
    function createConfigPopup() {
        if (configPopup) {
            document.removeEventListener('click', handleDocumentClick);
            configPopup.remove();
        }

        configPopup = document.createElement('div');
        configPopup.classList.add('speed-control-config-popup');

        // input for speed
        const input = document.createElement('input');
        input.type = 'number';
        input.min = MIN_SPEED;
        input.max = MAX_SPEED;
        input.step = DELTA;
        input.value = savedSpeed;

        // square design toggle
        const toggleContainer = document.createElement('div');
        toggleContainer.classList.add('toggle-container');
        const squareCheckbox = document.createElement('input');
        squareCheckbox.type = 'checkbox';
        squareCheckbox.id = 'squareDesignToggle';
        squareCheckbox.checked = squareDesignEnabled;
        const squareLabel = document.createElement('label');
        squareLabel.textContent = 'Square Design';
        squareLabel.htmlFor = 'squareDesignToggle';
        toggleContainer.appendChild(squareCheckbox);
        toggleContainer.appendChild(squareLabel);

        // save button
        const saveButton = document.createElement('button');
        saveButton.textContent = 'Save';

        async function handleSave() {
            const newSpeed = parseFloat(input.value);
            if (newSpeed >= MIN_SPEED && newSpeed <= MAX_SPEED) {
                await GM.setValue('defaultSpeed', newSpeed);
                playbackSpeed = newSpeed;
                updateSpeedDisplay();
                setPlaybackSpeed();
            }

            // save square design setting
            squareDesignEnabled = squareCheckbox.checked;
            await GM.setValue('squareDesign', squareDesignEnabled);
            applySquareDesign(squareDesignEnabled);

            configPopup.classList.remove('show');
        }

        saveButton.addEventListener('click', handleSave);

        document.addEventListener('click', handleDocumentClick);

        configPopup.appendChild(input);
        configPopup.appendChild(toggleContainer);
        configPopup.appendChild(saveButton);
        document.body.appendChild(configPopup);

        return configPopup;
    }

    function handleDocumentClick(e) {
        if (!configPopup.contains(e.target) && !e.target.classList.contains('speed-display')) {
            configPopup.classList.remove('show');
        }
    }

    // speed display
    function updateSpeedDisplay() {
        const speedDisplay = controlsContainer.querySelector('.speed-display');
        if (speedDisplay) {
            // speedDisplay.textContent = `${playbackSpeed.toFixed(2)}x`; // display speed with always two decimals
            speedDisplay.textContent = `${playbackSpeed}x`; // raw speed value without formatting
        }
    }

    // create controls
    function createControlButtons() {
        if (controlsContainer && document.body.contains(controlsContainer)) return;

        controlsContainer = document.createElement('div');
        controlsContainer.classList.add('speed-control-container');

        const minusButton = document.createElement('button');
        minusButton.textContent = '-';
        minusButton.classList.add('speed-btn', 'minus');

        const speedDisplay = document.createElement('span');
        speedDisplay.classList.add('speed-display');
        // speedDisplay.textContent = `${playbackSpeed.toFixed(2)}x`; // display speed with always two decimals
        speedDisplay.textContent = `${playbackSpeed}x`; // raw speed value without formatting

        const plusButton = document.createElement('button');
        plusButton.textContent = '+';
        plusButton.classList.add('speed-btn', 'plus');

        function handleMinus() {
            ignoreRateChange = true;
            playbackSpeed = Math.max(MIN_SPEED, playbackSpeed - DELTA);
            lastUserRate = playbackSpeed;
            updateSpeedDisplay();
            setPlaybackSpeed();
        }

        function handlePlus() {
            ignoreRateChange = true;
            playbackSpeed = Math.min(MAX_SPEED, playbackSpeed + DELTA);
            lastUserRate = playbackSpeed;
            updateSpeedDisplay();
            setPlaybackSpeed();
        }

        function handleSpeedClick(e) {
            e.stopPropagation();
            if (!configPopup || !document.body.contains(configPopup)) {
                configPopup = createConfigPopup();
            }
            configPopup.classList.toggle('show');

            if (configPopup.classList.contains('show')) {
                const rect = e.target.getBoundingClientRect();
                configPopup.style.position = 'absolute';
                configPopup.style.bottom = `${window.innerHeight - rect.top + 10}px`;
                configPopup.style.left = `${rect.left + (rect.width / 2)}px`;
                configPopup.style.transform = 'translateX(-50%)';
            }
        }

        minusButton.addEventListener('click', handleMinus);
        plusButton.addEventListener('click', handlePlus);
        speedDisplay.addEventListener('click', handleSpeedClick);

        controlsContainer.appendChild(minusButton);
        controlsContainer.appendChild(speedDisplay);
        controlsContainer.appendChild(plusButton);

        const target = document.querySelector('div[style*="var(--vt-composer-system-hint-action)"]');
        if (target) target.insertAdjacentElement('beforebegin', controlsContainer);
        else if (document.querySelector('div[style*="var(--vt-composer-attach-file-action)"]')?.insertAdjacentElement('afterend', controlsContainer));
    }

    // handle cleanup
    function cleanup() {
        audioListeners.forEach((listeners, audio) => {
            audio.removeEventListener('play', listeners.playListener);
            audio.removeEventListener('ratechange', listeners.rateListener);
        });

        audioListeners.clear();
        if (observer) { observer.disconnect(); observer = null; }
        controlsContainer?.remove();
        configPopup?.remove();
    }

    // initialize everything when DOM is fully loaded
    function init() {
        // observer for new audio elements
        observer = new MutationObserver(mutations => {
            if (!document.body.contains(controlsContainer)) createControlButtons();

            let audioFound = false;
            for (const mutation of mutations) {
                if (mutation.addedNodes.length) {
                    const newAudioElements = mutation.target.querySelectorAll('audio');
                    if (newAudioElements.length > 0) {
                        audioFound = true;
                        break;
                    }
                }
            }

            if (audioFound) setPlaybackSpeed();
        });

        if (document.body) {
            observer.observe(document.body, { childList: true, subtree: true });

            // initiate the script
            initializeSpeed();
            createControlButtons();
        }
    }

    // wait for document to be ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else init();

    // cleanup when page unloads
    window.addEventListener('unload', cleanup);
})();