[251016] fix: VoiceInputOverlay ShopperHouse connect

🕐 커밋 시간: 2025. 10. 16. 10:26:16

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +65줄
  • 삭제: -36줄

📁 추가된 파일:
  + com.twin.app.shoptime/vui-implement.md
  + com.twin.app.shoptime/vui-react.md

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/actionTypes.js
  ~ com.twin.app.shoptime/src/actions/searchActions.js
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/actions/searchActions.js (javascript):
    🔄 Modified: updateSearchTimestamp()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less (unknown):
     Added: scaleY()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx (javascript):
     Added: VoicePromptScreen()
     Deleted: SpotlightContainerDecorator()
  📄 com.twin.app.shoptime/vui-implement.md (md파일):
     Added: dispatch(), Date(), useDispatch(), useSelector(), useEffect(), onSTTText(), SearchPanel(), useState(), useCallback(), setSearchQuery(), getSearch(), setIsVoiceOverlayVisible(), useSearchVoice(), setVoiceMode(), handleSearchSubmit(), onSearchChange(), setCurrentMode(), setTimeout(), onClose(), stopPropagation(), Search(), App(), getLaunchParams(), clearLaunchParams(), pushPanel(), Input(), registerVoiceFramework(), performAction(), handleSTTText()
  📄 com.twin.app.shoptime/vui-react.md (md파일):
     Added: Interface(), Search(), Input(), function(), register(), App(), useDispatch(), useCallback(), getLaunchParams(), clearLaunchParams(), dispatch(), pushPanel(), useEffect(), SearchPanel(), getSearch(), onSuccess(), cancel(), LS2Request(), onCommand(), onFailure(), instance(), deleteInstance(), useRef(), onVoiceInput(), reportActionResult(), registerVoiceConductor(), setVoiceContext(), unregisterVoiceConductor(), setSearchQuery(), useVoiceConductor(), handleSearchSubmit(), setContext()

🔧 주요 변경 내용:
  • 타입 시스템 안정성 강화
  • 핵심 비즈니스 로직 개선
  • 개발 문서 및 가이드 개선
This commit is contained in:
2025-10-16 10:26:18 +09:00
parent a30809d832
commit 9dd5897c24
7 changed files with 1593 additions and 63 deletions

View File

@@ -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 텍스트 수신
};

View File

@@ -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);
};

View File

@@ -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 <VoicePromptScreen suggestions={suggestions} />;
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
);
case VOICE_MODES.LISTENING:
return <VoiceListening />;
case VOICE_MODES.MODE_3:
@@ -86,7 +130,9 @@ const VoiceInputOverlay = ({
// 추후 MODE_4 컴포넌트 추가
return <div>Mode 4 (Coming soon)</div>;
default:
return <VoicePromptScreen suggestions={suggestions} />;
return (
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
);
}
};
@@ -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 (
<TFullPopup
@@ -140,7 +192,7 @@ const VoiceInputOverlay = ({
spotlightRestrict="self-only"
spotlightId={OVERLAY_SPOTLIGHT_ID}
noAnimation={false}
scrimType="none"
scrimType="transparent"
className={css.tFullPopupWrapper}
>
<div className={css.voiceOverlayContainer}>
@@ -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}

View File

@@ -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);
}

View File

@@ -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 = {

View File

@@ -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 (
<TPanel className={css.container} handleCancel={onCancel} spotlightId={spotlightId}>
<TBody className={css.tBody}>
{/* 기존 SearchPanel UI */}
<TInput
value={searchQuery}
onChange={handleSearchChange}
onIconClick={() => handleSearchSubmit(searchQuery)}
// ... props
/>
{/* ⭐ 마이크 버튼 추가 (선택사항) */}
<button onClick={handleMicButtonClick}>
🎤 Voice Search
</button>
{/* ⭐ Voice Overlay */}
<VoiceInputOverlay
isVisible={isVoiceOverlayVisible}
onClose={handleVoiceOverlayClose}
mode={voiceMode}
searchQuery={searchQuery}
onSearchChange={(e) => setSearchQuery(e.value)}
onSearchSubmit={handleSearchSubmit}
suggestions={paginatedKeywords?.map(k => k.keyword) || []}
/>
{/* 검색 결과 등 나머지 UI */}
</TBody>
</TPanel>
);
}
---
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 (
<TFullPopup open={isVisible} onClose={onClose}>
{/* 기존 UI */}
{/* Voice 등록 상태 표시 (디버깅용, 나중에 제거 가능) */}
{__DEV__ && (
<div style={{ position: 'absolute', top: 10, right: 10, color: '#fff' }}>
Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'}
</div>
)}
{/* 나머지 UI */}
</TFullPopup>
);
};
---
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 (
<div className="app">
{/* 앱 컴포넌트 */}
</div>
);
}
// ⭐ 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 액션 확인
---

View File

@@ -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 (
<div className="app">
{/* 앱 컴포넌트 */}
</div>
);
}
// ✅ 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 (
<div>
{/* SearchPanel UI */}
</div>
);
}
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 (
<div className="search-panel">
<TInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.value)}
onIconClick={() => handleSearchSubmit(searchQuery)}
placeholder="Say or type to search..." // 음성 입력 안내
/>
{/* 검색 결과 표시 */}
</div>
);
}
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