diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx index 8e272264..7d681dc0 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx @@ -37,9 +37,16 @@ export const VOICE_MODES = { MODE_4: 'mode4', // 추후 추가 }; +// 음성인식 입력 모드 (VUI vs WebSpeech) +export const VOICE_INPUT_MODE = { + VUI: 'vui', // VUI (Voice UI Framework) + WEBSPEECH: 'webspeech', // Web Speech API +}; + const OVERLAY_SPOTLIGHT_ID = 'voice-input-overlay-container'; const INPUT_SPOTLIGHT_ID = 'voice-overlay-input-box'; const MIC_SPOTLIGHT_ID = 'voice-overlay-mic-button'; +const MIC_WEBSPEECH_SPOTLIGHT_ID = 'voice-overlay-mic-webspeech-button'; const VoiceInputOverlay = ({ isVisible, @@ -54,11 +61,15 @@ const VoiceInputOverlay = ({ const lastFocusedElement = useRef(null); const [inputFocus, setInputFocus] = useState(false); const [micFocused, setMicFocused] = useState(false); + const [micWebSpeechFocused, setMicWebSpeechFocused] = useState(false); // 내부 모드 상태 관리 (prompt -> listening -> close) const [currentMode, setCurrentMode] = useState(mode); + // 음성인식 입력 모드 (VUI vs WebSpeech) + const [voiceInputMode, setVoiceInputMode] = useState(null); + // ⛔ VUI 테스트 비활성화: VoicePanel 독립 테스트 시 충돌 방지 // Redux에서 voice 상태 가져오기 - const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice); + // const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice); // Redux에서 shopperHouse 검색 결과 가져오기 const shopperHouseData = useSelector((state) => state.search.shopperHouseData); @@ -78,25 +89,26 @@ const VoiceInputOverlay = ({ } }, [shopperHouseData, isVisible, onClose]); + // ⛔ VUI 테스트 비활성화: STT 텍스트 수신 처리 // STT 텍스트 수신 시 처리 - useEffect(() => { - if (lastSTTText && sttTimestamp && isVisible) { - console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText); + // useEffect(() => { + // if (lastSTTText && sttTimestamp && isVisible) { + // console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText); - // 입력창에 텍스트 표시 (부모 컴포넌트로 전달) - if (onSearchChange) { - onSearchChange({ value: lastSTTText }); - } + // // 입력창에 텍스트 표시 (부모 컴포넌트로 전달) + // if (onSearchChange) { + // onSearchChange({ value: lastSTTText }); + // } - // listening 모드로 전환 (시각적 피드백) - setCurrentMode(VOICE_MODES.LISTENING); + // // listening 모드로 전환 (시각적 피드백) + // setCurrentMode(VOICE_MODES.LISTENING); - // 1초 후 자동 닫기 (선택사항) - setTimeout(() => { - onClose(); - }, 1000); - } - }, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]); + // // 1초 후 자동 닫기 (선택사항) + // setTimeout(() => { + // onClose(); + // }, 1000); + // } + // }, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]); // Overlay가 열릴 때 포커스를 overlay 내부로 이동 useEffect(() => { @@ -106,13 +118,15 @@ const VoiceInputOverlay = ({ // 모드 초기화 (항상 prompt 모드로 시작) setCurrentMode(mode); + setVoiceInputMode(null); // Overlay 내부로 포커스 이동 setTimeout(() => { Spotlight.focus(OVERLAY_SPOTLIGHT_ID); }, 100); } else { - // Overlay가 닫힐 때 원래 포커스 복원 + // Overlay가 닫힐 때 원래 포커스 복원 및 상태 초기화 + setVoiceInputMode(null); if (lastFocusedElement.current) { setTimeout(() => { Spotlight.focus(lastFocusedElement.current); @@ -199,7 +213,7 @@ const VoiceInputOverlay = ({ setInputFocus(false); }, []); - // 마이크 버튼 포커스 핸들러 + // 마이크 버튼 포커스 핸들러 (VUI) const handleMicFocus = useCallback(() => { setMicFocused(true); }, []); @@ -208,10 +222,55 @@ const VoiceInputOverlay = ({ setMicFocused(false); }, []); - // 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close) - const handleMicClick = useCallback( + // WebSpeech 마이크 버튼 포커스 핸들러 + const handleMicWebSpeechFocus = useCallback(() => { + setMicWebSpeechFocused(true); + }, []); + + const handleMicWebSpeechBlur = useCallback(() => { + setMicWebSpeechFocused(false); + }, []); + + // ⛔ VUI 테스트 비활성화: VUI 마이크 버튼 클릭 핸들러 + // VUI 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close) + // const handleVUIMicClick = useCallback( + // (e) => { + // console.log('[VoiceInputOverlay] handleVUIMicClick called, currentMode:', currentMode); + + // // 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지 + // if (e && e.stopPropagation) { + // e.stopPropagation(); + // } + // if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) { + // e.nativeEvent.stopImmediatePropagation(); + // } + + // if (currentMode === VOICE_MODES.PROMPT) { + // // prompt 모드에서 클릭 시 -> VUI listening 모드로 전환 + // console.log('[VoiceInputOverlay] Switching to VUI LISTENING mode'); + // setVoiceInputMode(VOICE_INPUT_MODE.VUI); + // setCurrentMode(VOICE_MODES.LISTENING); + // // 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작 + // // (이미 registerVoiceFramework()로 등록되어 있으므로) + // } else if (currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.VUI) { + // // VUI listening 모드에서 클릭 시 -> 종료 + // console.log('[VoiceInputOverlay] Closing from VUI LISTENING mode'); + // setVoiceInputMode(null); + // onClose(); + // } else { + // // 기타 모드에서는 바로 종료 + // console.log('[VoiceInputOverlay] Closing from other mode'); + // setVoiceInputMode(null); + // onClose(); + // } + // }, + // [currentMode, voiceInputMode, onClose] + // ); + + // WebSpeech 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close) + const handleWebSpeechMicClick = useCallback( (e) => { - console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode); + console.log('[VoiceInputOverlay] handleWebSpeechMicClick called, currentMode:', currentMode); // 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지 if (e && e.stopPropagation) { @@ -222,28 +281,34 @@ const VoiceInputOverlay = ({ } if (currentMode === VOICE_MODES.PROMPT) { - // prompt 모드에서 클릭 시 -> listening 모드로 전환 - console.log('[VoiceInputOverlay] Switching to LISTENING mode'); + // prompt 모드에서 클릭 시 -> WebSpeech listening 모드로 전환 + console.log('[VoiceInputOverlay] Switching to WebSpeech LISTENING mode'); + setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH); setCurrentMode(VOICE_MODES.LISTENING); - // 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작 - // (이미 registerVoiceFramework()로 등록되어 있으므로) - } else if (currentMode === VOICE_MODES.LISTENING) { - // listening 모드에서 클릭 시 -> 종료 - console.log('[VoiceInputOverlay] Closing from LISTENING mode'); + // TODO: Web Speech API 시작 로직 추가 + } else if ( + currentMode === VOICE_MODES.LISTENING && + voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH + ) { + // WebSpeech listening 모드에서 클릭 시 -> 종료 + console.log('[VoiceInputOverlay] Closing from WebSpeech LISTENING mode'); + setVoiceInputMode(null); onClose(); } else { // 기타 모드에서는 바로 종료 console.log('[VoiceInputOverlay] Closing from other mode'); + setVoiceInputMode(null); onClose(); } }, - [currentMode, onClose] + [currentMode, voiceInputMode, onClose] ); // dim 레이어 클릭 핸들러 (마이크 버튼과 분리) const handleDimClick = useCallback( (e) => { console.log('[VoiceInputOverlay] dimBackground clicked'); + setVoiceInputMode(null); onClose(); }, [onClose] @@ -264,8 +329,8 @@ const VoiceInputOverlay = ({ {/* 배경 dim 레이어 - 클릭하면 닫힘 */}
- {/* Voice 등록 상태 표시 (디버깅용) */} - {process.env.NODE_ENV === 'development' && ( + {/* ⛔ VUI 테스트 비활성화: Voice 등록 상태 표시 (디버깅용) */} + {/* {process.env.NODE_ENV === 'development' && (
Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'} +
+ Mode: {voiceInputMode || 'None'}
- )} + )} */} {/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */} + {/* VUI 마이크 버튼 (⛔ 기능 비활성화: 클릭 핸들러만 무효화) */} { + // ⛔ VUI 테스트 비활성화: handleVUIMicClick 호출 안 함 + e.stopPropagation(); + console.log('[VoiceInputOverlay] VUI mic clicked (disabled for testing)'); + }} onKeyDown={(e) => { if (e.key === 'Enter' || e.keyCode === 13) { e.preventDefault(); e.stopPropagation(); - handleMicClick(e); + // ⛔ VUI 테스트 비활성화: handleVUIMicClick 호출 안 함 + console.log('[VoiceInputOverlay] VUI mic Enter key (disabled for testing)'); } }} onFocus={handleMicFocus} @@ -320,21 +395,63 @@ const VoiceInputOverlay = ({ spotlightId={MIC_SPOTLIGHT_ID} >
- Microphone + Voice AI
- {currentMode === VOICE_MODES.LISTENING && ( - - - + {currentMode === VOICE_MODES.LISTENING && + voiceInputMode === VOICE_INPUT_MODE.VUI && ( + + + + )} +
+ + {/* WebSpeech 마이크 버튼 */} + { + if (e.key === 'Enter' || e.keyCode === 13) { + e.preventDefault(); + e.stopPropagation(); + handleWebSpeechMicClick(e); + } + }} + onFocus={handleMicWebSpeechFocus} + onBlur={handleMicWebSpeechBlur} + spotlightId={MIC_WEBSPEECH_SPOTLIGHT_ID} + > +
+ Voice Input +
+ {currentMode === VOICE_MODES.LISTENING && + voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH && ( + + + + )}
diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less index fd122833..e1e3df25 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less @@ -197,6 +197,79 @@ } } +// WebSpeech 마이크 버튼 (블루 계열) +.microphoneButtonWebSpeech { + width: 100px; + height: 100px; + position: relative; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + padding: 0; + z-index: 1003; + + .microphoneCircle { + width: 100%; + height: 100%; + position: relative; + background: white; + overflow: hidden; + border-radius: 1000px; + border: 5px solid #ccc; + display: flex; + justify-content: center; + align-items: center; + transition: all 0.3s ease; + + .microphoneIcon { + height: 50px; + box-sizing: border-box; + transition: filter 0.3s ease; + } + } + + &:hover { + .microphoneCircle { + border-color: white; + } + } + + // active 상태 (음성 입력 모드 - 블루 색상) + &.active { + .microphoneCircle { + background-color: #4A90E2; + border-color: #4A90E2; + box-shadow: 0 0 22px 0 rgba(74, 144, 226, 0.5); + + .microphoneIcon { + filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경 + } + } + } + + &.active.focused { + .microphoneCircle { + background-color: #4A90E2; + border-color: white; + box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5); + } + } + + // listening 상태 (배경 투명, 테두리 ripple 애니메이션) + &.listening { + .microphoneCircle { + background-color: transparent; + border-color: transparent; // 테두리 투명 + box-shadow: none; + + .microphoneIcon { + filter: brightness(0) invert(1); // 아이콘은 흰색 유지 + } + } + } +} + // Ripple 애니메이션 (원형 테두리가 점에서 시작해서 그려짐) .rippleSvg { position: absolute; diff --git a/com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.jsx b/com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.jsx index 5b9e98e4..bc2bc5d9 100644 --- a/com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.jsx +++ b/com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.jsx @@ -29,6 +29,11 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) { useEffect(() => { if (isOnTop) { dispatch(sendLogGNB(LOG_MENU.SEARCH_SEARCH)); + } else { + // ⭐ VoicePanel이 백그라운드로 가면 voice framework 자동 해제 + // (SearchPanel 등 다른 패널과 충돌 방지) + console.log('[VoicePanel] Going to background, unregistering voice framework'); + dispatch(unregisterVoiceFramework()); } }, [isOnTop, dispatch]); diff --git a/com.twin.app.shoptime/vui-test.1.md b/com.twin.app.shoptime/vui-test.1.md new file mode 100644 index 00000000..1cec909b --- /dev/null +++ b/com.twin.app.shoptime/vui-test.1.md @@ -0,0 +1,309 @@ +1-1 Can you recommend a 4K TV with Dolby Atmos support under $1,500? + + +오버레이 인식 : 확인 + +STT Text : Can you recommend a 4K TV with Dolby Atmos support under $1,500? + +Event Logs : + +RESPONSE : + +{ + "subscribed": true, + "command": "setContext", + "returnValue": true, + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036" +} + +COMMAND : + +{ + "command": "setContext", + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036" +} + +REQUEST : +{ + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036", + "intentCount": 3, + "intents": [ + { + "intent": "UseIME", + "supportAsrOnly": true + }, + { + "intent": "Select", + "supportOrdinal": true, + "items": [ + { + "itemId": "voice-search-button", + "value": [ + "Search", + "Search Products", + "Find Items" + ], + "title": "Search" + }, + { + "itemId": "voice-cart-button", + "value": [ + "Cart", + "Shopping Cart", + "My Cart" + ], + "title": "Cart" + }, + { + "itemId": "voice-home-button", + "value": [ + "Home", + "Go Home", + "Main Page" + ], + "title": "Home" + }, + { + "itemId": "voice-mypage-button", + "value": [ + "My Page", + "Account", + "Profile" + ], + "title": "My Page" + } + ] + }, + { + "intent": "Scroll", + "supportOrdinal": false, + "items": [ + { + "itemId": "voice-scroll-up", + "value": [ + "Scroll Up", + "Page Up" + ] + }, + { + "itemId": "voice-scroll-down", + "value": [ + "Scroll Down", + "Page Down" + ] + } + ] + } + ] +} + +RESPONSE : + +{ + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036", + "intentCount": 3, + "intents": [ + { + "intent": "UseIME", + "supportAsrOnly": true + }, + { + "intent": "Select", + "supportOrdinal": true, + "items": [ + { + "itemId": "voice-search-button", + "value": [ + "Search", + "Search Products", + "Find Items" + ], + "title": "Search" + }, + { + "itemId": "voice-cart-button", + "value": [ + "Cart", + "Shopping Cart", + "My Cart" + ], + "title": "Cart" + }, + { + "itemId": "voice-home-button", + "value": [ + "Home", + "Go Home", + "Main Page" + ], + "title": "Home" + }, + { + "itemId": "voice-mypage-button", + "value": [ + "My Page", + "Account", + "Profile" + ], + "title": "My Page" + } + ] + }, + { + "intent": "Scroll", + "supportOrdinal": false, + "items": [ + { + "itemId": "voice-scroll-up", + "value": [ + "Scroll Up", + "Page Up" + ] + }, + { + "itemId": "voice-scroll-down", + "value": [ + "Scroll Down", + "Page Down" + ] + } + ] + } + ] +} + + +ACTION : + +{ + "message": "Context set successfully. Press the MIC button on remote and speak.", + "nextStep": "Waiting for performAction event...", + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036" +} + + + + + + + + +그러나 두번째 시도에서 + + + +STT Text : No STT text received yet. Speak after registering to see the result. + +RESPONSE : + +{ + "subscribed": true, + "command": "setContext", + "returnValue": true, + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b" +} + +COMMAND : +{ + "command": "setContext", + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b" +} + + +REQUEST : +{ + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b", + "intentCount": 3, + "intents": [ + { + "intent": "UseIME", + "supportAsrOnly": true + }, + { + "intent": "Select", + "supportOrdinal": true, + "items": [ + { + "itemId": "voice-search-button", + "value": [ + "Search", + "Search Products", + "Find Items" + ], + "title": "Search" + }, + { + "itemId": "voice-cart-button", + "value": [ + "Cart", + "Shopping Cart", + "My Cart" + ], + "title": "Cart" + }, + { + "itemId": "voice-home-button", + "value": [ + "Home", + "Go Home", + "Main Page" + ], + "title": "Home" + }, + { + "itemId": "voice-mypage-button", + "value": [ + "My Page", + "Account", + "Profile" + ], + "title": "My Page" + } + ] + }, + { + "intent": "Scroll", + "supportOrdinal": false, + "items": [ + { + "itemId": "voice-scroll-up", + "value": [ + "Scroll Up", + "Page Up" + ] + }, + { + "itemId": "voice-scroll-down", + "value": [ + "Scroll Down", + "Page Down" + ] + } + ] + } + ] +} + +RESPONSE : +{ + "returnValue": true +} + +ACTION : +{ + "message": "Context set successfully. Press the MIC button on remote and speak.", + "nextStep": "Waiting for performAction event...", + "voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b" +} + +ERROR : +{ + "message": "performAction event was not received within 15 seconds after setContext.", + "possibleReasons": [ + "1. Did you press the MIC button on the remote control?", + "2. Did you speak after pressing the MIC button?", + "3. UseIME intent might not be supported on this webOS version", + "4. Voice framework might not be routing events correctly" + ], + "suggestion": "Try pressing the remote MIC button and speaking clearly. Check VoicePanel logs for performAction event." +} \ No newline at end of file diff --git a/com.twin.app.shoptime/web-speech.md b/com.twin.app.shoptime/web-speech.md new file mode 100644 index 00000000..a46291d9 --- /dev/null +++ b/com.twin.app.shoptime/web-speech.md @@ -0,0 +1,1417 @@ +# Web Speech API 구현 가이드 (Plan B) + +> **프로젝트**: ShopTime webOS TV Application +> **목적**: webOS VUI Framework 대안으로 Web Speech API 구현 +> **환경**: Chrome 68, React 16.7, Enact 프레임워크, Redux 3.7.2 +> **작성일**: 2025-10-16 + +--- + +## 📋 개요 + +이 문서는 현재 구현 중인 **webOS VUI Framework(Plan A)**의 대안으로 **Web Speech API(Plan B)**를 사용하는 구현 방법을 설명합니다. + +### Plan A vs Plan B 비교 + +| 구분 | Plan A (VUI Framework) | Plan B (Web Speech API) | +|-----|----------------------|------------------------| +| **API** | webOS Voice Conductor Service | 브라우저 네이티브 Web Speech API | +| **의존성** | webOS 플랫폼 전용 | 범용 웹 브라우저 | +| **네트워크** | webOS 음성 서버 | Google 음성 서버 | +| **호환성** | webOS TV만 | Chrome 68+ 모든 플랫폼 | +| **권한** | PalmSystem API | navigator.mediaDevices | +| **장점** | TV 환경 최적화, 리모컨 통합 | 크로스 플랫폼, 개발 편의성 | +| **단점** | webOS 전용, 복잡한 구조 | 네트워크 의존, TV 환경 최적화 필요 | + +--- + +## 🔍 환경 분석 + +### 1. 프로젝트 환경 + +```json +{ + "platform": "webOS TV", + "browser": "Chrome 68", + "react": "16.7.0", + "redux": "3.7.2", + "enact": "3.3.0" +} +``` + +### 2. Chrome 68의 Web Speech API 지원 + +Chrome 68(2018년 7월 출시)은 **Web Speech API를 완벽하게 지원**합니다: + +- ✅ `SpeechRecognition` / `webkitSpeechRecognition` +- ✅ `SpeechRecognitionEvent` +- ✅ `SpeechRecognitionResult` +- ✅ 한국어 및 다국어 지원 +- ✅ Continuous / Interim Results +- ✅ MaxAlternatives 지원 + +**호환성 확인:** + +```javascript +// Chrome 68에서의 Speech Recognition API 지원 확인 +const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; +const isSupported = !!SpeechRecognition; +console.log('Web Speech API 지원:', isSupported); // true +``` + +### 3. 기존 구조 분석 + +**현재 VUI Framework 구조:** +``` +SearchPanel.new.jsx + ↓ (uses) +useSearchVoice Hook + ↓ (dispatches) +voiceActions.js (registerVoiceFramework, sendVoiceIntents) + ↓ (updates) +voiceReducer.js (lastSTTText, sttTimestamp) + ↓ (displays) +VoiceInputOverlay.jsx (PROMPT/LISTENING modes) +``` + +**Web Speech API 구조 (Plan B):** +``` +SearchPanel.new.jsx + ↓ (uses) +useWebSpeech Hook (NEW) + ↓ (uses) +WebSpeechService.js (NEW) + ↓ (updates) +voiceReducer.js (기존 Redux 활용) + ↓ (displays) +VoiceInputOverlay.jsx (기존 컴포넌트 재사용) +``` + +--- + +## 🏗️ 구현 아키텍처 + +### 파일 구조 + +``` +src/ +├── services/ +│ └── webSpeech/ +│ ├── WebSpeechService.js # Web Speech API 래퍼 서비스 (NEW) +│ └── webSpeechConfig.js # 언어 및 설정 (NEW) +├── actions/ +│ └── webSpeechActions.js # Web Speech Redux 액션 (NEW) +├── hooks/ +│ └── useWebSpeech.js # Web Speech Hook (NEW) +├── reducers/ +│ └── voiceReducer.js # 기존 활용 (약간 수정) +└── views/ + └── SearchPanel/ + ├── SearchPanel.new.jsx # 기존 활용 (useWebSpeech 통합) + └── VoiceInputOverlay/ + └── VoiceInputOverlay.jsx # 기존 재사용 +``` + +--- + +## 📝 단계별 구현 + +### Step 1: WebSpeechService 구현 + +**파일**: `src/services/webSpeech/WebSpeechService.js` + +```javascript +// src/services/webSpeech/WebSpeechService.js + +/** + * Web Speech API 래퍼 서비스 + * - SpeechRecognition 객체 관리 + * - 이벤트 핸들링 + * - 상태 관리 + */ +class WebSpeechService { + constructor() { + this.recognition = null; + this.isSupported = this.checkSupport(); + this.isListening = false; + this.callbacks = { + onResult: null, + onError: null, + onStart: null, + onEnd: null, + }; + } + + /** + * Web Speech API 지원 여부 확인 + */ + checkSupport() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + return !!SpeechRecognition; + } + + /** + * Speech Recognition 초기화 + * @param {Object} config - 설정 옵션 + * @param {string} config.lang - 언어 코드 (예: 'ko-KR', 'en-US') + * @param {boolean} config.continuous - 연속 인식 여부 + * @param {boolean} config.interimResults - 중간 결과 표시 여부 + * @param {number} config.maxAlternatives - 대체 결과 최대 개수 + */ + initialize(config = {}) { + if (!this.isSupported) { + console.error('[WebSpeech] Speech Recognition not supported'); + return false; + } + + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + this.recognition = new SpeechRecognition(); + + // 설정 적용 + this.recognition.lang = config.lang || 'ko-KR'; + this.recognition.continuous = config.continuous || false; + this.recognition.interimResults = config.interimResults !== false; // default true + this.recognition.maxAlternatives = config.maxAlternatives || 1; + + // 이벤트 핸들러 등록 + this.setupEventHandlers(); + + console.log('[WebSpeech] Initialized with config:', { + lang: this.recognition.lang, + continuous: this.recognition.continuous, + interimResults: this.recognition.interimResults, + }); + + return true; + } + + /** + * 이벤트 핸들러 설정 + */ + setupEventHandlers() { + // 음성 인식 시작 + this.recognition.onstart = () => { + console.log('[WebSpeech] Recognition started'); + this.isListening = true; + if (this.callbacks.onStart) { + this.callbacks.onStart(); + } + }; + + // 음성 인식 결과 + this.recognition.onresult = (event) => { + const results = event.results; + const lastResult = results[results.length - 1]; + const transcript = lastResult[0].transcript; + const isFinal = lastResult.isFinal; + const confidence = lastResult[0].confidence; + + console.log('[WebSpeech] Result:', { transcript, isFinal, confidence }); + + if (this.callbacks.onResult) { + this.callbacks.onResult({ + transcript, + isFinal, + confidence, + alternatives: Array.from(lastResult).map((alt) => ({ + transcript: alt.transcript, + confidence: alt.confidence, + })), + }); + } + }; + + // 에러 처리 + this.recognition.onerror = (event) => { + console.error('[WebSpeech] Recognition error:', event.error); + this.isListening = false; + + if (this.callbacks.onError) { + this.callbacks.onError({ + error: event.error, + message: this.getErrorMessage(event.error), + }); + } + }; + + // 음성 인식 종료 + this.recognition.onend = () => { + console.log('[WebSpeech] Recognition ended'); + this.isListening = false; + + if (this.callbacks.onEnd) { + this.callbacks.onEnd(); + } + }; + } + + /** + * 에러 메시지 번역 + */ + getErrorMessage(error) { + const errorMessages = { + 'no-speech': '음성이 감지되지 않았습니다. 다시 시도해주세요.', + 'audio-capture': '마이크에 접근할 수 없습니다.', + 'not-allowed': '마이크 사용 권한이 거부되었습니다.', + 'network': '네트워크 오류가 발생했습니다.', + 'aborted': '음성 인식이 중단되었습니다.', + 'service-not-allowed': '음성 인식 서비스를 사용할 수 없습니다.', + }; + + return errorMessages[error] || `알 수 없는 오류: ${error}`; + } + + /** + * 콜백 등록 + * @param {string} event - 이벤트 이름 ('result', 'error', 'start', 'end') + * @param {Function} callback - 콜백 함수 + */ + on(event, callback) { + const eventKey = `on${event.charAt(0).toUpperCase() + event.slice(1)}`; + if (this.callbacks.hasOwnProperty(eventKey)) { + this.callbacks[eventKey] = callback; + } + } + + /** + * 음성 인식 시작 + */ + start() { + if (!this.recognition) { + console.error('[WebSpeech] Recognition not initialized. Call initialize() first.'); + return false; + } + + if (this.isListening) { + console.warn('[WebSpeech] Already listening'); + return false; + } + + try { + this.recognition.start(); + console.log('[WebSpeech] Starting recognition...'); + return true; + } catch (error) { + console.error('[WebSpeech] Failed to start:', error); + return false; + } + } + + /** + * 음성 인식 중지 + */ + stop() { + if (!this.recognition) { + return; + } + + if (!this.isListening) { + console.warn('[WebSpeech] Not listening'); + return; + } + + try { + this.recognition.stop(); + console.log('[WebSpeech] Stopping recognition...'); + } catch (error) { + console.error('[WebSpeech] Failed to stop:', error); + } + } + + /** + * 음성 인식 중단 (즉시 종료) + */ + abort() { + if (this.recognition) { + this.recognition.abort(); + this.isListening = false; + } + } + + /** + * 리소스 정리 + */ + cleanup() { + this.abort(); + this.callbacks = { + onResult: null, + onError: null, + onStart: null, + onEnd: null, + }; + } +} + +// Singleton 인스턴스 생성 +const webSpeechService = new WebSpeechService(); + +export default webSpeechService; +``` + +--- + +### Step 2: Redux 액션 추가 + +**파일**: `src/actions/webSpeechActions.js` + +```javascript +// src/actions/webSpeechActions.js + +import { types } from './actionTypes'; +import webSpeechService from '../services/webSpeech/WebSpeechService'; + +/** + * Web Speech 초기화 및 시작 + * @param {Object} config - 언어 및 설정 + */ +export const initializeWebSpeech = (config = {}) => (dispatch) => { + console.log('[WebSpeechActions] Initializing Web Speech...'); + + // 지원 여부 확인 + if (!webSpeechService.isSupported) { + const error = 'Web Speech API is not supported in this browser'; + console.error('[WebSpeechActions]', error); + dispatch({ + type: types.WEB_SPEECH_ERROR, + payload: { error, message: error }, + }); + return false; + } + + // 초기화 + const initialized = webSpeechService.initialize({ + lang: config.lang || 'ko-KR', + continuous: config.continuous || false, + interimResults: config.interimResults !== false, + maxAlternatives: config.maxAlternatives || 1, + }); + + if (!initialized) { + dispatch({ + type: types.WEB_SPEECH_ERROR, + payload: { error: 'Failed to initialize', message: 'Failed to initialize Web Speech' }, + }); + return false; + } + + // 이벤트 핸들러 등록 + webSpeechService.on('start', () => { + dispatch({ + type: types.WEB_SPEECH_START, + }); + }); + + webSpeechService.on('result', (result) => { + console.log('[WebSpeechActions] Result:', result); + + // Interim 결과 (중간 결과) + if (!result.isFinal) { + dispatch({ + type: types.WEB_SPEECH_INTERIM_RESULT, + payload: result.transcript, + }); + } + // Final 결과 (최종 결과) + else { + dispatch({ + type: types.VOICE_STT_TEXT_RECEIVED, // 기존 VUI와 동일한 액션 사용 + payload: result.transcript, + }); + } + }); + + webSpeechService.on('error', (errorInfo) => { + console.error('[WebSpeechActions] Error:', errorInfo); + dispatch({ + type: types.WEB_SPEECH_ERROR, + payload: errorInfo, + }); + }); + + webSpeechService.on('end', () => { + dispatch({ + type: types.WEB_SPEECH_END, + }); + }); + + dispatch({ + type: types.WEB_SPEECH_INITIALIZED, + }); + + return true; +}; + +/** + * 음성 인식 시작 + */ +export const startWebSpeech = () => (dispatch) => { + console.log('[WebSpeechActions] Starting recognition...'); + const started = webSpeechService.start(); + + if (!started) { + dispatch({ + type: types.WEB_SPEECH_ERROR, + payload: { error: 'Failed to start', message: 'Failed to start recognition' }, + }); + } +}; + +/** + * 음성 인식 중지 + */ +export const stopWebSpeech = () => (dispatch) => { + console.log('[WebSpeechActions] Stopping recognition...'); + webSpeechService.stop(); +}; + +/** + * 음성 인식 중단 + */ +export const abortWebSpeech = () => (dispatch) => { + console.log('[WebSpeechActions] Aborting recognition...'); + webSpeechService.abort(); +}; + +/** + * 리소스 정리 + */ +export const cleanupWebSpeech = () => (dispatch) => { + console.log('[WebSpeechActions] Cleaning up...'); + webSpeechService.cleanup(); + dispatch({ + type: types.WEB_SPEECH_CLEANUP, + }); +}; +``` + +--- + +### Step 3: actionTypes 업데이트 + +**파일**: `src/actions/actionTypes.js` + +기존 파일에 다음 타입들을 추가: + +```javascript +// src/actions/actionTypes.js + +export const types = { + // ... 기존 types + + // Web Speech API 관련 + WEB_SPEECH_INITIALIZED: 'WEB_SPEECH_INITIALIZED', + WEB_SPEECH_START: 'WEB_SPEECH_START', + WEB_SPEECH_INTERIM_RESULT: 'WEB_SPEECH_INTERIM_RESULT', + WEB_SPEECH_END: 'WEB_SPEECH_END', + WEB_SPEECH_ERROR: 'WEB_SPEECH_ERROR', + WEB_SPEECH_CLEANUP: 'WEB_SPEECH_CLEANUP', + + // 기존 VOICE_STT_TEXT_RECEIVED도 그대로 사용 (호환성) +}; +``` + +--- + +### Step 4: voiceReducer 업데이트 + +**파일**: `src/reducers/voiceReducer.js` + +기존 voiceReducer에 Web Speech 상태 추가: + +```javascript +// src/reducers/voiceReducer.js (수정) + +import { types } from '../actions/actionTypes'; + +const initialState = { + // ... 기존 VUI Framework state + + // Web Speech API state (NEW) + webSpeech: { + isInitialized: false, + isListening: false, + interimText: null, + error: null, + }, + + // STT text state (기존 - 두 방식 모두 공유) + lastSTTText: null, + sttTimestamp: null, +}; + +export const voiceReducer = (state = initialState, action) => { + switch (action.type) { + // ... 기존 VUI Framework cases + + // Web Speech API cases (NEW) + case types.WEB_SPEECH_INITIALIZED: + return { + ...state, + webSpeech: { + ...state.webSpeech, + isInitialized: true, + error: null, + }, + }; + + case types.WEB_SPEECH_START: + return { + ...state, + webSpeech: { + ...state.webSpeech, + isListening: true, + interimText: null, + error: null, + }, + }; + + case types.WEB_SPEECH_INTERIM_RESULT: + return { + ...state, + webSpeech: { + ...state.webSpeech, + interimText: action.payload, + }, + }; + + case types.WEB_SPEECH_END: + return { + ...state, + webSpeech: { + ...state.webSpeech, + isListening: false, + interimText: null, + }, + }; + + case types.WEB_SPEECH_ERROR: + return { + ...state, + webSpeech: { + ...state.webSpeech, + isListening: false, + error: action.payload, + }, + }; + + case types.WEB_SPEECH_CLEANUP: + return { + ...state, + webSpeech: { + isInitialized: false, + isListening: false, + interimText: null, + error: null, + }, + }; + + // VOICE_STT_TEXT_RECEIVED는 기존 그대로 유지 (VUI와 Web Speech 모두 사용) + case types.VOICE_STT_TEXT_RECEIVED: + return { + ...state, + lastSTTText: action.payload, + sttTimestamp: new Date().toISOString(), + }; + + default: + return state; + } +}; + +export default voiceReducer; +``` + +--- + +### Step 5: useWebSpeech Hook 구현 + +**파일**: `src/hooks/useWebSpeech.js` + +```javascript +// src/hooks/useWebSpeech.js + +import { useEffect, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + initializeWebSpeech, + startWebSpeech, + stopWebSpeech, + cleanupWebSpeech, +} from '../actions/webSpeechActions'; + +/** + * Web Speech API Hook + * - SearchPanel에서 사용하는 음성 입력 Hook + * - VoiceInputOverlay와 통합 + * + * @param {boolean} isActive - Hook 활성화 여부 (예: SearchPanel이 foreground인지) + * @param {function} onSTTText - STT 텍스트 수신 콜백 + * @param {Object} config - Web Speech 설정 + */ +export const useWebSpeech = (isActive, onSTTText, config = {}) => { + const dispatch = useDispatch(); + const { lastSTTText, sttTimestamp, webSpeech } = useSelector((state) => state.voice); + + // Web Speech 초기화 + useEffect(() => { + if (isActive) { + console.log('[useWebSpeech] Initializing Web Speech API'); + dispatch( + initializeWebSpeech({ + lang: config.lang || 'ko-KR', + continuous: config.continuous || false, + interimResults: config.interimResults !== false, + }) + ); + } else { + console.log('[useWebSpeech] Cleaning up Web Speech API'); + dispatch(cleanupWebSpeech()); + } + + // Cleanup on unmount + return () => { + dispatch(cleanupWebSpeech()); + }; + }, [isActive, dispatch]); + + // STT 텍스트 수신 처리 + useEffect(() => { + if (lastSTTText && sttTimestamp) { + console.log('[useWebSpeech] STT text received:', lastSTTText); + if (onSTTText) { + onSTTText(lastSTTText); + } + } + }, [lastSTTText, sttTimestamp, onSTTText]); + + // 음성 인식 시작/중지 함수 반환 + const startListening = useCallback(() => { + dispatch(startWebSpeech()); + }, [dispatch]); + + const stopListening = useCallback(() => { + dispatch(stopWebSpeech()); + }, [dispatch]); + + return { + isInitialized: webSpeech.isInitialized, + isListening: webSpeech.isListening, + interimText: webSpeech.interimText, + error: webSpeech.error, + startListening, + stopListening, + }; +}; + +export default useWebSpeech; +``` + +--- + +### Step 6: SearchPanel 통합 + +**파일**: `src/views/SearchPanel/SearchPanel.new.jsx` + +기존 SearchPanel에서 `useSearchVoice` 대신 `useWebSpeech` 사용: + +```javascript +// src/views/SearchPanel/SearchPanel.new.jsx (수정) + +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +// import { useSearchVoice } from '../../hooks/useSearchVoice'; // VUI Framework (Plan A) +import { useWebSpeech } from '../../hooks/useWebSpeech'; // Web Speech API (Plan B) +import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay'; + +export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) { + const dispatch = useDispatch(); + const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal || ''); + const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false); + const [voiceMode, setVoiceMode] = useState(VOICE_MODES.PROMPT); + + // STT 텍스트 수신 핸들러 + const handleSTTText = useCallback( + (sttText) => { + console.log('[SearchPanel] STT text received:', sttText); + + // 1. searchQuery 업데이트 + setSearchQuery(sttText); + + // 2. ShopperHouse 검색 실행 + if (sttText && sttText.trim()) { + dispatch(getShopperHouseSearch(sttText.trim())); + } + + // 3. Voice Overlay 닫기 + setTimeout(() => { + setIsVoiceOverlayVisible(false); + }, 500); + }, + [dispatch] + ); + + // ⭐ Web Speech Hook 활성화 (Plan B) + const { isListening, interimText, startListening, stopListening } = useWebSpeech( + isOnTop, + handleSTTText, + { + lang: 'ko-KR', // 한국어 + continuous: false, // 한 번만 인식 + interimResults: true, // 중간 결과 표시 + } + ); + + // 마이크 버튼 클릭 핸들러 + const onClickMic = useCallback(() => { + if (!isOnTop) return; + + // Voice Overlay 열기 + setVoiceMode(VOICE_MODES.PROMPT); + setIsVoiceOverlayVisible(true); + }, [isOnTop]); + + // Voice Overlay 닫기 + const handleVoiceOverlayClose = useCallback(() => { + setIsVoiceOverlayVisible(false); + // 음성 인식 중단 + if (isListening) { + stopListening(); + } + }, [isListening, stopListening]); + + return ( + + + {/* 기존 SearchPanel UI */} + handleSearchSubmit(searchQuery)} + // ... + /> + + {/* 마이크 버튼 */} + + Microphone + + + {/* Voice Overlay */} + setSearchQuery(e.value)} + suggestions={voiceSuggestions} + // Web Speech 전용 props + onStartListening={startListening} + isListening={isListening} + interimText={interimText} + /> + + + ); +} +``` + +--- + +### Step 7: VoiceInputOverlay 수정 + +**파일**: `src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx` + +기존 VoiceInputOverlay에 Web Speech 지원 추가: + +```javascript +// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (수정) + +const VoiceInputOverlay = ({ + isVisible, + onClose, + mode, + suggestions, + searchQuery, + onSearchChange, + onSearchSubmit, + // ⭐ Web Speech 전용 props (NEW) + onStartListening, + isListening, + interimText, +}) => { + const dispatch = useDispatch(); + const [currentMode, setCurrentMode] = useState(mode); + + // Redux에서 voice 상태 가져오기 (기존 VUI Framework 또는 Web Speech) + const { lastSTTText, sttTimestamp } = useSelector((state) => state.voice); + + // STT 텍스트 수신 시 처리 (기존과 동일) + useEffect(() => { + if (lastSTTText && sttTimestamp && isVisible) { + console.log('[VoiceInputOverlay] STT text received:', lastSTTText); + + // 입력창에 텍스트 표시 + if (onSearchChange) { + onSearchChange({ value: lastSTTText }); + } + + // listening 모드로 전환 + setCurrentMode(VOICE_MODES.LISTENING); + + // 1초 후 자동 닫기 + setTimeout(() => { + onClose(); + }, 1000); + } + }, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]); + + // ⭐ Interim 텍스트 표시 (Web Speech 전용) + useEffect(() => { + if (interimText && isVisible) { + console.log('[VoiceInputOverlay] Interim text:', interimText); + // 입력창에 중간 결과 표시 (회색 텍스트 등으로 표시 가능) + if (onSearchChange) { + onSearchChange({ value: interimText }); + } + } + }, [interimText, isVisible, onSearchChange]); + + // 마이크 버튼 클릭 핸들러 + const handleMicClick = useCallback( + (e) => { + e?.stopPropagation(); + + if (currentMode === VOICE_MODES.PROMPT) { + // ⭐ Web Speech API 음성 인식 시작 + console.log('[VoiceInputOverlay] Starting Web Speech recognition'); + setCurrentMode(VOICE_MODES.LISTENING); + + if (onStartListening) { + onStartListening(); // Web Speech startListening 호출 + } + } else if (currentMode === VOICE_MODES.LISTENING) { + // listening 모드에서 클릭 시 -> 종료 + console.log('[VoiceInputOverlay] Closing from LISTENING mode'); + onClose(); + } + }, + [currentMode, onClose, onStartListening] + ); + + return ( + + {/* ... 기존 UI ... */} + + {/* Web Speech 상태 표시 (디버깅용) */} + {process.env.NODE_ENV === 'development' && ( +
+ Web Speech: {isListening ? '🎤 Listening' : '⏸ Ready'} + {interimText &&
Interim: {interimText}
} +
+ )} + + {/* 마이크 버튼 */} + +
+ Microphone +
+ {isListening && ( + + + + )} +
+ + {/* 모드별 컨텐츠 */} + {renderModeContent()} +
+ ); +}; +``` + +--- + +## 🧪 테스트 가이드 + +### 1. 개발 환경 테스트 (Chrome 브라우저) + +**⚠️ 주의**: 개발 환경에서는 마이크 권한 팝업이 표시됩니다 (webOS TV와 다름) + +```bash +# 1. 프로젝트 실행 +npm run serve + +# 2. Chrome 브라우저에서 접속 +# http://localhost:8080 + +# 3. 콘솔에서 Web Speech API 지원 확인 +console.log('Web Speech API 지원:', !!(window.SpeechRecognition || window.webkitSpeechRecognition)); + +# 4. SearchPanel 열기 +# 5. 마이크 버튼 클릭 +# 6. ⚠️ 브라우저에서 마이크 권한 허용 팝업 표시됨 (최초 1회) +# - "Allow" 버튼 클릭 +# 7. 음성 발화 ("아이폰" 등) +# 8. 콘솔에서 STT 결과 확인: +# - [WebSpeech] Result: { transcript: "아이폰", isFinal: true, confidence: 0.9 } +# - [WebSpeechActions] Result: ... +# - [useWebSpeech] STT text received: 아이폰 +``` + +**개발 환경 마이크 권한 문제 해결:** + +```javascript +// Chrome 브라우저에서 마이크 권한이 차단되었을 경우: +// 1. 주소창 왼쪽의 자물쇠 아이콘 클릭 +// 2. "마이크" 권한 설정 +// 3. "허용"으로 변경 +// 4. 페이지 새로고침 +``` + +### 2. webOS TV 시뮬레이터 테스트 + +**✅ 장점**: webOS TV 환경에서는 마이크 권한 팝업이 없음! + +```bash +# 1. appinfo.json 권한 확인 (필수!) +# webos-meta/appinfo.json에 "audio.capture" 권한이 있는지 확인 + +# 2. 빌드 +npm run build + +# 3. 패키징 +npm run package + +# 4. 시뮬레이터에 설치 +npm run install-tv + +# 5. 실행 +npm run launch-tv + +# 6. 음성 입력 테스트 +# - SearchPanel 열기 +# - 마이크 버튼 클릭 +# - ✅ 권한 팝업 없이 바로 음성 인식 시작됨 +# - 음성 발화 ("아이폰" 등) +# - 결과 확인 (SearchPanel에 검색 결과 표시) + +# 7. 로그 확인 (Remote Inspector) +# - Chrome에서 chrome://webos-devtools 접속 +# - 시뮬레이터 앱 선택 → Inspect +# - 콘솔에서 [WebSpeech] 로그 확인 +``` + +**시뮬레이터 마이크 테스트:** + +```bash +# 시뮬레이터에서는 PC의 마이크를 사용합니다 +# - macOS/Windows: 기본 마이크 자동 사용 +# - 마이크가 없으면 음성 인식 불가 ("no-speech" 에러) +``` + +### 3. 실제 TV 테스트 + +**✅ 최종 테스트**: 실제 webOS TV에서 리모컨 마이크 사용 + +```bash +# 1. TV를 개발자 모드로 설정 +# - TV 설정 → 일반 → 정보 → TV 정보 +# - "개발자 모드 앱" 다운로드 +# - Dev Mode 활성화 + +# 2. ares-setup-device로 TV 등록 +ares-setup-device --add tv --info "{'host': '192.168.x.x', 'port': '9922', 'username': 'prisoner'}" + +# 3. appinfo.json 권한 확인 (필수!) +# webos-meta/appinfo.json에 "audio.capture" 권한 추가되어 있는지 확인 + +# 4. 설치 및 실행 +npm run build-ipk +npm run install-tv +npm run launch-tv +``` + +**실제 TV 테스트 시나리오:** + +1. **앱 설치 시**: + - ✅ 마이크 권한 팝업 없음 (appinfo.json 권한으로 자동 허용) + +2. **마이크 버튼 클릭 테스트**: + ``` + SearchPanel → 마이크 버튼 클릭 + → VoiceInputOverlay 표시 + → 마이크 버튼 다시 클릭 + → 즉시 음성 인식 시작 (권한 팝업 없음) + → "아이폰" 발화 + → 검색 결과 표시 + ``` + +3. **리모컨 음성 버튼 테스트** (선택사항): + ``` + SearchPanel 화면에서 + → 리모컨 음성 버튼(🎤) 누름 + → VoiceInputOverlay 자동 표시 + 음성 인식 시작 + → "갤럭시" 발화 + → 검색 결과 표시 + ``` + +4. **로그 확인**: + ```bash + # Remote Inspector로 실제 TV 디버깅 + ares-inspect com.lgshop.app --device tv --open + + # 콘솔 로그 확인: + # [WebSpeech] Initialized with config: { lang: 'ko-KR', ... } + # [WebSpeech] Starting recognition... + # [WebSpeech] Recognition started + # [WebSpeech] Result: { transcript: "아이폰", isFinal: true, ... } + # [useWebSpeech] STT text received: 아이폰 + ``` + +--- + +## 🔧 webOS TV 환경 최적화 + +### 1. 마이크 권한 처리 (중요!) + +**webOS TV 환경의 특별한 점:** + +webOS TV에서는 일반 웹 브라우저와 달리 **별도의 런타임 마이크 권한 요청 팝업이 없습니다** (LG 담당자 확인). + +**권한 설정 방법:** + +1. `webos-meta/appinfo.json`에 `audio.capture` 권한 추가 +2. 앱 설치 시 자동으로 권한 부여 +3. 사용자에게 별도 팝업 표시 없음 + +**파일**: `webos-meta/appinfo.json` + +```json +{ + "id": "com.lgshop.app", + "version": "2.0.0", + "vendor": "LG", + "type": "web", + "main": "index.html", + "title": "ShopTime", + "icon": "icon.png", + "requiredPermissions": [ + "audio.capture" + ] +} +``` + +**중요 사항:** + +- ✅ **webOS TV**: 권한 팝업 없음, appinfo.json만 설정 +- ⚠️ **Chrome 브라우저 (개발 환경)**: 최초 1회 권한 요청 팝업 표시됨 +- 💡 **결론**: TV 환경에서는 사용자 경험 단절 없이 바로 음성 인식 시작 가능 + +**권한 체크 코드 (불필요):** + +webOS TV에서는 아래 권한 체크 코드가 불필요합니다: + +```javascript +// ❌ webOS TV에서는 불필요한 코드 +// (Chrome 브라우저 개발 환경에서만 필요) +const checkMicrophonePermission = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach(track => track.stop()); + return true; + } catch (error) { + console.error('Microphone permission denied:', error); + return false; + } +}; +``` + +**실제 필요한 것:** + +```javascript +// ✅ webOS TV에서 필요한 것: appinfo.json 설정만 +// WebSpeechService.start()를 바로 호출하면 됨 +``` + +### 2. 리모컨 버튼 통합 + +webOS TV 리모컨의 음성 버튼과 통합: + +```javascript +// src/views/SearchPanel/SearchPanel.new.jsx + +useEffect(() => { + if (!isOnTop) return; + + // 리모컨 음성 버튼 (KeyCode 461) 감지 + const handleKeyDown = (event) => { + if (event.keyCode === 461) { + // 음성 버튼 + event.preventDefault(); + setIsVoiceOverlayVisible(true); + // 자동으로 음성 인식 시작 + setTimeout(() => { + startListening(); + }, 300); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; +}, [isOnTop, startListening]); +``` + +### 3. 네트워크 오류 처리 + +Web Speech API는 Google 서버를 사용하므로 네트워크 오류 처리 필요: + +```javascript +// src/hooks/useWebSpeech.js (수정) + +useEffect(() => { + if (webSpeech.error) { + console.error('[useWebSpeech] Error:', webSpeech.error); + + // 네트워크 오류 시 Toast 표시 + if (webSpeech.error.error === 'network') { + dispatch( + showErrorToast('네트워크 오류가 발생했습니다. 인터넷 연결을 확인해주세요.', { + duration: 3000, + }) + ); + } + // 마이크 권한 오류 + else if (webSpeech.error.error === 'not-allowed') { + dispatch( + showErrorToast('마이크 사용 권한이 필요합니다.', { + duration: 3000, + }) + ); + } + // 음성 감지 실패 + else if (webSpeech.error.error === 'no-speech') { + dispatch( + showWarningToast('음성이 감지되지 않았습니다. 다시 시도해주세요.', { + duration: 3000, + }) + ); + } + } +}, [webSpeech.error, dispatch]); +``` + +--- + +## 📊 Plan A vs Plan B 전환 + +### Plan A (VUI Framework) 사용 시 + +```javascript +// src/views/SearchPanel/SearchPanel.new.jsx + +import { useSearchVoice } from '../../hooks/useSearchVoice'; + +// ... + +useSearchVoice(isOnTop, handleSTTText); +``` + +### Plan B (Web Speech API) 사용 시 + +```javascript +// src/views/SearchPanel/SearchPanel.new.jsx + +import { useWebSpeech } from '../../hooks/useWebSpeech'; + +// ... + +const { isListening, startListening, stopListening } = useWebSpeech( + isOnTop, + handleSTTText, + { lang: 'ko-KR' } +); +``` + +### 두 가지 방식 병행 사용 (권장) + +```javascript +// src/views/SearchPanel/SearchPanel.new.jsx + +import { useSearchVoice } from '../../hooks/useSearchVoice'; // Plan A +import { useWebSpeech } from '../../hooks/useWebSpeech'; // Plan B + +// ... + +// 환경 변수로 제어 +const USE_WEB_SPEECH_API = process.env.USE_WEB_SPEECH_API === 'true'; + +if (USE_WEB_SPEECH_API) { + // Plan B: Web Speech API + const { isListening, startListening, stopListening } = useWebSpeech( + isOnTop, + handleSTTText, + { lang: 'ko-KR' } + ); +} else { + // Plan A: VUI Framework + useSearchVoice(isOnTop, handleSTTText); +} +``` + +--- + +## 🚀 구현 체크리스트 + +### 필수 구현 + +- [ ] `WebSpeechService.js` 생성 +- [ ] `webSpeechActions.js` 생성 +- [ ] `actionTypes.js`에 Web Speech 타입 추가 +- [ ] `voiceReducer.js`에 Web Speech 상태 추가 +- [ ] `useWebSpeech.js` Hook 생성 +- [ ] `SearchPanel.new.jsx`에 useWebSpeech 통합 +- [ ] `VoiceInputOverlay.jsx`에 Web Speech 지원 추가 + +### 선택적 최적화 + +- [ ] 리모컨 음성 버튼 통합 +- [ ] 네트워크 오류 Toast 표시 +- [ ] Interim 결과 시각적 표시 +- [ ] 다국어 지원 (en-US, ja-JP 등) +- [ ] webos-meta/appinfo.json에 권한 추가 +- [ ] 환경 변수로 Plan A/B 전환 가능하도록 + +### 테스트 + +- [ ] Chrome 브라우저에서 테스트 +- [ ] webOS 시뮬레이터에서 테스트 +- [ ] 실제 TV에서 테스트 +- [ ] 한국어/영어 음성 인식 테스트 +- [ ] 네트워크 오류 시나리오 테스트 + +--- + +## 📚 참고 자료 + +### Web Speech API 문서 + +- [MDN Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API) +- [SpeechRecognition Interface](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition) +- [Chrome Platform Status - Web Speech](https://chromestatus.com/feature/4782875580825600) + +### webOS 관련 + +- [webOS TV Developer Guide](https://webostv.developer.lge.com/) +- [webOS TV API Reference](https://webostv.developer.lge.com/api/) + +### 프로젝트 문서 + +- `vui-implement.md` - VUI Framework 구현 (Plan A) +- `web-speech.md` - Web Speech API 구현 (Plan B, 이 문서) + +--- + +## 💡 주의사항 + +1. **네트워크 의존성**: Web Speech API는 Google 서버를 사용하므로 **인터넷 연결 필수** +2. **HTTPS 필요**: 로컬호스트 외 환경에서는 HTTPS 필수 (webOS TV는 내부적으로 처리) +3. **마이크 권한 (중요!)**: + - **webOS TV**: `appinfo.json`에 `audio.capture` 권한만 추가하면 됨. **별도 팝업 없음** + - **Chrome 브라우저 (개발 환경)**: 최초 1회 권한 요청 팝업 표시 + - LG 담당자 확인: webOS TV에서는 런타임 권한 요청 없음 +4. **브라우저 호환성**: Chrome/Chromium 기반 브라우저만 지원 (Chrome 68 완벽 지원) +5. **언어 지원**: 한국어('ko-KR'), 영어('en-US'), 일본어('ja-JP') 등 주요 언어 지원 +6. **연속 인식**: `continuous: false` 권장 (한 번 발화 → 한 번 인식이 TV UX에 적합) + +--- + +## 🎯 다음 단계 + +1. **WebSpeechService 구현** - 가장 먼저 구현 +2. **Redux 통합** - 액션 및 리듀서 추가 +3. **useWebSpeech Hook** - SearchPanel에서 사용할 Hook +4. **SearchPanel 통합** - 기존 코드 최소 수정 +5. **테스트** - Chrome → 시뮬레이터 → 실제 TV 순서로 +6. **최적화** - 리모컨 버튼, 에러 처리 등 + +--- + +## 📌 핵심 요약 (TL;DR) + +### webOS TV 환경의 특별한 점 + +| 항목 | Chrome 브라우저 (개발) | webOS TV (실제 환경) | +|------|---------------------|-------------------| +| **마이크 권한** | 런타임 팝업 표시 (최초 1회) | **팝업 없음** (appinfo.json만 설정) | +| **권한 설정** | 브라우저 설정에서 수동 허용 | 앱 설치 시 자동 허용 | +| **사용자 경험** | 권한 허용 단계 필요 | **즉시 음성 인식 시작** | + +### Plan A vs Plan B 최종 비교 + +| 구분 | Plan A (VUI Framework) | Plan B (Web Speech API) | +|------|----------------------|------------------------| +| **API** | webOS Voice Conductor | Web Speech API | +| **구현 복잡도** | ⭐⭐⭐⭐ 높음 | ⭐⭐ 중간 | +| **플랫폼 의존성** | webOS 전용 | 범용 (Chrome 68+) | +| **마이크 권한** | appinfo.json | appinfo.json (동일) | +| **개발 편의성** | 복잡한 Luna 서비스 | 간단한 브라우저 API | +| **디버깅** | VoicePanel 필요 | Chrome DevTools | +| **리모컨 통합** | 자동 통합 | 수동 구현 필요 | +| **네트워크 의존** | webOS 서버 | Google 서버 | + +### 결론 및 권장사항 + +**Plan B (Web Speech API) 추천 상황:** +- ✅ 빠른 프로토타이핑이 필요할 때 +- ✅ 크로스 플랫폼 개발을 고려할 때 +- ✅ Chrome 브라우저에서도 테스트하고 싶을 때 +- ✅ 간단하고 직관적인 API를 선호할 때 + +**Plan A (VUI Framework) 추천 상황:** +- ✅ webOS TV 전용 앱일 때 +- ✅ 리모컨 통합이 필수일 때 +- ✅ LG의 공식 음성 서비스 사용이 필요할 때 +- ✅ 오프라인 환경에서도 동작해야 할 때 (일부 기능) + +**현재 상황:** +- 현재 Plan A (VUI Framework)를 테스트 중 +- Plan B (Web Speech API)는 대안 (fallback)으로 준비 +- 두 가지 모두 Redux 상태를 공유하므로 전환 용이 + +--- + +## 🚀 즉시 시작하기 + +**1분 만에 Web Speech API 테스트:** + +```javascript +// Chrome 콘솔에서 바로 테스트 가능! +const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)(); +recognition.lang = 'ko-KR'; +recognition.onresult = (event) => { + console.log('인식 결과:', event.results[0][0].transcript); +}; +recognition.start(); +// 발화: "안녕하세요" +// 콘솔 출력: 인식 결과: 안녕하세요 +``` + +**webOS TV에서 바로 사용하려면:** + +1. `webos-meta/appinfo.json`에 `"audio.capture"` 권한 추가 +2. `WebSpeechService.js` 복사 +3. `useWebSpeech` Hook 적용 +4. 끝! 🎉 + +--- + +이 문서를 참고하여 단계별로 구현하시면 됩니다. 질문이 있으시면 언제든지 말씀해주세요! 🚀