PageSourceSearch

http://vortaro.cn/js/ai-tts.js?v=20260509

js vortaro.cn collected 2026-10-06 13:15:13 UTC 11,757 bytes, 267 lines download raw bytes

1/**
2 * AI语音模块
3 * 文件: js/ai-tts.js
4 */
5(function() {
6    'use strict';
7    if (window._aiTtsLoaded) return;
8    window._aiTtsLoaded = true;
9
10    // ===== 播放器 =====
11    var aiAudioPlayer = document.createElement('audio');
12    aiAudioPlayer.id = 'aiAudioPlayer';
13    aiAudioPlayer.style.display = 'none';
14    document.body.appendChild(aiAudioPlayer);
15
16    var hiddenAudioPlayer = document.createElement('audio');
17    hiddenAudioPlayer.id = 'hiddenAudioPlayer';
18    hiddenAudioPlayer.style.display = 'none';
19    document.body.appendChild(hiddenAudioPlayer);
20
21    var currentAiButton = null;
22    var currentAbortController = null;
23
24    var API_URL = window.location.protocol + '//' + window.location.host + '/api/ai-tts-cached';
25
26    var PLAY_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTAiIGZpbGw9IiNmZmZmZmYiIHN0cm9rZT0iIzRjYWY4NCIgc3Ryb2tlLXdpZHRoPSIxLjUiLz48cGF0aCBkPSJNMTAgOHY4bDYuNzYtNHoiIGZpbGw9InVybCgjZ3JhZGllbnQpIi8+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkaWVudCIgeDE9IjAiIHkxPSIwIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCUiIHN0b3AtY29sb3I9IiM0Y2FmODQiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM4MWM3ODQiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=';
27    var AUDIO_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTAiIGZpbGw9IiNmZmZmZmYiIHN0cm9rZT0iI2Q0MGEwMCIgc3Ryb2tlLXdpZHRoPSIxLjUiLz48cGF0aCBkPSJNMTAgOHY4bDYuNzYtNHoiIGZpbGw9InVybCgjZ3JhZGllbnQpIi8+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkaWVudCIgeDE9IjAiIHkxPSIwIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCUiIHN0b3AtY29sb3I9IiNlZDM1MzUiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiNmZmNiMDciLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4=';
28
29    function cleanText(t){
30        if (!t) return '';
31        t = t.trim();
32        var matches = t.match(/[a-zA-ZĉĝĥĵŝŭĈĜĤĴŜŬ\s'-]+/g);
33        
34        if(matches && matches.length>0){
35            var longest='';
36            for(var i=0;i<matches.length;i++){
37                var s=matches[i].trim();
38                if(s.length>longest.length) longest=s;
39            }
40            var combined=matches.filter(function(m){return m.trim().length>0;}).join(' ').trim();
41            var result=combined.length>longest.length*1.5?combined:longest;
42            return result.replace(/[.,;:!?\s]+$/,'');
43        }
44        
45        return t;
46    }
47
48    // ===== 提取不含sup标签的文本 =====
49    function getTextWithoutSup(el) {
50        // 临时清空sup标签内容,获取文本后再恢复
51        var sups = el.querySelectorAll('sup');
52        var backup = [];
53        for (var i = 0; i < sups.length; i++) {
54            backup.push(sups[i].textContent);
55            sups[i].textContent = '';
56        }
57        var text = el.textContent;
58        // 恢复sup内容
59        for (var i = 0; i < sups.length; i++) {
60            sups[i].textContent = backup[i];
61        }
62        return text;
63    }
64
65    // ===== 全局函数:创建图标 =====
66    window.createAiPlayIcon = function() {
67        var icon = document.createElement('img');
68        icon.src = PLAY_ICON;
69        icon.width = 14; icon.height = 14;
70        icon.style.verticalAlign = 'middle';
71        icon.alt = '';
72        return icon;
73    };
74
75    window.createAudioPlayIcon = function() {
76        var icon = document.createElement('img');
77        icon.src = AUDIO_ICON;
78        icon.width = 18; icon.height = 18;
79        icon.style.verticalAlign = 'middle';
80        icon.alt = '';
81        return icon;
82    };
83
84    // ===== 全局函数:音频播放 =====
85    window.playAudioWithControls = function(audioUrl) {
86        var p = document.getElementById('hmrbPlayer');
87        var c = document.getElementById('audioPlayerContainer');
88        if (!p || !c) return;
89        p.src = audioUrl;
90        c.classList.remove('hidden');
91        p.controls = true;
92        p.play().catch(function() {});
93    };
94
95    window.playAudioWithoutControls = function(audioUrl) {
96        hiddenAudioPlayer.src = audioUrl;
97        hiddenAudioPlayer.play().catch(function() {});
98    };
99
100    window.closeAudioPlayer = function() {
101        var p = document.getElementById('hmrbPlayer');
102        var c = document.getElementById('audioPlayerContainer');
103        if (p) { p.pause(); p.src = ''; }
104        if (c) c.classList.add('hidden');
105    };
106
107    window.initAudioPlayer = function() {
108        if (!document.getElementById('audioPlayerContainer')) {
109            var c = document.createElement('div');
110            c.id = 'audioPlayerContainer';
111            c.className = 'audio-player-container hidden';
112            c.innerHTML = '<div class="audio-player"><audio id="hmrbPlayer" controls></audio><button class="close-audio-btn" onclick="closeAudioPlayer()">&times;</button></div>';
113            document.body.appendChild(c);
114        }
115        document.addEventListener('click', function(e) {
116            var t = e.target;
117            if (t.tagName === 'A') {
118                var h = t.getAttribute('href');
119                if (h && h.toLowerCase().endsWith('.mp3')) {
120                    e.preventDefault();
121                    var w = document.getElementById('webBrowser');
122                    if (w && w.contains(t)) window.playAudioWithControls(h);
123                    else window.playAudioWithoutControls(h);
124                }
125            }
126        });
127    };
128
129    // ===== 全局函数:重置按钮 =====
130    window.resetAiButton = function() {
131        if (currentAbortController) {
132            try { currentAbortController.abort(); } catch(e) {}
133            currentAbortController = null;
134        }
135        if (currentAiButton) {
136            currentAiButton.classList.remove('playing', 'loading');
137            currentAiButton.title = 'SAI朗读例句';
138            currentAiButton = null;
139        }
140        aiAudioPlayer.onended = aiAudioPlayer.onerror = aiAudioPlayer.oncanplaythrough = null;
141        try { aiAudioPlayer.pause(); } catch(e) {}
142        if (aiAudioPlayer.src) {
143            URL.revokeObjectURL(aiAudioPlayer.src);
144            aiAudioPlayer.removeAttribute('src');
145        }
146    };
147
148    // ===== 全局函数:调用AI语音 =====
149    window.callAiTts = async function(text, button) {
150        var abortController = new AbortController();
151        currentAbortController = abortController;
152
153        try {
154            aiAudioPlayer.pause();
155            if (aiAudioPlayer.src) {
156                URL.revokeObjectURL(aiAudioPlayer.src);
157                aiAudioPlayer.removeAttribute('src');
158            }
159            if (currentAiButton && currentAiButton !== button) {
160                currentAiButton.classList.remove('playing', 'loading');
161                currentAiButton.title = 'SAI朗读例句';
162            }
163            currentAiButton = button;
164            button.classList.add('loading');
165            button.title = '正在获取SAI语音...';
166
167            var clean = cleanText(text);
168            var response = await fetch(API_URL, {
169                method: 'POST',
170                headers: { 'Content-Type': 'application/json' },
171                body: JSON.stringify({ text: clean }),
172                signal: abortController.signal
173            });
174
175            if (!response.ok) {
176                var em = 'SAI语音服务暂时不可用';
177                try { var d = await response.json(); em = d.error || em; } catch(e) {}
178                throw new Error(em);
179            }
180
181            var blob = await response.blob();
182            if (!blob.size) throw new Error('空文件');
183            if (abortController.signal.aborted) return;
184
185            if (aiAudioPlayer.src) { URL.revokeObjectURL(aiAudioPlayer.src); aiAudioPlayer.removeAttribute('src'); }
186            var url = URL.createObjectURL(blob);
187            aiAudioPlayer.src = url;
188
189            await new Promise(function(resolve, reject) {
190                var t = setTimeout(function() { reject(new Error('加载超时')); }, 15000);
191                aiAudioPlayer.oncanplaythrough = function() { clearTimeout(t); resolve(); };
192                aiAudioPlayer.onerror = function() { clearTimeout(t); reject(new Error('加载失败')); };
193            });
194
195            if (abortController.signal.aborted) { URL.revokeObjectURL(url); return; }
196
197            button.classList.remove('loading');
198            button.classList.add('playing');
199            button.title = '正在播放...';
200
201            await aiAudioPlayer.play();
202            await new Promise(function(resolve) {
203                aiAudioPlayer.onended = function() { aiAudioPlayer.onended = aiAudioPlayer.onerror = null; resolve(); };
204                aiAudioPlayer.onerror = function() { aiAudioPlayer.onerror = aiAudioPlayer.onended = null; resolve(); };
205            });
206
207            button.classList.remove('playing', 'loading');
208            button.title = 'SAI朗读例句';
209
210        } catch (e) {
211            if (e.name === 'AbortError') return;
212            if (button) {
213                button.classList.remove('playing', 'loading');
214                button.title = 'SAI朗读例句(点击重试)';
215            }
216        } finally {
217            if (aiAudioPlayer.src) { URL.revokeObjectURL(aiAudioPlayer.src); aiAudioPlayer.removeAttribute('src'); }
218            currentAbortController = null;
219            if (currentAiButton === button) currentAiButton = null;
220        }
221    };
222
223    // ===== 全局函数:添加AI按钮 =====
224    window.addAiSpeakButtons = function() {
225        var wb = document.getElementById('webBrowser');
226        if (!wb) return;
227        var ckz = wb.querySelectorAll('strong.ckz');
228        ckz.forEach(function(c) {
229            var n = c.nextElementSibling;
230            if (n && n.classList.contains('ai-speak-btn')) return;
231            
232            // 获取不含sup标签的文本
233            var rawText = getTextWithoutSup(c);
234            var t = cleanText(rawText);
235            if (!t || t.length < 2) return;
236            
237            var b = document.createElement('span');
238            b.className = 'ai-speak-btn';
239            b.title = 'SAI朗读例句';
240            b.setAttribute('data-ckz-text', t);
241            b.appendChild(window.createAiPlayIcon());
242            b.onclick = function(e) {
243                e.preventDefault(); e.stopPropagation();
244                var s = this;
245                if (s.classList.contains('playing')) {
246                    aiAudioPlayer.pause();
247                    if (aiAudioPlayer.src) { URL.revokeObjectURL(aiAudioPlayer.src); aiAudioPlayer.removeAttribute('src'); }
248                    window.resetAiButton();
249                    return;
250                }
251                if (s.classList.contains('loading')) {
252                    if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
253                    window.resetAiButton();
254                    return;
255                }
256                aiAudioPlayer.pause();
257                if (aiAudioPlayer.src) { URL.revokeObjectURL(aiAudioPlayer.src); aiAudioPlayer.removeAttribute('src'); }
258                document.querySelectorAll('.ai-speak-btn.playing, .ai-speak-btn.loading').forEach(function(x) {
259                    x.classList.remove('playing', 'loading'); x.title = 'SAI朗读例句';
260                });
261                window.callAiTts(s.getAttribute('data-ckz-text'), s);
262            };
263            c.insertAdjacentElement('afterend', b);
264        });
265    };
266
267})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.