/** * Text-to-Speech 기능 구현 * Web Speech API를 사용하여, 페이지 내용을 음성으로 변환 */ class TextToSpeech { constructor(options = {}) { // TTS 엔진 초기화 this.synth = window.speechSynthesis; this.utterance = null; // 기본 설정 this.options = { lang: options.lang || 'ko-KR', rate: options.rate || 1.0, pitch: options.pitch || 1.0, volume: options.volume || 0.8 }; // 현재 상태 this.status = { isPaused: false, isPlaying: false, currentElement: null }; // UI 요소 업데이트 this.elements = { playBtn: document.getElementById('tts-play'), pauseBtn: document.getElementById('tts-pause'), stopBtn: document.getElementById('tts-stop'), settingsPanel: document.getElementById('tts-settings-panel'), closeSettingsBtn: document.getElementById('tts-close-settings'), statusIndicator: document.getElementById('tts-status') }; // 이벤트 리스너 설정 this.setupEventListeners(); // 사용 가능한 음성 목록 가져오기 this.voices = []; this.loadVoices(); // 상태 표시기 표시 if (this.elements.statusIndicator) { this.elements.statusIndicator.classList.remove('d-none'); } } /** * 이벤트 리스너 설정 */ setupEventListeners() { // 상태 표시기 클릭 이벤트 if (this.elements.statusIndicator) { // 재생 버튼 부분만 클릭 이벤트 처리 const playArea = this.elements.statusIndicator.querySelector('.tts-status-text'); const playIcon = this.elements.statusIndicator.querySelector('.tts-status-icon'); if (playArea) { playArea.addEventListener('click', () => { if (this.status.isPlaying) { if (this.status.isPaused) { this.resume(); } else { this.pause(); } } else { this.play(); } }); } if (playIcon) { playIcon.addEventListener('click', () => { if (this.status.isPlaying) { if (this.status.isPaused) { this.resume(); } else { this.pause(); } } else { this.play(); } }); } // 설정 토글 버튼 const settingsToggle = document.getElementById('tts-toggle-settings'); if (settingsToggle) { settingsToggle.addEventListener('click', (e) => { e.stopPropagation(); // 상태 표시기 클릭 이벤트 전파 방지 this.toggleSettingsPanel(); }); } } // 재생 버튼 (패널 내부) if (this.elements.playBtn) { this.elements.playBtn.addEventListener('click', () => this.play()); } // 일시정지 버튼 if (this.elements.pauseBtn) { this.elements.pauseBtn.addEventListener('click', () => this.pause()); } // 정지 버튼 if (this.elements.stopBtn) { this.elements.stopBtn.addEventListener('click', () => this.stop()); } // 설정 닫기 버튼 if (this.elements.closeSettingsBtn) { this.elements.closeSettingsBtn.addEventListener('click', () => { this.toggleSettingsPanel(false); }); } // 설정 변경 이벤트 document.querySelectorAll('.tts-setting').forEach(element => { element.addEventListener('change', (e) => { const setting = e.target.dataset.setting; const value = parseFloat(e.target.value); if (setting && !isNaN(value)) { this.options[setting] = value; // 현재 재생 중이면 설정 업데이트 if (this.status.isPlaying) { this.restart(); } } }); }); } /** * 상태 표시기 업데이트 * @param {boolean} isPlaying 재생 중인지 여부 */ updateStatusIndicator(isPlaying, isPaused) { if (this.elements.statusIndicator) { if (isPlaying) { this.elements.statusIndicator.classList.add('playing'); if (isPaused) { this.elements.statusIndicator.querySelector('.tts-status-icon').className = 'bi bi-pause-fill tts-status-icon'; this.elements.statusIndicator.querySelector('.tts-status-text').textContent = '일시정지'; } else { this.elements.statusIndicator.querySelector('.tts-status-icon').className = 'bi bi-volume-up tts-status-icon'; this.elements.statusIndicator.querySelector('.tts-status-text').textContent = '음성재생'; } } else { this.elements.statusIndicator.classList.remove('playing'); this.elements.statusIndicator.querySelector('.tts-status-icon').className = 'bi bi-volume-up tts-status-icon'; this.elements.statusIndicator.querySelector('.tts-status-text').textContent = '음성재생'; } } } /** * 설정 패널 토글 * @param {boolean} show 표시 여부 (미지정 시 토글) */ toggleSettingsPanel(show) { if (this.elements.settingsPanel) { if (show === undefined) { this.elements.settingsPanel.classList.toggle('show'); } else if (show) { this.elements.settingsPanel.classList.add('show'); } else { this.elements.settingsPanel.classList.remove('show'); } } } /** * 사용 가능한 음성 목록 로드 */ loadVoices() { // 브라우저에 따라 음성 로드 방식이 다름 if (this.synth.onvoiceschanged !== undefined) { this.synth.onvoiceschanged = () => { this.voices = this.synth.getVoices(); this.updateVoiceOptions(); }; } else { this.voices = this.synth.getVoices(); this.updateVoiceOptions(); } } /** * 음성 옵션 업데이트 */ updateVoiceOptions() { const voiceSelect = document.getElementById('tts-voice'); if (!voiceSelect) return; // 음성 목록을 채움 voiceSelect.innerHTML = ''; // 먼저, 한국어 음성 추가 const koreanVoices = this.voices.filter(voice => voice.lang.startsWith('ko') || voice.lang.includes('Korean') ); // 한국어 음성이 있으면 if (koreanVoices.length > 0) { const korOptGroup = document.createElement('optgroup'); korOptGroup.label = '한국어'; koreanVoices.forEach((voice, index) => { const option = document.createElement('option'); option.value = this.voices.indexOf(voice); option.textContent = `${voice.name}`; if (index === 0) option.selected = true; korOptGroup.appendChild(option); }); voiceSelect.appendChild(korOptGroup); } // 영어 음성 추가 const englishVoices = this.voices.filter(voice => voice.lang.startsWith('en') ); if (englishVoices.length > 0) { const engOptGroup = document.createElement('optgroup'); engOptGroup.label = '영어'; englishVoices.forEach((voice, index) => { const option = document.createElement('option'); option.value = this.voices.indexOf(voice); option.textContent = `${voice.name}`; engOptGroup.appendChild(option); }); voiceSelect.appendChild(engOptGroup); } // 기타 음성 추가 const otherVoices = this.voices.filter(voice => !voice.lang.startsWith('ko') && !voice.lang.includes('Korean') && !voice.lang.startsWith('en') ); if (otherVoices.length > 0) { const otherOptGroup = document.createElement('optgroup'); otherOptGroup.label = '기타 언어'; otherVoices.forEach(voice => { const option = document.createElement('option'); option.value = this.voices.indexOf(voice); option.textContent = `${voice.name} (${voice.lang})`; otherOptGroup.appendChild(option); }); voiceSelect.appendChild(otherOptGroup); } // 기본 선택 if (voiceSelect.options.length > 0 && !voiceSelect.value) { voiceSelect.selectedIndex = 0; } } /** * 지정한 텍스트 재생 * @param {string} text 재생할 텍스트 */ speak(text) { // 이전 발화 취소 this.stop(); // 새 발화 생성 this.utterance = new SpeechSynthesisUtterance(text); // 설정 적용 this.utterance.lang = this.options.lang; this.utterance.rate = this.options.rate; this.utterance.pitch = this.options.pitch; this.utterance.volume = this.options.volume; // 음성 설정 const voiceSelect = document.getElementById('tts-voice'); if (voiceSelect && voiceSelect.value) { this.utterance.voice = this.voices[parseInt(voiceSelect.value)]; } // 이벤트 핸들러 this.utterance.onstart = () => { this.status.isPlaying = true; this.status.isPaused = false; this.updateStatusIndicator(true, false); this.updateUI(); }; this.utterance.onpause = () => { this.status.isPaused = true; this.updateStatusIndicator(true, true); this.updateUI(); }; this.utterance.onresume = () => { this.status.isPaused = false; this.updateStatusIndicator(true, false); this.updateUI(); }; this.utterance.onend = () => { this.status.isPlaying = false; this.status.isPaused = false; this.updateStatusIndicator(false, false); this.updateUI(); }; this.utterance.onerror = (e) => { console.error('TTS 오류:', e); this.status.isPlaying = false; this.status.isPaused = false; this.showStatusIndicator(false); this.updateUI(); }; // 문장 단위로 읽기 위한 처리 this.utterance.onboundary = (event) => { if (event.name === 'sentence') { this.highlightText(event.charIndex, event.charLength); } }; // 재생 시작 this.synth.speak(this.utterance); this.status.isPlaying = true; this.updateUI(); } /** * 상태 표시기 보기/숨기기 * @param {boolean} show 표시 여부 */ showStatusIndicator(show) { if (this.elements.statusIndicator) { if (show) { this.elements.statusIndicator.classList.remove('d-none'); this.elements.statusIndicator.classList.add('show'); } else { this.elements.statusIndicator.classList.remove('show'); setTimeout(() => { this.elements.statusIndicator.classList.add('d-none'); }, 300); } } } /** * 페이지 콘텐츠 재생 */ play() { if (this.status.isPaused) { this.resume(); } else if (!this.status.isPlaying) { const content = document.querySelector('.content'); if (content) { let text = this.extractText(content); if (text) { this.speak(text); this.toggleSettingsPanel(false); } } } } /** * 일시정지 */ pause() { if (this.status.isPlaying && !this.status.isPaused) { this.synth.pause(); this.status.isPaused = true; this.updateUI(); } } /** * 재개 */ resume() { if (this.status.isPaused) { this.synth.resume(); this.status.isPaused = false; this.updateUI(); } } /** * 정지 */ stop() { this.synth.cancel(); this.status.isPlaying = false; this.status.isPaused = false; // 하이라이트 제거 this.removeHighlight(); // 상태 표시 업데이트 this.showStatusIndicator(false); this.updateUI(); } /** * 현재 설정으로 재시작 */ restart() { if (this.status.isPlaying && this.utterance) { const currentText = this.utterance.text; this.stop(); this.speak(currentText); } } /** * 콘텐츠에서 텍스트 추출 * @param {HTMLElement} element 텍스트를 추출할 요소 * @returns {string} 추출된 텍스트 */ extractText(element) { // 클론하여 불필요한 요소 제거 const clone = element.cloneNode(true); // 코드 블록, 스크립트 등 제거 const excludeSelectors = ['script', 'style', 'code', 'pre', '[class*="code"]', '[data-code-block="true"]']; excludeSelectors.forEach(selector => { clone.querySelectorAll(selector).forEach(el => el.remove()); }); // 텍스트 정리 let text = clone.textContent || ''; text = text.replace(/\s+/g, ' ').trim(); return text; } /** * 현재 읽고 있는 텍스트 하이라이트 * @param {number} startIndex 시작 인덱스 * @param {number} length 길이 */ highlightText(startIndex, length) { // 현재 하이라이트 제거 this.removeHighlight(); // 콘텐츠 요소 const content = document.querySelector('.content'); if (!content) return; // 텍스트 노드 찾기 (간소화된 버전) try { const textNodes = []; const walkTreeForTextNodes = (node) => { if (node.nodeType === Node.TEXT_NODE) { textNodes.push(node); } else { for (let i = 0; i < node.childNodes.length; i++) { walkTreeForTextNodes(node.childNodes[i]); } } }; walkTreeForTextNodes(content); // 해당 인덱스의 텍스트 노드 찾기 let currentIndex = 0; for (let i = 0; i < textNodes.length; i++) { const node = textNodes[i]; const nodeLength = node.textContent.length; if (currentIndex <= startIndex && startIndex < currentIndex + nodeLength) { // 하이라이트 요소 생성 const nodeStartIndex = startIndex - currentIndex; const nodeEndIndex = Math.min(nodeStartIndex + length, nodeLength); const range = document.createRange(); range.setStart(node, nodeStartIndex); range.setEnd(node, nodeEndIndex); const highlight = document.createElement('span'); highlight.className = 'tts-highlight'; range.surroundContents(highlight); this.status.currentElement = highlight; // 화면에 보이게 스크롤 highlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); break; } currentIndex += nodeLength; } } catch (e) { console.error('텍스트 하이라이트 오류:', e); } } /** * 하이라이트 제거 */ removeHighlight() { const highlights = document.querySelectorAll('.tts-highlight'); highlights.forEach(el => { const parent = el.parentNode; if (parent) { // 하이라이트 요소의 콘텐츠를 부모에 직접 삽입 while (el.firstChild) { parent.insertBefore(el.firstChild, el); } parent.removeChild(el); } }); this.status.currentElement = null; } /** * UI 상태 업데이트 */ updateUI() { // 상태 표시기 업데이트 this.updateStatusIndicator(this.status.isPlaying, this.status.isPaused); // 재생 버튼 if (this.elements.playBtn) { this.elements.playBtn.disabled = false; this.elements.playBtn.title = this.status.isPaused ? '재개' : '재생'; } // 일시정지 버튼 if (this.elements.pauseBtn) { this.elements.pauseBtn.disabled = !(this.status.isPlaying && !this.status.isPaused); } // 정지 버튼 if (this.elements.stopBtn) { this.elements.stopBtn.disabled = !this.status.isPlaying; } } } // 페이지 로드 시 TTS 초기화 document.addEventListener('DOMContentLoaded', () => { // 브라우저 지원 확인 if ('speechSynthesis' in window) { window.tts = new TextToSpeech(); } else { // TTS 미지원 시 메시지 표시 const ttsStatus = document.getElementById('tts-status'); if (ttsStatus) { ttsStatus.querySelector('.tts-status-text').textContent = '음성 기능 미지원'; ttsStatus.style.backgroundColor = 'rgba(108, 117, 125, 0.9)'; } console.warn('이 브라우저는 음성 합성 기능을 지원하지 않습니다.'); } });