[251016] fix: VoicePanel STT
🕐 커밋 시간: 2025. 10. 16. 11:02:03 📊 변경 통계: • 총 파일: 8개 • 추가: +321줄 • 삭제: -7줄 📁 추가된 파일: + com.twin.app.shoptime/src/hooks/useSearchVoice.js 📝 수정된 파일: ~ com.twin.app.shoptime/src/App/App.js ~ com.twin.app.shoptime/src/actions/voiceActions.js ~ com.twin.app.shoptime/src/reducers/voiceReducer.js ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.jsx ~ com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.module.less 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/App/App.js (javascript): 🔄 Modified: function() 📄 com.twin.app.shoptime/src/actions/voiceActions.js (javascript): 🔄 Modified: registerVoiceFramework() 🔧 주요 변경 내용: • 핵심 비즈니스 로직 개선
This commit is contained in:
@@ -302,14 +302,55 @@ function AppBase(props) {
|
||||
);
|
||||
|
||||
const handleRelaunchEvent = useCallback(() => {
|
||||
console.log('handleRelaunchEvent started');
|
||||
console.log('[App] handleRelaunchEvent triggered');
|
||||
|
||||
const launchParams = getLaunchParams();
|
||||
clearLaunchParams();
|
||||
|
||||
if (!launchParams) {
|
||||
if (introTermsAgreeRef.current) {
|
||||
initService(false);
|
||||
}
|
||||
if (typeof window === 'object' && window.PalmSystem) {
|
||||
window.PalmSystem.activate();
|
||||
}
|
||||
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: Config.panel_names.SEARCH_PANEL,
|
||||
panelInfo: {
|
||||
voiceSearch: true, // 음성 검색 플래그
|
||||
searchVal: intentParam, // 검색어
|
||||
languageCode: languageCode,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`[App] Opening SearchPanel with voice query: ${intentParam}`);
|
||||
if (typeof window === 'object' && window.PalmSystem) {
|
||||
window.PalmSystem.activate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 로직 유지
|
||||
if (introTermsAgreeRef.current) {
|
||||
initService(false);
|
||||
}
|
||||
if (typeof window === 'object' && window.PalmSystem) {
|
||||
window.PalmSystem.activate();
|
||||
}
|
||||
}, [initService, introTermsAgreeRef]);
|
||||
}, [initService, introTermsAgreeRef, dispatch]);
|
||||
|
||||
const visibilityChanged = useCallback(() => {
|
||||
console.log('document is hidden', document.hidden);
|
||||
|
||||
@@ -143,6 +143,11 @@ export const sendVoiceIntents = (voiceTicket) => (dispatch, getState) => {
|
||||
// Define the intents that this app supports
|
||||
// This is a sample configuration - customize based on your app's features
|
||||
const inAppIntents = [
|
||||
// UseIME Intent - STT 텍스트 받기
|
||||
{
|
||||
intent: 'UseIME',
|
||||
supportAsrOnly: true, // STT만 사용 (NLP 불필요)
|
||||
},
|
||||
{
|
||||
intent: 'Select',
|
||||
supportOrdinal: true,
|
||||
@@ -248,8 +253,45 @@ export const handleVoiceAction = (voiceTicket, action) => (dispatch, getState) =
|
||||
let feedback = null;
|
||||
|
||||
try {
|
||||
// UseIME Intent 처리 - STT 텍스트 수신
|
||||
if (action.intent === 'UseIME' && action.value) {
|
||||
console.log('[Voice] ⭐ STT Text received:', action.value);
|
||||
|
||||
// 📝 로그: STT 텍스트 추출 과정
|
||||
dispatch(
|
||||
addLog('ACTION', '🎤 STT Text Extracted (Speech → Text)', {
|
||||
intent: 'UseIME',
|
||||
extractedText: action.value,
|
||||
textLength: action.value.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
description: 'User speech has been converted to text successfully',
|
||||
})
|
||||
);
|
||||
|
||||
// STT 텍스트를 Redux로 dispatch
|
||||
dispatch({
|
||||
type: types.VOICE_STT_TEXT_RECEIVED,
|
||||
payload: action.value,
|
||||
});
|
||||
|
||||
// 📝 로그: Redux 저장 완료
|
||||
dispatch(
|
||||
addLog('ACTION', '✅ STT Text Saved to Redux', {
|
||||
savedText: action.value,
|
||||
reduxAction: 'VOICE_STT_TEXT_RECEIVED',
|
||||
state: 'lastSTTText updated',
|
||||
})
|
||||
);
|
||||
|
||||
result = true;
|
||||
feedback = {
|
||||
voiceUi: {
|
||||
systemUtterance: `Searching for ${action.value}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Process action based on intent and itemId
|
||||
if (action.intent === 'Select' && action.itemId) {
|
||||
else if (action.intent === 'Select' && action.itemId) {
|
||||
result = dispatch(handleSelectIntent(action.itemId));
|
||||
} else if (action.intent === 'Scroll' && action.itemId) {
|
||||
result = dispatch(handleScrollIntent(action.itemId));
|
||||
|
||||
46
com.twin.app.shoptime/src/hooks/useSearchVoice.js
Normal file
46
com.twin.app.shoptime/src/hooks/useSearchVoice.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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로 설정
|
||||
*
|
||||
* @param {boolean} isOnTop - SearchPanel이 foreground 상태인지 여부
|
||||
* @param {function} onSTTText - STT 텍스트 수신 시 호출될 콜백 함수
|
||||
*/
|
||||
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);
|
||||
if (onSTTText) {
|
||||
onSTTText(lastSTTText);
|
||||
}
|
||||
}
|
||||
}, [lastSTTText, sttTimestamp, onSTTText]);
|
||||
};
|
||||
|
||||
export default useSearchVoice;
|
||||
@@ -23,6 +23,10 @@ const initialState = {
|
||||
isProcessingAction: false,
|
||||
actionError: null,
|
||||
|
||||
// STT text state
|
||||
lastSTTText: null,
|
||||
sttTimestamp: null,
|
||||
|
||||
// Logging for debugging
|
||||
logs: [],
|
||||
logIdCounter: 0,
|
||||
@@ -122,6 +126,13 @@ export const voiceReducer = (state = initialState, action) => {
|
||||
logIdCounter: 0,
|
||||
};
|
||||
|
||||
case types.VOICE_STT_TEXT_RECEIVED:
|
||||
return {
|
||||
...state,
|
||||
lastSTTText: action.payload,
|
||||
sttTimestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import TInput, { ICONS, KINDS } from '../../components/TInput/TInput';
|
||||
import TPanel from '../../components/TPanel/TPanel';
|
||||
import TVerticalPagenator from '../../components/TVerticalPagenator/TVerticalPagenator';
|
||||
import usePrevious from '../../hooks/usePrevious';
|
||||
import useSearchVoice from '../../hooks/useSearchVoice';
|
||||
import { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from '../../utils/Config';
|
||||
import { SpotlightIds } from '../../utils/SpotlightIds';
|
||||
import NoSearchResults from './NoSearchResults/NoSearchResults';
|
||||
@@ -144,6 +145,31 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
[dispatch, searchPerformed, searchDatas, searchQuery]
|
||||
);
|
||||
|
||||
// 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',
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Voice Hook 활성화
|
||||
useSearchVoice(isOnTop, handleSTTText);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (!isOnTopRef.current) {
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import Spotlight from '@enact/spotlight';
|
||||
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
|
||||
import Spottable from '@enact/spotlight/Spottable';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } 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';
|
||||
@@ -52,6 +52,29 @@ const VoiceInputOverlay = ({
|
||||
// 내부 모드 상태 관리 (prompt -> listening -> close)
|
||||
const [currentMode, setCurrentMode] = useState(mode);
|
||||
|
||||
// Redux에서 voice 상태 가져오기
|
||||
const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
|
||||
|
||||
// STT 텍스트 수신 시 처리
|
||||
useEffect(() => {
|
||||
if (lastSTTText && sttTimestamp && 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]);
|
||||
|
||||
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
@@ -162,6 +185,8 @@ const VoiceInputOverlay = ({
|
||||
// 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');
|
||||
@@ -199,6 +224,13 @@ const VoiceInputOverlay = ({
|
||||
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
|
||||
<div className={css.dimBackground} onClick={handleDimClick} />
|
||||
|
||||
{/* Voice 등록 상태 표시 (디버깅용) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div style={{ position: 'absolute', top: 10, right: 10, color: '#fff', zIndex: 10000 }}>
|
||||
Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */}
|
||||
<OverlayContainer
|
||||
className={css.contentArea}
|
||||
|
||||
@@ -23,7 +23,8 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
|
||||
// Voice state from Redux
|
||||
const voiceState = useSelector((state) => state.voice);
|
||||
const { isRegistered, voiceTicket, logs, registrationError } = voiceState;
|
||||
const { isRegistered, voiceTicket, logs, registrationError, lastSTTText, sttTimestamp } =
|
||||
voiceState;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnTop) {
|
||||
@@ -68,8 +69,64 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
}, [dispatch]);
|
||||
|
||||
const handleLoadMockData = useCallback(() => {
|
||||
console.log('[VoicePanel] Loading 200 mock log entries for scroll test');
|
||||
// Add all mock logs to Redux
|
||||
console.log('[VoicePanel] Loading mock data: STT text + logs');
|
||||
|
||||
// 1. Mock STT Text 추가 (실제 음성 인식 결과처럼 표시)
|
||||
dispatch({
|
||||
type: types.VOICE_STT_TEXT_RECEIVED,
|
||||
payload: 'iPhone 15 Pro Max',
|
||||
});
|
||||
|
||||
// 2. Mock logs 추가 - STT 추출 과정 시뮬레이션
|
||||
dispatch({
|
||||
type: types.VOICE_ADD_LOG,
|
||||
payload: {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'COMMAND',
|
||||
title: 'performAction Command Received',
|
||||
data: {
|
||||
command: 'performAction',
|
||||
voiceTicket: 'mock-ticket-12345',
|
||||
action: {
|
||||
type: 'IntentMatch',
|
||||
intent: 'UseIME',
|
||||
value: 'iPhone 15 Pro Max',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: types.VOICE_ADD_LOG,
|
||||
payload: {
|
||||
timestamp: new Date(Date.now() + 100).toISOString(),
|
||||
type: 'ACTION',
|
||||
title: '🎤 STT Text Extracted (Speech → Text)',
|
||||
data: {
|
||||
intent: 'UseIME',
|
||||
extractedText: 'iPhone 15 Pro Max',
|
||||
textLength: 18,
|
||||
timestamp: new Date().toISOString(),
|
||||
description: 'User speech has been converted to text successfully',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: types.VOICE_ADD_LOG,
|
||||
payload: {
|
||||
timestamp: new Date(Date.now() + 200).toISOString(),
|
||||
type: 'ACTION',
|
||||
title: '✅ STT Text Saved to Redux',
|
||||
data: {
|
||||
savedText: 'iPhone 15 Pro Max',
|
||||
reduxAction: 'VOICE_STT_TEXT_RECEIVED',
|
||||
state: 'lastSTTText updated',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 3. 기존 mock logs도 추가
|
||||
mockLogs.forEach((log) => {
|
||||
dispatch({
|
||||
type: types.VOICE_ADD_LOG,
|
||||
@@ -191,6 +248,41 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* STT Text Display - 별도 섹션으로 명확하게 표시 */}
|
||||
<div className={css.sttTextPanel}>
|
||||
<div className={css.sttHeader}>
|
||||
<span>📝 STT Text (Speech → Text)</span>
|
||||
</div>
|
||||
<div className={css.sttContent}>
|
||||
{lastSTTText ? (
|
||||
<div className={css.sttTextDisplay}>
|
||||
<div className={css.sttTextValue}>"{lastSTTText}"</div>
|
||||
<div className={css.sttTextInfo}>
|
||||
<span className={css.sttLabel}>Received at:</span>
|
||||
<span className={css.sttTimestamp}>
|
||||
{sttTimestamp
|
||||
? new Date(sttTimestamp).toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.sttTextInfo}>
|
||||
<span className={css.sttLabel}>Length:</span>
|
||||
<span>{lastSTTText.length} characters</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={css.emptyStt}>
|
||||
No STT text received yet. Speak after registering to see the result.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Viewer */}
|
||||
<div className={css.logSection}>
|
||||
<div className={css.logHeader}>
|
||||
|
||||
@@ -112,6 +112,76 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
// STT Text Panel - 음성 텍스트 표시
|
||||
.sttTextPanel {
|
||||
background: rgba(0, 100, 255, 0.1);
|
||||
border: 2px solid rgba(0, 150, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 20px 30px;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sttHeader {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #4A90E2;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sttContent {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.sttTextDisplay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sttTextValue {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #00FF88;
|
||||
font-family: 'Courier New', monospace;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #00FF88;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sttTextInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.sttLabel {
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.sttTimestamp {
|
||||
color: #FFB84D;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.emptyStt {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 18px;
|
||||
padding: 30px 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Log Section - Dark theme with better visibility
|
||||
.logSection {
|
||||
flex: 1;
|
||||
|
||||
Reference in New Issue
Block a user