PageSourceSearch

https://jreadability.net/jwriter/js/play_sound.js?v=0.9.8f

js jreadability.net collected 2026-09-30 22:43:05 UTC 15,050 bytes, 482 lines download raw bytes

1/**
2 * jWriter Text-to-Speech機能
3 * Chrome, Edge, Safari対応版
4 *
5 * デバッグモード: TTS_DEBUG_MODE = true; をブラウザコンソールで実行すると有効化
6 * Debug mode: Run TTS_DEBUG_MODE = true; in browser console to enable
7 */
8
9// デバッグモード設定(デバッグが必要な場合はtrueに設定)
10var TTS_DEBUG_MODE = false;
11
12// デバッグログ関数
13function ttsLog(message) {
14  if (TTS_DEBUG_MODE) {
15    console.log(message);
16  }
17}
18
19// グローバル変数
20var ifSpeakable = false;
21var isSpeaking = false;
22// ユーザによる停止フラグ(cancel時のエラーハンドリング抑制用)
23var userCancelled = false;
24var ttsVoices = [];
25var selectedVoice = null;
26var currentUtterance = null;
27
28// ブラウザ判定
29var browserInfo = {
30  isChrome: /Chrome/.test(navigator.userAgent) && !/Edge|Edg/.test(navigator.userAgent),
31  isEdge: /Edge|Edg/.test(navigator.userAgent),
32  isSafari: /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent),
33  isFirefox: /Firefox/.test(navigator.userAgent)
34};
35
36// 初期化フラグ
37var ttsInitialized = false;
38// リモート音声警告表示フラグ
39var remoteVoiceWarningShown = false;
40
41/**
42 * TTS初期化
43 */
44function initTTS() {
45  ttsLog('[TTS] Initializing TTS system...');
46  ttsLog('[TTS] Browser: ' + JSON.stringify(browserInfo));
47
48  // Speech Synthesis API の可用性チェック
49  if (!window.speechSynthesis) {
50    console.error('[TTS] Speech Synthesis API not supported');
51    return false;
52  }
53
54  ifSpeakable = true;
55
56  // 音声リストの読み込み
57  loadVoices();
58
59  // 音声リスト変更イベント
60  if (window.speechSynthesis.onvoiceschanged !== undefined) {
61    window.speechSynthesis.onvoiceschanged = loadVoices;
62  }
63
64  ttsLog('[TTS] TTS system initialized');
65  return true;
66}
67
68/**
69 * 音声リストの読み込み
70 */
71function loadVoices() {
72  ttsVoices = window.speechSynthesis.getVoices();
73  ttsLog('[TTS] Total voices loaded: ' + ttsVoices.length);
74
75  if (ttsVoices.length > 0) {
76    selectBestVoice();
77    ttsInitialized = true;
78    // 日本語音声リストをUIに反映
79    populateVoiceList();
80  }
81}
82
83/**
84 * 最適な日本語音声を選択
85 */
86function selectBestVoice() {
87  // 日本語音声をフィルタリング
88  var japaneseVoices = ttsVoices.filter(function(voice) {
89    return voice.lang === 'ja-JP' || voice.lang.startsWith('ja');
90  });
91  
92  if (japaneseVoices.length === 0) {
93    console.warn('[TTS] No Japanese voices found');
94    selectedVoice = ttsVoices.length > 0 ? ttsVoices[0] : null;
95    return;
96  }
97  
98  // ブラウザ別の最適な音声選択
99  if (browserInfo.isChrome) {
100    // Chrome: OSローカルの日本語音声を優先
101    var localVoices = japaneseVoices.filter(function(voice) {
102      return voice.localService;
103    });
104    selectedVoice = localVoices.length > 0 ? localVoices[0] : japaneseVoices[0];
105  } else if (browserInfo.isEdge) {
106    // Edge: Microsoft音声を優先
107    selectedVoice = japaneseVoices.find(function(voice) {
108      return voice.name.includes('Nanami') || voice.name.includes('Ayumi');
109    }) || japaneseVoices[0];
110  } else if (browserInfo.isSafari) {
111    // Safari: Kyoko音声を優先
112    selectedVoice = japaneseVoices.find(function(voice) {
113      return voice.name.includes('Kyoko');
114    }) || japaneseVoices[0];
115  } else {
116    // その他: 最初の日本語音声
117    selectedVoice = japaneseVoices[0];
118  }
119  
120  ttsLog('[TTS] Selected voice: ' + (selectedVoice ? selectedVoice.name : 'none'));
121
122  // 利用可能な日本語音声をログ出力
123  if (TTS_DEBUG_MODE) {
124    console.log('[TTS] Available Japanese voices:');
125    japaneseVoices.forEach(function(voice, index) {
126      console.log('  [' + index + '] ' + voice.name + ' (' + voice.lang + ')');
127    });
128  }
129}
130/**
131 * 利用可能な日本語音声の一覧をUIに追加
132 */
133function populateVoiceList() {
134  if (typeof $ === 'undefined') return;
135  var select = $('#voice_select');
136  select.empty();
137  ttsVoices.forEach(function(voice, index) {
138    if (voice.lang === 'ja-JP' || voice.lang.startsWith('ja')) {
139      // 音声名から不要な言語情報を削除: (ja-JP)/(Japan)/(Japanese/(Japan))/(日本語(日本))
140      var label = voice.name.replace(/\s*\((?:(?:Japanese(?: \(Japan\))?)|Japan|ja-JP|日本語(?:(日本))?)\)\s*/g, ' ');
141      label = label.replace(/\s*-\s*Japanese/g, '');
142      label = label.replace(/\s{2,}/g, ' ').trim();
143      // Local/Remote情報を付加
144      label += voice.localService ? ' (Local)' : ' (Remote)';
145      select.append($('<option>').val(index).text(label));
146    }
147  });
148  var selIndex = ttsVoices.indexOf(selectedVoice);
149  if (selIndex >= 0) select.val(selIndex);
150  select.show();
151  select.off('change').on('change', function() {
152    var idx = parseInt($(this).val(), 10);
153    if (!isNaN(idx) && ttsVoices[idx]) {
154      selectedVoice = ttsVoices[idx];
155      ttsLog('[TTS] Voice changed by user to ' + selectedVoice.name);
156      // 既存の音声を停止
157      if (window.speechSynthesis.speaking) {
158        window.speechSynthesis.cancel();
159      }
160      // Chrome + リモート音声選択時の警告表示
161      if (browserInfo.isChrome && selectedVoice && !selectedVoice.localService && !remoteVoiceWarningShown) {
162        showRemoteVoiceWarning();
163        remoteVoiceWarningShown = true;
164      }
165    }
166  });
167}
168
169/**
170 * 音声パラメータの取得(ブラウザ別最適化)
171 */
172function getOptimalSpeechParams() {
173  var params = {
174    rate: 1.0,
175    pitch: 1.0,
176    volume: 1.0
177  };
178  
179  if (browserInfo.isChrome) {
180    params.rate = 0.9;
181    params.volume = 0.8;
182  } else if (browserInfo.isEdge) {
183    params.rate = 1.0;
184    params.volume = 0.9;
185  } else if (browserInfo.isSafari) {
186    params.rate = 1.1;
187    params.volume = 1.0;
188  }
189  
190  return params;
191}
192
193/**
194 * 音声再生のメイン関数
195 */
196function playSpeechSound(text) {
197  ttsLog('[TTS] playSpeechSound called');
198
199  // 基本チェック
200  if (!ifSpeakable) {
201    console.warn('[TTS] TTS not available');
202    return false;
203  }
204
205  if (!text || text.trim() === '') {
206    console.warn('[TTS] No text provided');
207    return false;
208  }
209
210  // 既存の音声を停止
211  if (isSpeaking) {
212    ttsLog('[TTS] Stopping current speech');
213    stopSpeech();
214    return false;
215  }
216  
217  // TTS初期化確認(初回のみ音声リストを読み込む)
218  if (!ttsInitialized) {
219    loadVoices();
220  }
221  
222  // ブラウザ最適化パラメータ
223  var params = getOptimalSpeechParams();
224  
225  // 長文の場合は文ごとに分割して逐次再生(Chunk TTSの制約回避)
226  if (text.length > 200) {
227    // 「。」や「!?」で文を分割
228    var segments = text.match(/[^。!??]+[。!??]?/g);
229    if (segments && segments.length > 1) {
230      ttsLog('[TTS] Splitting into ' + segments.length + ' segments for playback');
231      // 再生キューをクリア
232      window.speechSynthesis.cancel();
233      isSpeaking = true;
234      updateUIForSpeechStart();
235      
236      // 各セグメントに対して発話を設定
237      var lastSegmentIndex = segments.length - 1;
238      segments.forEach(function(seg, index) {
239        var u = new SpeechSynthesisUtterance(seg);
240        u.lang = 'ja-JP';
241        u.voice = selectedVoice;
242        u.rate = params.rate;
243        u.pitch = params.pitch;
244        u.volume = params.volume;
245        
246        // 最後のセグメントの終了時にクリーンアップ
247        if (index === lastSegmentIndex) {
248          u.onend = function() {
249            ttsLog('[TTS] All segments completed');
250            cleanupAfterSpeech();
251          };
252          u.onerror = function(event) {
253            console.error('[TTS] Segment error:', event.error);
254            if (event.error === 'canceled' && userCancelled) {
255              userCancelled = false;
256            }
257            cleanupAfterSpeech();
258          };
259        }
260        
261        window.speechSynthesis.speak(u);
262      });
263      return true;
264    }
265  }
266  
267  // 通常の単一Utterance生成
268  currentUtterance = new SpeechSynthesisUtterance(text);
269  currentUtterance.lang = 'ja-JP';
270  currentUtterance.rate = params.rate;
271  currentUtterance.pitch = params.pitch;
272  currentUtterance.volume = params.volume;
273  
274  // 音声設定
275  if (selectedVoice) {
276    currentUtterance.voice = selectedVoice;
277    ttsLog('[TTS] Using voice: ' + selectedVoice.name);
278  }
279
280  // イベントハンドラー設定
281  setupUtteranceEvents();
282
283  // 音声再生実行
284  try {
285    ttsLog('[TTS] SpeechSynthesis state before play - speaking: ' + window.speechSynthesis.speaking +
286           ', pending: ' + window.speechSynthesis.pending +
287           ', paused: ' + window.speechSynthesis.paused);
288    // キューをクリア
289    window.speechSynthesis.cancel();
290    ttsLog('[TTS] Previous utterances cleared');
291    ttsLog('[TTS] Utterance text: ' + text);
292    // 再生開始
293    ttsLog('[TTS] Starting speech synthesis...');
294    window.speechSynthesis.speak(currentUtterance);
295    // 即座に再生中フラグをセット
296    isSpeaking = true;
297    // 一時停止状態からの復帰
298    if (window.speechSynthesis.paused) {
299      window.speechSynthesis.resume();
300    }
301    ttsLog('[TTS] SpeechSynthesis state after play - speaking: ' + window.speechSynthesis.speaking +
302           ', pending: ' + window.speechSynthesis.pending +
303           ', paused: ' + window.speechSynthesis.paused);
304    // UI更新
305    updateUIForSpeechStart();
306    return true;
307  } catch (error) {
308    console.error('[TTS] Speech synthesis failed:', error);
309    return false;
310  }
311}
312
313/**
314 * Utteranceイベントハンドラーの設定
315 */
316function setupUtteranceEvents() {
317  currentUtterance.onstart = function() {
318    ttsLog('[TTS] Speech started');
319    isSpeaking = true;
320    updateUIForSpeechStart();
321  };
322
323  currentUtterance.onend = function() {
324    ttsLog('[TTS] Speech ended normally');
325    cleanupAfterSpeech();
326  };
327
328  currentUtterance.onerror = function(event) {
329    console.error('[TTS] Speech error:', event.error);
330    console.error('[TTS] Error details:', event);
331    // ユーザ操作による停止かどうかを判定
332    if (event.error === 'canceled' && userCancelled) {
333      ttsLog('[TTS] Speech canceled by user');
334      userCancelled = false;
335      cleanupAfterSpeech();
336      return;
337    }
338    // Chrome の仕様で canceled が発生した場合もクリーンアップ
339    cleanupAfterSpeech();
340  };
341
342  currentUtterance.onpause = function() {
343    ttsLog('[TTS] Speech paused');
344  };
345
346  currentUtterance.onresume = function() {
347    ttsLog('[TTS] Speech resumed');
348  };
349}
350
351/**
352 * 音声停止
353 */
354function stopSpeech() {
355  ttsLog('[TTS] Stopping speech...');
356  // ユーザ操作としてキャンセルをマーク
357  userCancelled = true;
358  if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
359    window.speechSynthesis.cancel();
360  }
361  cleanupAfterSpeech();
362}
363
364/**
365 * 音声終了後のクリーンアップ
366 */
367function cleanupAfterSpeech() {
368  isSpeaking = false;
369  currentUtterance = null;
370  // 停止フラグをリセット
371  userCancelled = false;
372  updateUIForSpeechEnd();
373}
374
375/**
376 * UI更新 - 音声開始時
377 */
378function updateUIForSpeechStart() {
379  $('#speaker_icon').removeClass('glyphicon-headphones').addClass('glyphicon-stop');
380  $('#speaking').removeClass('btn-danger').addClass('btn-warning');
381}
382
383/**
384 * UI更新 - 音声終了時
385 */
386function updateUIForSpeechEnd() {
387  $('#speaker_icon').removeClass('glyphicon-stop').addClass('glyphicon-headphones');
388  $('#speaking').removeClass('btn-warning').addClass('btn-danger');
389}
390
391/**
392 * リモート音声警告ポップアップ表示
393 */
394function showRemoteVoiceWarning() {
395  var lang = $('#current_mode').attr('language') || 'ja';
396  var title, message, closeText;
397  
398  if (lang === 'en') {
399    title = "Chrome Remote Voice Notice";
400    message = "When using Google's remote voice in Chrome, " +
401              "the first click on the play button may not produce sound.<br><br>" +
402              "If this happens, please click the stop button once, then click play again.<br><br>" +
403              "This is due to Chrome's security restrictions on remote voice synthesis.";
404    closeText = "Close";
405  } else {
406    title = "Chromeリモート音声について";
407    message = "Chromeで「Google 日本語 (Remote)」を使用する場合、" +
408              "最初の再生ボタンクリックでは音声が再生されないことがあります。<br><br>" +
409              "その場合は、一度停止ボタンをクリックしてから、もう一度再生ボタンを" +
410              "クリックしてください。<br><br>" +
411              "これはChromeのリモート音声合成に関するセキュリティ制限によるものです。";
412    closeText = "閉じる";
413  }
414  
415  // カスタムモーダル作成
416  var modalHTML = 
417    '<div id="tts-warning-modal" style="' +
418      'position: fixed; ' +
419      'top: 0; left: 0; right: 0; bottom: 0; ' +
420      'background-color: rgba(0,0,0,0.5); ' +
421      'z-index: 9999; ' +
422      'display: flex; ' +
423      'align-items: center; ' +
424      'justify-content: center;">' +
425      '<div style="' +
426        'background-color: white; ' +
427        'padding: 30px; ' +
428        'border-radius: 8px; ' +
429        'max-width: 500px; ' +
430        'box-shadow: 0 4px 6px rgba(0,0,0,0.1);">' +
431        '<h3 style="margin-top: 0; margin-bottom: 20px;">' + title + '</h3>' +
432        '<p style="line-height: 1.6; margin-bottom: 20px;">' + message + '</p>' +
433        '<button id="tts-warning-close" style="' +
434          'background-color: #007bff; ' +
435          'color: white; ' +
436          'border: none; ' +
437          'padding: 10px 20px; ' +
438          'border-radius: 4px; ' +
439          'cursor: pointer; ' +
440          'font-size: 16px;">' + closeText + '</button>' +
441      '</div>' +
442    '</div>';
443  
444  // モーダルを表示
445  $('body').append(modalHTML);
446  
447  // 閉じるボタンのイベント処理
448  $('#tts-warning-close, #tts-warning-modal').on('click', function(e) {
449    if (e.target.id === 'tts-warning-close' || e.target.id === 'tts-warning-modal') {
450      $('#tts-warning-modal').remove();
451    }
452  });
453}
454
455/**
456 * ページ読み込み時の初期化
457 */
458$(document).ready(function() {
459  ttsLog('[TTS] Document ready, initializing...');
460
461  // 少し遅延してからTTS初期化
462  setTimeout(function() {
463    initTTS();
464
465    // 初期化確認
466    setTimeout(function() {
467      if (TTS_DEBUG_MODE) {
468        console.log('[TTS] Initialization check:');
469        console.log('  ifSpeakable:', ifSpeakable);
470        console.log('  ttsInitialized:', ttsInitialized);
471        console.log('  selectedVoice:', selectedVoice ? selectedVoice.name : 'none');
472        console.log('  Total voices:', ttsVoices.length);
473      }
474      
475      // 初期選択がリモート音声の場合も警告表示
476      if (browserInfo.isChrome && selectedVoice && !selectedVoice.localService && !remoteVoiceWarningShown) {
477        showRemoteVoiceWarning();
478        remoteVoiceWarningShown = true;
479      }
480    }, 1000);
481  }, 100);
482});

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.