[251016] fix: VUI Test-2

🕐 커밋 시간: 2025. 10. 16. 15:01:09

📊 변경 통계:
  • 총 파일: 5개
  • 추가: +240줄
  • 삭제: -48줄

📁 추가된 파일:
  + com.twin.app.shoptime/vui-test.1.md
  + com.twin.app.shoptime/web-speech.md

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less
  ~ com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/web-speech.md (md파일):
     Added: Framework(), Hook(), constructor(), checkSupport(), initialize(), SpeechRecognition(), setupEventHandlers(), onStart(), onResult(), onError(), getErrorMessage(), onEnd(), start(), abort(), cleanup(), WebSpeechService(), dispatch(), Date(), useDispatch(), useSelector(), useEffect(), initializeWebSpeech(), onSTTText(), useCallback(), SearchPanel(), useState(), setSearchQuery(), setTimeout(), setIsVoiceOverlayVisible(), useWebSpeech(), setVoiceMode(), stopListening(), handleSearchSubmit(), onSearchChange(), setCurrentMode(), onClose(), stopPropagation(), classNames(), renderModeContent(), async(), getUserMedia(), getTracks(), preventDefault(), startListening(), useSearchVoice()

🔧 주요 변경 내용:
  • 테스트 커버리지 및 안정성 향상
  • 개발 문서 및 가이드 개선
This commit is contained in:
2025-10-16 15:01:11 +09:00
parent 31cdfedf3f
commit 297ca5791f
5 changed files with 1969 additions and 48 deletions

View File

