diff --git a/com.twin.app.shoptime/src/actions/actionTypes.js b/com.twin.app.shoptime/src/actions/actionTypes.js index 3661d3f8..ecfa24b6 100644 --- a/com.twin.app.shoptime/src/actions/actionTypes.js +++ b/com.twin.app.shoptime/src/actions/actionTypes.js @@ -293,4 +293,5 @@ export const types = { VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE', VOICE_ADD_LOG: 'VOICE_ADD_LOG', VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS', + VOICE_STT_TEXT_RECEIVED: 'VOICE_STT_TEXT_RECEIVED', // STT 텍스트 수신 }; diff --git a/com.twin.app.shoptime/src/actions/searchActions.js b/com.twin.app.shoptime/src/actions/searchActions.js index a35ae8b4..e08e43e6 100644 --- a/com.twin.app.shoptime/src/actions/searchActions.js +++ b/com.twin.app.shoptime/src/actions/searchActions.js @@ -112,6 +112,7 @@ export const getShopperHouseSearch = if (searchId) { params.searchid = searchId; } + console.log('[ShopperHouse] getShopperHouseSearch params: ', JSON.stringify(params)); TAxios(dispatch, getState, 'post', URLS.GET_SHOPPERHOUSE_SEARCH, {}, params, onSuccess, onFail); }; 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 9955444e..9cf19753 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx @@ -6,9 +6,11 @@ import Spotlight from '@enact/spotlight'; import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator'; import Spottable from '@enact/spotlight/Spottable'; +import { useDispatch } from 'react-redux'; import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput'; import TFullPopup from '../../../components/TFullPopup/TFullPopup'; import micIcon from '../../../../assets/images/searchpanel/image-mic.png'; +import { getShopperHouseSearch } from '../../../actions/searchActions'; import css from './VoiceInputOverlay.module.less'; import VoicePromptScreen from './modes/VoicePromptScreen'; import VoiceListening from './modes/VoiceListening'; @@ -44,6 +46,7 @@ const VoiceInputOverlay = ({ onSearchChange, onSearchSubmit, }) => { + const dispatch = useDispatch(); const lastFocusedElement = useRef(null); const [inputFocus, setInputFocus] = useState(false); // 내부 모드 상태 관리 (prompt -> listening -> close) @@ -72,11 +75,52 @@ const VoiceInputOverlay = ({ } }, [isVisible, mode]); + // Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트만 설정 + const handleSuggestionClick = useCallback( + (suggestion) => { + console.log('[VoiceInputOverlay] Suggestion clicked:', suggestion); + // 따옴표 제거 + const query = suggestion.replace(/^["']|["']$/g, '').trim(); + // Input 창에 텍스트 설정 + if (onSearchChange) { + onSearchChange({ value: query }); + } + }, + [onSearchChange] + ); + + // Input 창에서 API 호출 핸들러 (엔터키 또는 돋보기 아이콘 클릭) + const handleSearchSubmit = useCallback(() => { + console.log('[VoiceInputOverlay] Search submit:', searchQuery); + if (searchQuery && searchQuery.trim()) { + // ShopperHouse API 호출 + dispatch(getShopperHouseSearch(searchQuery.trim())); + + // VoiceInputOverlay는 SearchPanel과 다른 API를 사용하므로 onSearchSubmit 호출 안 함 + // if (onSearchSubmit) { + // onSearchSubmit(searchQuery); + // } + } + }, [dispatch, searchQuery]); + + // Input 창에서 엔터키 핸들러 + const handleInputKeyDown = useCallback( + (e) => { + if (e.key === 'Enter' || e.keyCode === 13) { + e.preventDefault(); + handleSearchSubmit(); + } + }, + [handleSearchSubmit] + ); + // 모드에 따른 컨텐츠 렌더링 const renderModeContent = () => { switch (currentMode) { case VOICE_MODES.PROMPT: - return ; + return ( + + ); case VOICE_MODES.LISTENING: return ; case VOICE_MODES.MODE_3: @@ -86,7 +130,9 @@ const VoiceInputOverlay = ({ // 추후 MODE_4 컴포넌트 추가 return
Mode 4 (Coming soon)
; default: - return ; + return ( + + ); } }; @@ -100,37 +146,43 @@ const VoiceInputOverlay = ({ }, []); // 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close) - const handleMicClick = useCallback((e) => { - console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode); + const handleMicClick = useCallback( + (e) => { + console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode); - // 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지 - if (e && e.stopPropagation) { - e.stopPropagation(); - } - if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) { - e.nativeEvent.stopImmediatePropagation(); - } + // 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지 + if (e && e.stopPropagation) { + e.stopPropagation(); + } + if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) { + e.nativeEvent.stopImmediatePropagation(); + } - if (currentMode === VOICE_MODES.PROMPT) { - // prompt 모드에서 클릭 시 -> listening 모드로 전환 - console.log('[VoiceInputOverlay] Switching to LISTENING mode'); - setCurrentMode(VOICE_MODES.LISTENING); - } else if (currentMode === VOICE_MODES.LISTENING) { - // listening 모드에서 클릭 시 -> 종료 - console.log('[VoiceInputOverlay] Closing from LISTENING mode'); - onClose(); - } else { - // 기타 모드에서는 바로 종료 - console.log('[VoiceInputOverlay] Closing from other mode'); - onClose(); - } - }, [currentMode, onClose]); + if (currentMode === VOICE_MODES.PROMPT) { + // prompt 모드에서 클릭 시 -> listening 모드로 전환 + console.log('[VoiceInputOverlay] Switching to LISTENING mode'); + setCurrentMode(VOICE_MODES.LISTENING); + } else if (currentMode === VOICE_MODES.LISTENING) { + // listening 모드에서 클릭 시 -> 종료 + console.log('[VoiceInputOverlay] Closing from LISTENING mode'); + onClose(); + } else { + // 기타 모드에서는 바로 종료 + console.log('[VoiceInputOverlay] Closing from other mode'); + onClose(); + } + }, + [currentMode, onClose] + ); // dim 레이어 클릭 핸들러 (마이크 버튼과 분리) - const handleDimClick = useCallback((e) => { - console.log('[VoiceInputOverlay] dimBackground clicked'); - onClose(); - }, [onClose]); + const handleDimClick = useCallback( + (e) => { + console.log('[VoiceInputOverlay] dimBackground clicked'); + onClose(); + }, + [onClose] + ); return (
@@ -162,7 +214,8 @@ const VoiceInputOverlay = ({ icon={ICONS.search} value={searchQuery} onChange={onSearchChange} - onIconClick={() => onSearchSubmit && onSearchSubmit(searchQuery)} + onKeyDown={handleInputKeyDown} + onIconClick={handleSearchSubmit} spotlightId={INPUT_SPOTLIGHT_ID} onFocus={handleInputFocus} onBlur={handleInputBlur} diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less index 5eb52ce8..1912f895 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less @@ -71,7 +71,7 @@ .bar2 { width: 510px; left: 0; - background: @PRIMARY_COLOR_RED; + background: #FFB3B3; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 1.4s; // 가장 큰 막대 - 마지막 opacity: 0; // 애니메이션으로 제어 @@ -80,7 +80,7 @@ .bar3 { width: 480px; left: 15px; - background: @PRIMARY_COLOR_RED; + background: #FF8080; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 1.2s; opacity: 0; @@ -89,7 +89,7 @@ .bar4 { width: 390px; left: 60px; - background: @PRIMARY_COLOR_RED; + background: #FF6666; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 1.0s; opacity: 0; @@ -98,7 +98,7 @@ .bar5 { width: 350px; left: 80px; - background: @PRIMARY_COLOR_RED; + background: #FF4D4D; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.8s; opacity: 0; @@ -107,7 +107,7 @@ .bar6 { width: 320px; left: 95px; - background: @PRIMARY_COLOR_RED; + background: #FF3333; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.6s; opacity: 0; @@ -116,7 +116,7 @@ .bar7 { width: 260px; left: 125px; - background: @PRIMARY_COLOR_RED; + background: #FF1A1A; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.4s; opacity: 0; @@ -125,7 +125,7 @@ .bar8 { width: 200px; left: 155px; - background: @PRIMARY_COLOR_RED; + background: #FF0000; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.2s; opacity: 0; @@ -134,7 +134,7 @@ .bar9 { width: 150px; left: 180px; - background: @PRIMARY_COLOR_RED; + background: #E00000; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0.1s; opacity: 0; @@ -143,7 +143,7 @@ .bar10 { width: 100px; left: 205px; - background: @PRIMARY_COLOR_RED; + background: #CC0000; animation: waveAppear 1.6s ease-in-out infinite; animation-delay: 0s; // 가장 작은 막대 - 처음 시작 opacity: 0; @@ -155,7 +155,11 @@ opacity: 0; transform: scaleY(0); } - 50% { + 20% { + opacity: 1; + transform: scaleY(1); + } + 80% { opacity: 1; transform: scaleY(1); } diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx index effdc39f..229e9845 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx @@ -1,10 +1,8 @@ // src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; import Spottable from '@enact/spotlight/Spottable'; import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator'; -import { getShopperHouseSearch } from '../../../../actions/searchActions'; import css from './VoicePromptScreen.module.less'; const SpottableBubble = Spottable('div'); @@ -17,27 +15,14 @@ const PromptContainer = SpotlightContainerDecorator( 'div' ); -const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => { - const dispatch = useDispatch(); - const shopperHouseData = useSelector((state) => state.search.shopperHouseData); - const prevDataRef = useRef(null); - - // ShopperHouse 데이터가 변경될 때 콘솔 출력 - useEffect(() => { - if (shopperHouseData && shopperHouseData !== prevDataRef.current) { - console.log('[ShopperHouse]', JSON.stringify(shopperHouseData, null, 2)); - prevDataRef.current = shopperHouseData; - } - }, [shopperHouseData]); - +const VoicePromptScreen = ({ title = 'Try saying', suggestions = [], onSuggestionClick }) => { const handleBubbleClick = (suggestion, index) => { - console.log(`Bubble clicked: ${suggestion}`, index); + console.log(`[VoicePromptScreen] Bubble clicked: ${suggestion}`, index); - // suggestion에서 따옴표 제거하여 검색어 추출 - const query = suggestion.replace(/^["']|["']$/g, '').trim(); - - // ShopperHouse API 호출 - dispatch(getShopperHouseSearch(query)); + // 부모 컴포넌트로 suggestion 텍스트 전달 (API 호출은 부모에서 처리) + if (onSuggestionClick) { + onSuggestionClick(suggestion); + } }; return ( @@ -66,6 +51,7 @@ const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => { VoicePromptScreen.propTypes = { title: PropTypes.string, suggestions: PropTypes.arrayOf(PropTypes.string), + onSuggestionClick: PropTypes.func, }; VoicePromptScreen.defaultProps = { diff --git a/com.twin.app.shoptime/vui-implement.md b/com.twin.app.shoptime/vui-implement.md new file mode 100644 index 00000000..782af476 --- /dev/null +++ b/com.twin.app.shoptime/vui-implement.md @@ -0,0 +1,604 @@ +📋 구현 계획 + +● 이제 각 단계별로 자세히 설명드리겠습니다. + + --- + 🔧 구체적인 구현 방법 + + 1️⃣ voiceActions.js에 UseIME Intent 추가 + + 현재 문제: sendVoiceIntents 함수가 Select, Scroll만 등록하고 UseIME는 없음 + + 해결 방법: UseIME Intent를 추가하고, STT 텍스트를 Redux로 dispatch + + // src/actions/voiceActions.js - sendVoiceIntents 수정 + + export const sendVoiceIntents = (voiceTicket) => (dispatch, getState) => { + console.log('[Voice] Sending voice intents...'); + + const inAppIntents = [ + // ⭐ UseIME Intent 추가 - 음성 입력 텍스트 받기 + { + intent: 'UseIME', + supportAsrOnly: true, // STT만 사용 (NLP 불필요) + }, + // 기존 intents... + { + intent: 'Select', + supportOrdinal: true, + items: [ + // ... 기존 items + ], + }, + { + intent: 'Scroll', + supportOrdinal: false, + items: [ + // ... 기존 items + ], + }, + ]; + + // ... 나머지 코드 동일 + }; + + handleVoiceAction 수정 - UseIME 처리 추가: + + // src/actions/voiceActions.js + + export const handleVoiceAction = (voiceTicket, action) => (dispatch, getState) => { + console.log('[Voice] Handling voice action:', action); + + let result = false; + let feedback = null; + + try { + // ⭐ UseIME Intent 처리 추가 + if (action.intent === 'UseIME' && action.value) { + console.log('[Voice] STT Text received:', action.value); + + // STT 텍스트를 Redux로 dispatch + dispatch({ + type: types.VOICE_STT_TEXT_RECEIVED, + payload: action.value, + }); + + result = true; + feedback = { + voiceUi: { + systemUtterance: `Searching for ${action.value}`, + }, + }; + } + else if (action.intent === 'Select' && action.itemId) { + result = dispatch(handleSelectIntent(action.itemId)); + } + else if (action.intent === 'Scroll' && action.itemId) { + result = dispatch(handleScrollIntent(action.itemId)); + } + else { + console.warn('[Voice] Unknown intent:', action); + result = false; + feedback = { + voiceUi: { + systemUtterance: 'This action is not supported', + }, + }; + } + } catch (error) { + console.error('[Voice] Error processing action:', error); + result = false; + } + + dispatch(reportActionResult(voiceTicket, result, feedback)); + }; + + --- + 2️⃣ actionTypes.js에 새 타입 추가 + + // src/actions/actionTypes.js + + export const types = { + // ... 기존 types + + // Voice 관련 + VOICE_REGISTER_SUCCESS: 'VOICE_REGISTER_SUCCESS', + VOICE_REGISTER_FAILURE: 'VOICE_REGISTER_FAILURE', + VOICE_SET_TICKET: 'VOICE_SET_TICKET', + VOICE_SET_CONTEXT_SUCCESS: 'VOICE_SET_CONTEXT_SUCCESS', + VOICE_SET_CONTEXT_FAILURE: 'VOICE_SET_CONTEXT_FAILURE', + VOICE_UPDATE_INTENTS: 'VOICE_UPDATE_INTENTS', + VOICE_PERFORM_ACTION: 'VOICE_PERFORM_ACTION', + VOICE_REPORT_RESULT_SUCCESS: 'VOICE_REPORT_RESULT_SUCCESS', + VOICE_REPORT_RESULT_FAILURE: 'VOICE_REPORT_RESULT_FAILURE', + VOICE_CLEAR_STATE: 'VOICE_CLEAR_STATE', + VOICE_ADD_LOG: 'VOICE_ADD_LOG', + VOICE_CLEAR_LOGS: 'VOICE_CLEAR_LOGS', + + // ⭐ 새로 추가 + VOICE_STT_TEXT_RECEIVED: 'VOICE_STT_TEXT_RECEIVED', + }; + + --- + 3️⃣ voiceReducer.js 수정 + + // src/reducers/voiceReducer.js + + const initialState = { + // ... 기존 state + + // ⭐ STT 텍스트 state 추가 + lastSTTText: null, + sttTimestamp: null, + }; + + export const voiceReducer = (state = initialState, action) => { + switch (action.type) { + // ... 기존 cases + + // ⭐ STT 텍스트 수신 처리 + case types.VOICE_STT_TEXT_RECEIVED: + return { + ...state, + lastSTTText: action.payload, + sttTimestamp: new Date().toISOString(), + }; + + case types.VOICE_CLEAR_STATE: + return { + ...initialState, + }; + + default: + return state; + } + }; + + --- + 4️⃣ SearchPanel에서 VUI 통합 + + 방법 1: Custom Hook 생성 (권장) + + // src/hooks/useSearchVoice.js + + import { useEffect } from 'react'; + import { useDispatch, useSelector } from 'react-redux'; + import { registerVoiceFramework, unregisterVoiceFramework } from '../actions/voiceActions'; + + /** + * SearchPanel용 음성 입력 Hook + * - SearchPanel이 foreground일 때 voice framework 등록 + * - STT 텍스트를 자동으로 searchQuery로 설정 + */ + export const useSearchVoice = (isOnTop, onSTTText) => { + const dispatch = useDispatch(); + const { lastSTTText, sttTimestamp } = useSelector((state) => state.voice); + + // SearchPanel이 foreground일 때만 voice 등록 + useEffect(() => { + if (isOnTop) { + console.log('[useSearchVoice] Registering voice framework'); + dispatch(registerVoiceFramework()); + } else { + console.log('[useSearchVoice] Unregistering voice framework'); + dispatch(unregisterVoiceFramework()); + } + + // Cleanup on unmount + return () => { + dispatch(unregisterVoiceFramework()); + }; + }, [isOnTop, dispatch]); + + // STT 텍스트 수신 처리 + useEffect(() => { + if (lastSTTText && sttTimestamp) { + console.log('[useSearchVoice] STT text received:', lastSTTText); + onSTTText && onSTTText(lastSTTText); + } + }, [lastSTTText, sttTimestamp, onSTTText]); + }; + + SearchPanel.jsx 수정: + + // src/views/SearchPanel/SearchPanel.jsx + + import React, { useCallback, useState } from 'react'; + import { useSearchVoice } from '../../hooks/useSearchVoice'; + import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay'; + + export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) { + const dispatch = useDispatch(); + const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal || ''); + + // ⭐ Voice Overlay 상태 관리 + 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. 자동 검색 실행 + if (sttText && sttText.trim()) { + dispatch( + getSearch({ + service: 'com.lgshop.app', + query: sttText.trim(), + domain: 'theme,show,item', + }) + ); + } + + // 3. Voice Overlay 닫기 (선택사항) + setIsVoiceOverlayVisible(false); + }, [dispatch]); + + // ⭐ Voice Hook 활성화 + useSearchVoice(isOnTop, handleSTTText); + + // 마이크 버튼 클릭 핸들러 + const handleMicButtonClick = useCallback(() => { + console.log('[SearchPanel] Mic button clicked'); + setVoiceMode(VOICE_MODES.PROMPT); + setIsVoiceOverlayVisible(true); + }, []); + + const handleVoiceOverlayClose = useCallback(() => { + console.log('[SearchPanel] Voice overlay closed'); + setIsVoiceOverlayVisible(false); + }, []); + + return ( + + + {/* 기존 SearchPanel UI */} + handleSearchSubmit(searchQuery)} + // ... props + /> + + {/* ⭐ 마이크 버튼 추가 (선택사항) */} + + + {/* ⭐ Voice Overlay */} + setSearchQuery(e.value)} + onSearchSubmit={handleSearchSubmit} + suggestions={paginatedKeywords?.map(k => k.keyword) || []} + /> + + {/* 검색 결과 등 나머지 UI */} + + + ); + } + + --- + 5️⃣ VoiceInputOverlay 개선 + + 마이크 버튼 클릭 시 실제 음성 입력 시작: + + // src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx + + import { useSelector } from 'react-redux'; + + const VoiceInputOverlay = ({ + isVisible, + onClose, + mode, + suggestions, + searchQuery, + onSearchChange, + onSearchSubmit, + }) => { + const [currentMode, setCurrentMode] = useState(mode); + + // ⭐ Redux에서 voice 상태 가져오기 + const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice); + + // ⭐ STT 텍스트 수신 시 listening 모드로 전환 + useEffect(() => { + if (lastSTTText && isVisible) { + console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText); + + // 입력창에 텍스트 표시 (부모 컴포넌트로 전달) + if (onSearchChange) { + onSearchChange({ value: lastSTTText }); + } + + // listening 모드로 전환 (시각적 피드백) + setCurrentMode(VOICE_MODES.LISTENING); + + // 1초 후 자동 닫기 (선택사항) + setTimeout(() => { + onClose(); + }, 1000); + } + }, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]); + + // 마이크 버튼 클릭 핸들러 + const handleMicClick = useCallback((e) => { + e?.stopPropagation(); + + if (currentMode === VOICE_MODES.PROMPT) { + // prompt 모드에서 클릭 시 -> listening 모드로 전환 + console.log('[VoiceInputOverlay] Switching to LISTENING mode'); + setCurrentMode(VOICE_MODES.LISTENING); + + // 이 시점에서 webOS Voice Framework가 자동으로 음성 인식 시작 + // (이미 registerVoiceFramework()로 등록되어 있으므로) + } else if (currentMode === VOICE_MODES.LISTENING) { + // listening 모드에서 클릭 시 -> 종료 + console.log('[VoiceInputOverlay] Closing from LISTENING mode'); + onClose(); + } + }, [currentMode, onClose]); + + return ( + + {/* 기존 UI */} + + {/* Voice 등록 상태 표시 (디버깅용, 나중에 제거 가능) */} + {__DEV__ && ( +
+ Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'} +
+ )} + + {/* 나머지 UI */} +
+ ); + }; + + --- + 6️⃣ Global Voice Search (handleRelaunch) 구현 + + App.js 수정: + + // src/App/App.js + + import React, { useCallback, useEffect } from 'react'; + import { useDispatch } from 'react-redux'; + import { pushPanel } from '../actions/panelActions'; + import { panel_names } from '../utils/Config'; + + function App() { + const dispatch = useDispatch(); + + // ⭐ webOSRelaunch 이벤트 핸들러 + const handleRelaunchEvent = useCallback(() => { + console.log('[App] handleRelaunchEvent triggered'); + + const launchParams = getLaunchParams(); + clearLaunchParams(); + + if (!launchParams) return; + + // ======================================== + // ⭐ Voice Intent 처리 (최우선) + // ======================================== + if (launchParams.intent) { + const { intent, intentParam, languageCode } = launchParams; + console.log('[App] Voice Intent received:', { intent, intentParam, languageCode }); + + // SearchContent 또는 PlayContent intent 처리 + if (intent === 'SearchContent' || intent === 'PlayContent') { + dispatch( + pushPanel({ + name: panel_names.SEARCH_PANEL, + panelInfo: { + voiceSearch: true, // 음성 검색 플래그 + searchVal: intentParam, // 검색어 + languageCode: languageCode, + }, + }) + ); + + console.log(`[App] Opening SearchPanel with voice query: ${intentParam}`); + return; + } + } + + // ======================================== + // 기존 deeplink 처리 + // ======================================== + if (launchParams.contentTarget) { + console.log('[App] DeepLink:', launchParams.contentTarget); + // dispatch(handleDeepLink(launchParams.contentTarget)); + } + }, [dispatch]); + + // ⭐ webOSRelaunch 이벤트 리스너 등록 + useEffect(() => { + document.addEventListener('webOSRelaunch', handleRelaunchEvent); + + return () => { + document.removeEventListener('webOSRelaunch', handleRelaunchEvent); + }; + }, [handleRelaunchEvent]); + + return ( +
+ {/* 앱 컴포넌트 */} +
+ ); + } + + // ⭐ Launch 파라미터 헬퍼 함수 + function getLaunchParams() { + if (window.PalmSystem) { + try { + const params = JSON.parse(window.PalmSystem.launchParams || '{}'); + console.log('[App] Launch params:', params); + return params; + } catch (e) { + console.error('[App] Failed to parse launch params:', e); + return null; + } + } + return null; + } + + function clearLaunchParams() { + if (window.PalmSystem) { + window.PalmSystem.launchParams = '{}'; + } + } + + export default App; + + --- + 🎯 전체 흐름 요약 + + Foreground Voice Input (VoiceInputOverlay) + + 1. SearchPanel이 foreground가 됨 (isOnTop = true) + ↓ + 2. useSearchVoice Hook이 registerVoiceFramework() dispatch + ↓ + 3. voiceActions.js에서 luna://com.webos.service.voiceconductor/interactor/register 호출 + ↓ + 4. voiceTicket 수신 + ↓ + 5. setContext command 수신 → sendVoiceIntents() 자동 호출 + ↓ + 6. UseIME Intent 등록 완료 + ↓ + 7. 사용자가 마이크 버튼 클릭 → VoiceInputOverlay 표시 + ↓ + 8. 사용자가 음성 발화 (예: "iPhone") + ↓ + 9. performAction command 수신: + { + command: "performAction", + action: { intent: "UseIME", value: "iPhone" } + } + ↓ + 10. handleVoiceAction()에서 VOICE_STT_TEXT_RECEIVED dispatch + ↓ + 11. useSearchVoice의 useEffect가 lastSTTText 감지 + ↓ + 12. onSTTText('iPhone') 호출 + ↓ + 13. SearchPanel의 handleSTTText에서: + - setSearchQuery('iPhone') + - getSearch() dispatch + - VoiceInputOverlay 닫기 + + Global Voice Search + + 1. 사용자가 리모컨 음성 버튼으로 "Search for iPhone on Shop Time" 발화 + ↓ + 2. webOS가 앱 실행하면서 webOSRelaunch 이벤트 발생 + ↓ + 3. App.js의 handleRelaunchEvent에서: + launchParams = { + intent: "SearchContent", + intentParam: "iPhone", + languageCode: "en-US" + } + ↓ + 4. SearchPanel을 pushPanel하면서 panelInfo에 전달: + { + voiceSearch: true, + searchVal: "iPhone", + languageCode: "en-US" + } + ↓ + 5. SearchPanel이 마운트되면서 panelInfo.voiceSearch 감지 + ↓ + 6. 자동으로 getSearch('iPhone') dispatch + + --- + 📝 체크리스트 + + 각 단계가 완료되었는지 확인해보세요: + +● --- + 🔑 핵심 포인트 + + 1. UseIME이 STT 텍스트를 받는 핵심 + + // Intent 등록 + { + intent: "UseIME", + supportAsrOnly: true // 순수 STT만 (NLP 불필요) + } + + // performAction 수신 + { + action: { + intent: "UseIME", + value: "사용자가 말한 텍스트" // ⭐ 이게 핵심! + } + } + + 2. Voice 등록은 Foreground 패널에서만 + + - SearchPanel이 isOnTop = true일 때만 registerVoiceFramework() 호출 + - 다른 패널로 이동하면 unregisterVoiceFramework() 호출 + - 이미 VoicePanel에서 기본 구조는 구현되어 있으므로, 이를 SearchPanel에 복사/적용하면 됨 + + 3. VoiceInputOverlay는 UI만 담당 + + - VoiceInputOverlay는 시각적 피드백(prompt/listening)만 표시 + - 실제 voice framework 등록은 SearchPanel에서 담당 + - Redux의 lastSTTText 변경을 감지해서 UI 업데이트 + + 4. Redux를 통한 상태 공유 + + registerVoiceFramework() + ↓ + voiceTicket 수신 → Redux에 저장 + ↓ + performAction (UseIME) 수신 → Redux에 lastSTTText 저장 + ↓ + SearchPanel의 useEffect가 lastSTTText 감지 + ↓ + handleSTTText() 호출 → 검색 실행 + + --- + 🚀 다음 단계 + + 1. 먼저 voiceActions.js 수정 - UseIME Intent 추가 (가장 중요!) + 2. actionTypes 및 reducer 업데이트 - STT 텍스트 state 관리 + 3. useSearchVoice Hook 구현 - 재사용 가능한 hook + 4. SearchPanel 통합 - useSearchVoice 사용 + 5. VoiceInputOverlay 개선 - Redux 연결 + 6. App.js handleRelaunch - Global Voice Search + 7. 테스트 - VoicePanel에서 먼저 테스트 후 SearchPanel 적용 + + --- + 💡 디버깅 팁 + + Voice 등록 확인 + + // VoicePanel에서 "Register" 버튼 클릭 + // Logs에서 확인: + // 1. REQUEST: Register Voice Framework + // 2. RESPONSE: { returnValue: true, subscribed: true } + // 3. COMMAND: setContext Command Received (voiceTicket 있음) + // 4. REQUEST: Set Voice Context (UseIME intent 확인) + // 5. RESPONSE: Set Voice Context Success + + STT 텍스트 수신 확인 + + // 마이크 버튼 클릭 후 발화하면: + // Logs에서 확인: + // 1. COMMAND: performAction Command Received + // 2. action: { intent: "UseIME", value: "발화 텍스트" } + // 3. Redux devtools에서 VOICE_STT_TEXT_RECEIVED 액션 확인 + + --- \ No newline at end of file diff --git a/com.twin.app.shoptime/vui-react.md b/com.twin.app.shoptime/vui-react.md new file mode 100644 index 00000000..cda910e8 --- /dev/null +++ b/com.twin.app.shoptime/vui-react.md @@ -0,0 +1,881 @@ +# [251015] React 프로젝트 VUI 구현 완벽 가이드 + +## 📚 목차 +- [개요](#개요) +- [1. VUI 핵심 개념](#1-vui-핵심-개념) +- [2. voiceTicket과 STT 텍스트 수신 원리](#2-voiceticket과-stt-텍스트-수신-원리) +- [3. handleRelaunch 구현](#3-handlerelaunch-구현) +- [4. Luna Service 래퍼 구현](#4-luna-service-래퍼-구현) +- [5. Custom Hook 구현](#5-custom-hook-구현) +- [6. React 컴포넌트 통합](#6-react-컴포넌트-통합) +- [7. 전체 구현 체크리스트](#7-전체-구현-체크리스트) +- [8. 트러블슈팅](#8-트러블슈팅) + +--- + +## 개요 + +이 문서는 React 기반 webOS TV 앱에서 **Voice User Interface (VUI)** 기능을 구현하는 완벽한 가이드입니다. + +### 문서 목적 +- ✅ voiceTicket의 정확한 역할 이해 +- ✅ STT(Speech-to-Text) 텍스트를 받는 방법 명확히 설명 +- ✅ handleRelaunch 구현 예시 제공 +- ✅ 전체 React 프로젝트 구조에서의 구현 방법 안내 + +### 지원 환경 +- **webOS 버전**: 5.0 MR2 이상 (2020년형 TV 이후) +- **React 버전**: 16.8+ (Hooks 지원) +- **Luna Service**: `com.webos.service.voiceconductor` + +--- + +## 1. VUI 핵심 개념 + +### 1.1 VUI 기능 분류 + +webOS VUI는 크게 2가지 유형으로 나뉩니다: + +#### A. **Global Voice Search (전역 음성 검색)** +- 앱이 **백그라운드/종료 상태**에서도 동작 +- 사용자: "Search for iPhone on Shop Time" +- 시스템이 앱을 실행하고 `intent`, `intentParam` 전달 +- **handleRelaunch**를 통해 파라미터 수신 + +#### B. **Foreground Voice Input (포그라운드 음성 입력)** +- 앱이 **포그라운드**일 때만 동작 +- 사용자가 🎤 버튼을 누르고 발화 +- **VoiceConductor Service**와 직접 통신 +- STT 텍스트를 실시간으로 수신 + +--- + +## 2. voiceTicket과 STT 텍스트 수신 원리 + +### 2.1 voiceTicket이란? + +**voiceTicket**은 Voice Framework와 통신하기 위한 **인증 토큰**입니다. + +``` +[앱] ---register---> [Voice Framework] + <--- voiceTicket 발급 --- + +이후 모든 API 호출 시 voiceTicket 필요: +- setContext (Intent 등록) +- reportActionResult (결과 보고) +``` + +### 2.2 STT 텍스트 수신 메커니즘 ⭐ + +**핵심 원리**: `/interactor/register`를 `subscribe: true`로 호출하면, **음성 명령이 있을 때마다 같은 콜백이 계속 호출됩니다!** + +```javascript +// ❌ 잘못된 이해: voiceTicket으로 텍스트를 조회한다? +// ⭐ 올바른 이해: subscribe 모드의 onSuccess가 계속 호출되며 텍스트가 전달된다! + +webOS.service.request('luna://com.webos.service.voiceconductor', { + method: '/interactor/register', + parameters: { + type: 'foreground', + subscribe: true // ⚠️ 이것이 핵심! + }, + onSuccess: function(response) { + // 최초 1회: voiceTicket 발급 + if (response.voiceTicket) { + voiceTicket = response.voiceTicket; + } + + // 사용자가 발화할 때마다 이 콜백이 다시 호출됨! + if (response.command === 'performAction') { + // ✅ 여기서 STT 텍스트 수신! + console.log('STT 텍스트:', response.action.value); + console.log('Intent:', response.action.intent); + } + } +}); +``` + +### 2.3 전체 흐름도 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 1. /interactor/register (subscribe: true) │ +│ → onSuccess에서 voiceTicket 저장 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2. /interactor/setContext │ +│ → voiceTicket + UseIME intent 등록 │ +│ → Voice Framework에 "음성 입력 받을 준비 됨" 알림 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 3. 사용자가 리모컨 🎤 버튼 누르고 발화 │ +│ 예: "iPhone" │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 4. /interactor/register의 onSuccess가 다시 호출됨! ✅ │ +│ response.command === "performAction" │ +│ response.action.intent === "UseIME" │ +│ response.action.value === "iPhone" (STT 텍스트!) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 5. /interactor/reportActionResult │ +│ → Voice Framework에 처리 완료 보고 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. handleRelaunch 구현 + +### 3.1 Global Voice Search 파라미터 수신 + +`appinfo.json` 설정: + +```json +{ + "id": "com.lgshop.app", + "version": "2.0.0", + "inAppVoiceIntent": { + "contentTarget": { + "intent": "$INTENT", + "intentParam": "$INTENT_PARAM", + "languageCode": "$LANG_CODE" + }, + "voiceConfig": { + "supportedIntent": ["SearchContent", "PlayContent"], + "supportedVoiceLanguage": ["en-US", "ko-KR"] + } + } +} +``` + +### 3.2 App.js에서 handleRelaunch 구현 + +```javascript +// src/App/App.js +import React, { useCallback, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { pushPanel } from '../actions/panelActions'; +import { panel_names } from '../utils/Config'; + +function App() { + const dispatch = useDispatch(); + + // ✅ handleRelaunchEvent 구현 + const handleRelaunchEvent = useCallback(() => { + console.log("handleRelaunchEvent started"); + + const launchParams = getLaunchParams(); + clearLaunchParams(); + + // ======================================== + // ✅ Voice Intent 처리 (최우선) + // ======================================== + if (launchParams?.intent) { + const { intent, intentParam, languageCode } = launchParams; + console.log("[Voice Intent]", { intent, intentParam, languageCode }); + + // SearchContent 또는 PlayContent intent 처리 + if (intent === "SearchContent" || intent === "PlayContent") { + dispatch( + pushPanel({ + name: panel_names.SEARCH_PANEL, + panelInfo: { + voiceSearch: true, // 음성 검색 플래그 + searchVal: intentParam, // 검색어 + languageCode: languageCode, + }, + }) + ); + + // 로깅 (선택사항) + console.log(`[VUI] Opening SearchPanel with query: ${intentParam}`); + return; + } + } + + // ======================================== + // 기존 deeplink 처리 + // ======================================== + if (launchParams?.contentTarget) { + console.log("[DeepLink]", launchParams.contentTarget); + dispatch(handleDeepLink(launchParams.contentTarget)); + } + }, [dispatch]); + + // ✅ webOSRelaunch 이벤트 리스너 등록 + useEffect(() => { + document.addEventListener("webOSRelaunch", handleRelaunchEvent); + + // Cleanup + return () => { + document.removeEventListener("webOSRelaunch", handleRelaunchEvent); + }; + }, [handleRelaunchEvent]); + + return ( +
+ {/* 앱 컴포넌트 */} +
+ ); +} + +// ✅ Launch 파라미터 헬퍼 함수 +function getLaunchParams() { + // PalmSystem에서 launch params 추출 + if (window.PalmSystem) { + const params = JSON.parse(window.PalmSystem.launchParams || '{}'); + return params; + } + return null; +} + +function clearLaunchParams() { + if (window.PalmSystem) { + window.PalmSystem.launchParams = '{}'; + } +} + +export default App; +``` + +### 3.3 SearchPanel에서 voiceSearch 플래그 처리 + +```javascript +// src/views/SearchPanel/SearchPanel.jsx +import React, { useEffect } from 'react'; + +function SearchPanel({ panelInfo, isOnTop }) { + // ✅ 음성 검색으로 패널이 열렸을 때 자동 검색 + useEffect(() => { + if (panelInfo?.voiceSearch && panelInfo?.searchVal) { + console.log("[SearchPanel] Voice search triggered:", panelInfo.searchVal); + + // 자동으로 검색 실행 + dispatch( + getSearch({ + service: "com.lgshop.app", + query: panelInfo.searchVal, + domain: "theme,show,item", + }) + ); + } + }, [panelInfo?.voiceSearch, panelInfo?.searchVal]); + + return ( +
+ {/* SearchPanel UI */} +
+ ); +} + +export default SearchPanel; +``` + +--- + +## 4. Luna Service 래퍼 구현 + +### 4.1 voiceconductor.js 생성 + +```javascript +// src/lunaSend/voiceconductor.js +import LS2Request from "./LS2Request"; + +/** + * VoiceConductor 서비스: Foreground 앱의 음성 명령 처리 + */ + +// 현재 활성화된 voiceTicket +let currentVoiceTicket = null; +let voiceHandlerRef = null; + +/** + * Voice Framework에 Foreground 앱으로 등록 + * + * @param {Function} onCommand - performAction 수신 시 호출되는 콜백 + * @param {Function} onSuccess - 등록 성공 시 콜백 + * @param {Function} onFailure - 등록 실패 시 콜백 + * @returns {Object} LS2Request 인스턴스 + */ +export const registerVoiceConductor = ({ onCommand, onSuccess, onFailure }) => { + // ======================================== + // Mock 처리 (브라우저 환경) + // ======================================== + if (typeof window === "object" && !window.PalmSystem) { + console.log("[VoiceConductor] MOCK registerVoiceConductor"); + const mockTicket = "mock-voice-ticket-" + Date.now(); + currentVoiceTicket = mockTicket; + onSuccess && onSuccess({ returnValue: true, voiceTicket: mockTicket }); + return null; + } + + // ======================================== + // 기존 구독 취소 + // ======================================== + if (voiceHandlerRef) { + voiceHandlerRef.cancel(); + voiceHandlerRef = null; + } + + // ======================================== + // Voice Framework 등록 (subscribe: true!) + // ======================================== + voiceHandlerRef = new LS2Request().send({ + service: "luna://com.webos.service.voiceconductor", + method: "/interactor/register", + subscribe: true, // ⚠️ 핵심! + parameters: { + type: "foreground", + }, + onSuccess: (res) => { + console.log("[VoiceConductor] register response:", res); + + // ✅ 최초 등록 성공: voiceTicket 저장 + if (res.voiceTicket) { + currentVoiceTicket = res.voiceTicket; + console.log("[VoiceConductor] voiceTicket issued:", currentVoiceTicket); + } + + // ✅ performAction 수신 처리 (STT 텍스트!) + if (res.command === "performAction") { + console.log("[VoiceConductor] performAction received:", res.action); + onCommand && onCommand(res.action, res.voiceTicket); + } + + // 최초 등록 성공 콜백 + if (res.returnValue && res.voiceTicket && !res.command) { + onSuccess && onSuccess(res); + } + }, + onFailure: (err) => { + console.error("[VoiceConductor] register failed:", err); + currentVoiceTicket = null; + onFailure && onFailure(err); + }, + }); + + return voiceHandlerRef; +}; + +/** + * Voice Framework에 현재 화면에서 처리 가능한 Intent 등록 + * + * @param {Array} inAppIntents - Intent 배열 + * @param {Object} callbacks - onSuccess, onFailure 콜백 + * @returns {Object} LS2Request 인스턴스 + */ +export const setVoiceContext = (inAppIntents, { onSuccess, onFailure }) => { + // Mock 처리 + if (typeof window === "object" && !window.PalmSystem) { + console.log("[VoiceConductor] MOCK setVoiceContext:", inAppIntents); + onSuccess && onSuccess({ returnValue: true }); + return null; + } + + // voiceTicket 검증 + if (!currentVoiceTicket) { + console.warn("[VoiceConductor] No voiceTicket. Call registerVoiceConductor first."); + onFailure && onFailure({ returnValue: false, errorText: "No voiceTicket" }); + return null; + } + + return new LS2Request().send({ + service: "luna://com.webos.service.voiceconductor", + method: "/interactor/setContext", + parameters: { + voiceTicket: currentVoiceTicket, + inAppIntents: inAppIntents, + }, + onSuccess: (res) => { + console.log("[VoiceConductor] setContext success:", res); + onSuccess && onSuccess(res); + }, + onFailure: (err) => { + console.error("[VoiceConductor] setContext failed:", err); + onFailure && onFailure(err); + }, + }); +}; + +/** + * Voice 명령 처리 결과를 Voice Framework에 보고 + * + * @param {Boolean} result - 성공 여부 + * @param {String} utterance - TTS로 읽을 피드백 메시지 + * @param {String} exception - 에러 타입 + * @returns {Object} LS2Request 인스턴스 + */ +export const reportActionResult = ({ result, utterance, exception, onSuccess, onFailure }) => { + // Mock 처리 + if (typeof window === "object" && !window.PalmSystem) { + console.log("[VoiceConductor] MOCK reportActionResult:", { result, utterance, exception }); + onSuccess && onSuccess({ returnValue: true }); + return null; + } + + if (!currentVoiceTicket) { + console.warn("[VoiceConductor] No voiceTicket for reportActionResult."); + return null; + } + + const feedback = {}; + if (utterance || exception) { + feedback.voiceUi = {}; + if (utterance) feedback.voiceUi.systemUtterance = utterance; + if (exception) feedback.voiceUi.exception = exception; + } + + return new LS2Request().send({ + service: "luna://com.webos.service.voiceconductor", + method: "/interactor/reportActionResult", + parameters: { + voiceTicket: currentVoiceTicket, + result: result, + ...(Object.keys(feedback).length > 0 && { feedback }), + }, + onSuccess: (res) => { + console.log("[VoiceConductor] reportActionResult success:", res); + onSuccess && onSuccess(res); + }, + onFailure: (err) => { + console.error("[VoiceConductor] reportActionResult failed:", err); + onFailure && onFailure(err); + }, + }); +}; + +/** + * Voice Framework 등록 해제 + */ +export const unregisterVoiceConductor = () => { + if (voiceHandlerRef) { + console.log("[VoiceConductor] unregister"); + voiceHandlerRef.cancel(); + voiceHandlerRef = null; + } + currentVoiceTicket = null; +}; + +/** + * 현재 voiceTicket 가져오기 + */ +export const getVoiceTicket = () => currentVoiceTicket; +``` + +### 4.2 lunaSend/index.js에 export 추가 + +```javascript +// src/lunaSend/index.js +import { LS2RequestSingleton } from './LS2RequestSingleton'; + +export * from './account'; +export * from './common'; +export * from './voiceconductor'; // ✅ 추가 + +export const cancelReq = (instanceName) => { + let r = LS2RequestSingleton.instance(instanceName); + if (r) { + r.cancel(); + r.cancelled = false; + LS2RequestSingleton.deleteInstance(instanceName); + } +}; +``` + +--- + +## 5. Custom Hook 구현 + +### 5.1 useVoiceConductor.js 생성 + +```javascript +// src/hooks/useVoiceConductor.js +import { useEffect, useCallback, useRef } from "react"; +import { + registerVoiceConductor, + setVoiceContext, + reportActionResult, + unregisterVoiceConductor, +} from "../lunaSend/voiceconductor"; + +/** + * VoiceConductor Hook: 음성 명령 처리를 위한 React Hook + * + * @param {Boolean} isActive - 패널이 활성화(foreground)되었는지 여부 + * @param {Function} onVoiceInput - STT 텍스트 수신 시 호출되는 콜백 + * @param {Array} inAppIntents - 등록할 intent 목록 (선택, 기본값: UseIME) + * + * @example + * // SearchPanel에서 사용 + * useVoiceConductor(isOnTop, (text) => { + * console.log('음성 텍스트:', text); + * setSearchQuery(text); + * handleSearchSubmit(text); + * }); + */ +export const useVoiceConductor = (isActive, onVoiceInput, inAppIntents = null) => { + const isActiveRef = useRef(isActive); + + // isActive 상태를 ref로 관리 (콜백에서 최신 값 참조) + useEffect(() => { + isActiveRef.current = isActive; + }, [isActive]); + + // ======================================== + // performAction 수신 처리 + // ======================================== + const handleCommand = useCallback( + (action, voiceTicket) => { + // 패널이 활성화되지 않았으면 무시 + if (!isActiveRef.current) { + console.log("[useVoiceConductor] Not active, ignoring command"); + return; + } + + console.log("[useVoiceConductor] handleCommand:", action); + + const { intent, value } = action; + + // ✅ UseIME: 음성 입력 텍스트 전달 + if (intent === "UseIME" && value) { + console.log("[useVoiceConductor] STT 텍스트:", value); + onVoiceInput && onVoiceInput(value); + + // 성공 피드백 보고 + reportActionResult({ + result: true, + utterance: `Searching for ${value}`, + }); + } + + // 다른 intent 처리 가능 (Select, Scroll 등) + // if (intent === "Select" && action.itemId) { ... } + }, + [onVoiceInput] + ); + + // ======================================== + // Voice Framework 등록 및 Intent 설정 + // ======================================== + useEffect(() => { + if (!isActive) return; + + console.log("[useVoiceConductor] Registering VoiceConductor..."); + + // 1. Voice Framework 등록 + const handler = registerVoiceConductor({ + onCommand: handleCommand, + onSuccess: (res) => { + console.log("[useVoiceConductor] Registered, voiceTicket:", res.voiceTicket); + + // 2. Intent 등록 (UseIME) + const defaultIntents = [ + { + intent: "UseIME", + supportAsrOnly: true, // STT만 사용 (NLP 불필요) + }, + ]; + + const intentsToRegister = inAppIntents || defaultIntents; + + setVoiceContext(intentsToRegister, { + onSuccess: (contextRes) => { + console.log("[useVoiceConductor] Context set successfully"); + }, + onFailure: (err) => { + console.error("[useVoiceConductor] Failed to set context:", err); + }, + }); + }, + onFailure: (err) => { + console.error("[useVoiceConductor] Registration failed:", err); + }, + }); + + // 3. Cleanup: 패널이 닫히거나 비활성화될 때 + return () => { + console.log("[useVoiceConductor] Unregistering..."); + unregisterVoiceConductor(); + }; + }, [isActive, handleCommand, inAppIntents]); +}; +``` + +--- + +## 6. React 컴포넌트 통합 + +### 6.1 SearchPanel.jsx에서 사용 + +```javascript +// src/views/SearchPanel/SearchPanel.jsx +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { getSearch, resetSearch } from "../../actions/searchActions"; +import { useVoiceConductor } from "../../hooks/useVoiceConductor"; +import TInput from "../../components/TInput/TInput"; + +function SearchPanel({ panelInfo, isOnTop, spotlightId }) { + const dispatch = useDispatch(); + const [searchQuery, setSearchQuery] = useState(panelInfo?.searchVal || ""); + + // ======================================== + // ✅ Voice Input 처리 콜백 + // ======================================== + const handleVoiceInput = useCallback( + (voiceText) => { + console.log("[SearchPanel] Voice input received:", voiceText); + + // 검색어 설정 + setSearchQuery(voiceText); + + // 즉시 검색 수행 + if (voiceText && voiceText.trim()) { + dispatch( + getSearch({ + service: "com.lgshop.app", + query: voiceText.trim(), + domain: "theme,show,item", + }) + ); + } + }, + [dispatch] + ); + + // ======================================== + // ✅ VoiceConductor Hook 활성화 + // ======================================== + useVoiceConductor(isOnTop, handleVoiceInput); + + // ======================================== + // ✅ Global Voice Search 처리 + // ======================================== + useEffect(() => { + if (panelInfo?.voiceSearch && panelInfo?.searchVal) { + console.log("[SearchPanel] Global voice search:", panelInfo.searchVal); + setSearchQuery(panelInfo.searchVal); + + dispatch( + getSearch({ + service: "com.lgshop.app", + query: panelInfo.searchVal, + domain: "theme,show,item", + }) + ); + } + }, [panelInfo?.voiceSearch, panelInfo?.searchVal]); + + // ======================================== + // 수동 검색 처리 + // ======================================== + const handleSearchSubmit = useCallback( + (query) => { + if (query && query.trim()) { + dispatch( + getSearch({ + service: "com.lgshop.app", + query: query.trim(), + domain: "theme,show,item", + }) + ); + } + }, + [dispatch] + ); + + return ( +
+ setSearchQuery(e.value)} + onIconClick={() => handleSearchSubmit(searchQuery)} + placeholder="Say or type to search..." // 음성 입력 안내 + /> + + {/* 검색 결과 표시 */} +
+ ); +} + +export default SearchPanel; +``` + +--- + +## 7. 전체 구현 체크리스트 + +### Phase 1: Global Voice Search + +- [ ] **appinfo.json 수정** + - [ ] `inAppVoiceIntent` 섹션 추가 + - [ ] `supportedIntent`에 `SearchContent`, `PlayContent` 추가 + - [ ] `supportedVoiceLanguage` 설정 + +- [ ] **App.js 수정** + - [ ] `handleRelaunchEvent` 함수 구현 + - [ ] `webOSRelaunch` 이벤트 리스너 등록 + - [ ] Voice Intent 파라미터 파싱 + - [ ] SearchPanel 열기 및 검색 실행 + +- [ ] **SearchPanel 수정** + - [ ] `panelInfo.voiceSearch` 플래그 확인 + - [ ] 자동 검색 로직 추가 + +### Phase 2: Foreground Voice Input + +- [ ] **Luna Service 래퍼 생성** + - [ ] `src/lunaSend/voiceconductor.js` 생성 + - [ ] `registerVoiceConductor` 함수 구현 + - [ ] `setVoiceContext` 함수 구현 + - [ ] `reportActionResult` 함수 구현 + - [ ] `unregisterVoiceConductor` 함수 구현 + +- [ ] **lunaSend/index.js 수정** + - [ ] voiceconductor export 추가 + +- [ ] **Custom Hook 생성** + - [ ] `src/hooks/useVoiceConductor.js` 생성 + - [ ] subscribe 모드 콜백 처리 + - [ ] Intent 등록 로직 + - [ ] Cleanup 로직 + +- [ ] **SearchPanel 통합** + - [ ] `useVoiceConductor` Hook 추가 + - [ ] `handleVoiceInput` 콜백 구현 + - [ ] STT 텍스트로 자동 검색 + +### Phase 3: 테스트 + +- [ ] **브라우저 Mock 테스트** + - [ ] Console에 MOCK 로그 확인 + - [ ] 수동 트리거로 동작 확인 + +- [ ] **Emulator 테스트** + - [ ] 앱 빌드 및 설치 (`npm run pack`) + - [ ] Global Voice Search 테스트 + - [ ] Foreground Voice Input 테스트 + +- [ ] **실제 TV 테스트** + - [ ] Voice Remote로 명령 테스트 + - [ ] 다국어 테스트 (en-US, ko-KR) + - [ ] Luna Service 로그 확인 + +--- + +## 8. 트러블슈팅 + +### 문제 1: voiceTicket이 null로 나옴 + +**원인**: +- `subscribe: true`를 설정하지 않음 +- Luna Service 호출 실패 + +**해결**: +```javascript +// ✅ subscribe: true 확인 +registerVoiceConductor({ + subscribe: true, // 필수! + ... +}); +``` + +### 문제 2: STT 텍스트를 받지 못함 + +**원인**: +- `setContext`에서 UseIME intent를 등록하지 않음 +- `performAction` 이벤트 처리 누락 + +**해결**: +```javascript +// 1. Intent 등록 확인 +setVoiceContext([{ + intent: "UseIME", + supportAsrOnly: true, +}]); + +// 2. performAction 처리 확인 +if (response.command === "performAction") { + console.log(response.action.value); // STT 텍스트 +} +``` + +### 문제 3: 패널이 닫혀도 음성 입력이 계속 처리됨 + +**원인**: +- `unregisterVoiceConductor` 호출 누락 + +**해결**: +```javascript +// useEffect cleanup에서 반드시 호출 +useEffect(() => { + // ... 등록 로직 + + return () => { + unregisterVoiceConductor(); // ✅ 필수! + }; +}, [isActive]); +``` + +### 문제 4: 브라우저에서 에러 발생 + +**원인**: +- PalmSystem이 없는 환경에서 Luna Service 호출 + +**해결**: +```javascript +// Mock 처리 추가 +if (typeof window === "object" && !window.PalmSystem) { + console.log("[VoiceConductor] MOCK mode"); + // Mock 응답 반환 + return; +} +``` + +--- + +## 9. 핵심 요약 + +### voiceTicket의 역할 +- Voice Framework와 통신하기 위한 **인증 토큰** +- `/interactor/register` 최초 호출 시 발급 +- 이후 `setContext`, `reportActionResult`에 필수 + +### STT 텍스트 수신 방법 +1. `/interactor/register`를 **subscribe: true**로 호출 +2. 최초 onSuccess에서 voiceTicket 저장 +3. 사용자 발화 시 **같은 onSuccess가 다시 호출**됨 +4. `response.command === "performAction"` 확인 +5. `response.action.value`에 STT 텍스트 포함! + +### 전체 흐름 +``` +register (subscribe: true) + → voiceTicket 발급 + → setContext (UseIME 등록) + → 사용자 발화 + → onSuccess 다시 호출 (performAction) + → action.value = STT 텍스트! + → reportActionResult +``` + +--- + +## 10. 참고 문서 + +- **VUI 기본 가이드**: `docs/vui/VoiceUserInterface_ko.md` +- **SearchPanel VUI 구현**: `docs/vui/searchpanel-vui-implementation-guide.md` +- **테스트 시나리오**: `docs/vui/vui-test-scenarios.md` +- **Luna Service 가이드**: webOS Developer Portal + +--- + +**작성일**: 2025-10-15 +**작성자**: AI Assistant +**버전**: 1.0.0 +**관련 이슈**: React VUI Implementation Guide