[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:
@@ -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 텍스트 수신
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,7 +146,8 @@ const VoiceInputOverlay = ({
|
||||
}, []);
|
||||
|
||||
// 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
|
||||
const handleMicClick = useCallback((e) => {
|
||||
const handleMicClick = useCallback(
|
||||
(e) => {
|
||||
console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode);
|
||||
|
||||
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
|
||||
@@ -124,13 +171,18 @@ const VoiceInputOverlay = ({
|
||||
console.log('[VoiceInputOverlay] Closing from other mode');
|
||||
onClose();
|
||||
}
|
||||
}, [currentMode, onClose]);
|
||||
},
|
||||
[currentMode, onClose]
|
||||
);
|
||||
|
||||
// dim 레이어 클릭 핸들러 (마이크 버튼과 분리)
|
||||
const handleDimClick = useCallback((e) => {
|
||||
const handleDimClick = useCallback(
|
||||
(e) => {
|
||||
console.log('[VoiceInputOverlay] dimBackground clicked');
|
||||
onClose();
|
||||
}, [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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
604
com.twin.app.shoptime/vui-implement.md
Normal file
604
com.twin.app.shoptime/vui-implement.md
Normal 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 액션 확인
|
||||
|
||||
---
|
||||
881
com.twin.app.shoptime/vui-react.md
Normal file
881
com.twin.app.shoptime/vui-react.md
Normal 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
|
||||
Reference in New Issue
Block a user