YouTube字幕文本转语音TTS(适用于沉浸式翻译)

将YouTube上的沉浸式翻译中文字幕转换为语音播放,支持更改音色和调整语音速度

目前為 2024-11-29 提交的版本,檢視 最新版本

// ==UserScript==
// @name         YouTube字幕文本转语音TTS(适用于沉浸式翻译)
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  将YouTube上的沉浸式翻译中文字幕转换为语音播放,支持更改音色和调整语音速度
// @author       Sean2333
// @match        https://www.youtube.com/*
// @grant        none
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    let lastCaptionText = '';
    const synth = window.speechSynthesis;
    let selectedVoice = null;
    let pendingText = null; // 存储等待朗读的文本
    let isWaitingToSpeak = false; // 是否正在等待朗读
    let voiceSelectUI = null;
    let isDragging = false;
    let startX;
    let startY;
    let followVideoSpeed = true; // 是否跟随视频倍速
    let customSpeed = 1.0; // 自定义倍速值
    let isSpeechEnabled = true; // 控制语音播放的开关状态

    function loadVoices() {
        return new Promise(function(resolve) {
            let voices = synth.getVoices();
            if (voices.length !== 0) {
                console.log('成功加载语音列表,共', voices.length, '个语音');
                resolve(voices);
            } else {
                console.log('等待语音列表加载...');
                synth.onvoiceschanged = function() {
                    voices = synth.getVoices();
                    console.log('语音列表加载完成,共', voices.length, '个语音');
                    resolve(voices);
                };

                // 添加超时重试机制
                setTimeout(() => {
                    if (voices.length === 0) {
                        voices = synth.getVoices();
                        if (voices.length > 0) {
                            console.log('通过重试加载到语音列表,共', voices.length, '个语音');
                            resolve(voices);
                        }
                    }
                }, 1000);
            }
        });
    }

    function createVoiceSelectUI() {
        const container = document.createElement('div');
        container.className = 'voice-select-container';
        Object.assign(container.style, {
            position: 'fixed',
            top: '10px',
            right: '10px',
            background: 'rgba(255, 255, 255, 0.9)',
            padding: '10px',
            border: '1px solid rgba(221, 221, 221, 0.8)',
            borderRadius: '5px',
            zIndex: '9999',
            boxShadow: '0 2px 5px rgba(0, 0, 0, 0.15)',
            userSelect: 'none',
            transition: 'all 0.2s'
        });

        // 添加鼠标悬停效果
        container.addEventListener('mouseenter', () => {
            container.style.background = 'rgba(255, 255, 255, 0.95)';
            container.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)';
        });

        container.addEventListener('mouseleave', () => {
            container.style.background = 'rgba(255, 255, 255, 0.9)';
            container.style.boxShadow = '0 2px 5px rgba(0, 0, 0, 0.15)';
        });

        // 标题栏
        const titleBar = document.createElement('div');
        titleBar.className = 'title-bar';
        Object.assign(titleBar.style, {
            padding: '5px',
            marginBottom: '10px',
            borderBottom: '1px solid #eee',
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            cursor: 'move'
        });

        const title = document.createElement('span');
        title.textContent = '语音设置';

        const toggleButton = document.createElement('button');
        toggleButton.textContent = '−';
        Object.assign(toggleButton.style, {
            border: 'none',
            background: 'none',
            cursor: 'pointer',
            fontSize: '16px',
            padding: '0 5px'
        });

        // 内容区域
        const content = document.createElement('div');

        // 创建语音开关控制区域
        const speechToggleDiv = document.createElement('div');
        Object.assign(speechToggleDiv.style, {
            marginBottom: '10px',
            borderBottom: '1px solid #eee',
            paddingBottom: '10px'
        });

        const speechToggleCheckbox = document.createElement('input');
        speechToggleCheckbox.type = 'checkbox';
        speechToggleCheckbox.checked = isSpeechEnabled;
        speechToggleCheckbox.id = 'speechToggleCheckbox';

        const speechToggleLabel = document.createElement('label');
        speechToggleLabel.textContent = '启用语音播放';
        speechToggleLabel.htmlFor = 'speechToggleCheckbox';
        Object.assign(speechToggleLabel.style, {
            marginLeft: '5px'
        });

        // 语音开关事件处理
        speechToggleCheckbox.onchange = function() {
            isSpeechEnabled = this.checked;
            // 更新其他控件的状态
            select.disabled = !isSpeechEnabled;
            testButton.disabled = !isSpeechEnabled;
            followSpeedCheckbox.disabled = !isSpeechEnabled;
            customSpeedSelect.disabled = !isSpeechEnabled || followVideoSpeed;

            if (!isSpeechEnabled) {
                // 如果关闭语音,取消当前播放并恢复视频播放
                if (synth.speaking) {
                    synth.cancel();
                }
                if (isWaitingToSpeak) {
                    const video = document.querySelector('video');
                    if (video && video.paused) {
                        video.play();
                    }
                    isWaitingToSpeak = false;
                }
                pendingText = null;
            }

            console.log('语音播放已' + (isSpeechEnabled ? '启用' : '禁用'));
        };

        // 组装语音开关UI
        speechToggleDiv.appendChild(speechToggleCheckbox);
        speechToggleDiv.appendChild(speechToggleLabel);

        // 将语音开关添加到内容区域最上方
        content.insertBefore(speechToggleDiv, content.firstChild);

        // 音色选择区域
        const voiceDiv = document.createElement('div');
        Object.assign(voiceDiv.style, {
            marginBottom: '10px'
        });

        const voiceLabel = document.createElement('div');
        voiceLabel.textContent = '选择音色:';
        Object.assign(voiceLabel.style, {
            marginBottom: '5px'
        });

        const select = document.createElement('select');
        Object.assign(select.style, {
            width: '100%',
            padding: '5px',
            marginBottom: '5px',
            borderRadius: '3px'
        });

        const testButton = document.createElement('button');
        testButton.textContent = '测试音色';
        Object.assign(testButton.style, {
            padding: '5px 10px',
            borderRadius: '3px',
            cursor: 'pointer',
            width: '100%'
        });

        // 倍速控制区域
        const speedControl = document.createElement('div');
        Object.assign(speedControl.style, {
            marginTop: '10px',
            borderTop: '1px solid #eee',
            paddingTop: '10px'
        });

        // 跟随视频倍速选项
        const followSpeedDiv = document.createElement('div');
        Object.assign(followSpeedDiv.style, {
            marginBottom: '8px'
        });

        const followSpeedCheckbox = document.createElement('input');
        followSpeedCheckbox.type = 'checkbox';
        followSpeedCheckbox.checked = followVideoSpeed;
        followSpeedCheckbox.id = 'followSpeedCheckbox';

        const followSpeedLabel = document.createElement('label');
        followSpeedLabel.textContent = '跟随视频倍速';
        followSpeedLabel.htmlFor = 'followSpeedCheckbox';
        Object.assign(followSpeedLabel.style, {
            marginLeft: '5px'
        });

        // 自定义倍速选项
        const customSpeedDiv = document.createElement('div');

        const customSpeedLabel = document.createElement('div');
        customSpeedLabel.textContent = '自定义倍速:';
        Object.assign(customSpeedLabel.style, {
            marginBottom: '5px'
        });

        const customSpeedSelect = document.createElement('select');
        const speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
        speedOptions.forEach(speed => {
            const option = document.createElement('option');
            option.value = speed;
            option.textContent = `${speed}x`;
            if (speed === customSpeed) option.selected = true;
            customSpeedSelect.appendChild(option);
        });

        Object.assign(customSpeedSelect.style, {
            width: '100%',
            padding: '5px',
            borderRadius: '3px'
        });

        // 事件处理
        followSpeedCheckbox.onchange = function() {
            followVideoSpeed = this.checked;
            customSpeedSelect.disabled = this.checked;
            console.log('语音速度模式:', followVideoSpeed ? '跟随视频' : '自定义');
        };

        customSpeedSelect.onchange = function() {
            customSpeed = parseFloat(this.value);
            console.log('自定义语音速度设置为:', customSpeed);
        };

        testButton.onclick = (e) => {
            e.stopPropagation();
            if (selectedVoice) {
                speakText('这是一个测试语音', false);
            }
        };

        // 初始化状态
        customSpeedSelect.disabled = followVideoSpeed;

        // 组装UI
        titleBar.appendChild(title);
        titleBar.appendChild(toggleButton);

        voiceDiv.appendChild(voiceLabel);
        voiceDiv.appendChild(select);
        voiceDiv.appendChild(testButton);

        followSpeedDiv.appendChild(followSpeedCheckbox);
        followSpeedDiv.appendChild(followSpeedLabel);

        customSpeedDiv.appendChild(customSpeedLabel);
        customSpeedDiv.appendChild(customSpeedSelect);

        speedControl.appendChild(followSpeedDiv);
        speedControl.appendChild(customSpeedDiv);

        content.appendChild(voiceDiv);
        content.appendChild(speedControl);

        container.appendChild(titleBar);
        container.appendChild(content);

        document.body.appendChild(container);

        // 折叠/展开功能
        let isCollapsed = false;
        toggleButton.onclick = (e) => {
            e.stopPropagation();
            isCollapsed = !isCollapsed;

            // 保存当前位置的 right 值
            const currentRight = container.style.right;

            if (isCollapsed) {
                // 保存内容区域的宽度,用于展开时恢复
                container.dataset.expandedWidth = container.offsetWidth + 'px';
                content.style.display = 'none';
                // 调整容器宽度为标题栏所需的最小宽度
                container.style.width = 'auto';
                container.style.minWidth = '100px';
            } else {
                content.style.display = 'block';
                // 恢复原来的宽度
                container.style.width = container.dataset.expandedWidth;
            }

            // 保持 right 值不变
            container.style.right = currentRight;
            toggleButton.textContent = isCollapsed ? '+' : '−';
        };

        // 添加拖动事件监听
        document.addEventListener('mousedown', dragStart);
        document.addEventListener('mousemove', drag);
        document.addEventListener('mouseup', dragEnd);
        document.addEventListener('mouseleave', dragEnd);

        return { container, select, content };
    }

    function dragStart(e) {
        if (e.target.closest('.title-bar')) {
            isDragging = true;
            const container = e.target.closest('.voice-select-container');

            const rect = container.getBoundingClientRect();
            startX = e.clientX - rect.left;
            startY = e.clientY - rect.top;

            container.style.transition = 'none';
        }
    }

    function dragEnd(e) {
        isDragging = false;
        const container = document.querySelector('.voice-select-container');
        if (container) {
            container.style.transition = 'all 0.2s';
        }
    }

    function drag(e) {
        if (isDragging) {
            e.preventDefault();
            const container = document.querySelector('.voice-select-container');
            if (container) {
                let newX = e.clientX - startX;
                let newY = e.clientY - startY;

                const maxX = window.innerWidth - container.offsetWidth;
                const maxY = window.innerHeight - container.offsetHeight;

                newX = Math.min(Math.max(0, newX), maxX);
                newY = Math.min(Math.max(0, newY), maxY);

                // 转换为 right 值
                container.style.right = `${window.innerWidth - newX - container.offsetWidth}px`;
                container.style.top = `${newY}px`;
                // 清除 left 属性
                container.style.left = '';
            }
        }
    }

    function selectVoice() {
        loadVoices().then(function(voices) {
            if (!voiceSelectUI) {
                voiceSelectUI = createVoiceSelectUI();
            }

            const select = voiceSelectUI.select;
            while (select.firstChild) {
                select.removeChild(select.firstChild);
            }

            const chineseVoices = voices.filter(voice =>
                voice.lang.includes('zh') || voice.name.toLowerCase().includes('chinese')
            );

            chineseVoices.forEach((voice, index) => {
                const option = document.createElement('option');
                option.value = index;
                option.textContent = `${voice.name} (${voice.lang})`;
                select.appendChild(option);
            });

            selectedVoice = chineseVoices.find(voice =>
                voice.name === 'Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)'
            ) || chineseVoices[0];

            const selectedIndex = chineseVoices.indexOf(selectedVoice);
            if (selectedIndex >= 0) {
                select.selectedIndex = selectedIndex;
            }

            select.onchange = function() {
                selectedVoice = chineseVoices[this.value];
                console.log('已切换语音到:', selectedVoice.name);
            };

            console.log('可用的中文语音数量:', chineseVoices.length);
            if (chineseVoices.length > 0) {
                console.log('第一个可用的中文语音:', chineseVoices[0].name);
            }
        });
    }

    function speakText(text, isNewCaption = false) {
        // 如果语音播放被禁用,直接返回
        if (!isSpeechEnabled) {
            return;
        }

        const video = document.querySelector('video');

        if (isNewCaption && synth.speaking) {
            console.log('新字幕出现,但当前语音未完成');
            pendingText = text;
            if (video && !video.paused) {
                video.pause();
                isWaitingToSpeak = true;
                console.log('视频已暂停,等待当前语音完成');
            }
            return;
        }

        // console.log('准备朗读文本:', text);
        if (synth.speaking) {
            console.log('正在停止当前语音播放');
            synth.cancel();
        }

        if (text) {
            const utterance = new SpeechSynthesisUtterance(text);
            utterance.lang = 'zh-CN';

            if (selectedVoice) {
                utterance.voice = selectedVoice;
            }

            // 设置语速
            if (followVideoSpeed && video) {
                utterance.rate = video.playbackRate;
                console.log('使用视频倍速:', utterance.rate);
            } else {
                utterance.rate = customSpeed;
                console.log('使用自定义倍速:', utterance.rate);
            }

            utterance.onend = () => {
                console.log('当前语音播放完成');

                if (pendingText) {
                    console.log('播放等待的文本');
                    const nextText = pendingText;
                    pendingText = null;
                    speakText(nextText);
                }
                else if (isWaitingToSpeak && video && video.paused) {
                    isWaitingToSpeak = false;
                    video.play();
                    console.log('所有语音播放完成,视频继续播放');
                }
            };

            utterance.onerror = () => {
                console.error('语音播放出错');
                if (isWaitingToSpeak && video && video.paused) {
                    isWaitingToSpeak = false;
                    video.play();
                    console.log('语音播放出错,视频继续播放');
                }
                pendingText = null;
            };

            synth.speak(utterance);
            console.log('开始朗读');
        } else {
            console.log('文本为空,跳过朗读');
        }
    }

    function getCaptionText() {
        const immersiveCaptionWindow = document.querySelector('#immersive-translate-caption-window');
        if (immersiveCaptionWindow && immersiveCaptionWindow.shadowRoot) {
            // 直接查找所有 target-cue 类的元素
            const targetCaptions = immersiveCaptionWindow.shadowRoot.querySelectorAll('.target-cue');
            let captionText = '';
            targetCaptions.forEach(span => {
                captionText += span.textContent + ' ';
            });
            captionText = captionText.trim();
            return captionText;
        }
        return '';
    }

    function setupCaptionObserver() {
        function waitForCaptionContainer() {
            const immersiveCaptionWindow = document.querySelector('#immersive-translate-caption-window');
            if (immersiveCaptionWindow && immersiveCaptionWindow.shadowRoot) {
                // 改为监听 shadowRoot 下的第一层 div
                const rootContainer = immersiveCaptionWindow.shadowRoot.querySelector('div');
                if (rootContainer) {
                    console.log('找到字幕根容器,开始监听变化');

                    const observer = new MutationObserver(() => {
                        // 获取当前文本
                        const currentText = getCaptionText();
                        if (currentText && currentText !== lastCaptionText) {
                            lastCaptionText = currentText;
                            speakText(currentText, true);
                        }
                    });

                    const config = {
                        childList: true,
                        subtree: true,
                        characterData: true  // 添加对文本内容变化的监听
                    };

                    observer.observe(rootContainer, config);

                    const initialText = getCaptionText();
                    if (initialText) {
                        lastCaptionText = initialText;
                        speakText(initialText, true);
                    }
                } else {
                    setTimeout(waitForCaptionContainer, 1000);
                }
            } else {
                setTimeout(waitForCaptionContainer, 1000);
            }
        }

        waitForCaptionContainer();
    }

    function cleanup() {
        document.removeEventListener('mousedown', dragStart);
        document.removeEventListener('mousemove', drag);
        document.removeEventListener('mouseup', dragEnd);
        document.removeEventListener('mouseleave', dragEnd);
    }

    window.addEventListener('load', function() {
        console.log('页面加载完成,开始初始化脚本');
        setTimeout(() => {
            selectVoice();
            setupCaptionObserver();
        }, 1000);
    });

    window.addEventListener('unload', cleanup);

    // 修改窗口大小变化处理
    window.addEventListener('resize', function() {
        const container = document.querySelector('.voice-select-container');
        if (container) {
            const rect = container.getBoundingClientRect();
            const maxY = window.innerHeight - container.offsetHeight;

            // 保持右侧距离不变,只调整 top 值
            let newY = Math.min(Math.max(0, rect.top), maxY);
            container.style.top = `${newY}px`;
        }
    });

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址