Skip to content

Instantly share code, notes, and snippets.

@1d10t
Last active October 11, 2025 08:38
Show Gist options
  • Select an option

  • Save 1d10t/2fe98bd222d49009f214a00de4ea680b to your computer and use it in GitHub Desktop.

Select an option

Save 1d10t/2fe98bd222d49009f214a00de4ea680b to your computer and use it in GitHub Desktop.
Text-to-Speech для выделенного текста (с чанками)
// ==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