|
@@ -489,10 +489,17 @@
|
|
|
background: #f8fafc;
|
|
background: #f8fafc;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /* 音频播放器 */
|
|
|
|
|
- #audio-player {
|
|
|
|
|
- display: none; /* 隐藏默认音频控件,改用自定义按钮 */
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ /* 音频播放器 */
|
|
|
|
|
+ #audio-player {
|
|
|
|
|
+ display: none; /* 隐藏默认音频控件,改用自定义按钮 */
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .tts-progress-highlight {
|
|
|
|
|
+ --highlight-bg-color: rgba(125, 190, 255, 0.16);
|
|
|
|
|
+ --highlight-selected-bg-color: rgba(125, 190, 255, 0.16);
|
|
|
|
|
+ background-color: rgba(125, 190, 255, 0.16) !important;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
/* 目录分页样式 */
|
|
/* 目录分页样式 */
|
|
|
#pdfList {
|
|
#pdfList {
|
|
@@ -2097,10 +2104,13 @@
|
|
|
// 语音控制
|
|
// 语音控制
|
|
|
let isListening = false;
|
|
let isListening = false;
|
|
|
let audioContext = null;
|
|
let audioContext = null;
|
|
|
- let isPaused = false;
|
|
|
|
|
- let currentAudio = null;
|
|
|
|
|
- let activePlayController = null;
|
|
|
|
|
- let activeStreamReader = null;
|
|
|
|
|
|
|
+ let isPaused = false;
|
|
|
|
|
+ let currentAudio = null;
|
|
|
|
|
+ let activePlayController = null;
|
|
|
|
|
+ let activeStreamReader = null;
|
|
|
|
|
+ let playbackHighlightContext = null;
|
|
|
|
|
+ let activeHighlightNodes = [];
|
|
|
|
|
+ let activeHighlightFrame = null;
|
|
|
|
|
|
|
|
// 初始化 AudioContext
|
|
// 初始化 AudioContext
|
|
|
function initAudioContext() {
|
|
function initAudioContext() {
|
|
@@ -2179,9 +2189,11 @@
|
|
|
pausePlayButton.classList.remove('is-active');
|
|
pausePlayButton.classList.remove('is-active');
|
|
|
pausePlayButton.title = '暂停播放';
|
|
pausePlayButton.title = '暂停播放';
|
|
|
pausePlayButton.setAttribute('aria-label', '暂停播放');
|
|
pausePlayButton.setAttribute('aria-label', '暂停播放');
|
|
|
- if (showTip) {
|
|
|
|
|
- const loadingIndicator = document.getElementById('loading-indicator');
|
|
|
|
|
- loadingIndicator.textContent = '已停止';
|
|
|
|
|
|
|
+ clearActiveTtsHighlight();
|
|
|
|
|
+ playbackHighlightContext = null;
|
|
|
|
|
+ if (showTip) {
|
|
|
|
|
+ const loadingIndicator = document.getElementById('loading-indicator');
|
|
|
|
|
+ loadingIndicator.textContent = '已停止';
|
|
|
loadingIndicator.style.display = 'block';
|
|
loadingIndicator.style.display = 'block';
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
|
loadingIndicator.style.display = 'none';
|
|
loadingIndicator.style.display = 'none';
|
|
@@ -2208,9 +2220,9 @@
|
|
|
return count;
|
|
return count;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function findNthOccurrence(haystack, needle, n) {
|
|
|
|
|
- if (!haystack || !needle || !n || n < 1) return -1;
|
|
|
|
|
- let pos = 0;
|
|
|
|
|
|
|
+ function findNthOccurrence(haystack, needle, n) {
|
|
|
|
|
+ if (!haystack || !needle || !n || n < 1) return -1;
|
|
|
|
|
+ let pos = 0;
|
|
|
let seen = 0;
|
|
let seen = 0;
|
|
|
const source = haystack.toLowerCase();
|
|
const source = haystack.toLowerCase();
|
|
|
const target = needle.toLowerCase();
|
|
const target = needle.toLowerCase();
|
|
@@ -2219,9 +2231,202 @@
|
|
|
if (idx === -1) return -1;
|
|
if (idx === -1) return -1;
|
|
|
seen++;
|
|
seen++;
|
|
|
if (seen === n) return idx;
|
|
if (seen === n) return idx;
|
|
|
- pos = idx + target.length;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ pos = idx + target.length;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function normalizeForTtsMatch(text) {
|
|
|
|
|
+ return (text || '')
|
|
|
|
|
+ .replace(/\s+/g, ' ')
|
|
|
|
|
+ .replace(/([A-Za-z0-9])\s*\.\s*([A-Za-z0-9])/g, '$1.$2')
|
|
|
|
|
+ .trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function splitTextForHighlight(text) {
|
|
|
|
|
+ const normalized = normalizeForTtsMatch(text);
|
|
|
|
|
+ if (!normalized) return [];
|
|
|
|
|
+ return normalized
|
|
|
|
|
+ .split(/(?<=[。!?!?;;…\.])\s+/)
|
|
|
|
|
+ .map(item => item.trim())
|
|
|
|
|
+ .filter(Boolean);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function clearActiveTtsHighlight() {
|
|
|
|
|
+ if (activeHighlightFrame) {
|
|
|
|
|
+ cancelAnimationFrame(activeHighlightFrame);
|
|
|
|
|
+ activeHighlightFrame = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const node of activeHighlightNodes) {
|
|
|
|
|
+ node.remove();
|
|
|
|
|
+ }
|
|
|
|
|
+ activeHighlightNodes = [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getCurrentPageTextLayer() {
|
|
|
|
|
+ const { pdfViewer } = PDFViewerApplication || {};
|
|
|
|
|
+ const currentPageNumber = pdfViewer?.currentPageNumber;
|
|
|
|
|
+ if (!currentPageNumber) return null;
|
|
|
|
|
+ return document.querySelector(`.page[data-page-number="${currentPageNumber}"] .textLayer`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function buildTextLayerIndex(textLayer) {
|
|
|
|
|
+ if (!textLayer) return null;
|
|
|
|
|
+ const spans = Array.from(textLayer.querySelectorAll('span')).filter(span => {
|
|
|
|
|
+ return !span.classList.contains('endOfContent') && span.textContent && span.textContent.trim();
|
|
|
|
|
+ });
|
|
|
|
|
+ if (spans.length === 0) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const entries = [];
|
|
|
|
|
+ let fullText = '';
|
|
|
|
|
+ for (const span of spans) {
|
|
|
|
|
+ const spanText = span.textContent || '';
|
|
|
|
|
+ const normalizedText = normalizeForTtsMatch(spanText);
|
|
|
|
|
+ if (!normalizedText) continue;
|
|
|
|
|
+ if (fullText && !/\s$/.test(fullText)) {
|
|
|
|
|
+ fullText += ' ';
|
|
|
|
|
+ }
|
|
|
|
|
+ const start = fullText.length;
|
|
|
|
|
+ fullText += normalizedText;
|
|
|
|
|
+ entries.push({
|
|
|
|
|
+ span,
|
|
|
|
|
+ rawText: spanText,
|
|
|
|
|
+ normalizedText,
|
|
|
|
|
+ start,
|
|
|
|
|
+ end: fullText.length
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!entries.length || !fullText) return null;
|
|
|
|
|
+ return { textLayer, text: fullText, entries };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function buildPlaybackHighlightContext(fullText) {
|
|
|
|
|
+ const textLayer = getCurrentPageTextLayer();
|
|
|
|
|
+ const textLayerIndex = buildTextLayerIndex(textLayer);
|
|
|
|
|
+ if (!textLayerIndex) return null;
|
|
|
|
|
+ return {
|
|
|
|
|
+ textLayer,
|
|
|
|
|
+ textLayerIndex,
|
|
|
|
|
+ sentences: splitTextForHighlight(fullText),
|
|
|
|
|
+ searchOffset: 0,
|
|
|
|
|
+ lastWindowKey: '',
|
|
|
|
|
+ currentRange: null
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function updateHighlightWindow(rangeStart, rangeEnd) {
|
|
|
|
|
+ const ctx = playbackHighlightContext;
|
|
|
|
|
+ if (!ctx?.textLayerIndex) return;
|
|
|
|
|
+ const layerText = ctx.textLayerIndex.text;
|
|
|
|
|
+ const safeStart = Math.max(0, Math.min(rangeStart, layerText.length));
|
|
|
|
|
+ const safeEnd = Math.max(safeStart, Math.min(rangeEnd, layerText.length));
|
|
|
|
|
+ const windowKey = `${safeStart}:${safeEnd}`;
|
|
|
|
|
+ if (ctx.lastWindowKey === windowKey) return;
|
|
|
|
|
+ ctx.lastWindowKey = windowKey;
|
|
|
|
|
+
|
|
|
|
|
+ for (const node of activeHighlightNodes) {
|
|
|
|
|
+ node.remove();
|
|
|
|
|
+ }
|
|
|
|
|
+ activeHighlightNodes = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const entry of ctx.textLayerIndex.entries) {
|
|
|
|
|
+ if (entry.end <= safeStart) continue;
|
|
|
|
|
+ if (entry.start >= safeEnd) break;
|
|
|
|
|
+
|
|
|
|
|
+ const pageRect = ctx.textLayer.getBoundingClientRect();
|
|
|
|
|
+ const spanRect = entry.span.getBoundingClientRect();
|
|
|
|
|
+ const overlapStart = Math.max(safeStart, entry.start);
|
|
|
|
|
+ const overlapEnd = Math.min(safeEnd, entry.end);
|
|
|
|
|
+ if (overlapEnd <= overlapStart) continue;
|
|
|
|
|
+
|
|
|
|
|
+ const entryLength = Math.max(1, entry.end - entry.start);
|
|
|
|
|
+ const startRatio = (overlapStart - entry.start) / entryLength;
|
|
|
|
|
+ const endRatio = (overlapEnd - entry.start) / entryLength;
|
|
|
|
|
+ const clippedLeft = spanRect.width * startRatio;
|
|
|
|
|
+ const clippedWidth = Math.max(2, spanRect.width * (endRatio - startRatio));
|
|
|
|
|
+ const left = spanRect.left - pageRect.left + clippedLeft;
|
|
|
|
|
+ const top = spanRect.top - pageRect.top;
|
|
|
|
|
+ const width = Math.min(spanRect.width - clippedLeft, clippedWidth);
|
|
|
|
|
+ const height = spanRect.height;
|
|
|
|
|
+ if (width <= 0 || height <= 0) continue;
|
|
|
|
|
+
|
|
|
|
|
+ const overlay = document.createElement('div');
|
|
|
|
|
+ overlay.className = 'highlight appended tts-progress-highlight';
|
|
|
|
|
+ overlay.style.left = `${left}px`;
|
|
|
|
|
+ overlay.style.top = `${top}px`;
|
|
|
|
|
+ overlay.style.width = `${width}px`;
|
|
|
|
|
+ overlay.style.height = `${height}px`;
|
|
|
|
|
+ overlay.style.position = 'absolute';
|
|
|
|
|
+ ctx.textLayer.appendChild(overlay);
|
|
|
|
|
+ activeHighlightNodes.push(overlay);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function highlightSentenceAtIndex(sentenceIndex, sentenceText = '', audio = null) {
|
|
|
|
|
+ clearActiveTtsHighlight();
|
|
|
|
|
+ const ctx = playbackHighlightContext;
|
|
|
|
|
+ if (!ctx?.textLayerIndex) return;
|
|
|
|
|
+
|
|
|
|
|
+ const targetSentence = normalizeForTtsMatch(sentenceText || ctx.sentences?.[sentenceIndex] || '');
|
|
|
|
|
+ if (!targetSentence) return;
|
|
|
|
|
+
|
|
|
|
|
+ const layerText = ctx.textLayerIndex.text;
|
|
|
|
|
+ const startSearch = Math.max(0, ctx.searchOffset || 0);
|
|
|
|
|
+ let matchStart = layerText.indexOf(targetSentence, startSearch);
|
|
|
|
|
+ if (matchStart === -1) {
|
|
|
|
|
+ matchStart = layerText.indexOf(targetSentence);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (matchStart === -1) return;
|
|
|
|
|
+
|
|
|
|
|
+ const matchEnd = matchStart + targetSentence.length;
|
|
|
|
|
+ ctx.searchOffset = matchEnd;
|
|
|
|
|
+ ctx.currentRange = { start: matchStart, end: matchEnd };
|
|
|
|
|
+ ctx.lastWindowKey = '';
|
|
|
|
|
+
|
|
|
|
|
+ const words = targetSentence.match(/\S+/g) || [];
|
|
|
|
|
+ const totalWords = words.length;
|
|
|
|
|
+ const windowWordCount = Math.min(5, Math.max(1, totalWords));
|
|
|
|
|
+ const wordRanges = [];
|
|
|
|
|
+ let searchFrom = matchStart;
|
|
|
|
|
+ for (const word of words) {
|
|
|
|
|
+ const wordStart = layerText.indexOf(word, searchFrom);
|
|
|
|
|
+ if (wordStart === -1) continue;
|
|
|
|
|
+ const wordEnd = wordStart + word.length;
|
|
|
|
|
+ wordRanges.push({ start: wordStart, end: wordEnd });
|
|
|
|
|
+ searchFrom = wordEnd;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (wordRanges.length === 0) {
|
|
|
|
|
+ updateHighlightWindow(matchStart, Math.min(matchEnd, matchStart + 24));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const applyProgressWindow = () => {
|
|
|
|
|
+ if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) {
|
|
|
|
|
+ updateHighlightWindow(wordRanges[0].start, wordRanges[Math.min(windowWordCount - 1, wordRanges.length - 1)].end);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const progress = Math.min(1, Math.max(0, audio.currentTime / audio.duration));
|
|
|
|
|
+ const maxOffset = Math.max(0, wordRanges.length - windowWordCount);
|
|
|
|
|
+ const offset = Math.min(maxOffset, Math.round(progress * maxOffset));
|
|
|
|
|
+ const startWord = wordRanges[offset];
|
|
|
|
|
+ const endWord = wordRanges[Math.min(wordRanges.length - 1, offset + windowWordCount - 1)];
|
|
|
|
|
+ updateHighlightWindow(startWord.start, endWord.end);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ applyProgressWindow();
|
|
|
|
|
+ const runHighlightFrame = () => {
|
|
|
|
|
+ if (!audio || audio.ended) {
|
|
|
|
|
+ activeHighlightFrame = null;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!audio.paused) {
|
|
|
|
|
+ applyProgressWindow();
|
|
|
|
|
+ }
|
|
|
|
|
+ activeHighlightFrame = requestAnimationFrame(runHighlightFrame);
|
|
|
|
|
+ };
|
|
|
|
|
+ activeHighlightFrame = requestAnimationFrame(runHighlightFrame);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// 仅识别 PDF 页面文字层里的选择,并记录是该页第几次出现
|
|
// 仅识别 PDF 页面文字层里的选择,并记录是该页第几次出现
|
|
|
function getSelectedContextOnPage() {
|
|
function getSelectedContextOnPage() {
|
|
@@ -2294,6 +2499,7 @@
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
if (signal.aborted) break;
|
|
if (signal.aborted) break;
|
|
|
|
|
+ highlightSentenceAtIndex(chunk.index, chunk.text || chunk.sentence || chunk.chunk || '', audio);
|
|
|
loadingIndicator.textContent = `正在播放第 ${chunk.index + 1} 句`;
|
|
loadingIndicator.textContent = `正在播放第 ${chunk.index + 1} 句`;
|
|
|
loadingIndicator.style.display = 'block';
|
|
loadingIndicator.style.display = 'block';
|
|
|
await playAudio(audio);
|
|
await playAudio(audio);
|
|
@@ -2333,6 +2539,7 @@
|
|
|
currentAudio.load();
|
|
currentAudio.load();
|
|
|
currentAudio = null;
|
|
currentAudio = null;
|
|
|
}
|
|
}
|
|
|
|
|
+ clearActiveTtsHighlight();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2383,13 +2590,14 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 开始转换(同域代理,保持流式逐句播放)
|
|
// 开始转换(同域代理,保持流式逐句播放)
|
|
|
- async function startConversion(fullText) {
|
|
|
|
|
- const loadingIndicator = document.getElementById('loading-indicator');
|
|
|
|
|
- try {
|
|
|
|
|
- stopCurrentPlayback(false);
|
|
|
|
|
- const response = await fetch('/generate', {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
|
+ async function startConversion(fullText) {
|
|
|
|
|
+ const loadingIndicator = document.getElementById('loading-indicator');
|
|
|
|
|
+ try {
|
|
|
|
|
+ stopCurrentPlayback(false);
|
|
|
|
|
+ playbackHighlightContext = buildPlaybackHighlightContext(fullText);
|
|
|
|
|
+ const response = await fetch('/generate', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ user_input: fullText, voice: "af_heart", speed: 1.0 })
|
|
body: JSON.stringify({ user_input: fullText, voice: "af_heart", speed: 1.0 })
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -2432,16 +2640,20 @@
|
|
|
insertInOrder(audioQueue, createAudioChunk(data));
|
|
insertInOrder(audioQueue, createAudioChunk(data));
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- audioQueue.push(null);
|
|
|
|
|
- await playPromise;
|
|
|
|
|
- activePlayController = null;
|
|
|
|
|
- activeStreamReader = null;
|
|
|
|
|
-
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- activePlayController = null;
|
|
|
|
|
- activeStreamReader = null;
|
|
|
|
|
- if (error.name === 'AbortError') {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ audioQueue.push(null);
|
|
|
|
|
+ await playPromise;
|
|
|
|
|
+ clearActiveTtsHighlight();
|
|
|
|
|
+ activePlayController = null;
|
|
|
|
|
+ activeStreamReader = null;
|
|
|
|
|
+ playbackHighlightContext = null;
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ clearActiveTtsHighlight();
|
|
|
|
|
+ playbackHighlightContext = null;
|
|
|
|
|
+ activePlayController = null;
|
|
|
|
|
+ activeStreamReader = null;
|
|
|
|
|
+ if (error.name === 'AbortError') {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
console.error(error);
|
|
console.error(error);
|