@@ -37,9 +37,16 @@ export const VOICE_MODES = {
MODE_4: 'mode4', // 추후 추가
};
// 음성인식 입력 모드 (VUI vs WebSpeech)
export const VOICE_INPUT_MODE = {
VUI: 'vui', // VUI (Voice UI Framework)
WEBSPEECH: 'webspeech', // Web Speech API
};
const OVERLAY_SPOTLIGHT_ID = 'voice-input-overlay-container';
const INPUT_SPOTLIGHT_ID = 'voice-overlay-input-box';
const MIC_SPOTLIGHT_ID = 'voice-overlay-mic-button';
const MIC_WEBSPEECH_SPOTLIGHT_ID = 'voice-overlay-mic-webspeech-button';
const VoiceInputOverlay = ({
isVisible,
@@ -54,11 +61,15 @@ const VoiceInputOverlay = ({
const lastFocusedElement = useRef(null);
const [inputFocus, setInputFocus] = useState(false);
const [micFocused, setMicFocused] = useState(false);
const [micWebSpeechFocused, setMicWebSpeechFocused] = useState(false);
// 내부 모드 상태 관리 (prompt -> listening -> close)
const [currentMode, setCurrentMode] = useState(mode);
// 음성인식 입력 모드 (VUI vs WebSpeech)
const [voiceInputMode, setVoiceInputMode] = useState(null);
// ⛔ VUI 테스트 비활성화: VoicePanel 독립 테스트 시 충돌 방지
// Redux에서 voice 상태 가져오기
const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// Redux에서 shopperHouse 검색 결과 가져오기
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
@@ -78,25 +89,26 @@ const VoiceInputOverlay = ({
}
}, [shopperHouseData, isVisible, onClose]);
// ⛔ VUI 테스트 비활성화: STT 텍스트 수신 처리
// STT 텍스트 수신 시 처리
useEffect(() => {
if (lastSTTText && sttTimestamp && isVisible) {
console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);
// useEffect(() => {
// if (lastSTTText && sttTimestamp && isVisible) {
// console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);
// 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
if (onSearchChange) {
onSearchChange({ value: lastSTTText });
}
// // 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
// if (onSearchChange) {
// onSearchChange({ value: lastSTTText });
// }
// listening 모드로 전환 (시각적 피드백)
setCurrentMode(VOICE_MODES.LISTENING);
// // listening 모드로 전환 (시각적 피드백)
// setCurrentMode(VOICE_MODES.LISTENING);
// 1초 후 자동 닫기 (선택사항)
setTimeout(() => {
onClose();
}, 1000);
}
}, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);
// // 1초 후 자동 닫기 (선택사항)
// setTimeout(() => {
// onClose();
// }, 1000);
// }
// }, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
useEffect(() => {
@@ -106,13 +118,15 @@ const VoiceInputOverlay = ({
// 모드 초기화 (항상 prompt 모드로 시작)
setCurrentMode(mode);
setVoiceInputMode(null);
// Overlay 내부로 포커스 이동
setTimeout(() => {
Spotlight.focus(OVERLAY_SPOTLIGHT_ID);
}, 100);
} else {
// Overlay가 닫힐 때 원래 포커스 복원
// Overlay가 닫힐 때 원래 포커스 복원 및 상태 초기화
setVoiceInputMode(null);
if (lastFocusedElement.current) {
setTimeout(() => {
Spotlight.focus(lastFocusedElement.current);
@@ -199,7 +213,7 @@ const VoiceInputOverlay = ({
setInputFocus(false);
}, []);
// 마이크 버튼 포커스 핸들러
// 마이크 버튼 포커스 핸들러 (VUI)
const handleMicFocus = useCallback(() => {
setMicFocused(true);
}, []);
@@ -208,10 +222,55 @@ const VoiceInputOverlay = ({
setMicFocused(false);
}, []);
// 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
const handleMicClick = useCallback(
// WebSpeech 마이크 버튼 포커스 핸들러
const handleMicWebSpeechFocus = useCallback(() => {
setMicWebSpeechFocused(true);
}, []);
const handleMicWebSpeechBlur = useCallback(() => {
setMicWebSpeechFocused(false);
}, []);
// ⛔ VUI 테스트 비활성화: VUI 마이크 버튼 클릭 핸들러
// VUI 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
// const handleVUIMicClick = useCallback(
// (e) => {
// console.log('[VoiceInputOverlay] handleVUIMicClick called, currentMode:', currentMode);
// // 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
// if (e && e.stopPropagation) {
// e.stopPropagation();
// }
// if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
// e.nativeEvent.stopImmediatePropagation();
// }
// if (currentMode === VOICE_MODES.PROMPT) {
// // prompt 모드에서 클릭 시 -> VUI listening 모드로 전환
// console.log('[VoiceInputOverlay] Switching to VUI LISTENING mode');
// setVoiceInputMode(VOICE_INPUT_MODE.VUI);
// setCurrentMode(VOICE_MODES.LISTENING);
// // 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작
// // (이미 registerVoiceFramework()로 등록되어 있으므로)
// } else if (currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.VUI) {
// // VUI listening 모드에서 클릭 시 -> 종료
// console.log('[VoiceInputOverlay] Closing from VUI LISTENING mode');
// setVoiceInputMode(null);
// onClose();
// } else {
// // 기타 모드에서는 바로 종료
// console.log('[VoiceInputOverlay] Closing from other mode');
// setVoiceInputMode(null);
// onClose();
// }
// },
// [currentMode, voiceInputMode, onClose]
// );
// WebSpeech 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
const handleWebSpeechMicClick = useCallback(
(e) => {
console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode);
console.log('[VoiceInputOverlay] handleWebSpeechMicClick called, currentMode:', currentMode);
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
@@ -222,28 +281,34 @@ const VoiceInputOverlay = ({
}
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> listening 모드로 전환
console.log('[VoiceInputOverlay] Switching to LISTENING mode');
// prompt 모드에서 클릭 시 -> WebSpeech listening 모드로 전환
console.log('[VoiceInputOverlay] Switching to WebSpeech LISTENING mode');
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
// 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작
// (이미 registerVoiceFramework()로 등록되어 있으므로)
} else if (currentMode === VOICE_MODES.LISTENING) {
// listening 모드에서 클릭 시 -> 종료
console.log('[VoiceInputOverlay] Closing from LISTENING mode');
// TODO: Web Speech API 시작 로직 추가
} else if (
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH
) {
// WebSpeech listening 모드에서 클릭 시 -> 종료
console.log('[VoiceInputOverlay] Closing from WebSpeech LISTENING mode');
setVoiceInputMode(null);
onClose();
} else {
// 기타 모드에서는 바로 종료
console.log('[VoiceInputOverlay] Closing from other mode');
setVoiceInputMode(null);
onClose();
}
},
[currentMode, onClose]
[currentMode, voiceInputMode, onClose]
);
// dim 레이어 클릭 핸들러 (마이크 버튼과 분리)
const handleDimClick = useCallback(
(e) => {
console.log('[VoiceInputOverlay] dimBackground clicked');
setVoiceInputMode(null);
onClose();
},
[onClose]
@@ -264,8 +329,8 @@ const VoiceInputOverlay = ({
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
<div className={css.dimBackground} onClick={handleDimClick} />
{/* Voice 등록 상태 표시 (디버깅용) */}
{process.env.NODE_ENV === 'development' && (
{/* ⛔ VUI 테스트 비활성화: Voice 등록 상태 표시 (디버깅용) */}
{/* {process.env.NODE_ENV === 'development' && (
<div
style={{
position: 'absolute',
@@ -276,8 +341,10 @@ const VoiceInputOverlay = ({
}}
>
Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'}
<br />
Mode: {voiceInputMode || 'None'}
</div>
)}
)} */}
{/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */}
<OverlayContainer
@@ -300,19 +367,27 @@ const VoiceInputOverlay = ({
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
{/* VUI 마이크 버튼 (⛔ 기능 비활성화: 클릭 핸들러만 무효화) */}
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING && css.listening,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.VUI &&
css.listening,
micFocused && css.focused
)}
onClick={handleMicClick}
onClick={(e) => {
// ⛔ VUI 테스트 비활성화: handleVUIMicClick 호출 안 함
e.stopPropagation();
console.log('[VoiceInputOverlay] VUI mic clicked (disabled for testing)');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleMicClick(e);
// ⛔ VUI 테스트 비활성화: handleVUIMicClick 호출 안 함
console.log('[VoiceInputOverlay] VUI mic Enter key (disabled for testing)');
}
}}
onFocus={handleMicFocus}
@@ -320,9 +395,10 @@ const VoiceInputOverlay = ({
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} />
<img src={micIcon} alt="Voice AI" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING && (
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.VUI && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
@@ -336,6 +412,47 @@ const VoiceInputOverlay = ({
</svg>
)}
</SpottableMicButton>
{/* WebSpeech 마이크 버튼 */}
<SpottableMicButton
className={classNames(
css.microphoneButtonWebSpeech,
css.active,
currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH &&
css.listening,
micWebSpeechFocused && css.focused
)}
onClick={handleWebSpeechMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleWebSpeechMicClick(e);
}
}}
onFocus={handleMicWebSpeechFocus}
onBlur={handleMicWebSpeechBlur}
spotlightId={MIC_WEBSPEECH_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Voice Input" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING &&
voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#4A90E2"
strokeWidth="6"
/>
</svg>
)}
</SpottableMicButton>
</div>
</div>

View File

@@ -197,6 +197,79 @@
}
}
// WebSpeech 마이크 버튼 (블루 계열)
.microphoneButtonWebSpeech {
width: 100px;
height: 100px;
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
z-index: 1003;
.microphoneCircle {
width: 100%;
height: 100%;
position: relative;
background: white;
overflow: hidden;
border-radius: 1000px;
border: 5px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
.microphoneIcon {
height: 50px;
box-sizing: border-box;
transition: filter 0.3s ease;
}
}
&:hover {
.microphoneCircle {
border-color: white;
}
}
// active 상태 (음성 입력 모드 - 블루 색상)
&.active {
.microphoneCircle {
background-color: #4A90E2;
border-color: #4A90E2;
box-shadow: 0 0 22px 0 rgba(74, 144, 226, 0.5);
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경
}
}
}
&.active.focused {
.microphoneCircle {
background-color: #4A90E2;
border-color: white;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
// listening 상태 (배경 투명, 테두리 ripple 애니메이션)
&.listening {
.microphoneCircle {
background-color: transparent;
border-color: transparent; // 테두리 투명
box-shadow: none;
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘은 흰색 유지
}
}
}
}
// Ripple 애니메이션 (원형 테두리가 점에서 시작해서 그려짐)
.rippleSvg {
position: absolute;

View File

@@ -29,6 +29,11 @@ export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
useEffect(() => {
if (isOnTop) {
dispatch(sendLogGNB(LOG_MENU.SEARCH_SEARCH));
} else {
// ⭐ VoicePanel이 백그라운드로 가면 voice framework 자동 해제
// (SearchPanel 등 다른 패널과 충돌 방지)
console.log('[VoicePanel] Going to background, unregistering voice framework');
dispatch(unregisterVoiceFramework());
}
}, [isOnTop, dispatch]);

View File

@@ -0,0 +1,309 @@
1-1 Can you recommend a 4K TV with Dolby Atmos support under $1,500?
오버레이 인식 : 확인
STT Text : Can you recommend a 4K TV with Dolby Atmos support under $1,500?
Event Logs :
RESPONSE :
{
"subscribed": true,
"command": "setContext",
"returnValue": true,
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036"
}
COMMAND :
{
"command": "setContext",
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036"
}
REQUEST :
{
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036",
"intentCount": 3,
"intents": [
{
"intent": "UseIME",
"supportAsrOnly": true
},
{
"intent": "Select",
"supportOrdinal": true,
"items": [
{
"itemId": "voice-search-button",
"value": [
"Search",
"Search Products",
"Find Items"
],
"title": "Search"
},
{
"itemId": "voice-cart-button",
"value": [
"Cart",
"Shopping Cart",
"My Cart"
],
"title": "Cart"
},
{
"itemId": "voice-home-button",
"value": [
"Home",
"Go Home",
"Main Page"
],
"title": "Home"
},
{
"itemId": "voice-mypage-button",
"value": [
"My Page",
"Account",
"Profile"
],
"title": "My Page"
}
]
},
{
"intent": "Scroll",
"supportOrdinal": false,
"items": [
{
"itemId": "voice-scroll-up",
"value": [
"Scroll Up",
"Page Up"
]
},
{
"itemId": "voice-scroll-down",
"value": [
"Scroll Down",
"Page Down"
]
}
]
}
]
}
RESPONSE :
{
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036",
"intentCount": 3,
"intents": [
{
"intent": "UseIME",
"supportAsrOnly": true
},
{
"intent": "Select",
"supportOrdinal": true,
"items": [
{
"itemId": "voice-search-button",
"value": [
"Search",
"Search Products",
"Find Items"
],
"title": "Search"
},
{
"itemId": "voice-cart-button",
"value": [
"Cart",
"Shopping Cart",
"My Cart"
],
"title": "Cart"
},
{
"itemId": "voice-home-button",
"value": [
"Home",
"Go Home",
"Main Page"
],
"title": "Home"
},
{
"itemId": "voice-mypage-button",
"value": [
"My Page",
"Account",
"Profile"
],
"title": "My Page"
}
]
},
{
"intent": "Scroll",
"supportOrdinal": false,
"items": [
{
"itemId": "voice-scroll-up",
"value": [
"Scroll Up",
"Page Up"
]
},
{
"itemId": "voice-scroll-down",
"value": [
"Scroll Down",
"Page Down"
]
}
]
}
]
}
ACTION :
{
"message": "Context set successfully. Press the MIC button on remote and speak.",
"nextStep": "Waiting for performAction event...",
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-0036"
}
그러나 두번째 시도에서
STT Text : No STT text received yet. Speak after registering to see the result.
RESPONSE :
{
"subscribed": true,
"command": "setContext",
"returnValue": true,
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b"
}
COMMAND :
{
"command": "setContext",
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b"
}
REQUEST :
{
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b",
"intentCount": 3,
"intents": [
{
"intent": "UseIME",
"supportAsrOnly": true
},
{
"intent": "Select",
"supportOrdinal": true,
"items": [
{
"itemId": "voice-search-button",
"value": [
"Search",
"Search Products",
"Find Items"
],
"title": "Search"
},
{
"itemId": "voice-cart-button",
"value": [
"Cart",
"Shopping Cart",
"My Cart"
],
"title": "Cart"
},
{
"itemId": "voice-home-button",
"value": [
"Home",
"Go Home",
"Main Page"
],
"title": "Home"
},
{
"itemId": "voice-mypage-button",
"value": [
"My Page",
"Account",
"Profile"
],
"title": "My Page"
}
]
},
{
"intent": "Scroll",
"supportOrdinal": false,
"items": [
{
"itemId": "voice-scroll-up",
"value": [
"Scroll Up",
"Page Up"
]
},
{
"itemId": "voice-scroll-down",
"value": [
"Scroll Down",
"Page Down"
]
}
]
}
]
}
RESPONSE :
{
"returnValue": true
}
ACTION :
{
"message": "Context set successfully. Press the MIC button on remote and speak.",
"nextStep": "Waiting for performAction event...",
"voiceTicket": "13799ec6-fd1f-4cdd-b02e-34d58fe5f34b-68ef-003b"
}
ERROR :
{
"message": "performAction event was not received within 15 seconds after setContext.",
"possibleReasons": [
"1. Did you press the MIC button on the remote control?",
"2. Did you speak after pressing the MIC button?",
"3. UseIME intent might not be supported on this webOS version",
"4. Voice framework might not be routing events correctly"
],
"suggestion": "Try pressing the remote MIC button and speaking clearly. Check VoicePanel logs for performAction event."
}

File diff suppressed because it is too large Load Diff