[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:
2025-10-16 11:02:05 +09:00
parent 9dd5897c24
commit 267c64effe
8 changed files with 367 additions and 7 deletions

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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