Last active
October 11, 2025 08:38
-
-
Save 1d10t/2fe98bd222d49009f214a00de4ea680b to your computer and use it in GitHub Desktop.
Text-to-Speech для выделенного текста (с чанками)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Text-to-Speech для выделенного текста (с чанками) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.1 | |
| // @description Озвучивает выделенный текст на русском языке, разбивая его на чанки не более 1500 символов | |
| // @author You | |
| // @match *://*/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // --- Константы --- | |
| const MAX_CHUNK_LENGTH = 1500; // Максимальная длина чанка | |
| const LINE_BREAK_SEPARATOR = /(\r?\n\s*){2,}/; // Разделитель по двойному переносу строки (абзацы) | |
| const SENTENCE_SEPARATOR = /([.!?]+)\s+/; // Разделитель по точкам, восклицательным и вопросительным знакам | |
| // --- Переменные состояния --- | |
| let selectedText = ''; | |
| let utterance = null; | |
| let speakingQueue = []; // Очередь чанков для воспроизведения | |
| let currentChunkIndex = 0; | |
| let isSpeaking = false; | |
| // --- Вспомогательные функции --- | |
| // Функция для обновления статуса | |
| function updateStatus(message) { | |
| const statusEl = document.getElementById('tts-status'); | |
| if (statusEl) statusEl.textContent = message; | |
| } | |
| // 1. Функция для разделения текста на чанки не более MAX_CHUNK_LENGTH | |
| function chunkText(text) { | |
| if (text.length <= MAX_CHUNK_LENGTH) { | |
| return [text]; | |
| } | |
| const chunks = []; | |
| let currentText = text.trim(); | |
| // Попытка разделения по абзацам или предложениям | |
| while (currentText.length > MAX_CHUNK_LENGTH) { | |
| let chunk = ''; | |
| let bestSplitPoint = -1; | |
| // Ищем точку разделения как можно ближе к MAX_CHUNK_LENGTH, но не позднее. | |
| // Приоритет: абзац > предложение | |
| // 1. Ищем последний двойной перенос строки (абзац) | |
| let lastLineBreak = currentText.substring(0, MAX_CHUNK_LENGTH).search(LINE_BREAK_SEPARATOR); | |
| if (lastLineBreak > 0) { | |
| // Ищем самое последнее совпадение, которое в пределах лимита. | |
| let match; | |
| let lastMatchIndex = -1; | |
| const regex = new RegExp(LINE_BREAK_SEPARATOR, 'g'); | |
| while ((match = regex.exec(currentText.substring(0, MAX_CHUNK_LENGTH))) !== null) { | |
| lastMatchIndex = match.index + match[0].length; | |
| } | |
| if (lastMatchIndex > 0) { | |
| bestSplitPoint = lastMatchIndex; | |
| } | |
| } | |
| // 2. Если абзац не найден или находится слишком далеко от конца, ищем точку/вопросительный/восклицательный знак (предложение) | |
| if (bestSplitPoint === -1) { | |
| let match; | |
| let lastMatchIndex = -1; | |
| const regex = new RegExp(SENTENCE_SEPARATOR, 'g'); | |
| while ((match = regex.exec(currentText.substring(0, MAX_CHUNK_LENGTH))) !== null) { | |
| // + match[0].length включает разделитель | |
| lastMatchIndex = match.index + match[0].length; | |
| } | |
| if (lastMatchIndex > 0) { | |
| bestSplitPoint = lastMatchIndex; | |
| } | |
| } | |
| // 3. Если не удалось найти хорошее место для разделения, делим "грубо" | |
| if (bestSplitPoint === -1) { | |
| bestSplitPoint = MAX_CHUNK_LENGTH; | |
| } | |
| chunk = currentText.substring(0, bestSplitPoint).trim(); | |
| currentText = currentText.substring(bestSplitPoint).trim(); | |
| if (chunk.length > 0) { | |
| chunks.push(chunk); | |
| } | |
| // Защита от бесконечного цикла (хотя теоретически не должно случиться) | |
| if (bestSplitPoint === 0) { | |
| console.error("Ошибка при разбиении текста: bestSplitPoint = 0"); | |
| break; | |
| } | |
| } | |
| // Добавляем оставшийся текст | |
| if (currentText.length > 0) { | |
| chunks.push(currentText); | |
| } | |
| return chunks; | |
| } | |
| // Функция для воспроизведения следующего чанка | |
| function playNextChunk() { | |
| if (!isSpeaking || currentChunkIndex >= speakingQueue.length) { | |
| // Очередь закончена или остановлена | |
| stopSpeaking(true); // true означает "чистое" завершение | |
| return; | |
| } | |
| const chunk = speakingQueue[currentChunkIndex]; | |
| const speakButton = document.getElementById('tts-speak-button'); | |
| const stopButton = document.getElementById('tts-stop-button'); | |
| const totalChunks = speakingQueue.length; | |
| // Останавливаем текущее воспроизведение, если оно есть | |
| if (speechSynthesis.speaking) { | |
| speechSynthesis.cancel(); | |
| } | |
| utterance = new SpeechSynthesisUtterance(chunk); | |
| utterance.lang = 'ru-RU'; | |
| utterance.pitch = 1; | |
| utterance.rate = 1; | |
| // Находим русский голос, если доступен (загрузка голосов может быть асинхронной) | |
| const voices = speechSynthesis.getVoices(); | |
| const russianVoice = voices.find(voice => voice.lang === 'ru-RU' || voice.lang.startsWith('ru')); | |
| if (russianVoice) { | |
| utterance.voice = russianVoice; | |
| } | |
| utterance.onstart = function() { | |
| speakButton.disabled = true; | |
| stopButton.disabled = false; | |
| updateStatus(`Воспроизведение... Чанк ${currentChunkIndex + 1} из ${totalChunks}`); | |
| }; | |
| utterance.onend = function() { | |
| currentChunkIndex++; | |
| playNextChunk(); // Рекурсивный вызов для следующего чанка | |
| }; | |
| utterance.onerror = function(event) { | |
| updateStatus(`Ошибка воспроизведения чанка ${currentChunkIndex + 1}: ${event.error}. Очередь остановлена.`); | |
| stopSpeaking(false); // false означает "ошибка", не чистое завершение | |
| }; | |
| speechSynthesis.speak(utterance); | |
| // Обновляем отображение текста на текущий чанк (по желанию, можно оставить полный) | |
| // document.getElementById('tts-text-display').value = chunk; | |
| } | |
| // 2. Основная функция начала озвучивания очереди | |
| function startSpeakingQueue() { | |
| if (!selectedText.trim()) { | |
| updateStatus('Нет текста для озвучивания'); | |
| return; | |
| } | |
| if (!('speechSynthesis' in window)) { | |
| updateStatus('Браузер не поддерживает синтез речи'); | |
| return; | |
| } | |
| // Перезапуск очереди | |
| stopSpeaking(false); | |
| speakingQueue = chunkText(selectedText); | |
| currentChunkIndex = 0; | |
| isSpeaking = true; | |
| if (speakingQueue.length === 0) { | |
| updateStatus('Текст пуст после обработки'); | |
| return; | |
| } | |
| updateStatus(`Подготовка к воспроизведению ${speakingQueue.length} чанков...`); | |
| // Начинаем воспроизведение первого чанка | |
| playNextChunk(); | |
| } | |
| // 3. Функция остановки воспроизведения | |
| function stopSpeaking(isFinished = false) { | |
| if ('speechSynthesis' in window) { | |
| speechSynthesis.cancel(); | |
| } | |
| isSpeaking = false; | |
| speakingQueue = []; | |
| currentChunkIndex = 0; | |
| const speakButton = document.getElementById('tts-speak-button'); | |
| const stopButton = document.getElementById('tts-stop-button'); | |
| if (speakButton) speakButton.disabled = false; | |
| if (stopButton) stopButton.disabled = true; | |
| if (!isFinished) { | |
| updateStatus('Остановлено'); | |
| } else { | |
| updateStatus('Воспроизведение завершено'); | |
| } | |
| } | |
| // --- Создание интерфейса и стилей (оставлено без изменений) --- | |
| // Создаем стили для нашего интерфейса | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| #tts-floating-panel { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: white; | |
| border: 2px solid #007cba; | |
| border-radius: 10px; | |
| padding: 15px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.3); | |
| z-index: 10000; | |
| min-width: 250px; | |
| font-family: Arial, sans-serif; | |
| } | |
| #tts-floating-panel h3 { | |
| margin: 0 0 10px 0; | |
| color: #007cba; | |
| font-size: 14px; | |
| } | |
| #tts-text-display { | |
| width: 100%; | |
| height: 60px; | |
| margin: 10px 0; | |
| padding: 8px; | |
| border: 1px solid #ddd; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| resize: vertical; | |
| } | |
| .tts-controls { | |
| display: flex; | |
| gap: 5px; | |
| margin-bottom: 10px; | |
| } | |
| .tts-button { | |
| padding: 8px 12px; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| flex: 1; | |
| } | |
| #tts-speak-button { | |
| background: #007cba; | |
| color: white; | |
| } | |
| #tts-stop-button { | |
| background: #dc3545; | |
| color: white; | |
| } | |
| .tts-button:disabled { | |
| background: #ccc; | |
| cursor: not-allowed; | |
| } | |
| #tts-close-button { | |
| background: #6c757d; | |
| color: white; | |
| width: 100%; | |
| } | |
| .tts-status { | |
| font-size: 11px; | |
| color: #666; | |
| text-align: center; | |
| margin-top: 5px; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| // Создаем плавающую панель | |
| const panel = document.createElement('div'); | |
| panel.id = 'tts-floating-panel'; | |
| panel.innerHTML = ` | |
| <h3>Озвучивание текста</h3> | |
| <textarea id="tts-text-display" placeholder="Выделите текст на странице и вызовите контекстное меню..."></textarea> | |
| <div class="tts-controls"> | |
| <button id="tts-speak-button" class="tts-button">Озвучить</button> | |
| <button id="tts-stop-button" class="tts-button" disabled>Остановить</button> | |
| </div> | |
| <button id="tts-close-button" class="tts-button">Закрыть</button> | |
| <div id="tts-status" class="tts-status"></div> | |
| `; | |
| panel.style.display = 'none'; | |
| document.body.appendChild(panel); | |
| // --- Обработчики событий (Обновлены) --- | |
| // Обработчики кнопок | |
| document.getElementById('tts-speak-button').addEventListener('click', startSpeakingQueue); | |
| document.getElementById('tts-stop-button').addEventListener('click', () => stopSpeaking(false)); | |
| document.getElementById('tts-close-button').addEventListener('click', function() { | |
| stopSpeaking(false); | |
| panel.style.display = 'none'; | |
| }); | |
| // Обработчик правой кнопки мыши для отображения панели | |
| document.addEventListener('mousedown', function(event) { | |
| if (event.button === 2) { // Правая кнопка мыши | |
| setTimeout(() => { | |
| const text = window.getSelection().toString().trim(); | |
| if (text) { | |
| selectedText = text; | |
| const textDisplay = document.getElementById('tts-text-display'); | |
| textDisplay.value = selectedText; | |
| panel.style.display = 'block'; | |
| updateStatus(`Выделено: ${selectedText.length} символов`); | |
| // Автоматически начинаем воспроизведение, если текст не слишком длинный (опционально) | |
| // if (selectedText.length <= 1500) { | |
| // setTimeout(startSpeakingQueue, 500); | |
| // } | |
| } | |
| }, 100); | |
| } | |
| }); | |
| // Горячая клавиша для быстрого доступа (Ctrl+Shift+S) | |
| document.addEventListener('keydown', function(event) { | |
| if (event.ctrlKey && event.shiftKey && event.key === 'S') { | |
| event.preventDefault(); | |
| selectedText = window.getSelection().toString().trim(); | |
| if (selectedText) { | |
| const textDisplay = document.getElementById('tts-text-display'); | |
| textDisplay.value = selectedText; | |
| panel.style.display = 'block'; | |
| updateStatus(`Выделено: ${selectedText.length} символов`); | |
| startSpeakingQueue(); // Начинаем озвучивание | |
| } | |
| } | |
| }); | |
| // Инициализация голосов при загрузке | |
| if ('speechSynthesis' in window) { | |
| speechSynthesis.onvoiceschanged = function() { | |
| updateStatus('Голоса TTS загружены'); | |
| }; | |
| } | |
| console.log('Text-to-Speech скрипт с поддержкой чанков загружен.'); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment