[251020] fix: VoiceInputOverlay Voice Enhancement

🕐 커밋 시간: 2025. 10. 20. 20:44:44

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

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/searchActions.js
  ~ com.twin.app.shoptime/src/actions/webSpeechActions.js
  ~ com.twin.app.shoptime/src/reducers/searchReducer.js
  ~ com.twin.app.shoptime/src/services/webSpeech/WebSpeechService.js
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript):
    🔄 Modified: clearAllTimers()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • API 서비스 레이어 개선
This commit is contained in:
2025-10-20 20:44:46 +09:00
parent 0d7ac08a3f
commit f6f20e6c80
5 changed files with 184 additions and 102 deletions

View File

@@ -96,7 +96,12 @@ export const getShopperHouseSearch =
const currentSearchKey = new Date().getTime();
getShopperHouseSearchKey = currentSearchKey;
console.log('[ShopperHouse] 🔍 [DEBUG] API 호출 시작 - key:', currentSearchKey, 'query:', query);
console.log(
'[ShopperHouse] 🔍 [DEBUG] API 호출 시작 - key:',
currentSearchKey,
'query:',
query
);
const onSuccess = (response) => {
console.log('[ShopperHouse] 📥 [DEBUG] API 응답 도착 - key:', currentSearchKey);
@@ -105,21 +110,42 @@ export const getShopperHouseSearch =
// ✨ 현재 요청이 최신 요청인지 확인
if (currentSearchKey === getShopperHouseSearchKey) {
console.log('[ShopperHouse] ✅ [DEBUG] 유효한 응답 - Redux 업데이트');
console.log('[ShopperHouse] getShopperHouseSearch onSuccess: ', JSON.stringify(response.data));
console.log(
'[ShopperHouse] getShopperHouseSearch onSuccess: ',
JSON.stringify(response.data)
);
// ✅ API 성공 여부 확인
const retCode = response.data?.retCode;
if (retCode !== 0) {
console.error('[ShopperHouse] ❌ API 실패 - retCode:', retCode, 'retMsg:', response.data?.retMsg);
console.error(
'[ShopperHouse] ❌ API 실패 - retCode:',
retCode,
'retMsg:',
response.data?.retMsg
);
console.log('[VoiceInput] 📥 API 응답 실패');
console.log('[VoiceInput] ├─ retCode:', retCode);
console.log('[VoiceInput] └─ retMsg:', response.data?.retMsg);
return;
}
// ✅ result 데이터 존재 확인
if (!response.data?.data?.result) {
console.error('[ShopperHouse] ❌ API 응답에 result 데이터 없음');
console.log('[VoiceInput] 📥 API 응답 실패 (result 데이터 없음)');
return;
}
// 📥 [VoiceInput] API 응답 성공 로그
const resultData = response.data.data.result;
const results = resultData.results || [];
const receivedSearchId = results.length > 0 ? results[0].searchId : null;
console.log('[VoiceInput] 📥 API 응답 성공');
console.log('[VoiceInput] ├─ searchId:', receivedSearchId || '(없음)');
console.log('[VoiceInput] └─ 결과 수:', results.length);
dispatch({
type: types.GET_SHOPPERHOUSE_SEARCH,
payload: response.data,
@@ -133,6 +159,15 @@ export const getShopperHouseSearch =
const onFail = (error) => {
console.error('[ShopperHouse] getShopperHouseSearch onFail: ', JSON.stringify(error));
const retCode = error?.data?.retCode;
if (retCode === 401) {
console.log('[VoiceInput] ⚠️ Access Token 만료 - 자동 갱신 중...');
console.log('[VoiceInput] └─ TAxios가 자동으로 재요청합니다');
} else {
console.log('[VoiceInput] 📥 API 요청 실패');
console.log('[VoiceInput] └─ error:', error?.message || JSON.stringify(error));
}
};
const params = { query };

View File

@@ -49,20 +49,16 @@ export const initializeWebSpeech =
webSpeechService.on('result', (result) => {
console.log('[WebSpeechActions] Result:', result);
// ✅ continuous: true 모드에서는 중간 final result를 무시하고
// 항상 interim result만 사용 (15초 타이머로 제어)
// Interim 결과 (중간 결과) - 전체 연결된 텍스트 사용
if (!result.isFinal) {
dispatch({
type: types.WEB_SPEECH_INTERIM_RESULT,
payload: result.transcript, // 이미 전체 연결된 텍스트 (final + interim)
});
}
// Final 결과 (최종 결과)
else {
dispatch({
type: types.VOICE_STT_TEXT_RECEIVED, // 기존 VUI와 동일한 액션 사용
payload: result.transcript, // 전체 최종 텍스트
});
}
dispatch({
type: types.WEB_SPEECH_INTERIM_RESULT,
payload: result.transcript, // 이미 전체 연결된 텍스트 (final + interim)
});
// ✅ Final 결과는 무시 (15초 타이머가 끝날 때 VoiceInputOverlay에서 처리)
// continuous: true일 때 중간에 final이 와도 계속 듣기
});
webSpeechService.on('error', (errorInfo) => {

View File

@@ -73,6 +73,10 @@ export const searchReducer = (state = initialState, action) => {
// searchId 추출 (첫 번째 result에서)
const searchId = results.length > 0 ? results[0].searchId : null;
// [VoiceInput] Redux에 searchId 저장 로그
console.log('[VoiceInput] 💾 Redux에 searchId 저장');
console.log('[VoiceInput] └─ searchId:', searchId || '(없음)');
return {
...state,
shopperHouseData: resultData,
@@ -86,6 +90,7 @@ export const searchReducer = (state = initialState, action) => {
}
case types.CLEAR_SHOPPERHOUSE_DATA:
console.log('[VoiceInput] 🧹 Redux shopperHouseData 초기화 (searchId 리셋)');
return {
...state,
shopperHouseData: null,

View File

@@ -83,9 +83,14 @@ class WebSpeechService {
let finalTranscript = '';
let interimTranscript = '';
console.log('[WebSpeech] 🎤 onresult 이벤트 - results.length:', results.length);
for (let i = 0; i < results.length; i++) {
const transcript = results[i][0].transcript;
if (results[i].isFinal) {
const isFinal = results[i].isFinal;
console.log(`[WebSpeech] [${i}] isFinal: ${isFinal}, transcript: "${transcript}"`);
if (isFinal) {
finalTranscript += transcript + ' ';
} else {
interimTranscript += transcript;
@@ -97,10 +102,10 @@ class WebSpeechService {
const isFinal = lastResult.isFinal;
const confidence = lastResult[0].confidence;
console.log('[WebSpeech] Result:', {
console.log('[WebSpeech] 📝 Result Summary:', {
fullTranscript,
finalTranscript,
interimTranscript,
finalTranscript: finalTranscript.trim(),
interimTranscript: interimTranscript.trim(),
isFinal,
confidence,
});

View File

@@ -128,6 +128,7 @@ const VoiceInputOverlay = ({
const lastFocusedElement = useRef(null);
const listeningTimerRef = useRef(null);
const audioContextRef = useRef(null);
const interimTextRef = useRef(''); // Interim text 추적용 ref
// Timer refs for cleanup
const closeTimerRef = useRef(null);
@@ -136,6 +137,7 @@ const VoiceInputOverlay = ({
const searchSubmitFocusTimerRef = useRef(null);
const wakeWordRestartTimerRef = useRef(null);
const countdownIntervalRef = useRef(null);
const sttDebounceTimerRef = useRef(null); // STT 결과 debounce 타이머
// All timer refs array for batch cleanup
const allTimerRefs = [
@@ -146,6 +148,7 @@ const VoiceInputOverlay = ({
searchSubmitFocusTimerRef,
wakeWordRestartTimerRef,
countdownIntervalRef,
sttDebounceTimerRef,
];
const [micFocused, setMicFocused] = useState(false);
@@ -182,7 +185,9 @@ const VoiceInputOverlay = ({
useEffect(() => {
console.log('🔀 [DEBUG][VoiceInputOverlay] currentMode changed to:', currentMode);
if (isVisible) {
console.log(`📍 [DEBUG][VoiceInputOverlay] Current state: isVisible=true, mode=${currentMode}`);
console.log(
`📍 [DEBUG][VoiceInputOverlay] Current state: isVisible=true, mode=${currentMode}`
);
}
}, [currentMode, isVisible]);
@@ -287,68 +292,31 @@ const VoiceInputOverlay = ({
});
}, []); // dependency 없음 (setSearchHistory는 stable)
// Web Speech API Hook (WebSpeech 모드일 때만 활성화)
const handleWebSpeechSTT = useCallback(
(sttText) => {
if (DEBUG_MODE) {
console.log('🎤 [DEBUG] handleWebSpeechSTT called with:', sttText);
}
// 타이머 중지
clearTimerRef(listeningTimerRef);
// STT 텍스트 저장
setSttResponseText(sttText);
// RESPONSE 모드로 전환
setCurrentMode(VOICE_MODES.RESPONSE);
if (DEBUG_MODE) {
console.log('📺 [DEBUG] Switching to RESPONSE mode with text:', sttText);
}
// ✅ TInput에 텍스트 표시
if (onSearchChange) {
onSearchChange({ value: sttText });
}
// ✨ 검색 기록에 추가
addToSearchHistory(sttText);
// ✨ ShopperHouse API 자동 호출
if (sttText && sttText.trim().length >= 3) {
if (DEBUG_MODE) {
console.log('🔍 [DEBUG] Calling ShopperHouse API from STT with query:', sttText.trim());
console.log('🔍 [DEBUG] Query length:', sttText.trim().length);
console.log('🔍 [DEBUG] Query contains apostrophe:', sttText.includes("'"));
}
dispatch(getShopperHouseSearch(sttText.trim()));
}
},
[dispatch, addToSearchHistory, onSearchChange]
);
// WebSpeech config 메모이제이션 (불필요한 재초기화 방지)
const webSpeechConfig = useMemo(
() => ({
lang: 'en-US',
continuous: false, // 침묵 감지 후 자동 종료
continuous: true, // 15초 동안 계속 듣기 (침묵 감지로 중단되지 않음)
interimResults: true,
}),
[]
);
// useWebSpeech를 항상 활성화 (SearchPanel이 마운트되어 있는 동안 계속 유지)
// isVisible 변경으로 인한 재초기화 방지
// STT 결과는 Redux lastSTTText로 직접 처리 (stopListening dependency 문제 해결)
const { isListening, interimText, startListening, stopListening, isSupported } = useWebSpeech(
isVisible, // Overlay가 열려있을 때만 활성화 (voiceInputMode와 무관하게 초기화)
handleWebSpeechSTT,
true, // 항상 활성화 - VoiceInputOverlay가 마운트된 동안 유지
null, // Redux lastSTTText useEffect로 직접 처리
webSpeechConfig
);
// ⛔ VUI 테스트 비활성화: VoicePanel 독립 테스트 시 충돌 방지
// Redux에서 voice 상태 가져오기
// const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// Redux에서 STT 결과 가져오기
const { lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// Redux에서 shopperHouse 검색 결과 가져오기 (simplified ref usage)
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
const shopperHouseSearchId = useSelector((state) => state.search.shopperHouseSearchId); // 2차 발화용 searchId
const shopperHouseDataRef = useRef(null);
const isInitializingRef = useRef(false); // overlay 초기화 중 플래그
@@ -516,6 +484,21 @@ const VoiceInputOverlay = ({
// 이 useEffect 제거 - renderModeContent()에서 직접 판단하므로 불필요
// 🎤 Interim 텍스트 실시간 표시 및 ref 업데이트
useEffect(() => {
if (currentMode !== VOICE_MODES.LISTENING) return;
// ✅ Ref 업데이트 (15초 타이머에서 최신 값을 읽을 수 있도록)
interimTextRef.current = interimText || '';
if (!interimText) return;
// ✨ TInput에 실시간으로 텍스트 표시
if (onSearchChange) {
onSearchChange({ value: interimText });
}
}, [interimText, currentMode, onSearchChange]);
// 🎉 Wake Word Detection: PROMPT 모드에서 백그라운드 리스닝 시작
useEffect(() => {
if (!ENABLE_WAKE_WORD) return;
@@ -575,6 +558,24 @@ const VoiceInputOverlay = ({
}
}, [currentMode, voiceInputMode]);
// VoiceInputOverlay 마운트 시 초기화 (한 번만 실행)
useEffect(() => {
console.log('[VoiceInput] 🚀 VoiceInputOverlay 마운트 - searchId 초기화');
// ✨ Redux shopperHouseData 초기화
dispatch(clearShopperHouseData());
// ✨ Redux lastSTTText 초기화
dispatch(clearSTTText());
// Cleanup: 언마운트 시 초기화
return () => {
console.log('[VoiceInput] 🔚 VoiceInputOverlay 언마운트 - searchId 초기화');
dispatch(clearShopperHouseData());
dispatch(clearSTTText());
};
}, []); // 빈 dependency - 마운트/언마운트 시에만 실행
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
useEffect(() => {
if (DEBUG_MODE) {
@@ -584,15 +585,8 @@ const VoiceInputOverlay = ({
if (isVisible) {
if (DEBUG_MODE) {
console.log('✅ [DEBUG] ===== OVERLAY OPENING =====');
console.log('🔍 [DEBUG] Clearing Redux shopperHouseData');
}
// ✨ Redux shopperHouseData 초기화 (이전 검색 결과 제거)
dispatch(clearShopperHouseData());
// ✨ Redux lastSTTText 초기화 (이전 음성 인식 텍스트 제거)
dispatch(clearSTTText());
// ✨ 초기화 시작 플래그 설정 (close 로직 일시 차단)
isInitializingRef.current = true;
if (DEBUG_MODE) {
@@ -634,12 +628,6 @@ const VoiceInputOverlay = ({
// 타이머 정리
clearTimerRef(listeningTimerRef);
// ⛔ 독립 테스트: WebSpeech API 호출 비활성화
// WebSpeech 중지 (비동기로 처리)
// if (isListening) {
// stopListening();
// }
// 상태 초기화
setVoiceInputMode(null);
setCurrentMode(VOICE_MODES.PROMPT);
@@ -652,7 +640,7 @@ const VoiceInputOverlay = ({
}
return () => {
// Cleanup: 컴포넌트 언마운트 또는 isVisible 변경 시 타이머 정리
// Cleanup: isVisible 변경 시 타이머 정리
clearTimerRef(focusTimerRef);
clearTimerRef(focusRestoreTimerRef);
};
@@ -765,16 +753,23 @@ const VoiceInputOverlay = ({
if (DEBUG_MODE) {
console.log(
'🎬 [DEBUG][VoiceInputOverlay] renderModeContent called',
'| currentMode:', currentMode,
'| voiceInputMode:', voiceInputMode,
'| isListening:', isListening
'| currentMode:',
currentMode,
'| voiceInputMode:',
voiceInputMode,
'| isListening:',
isListening
);
}
switch (currentMode) {
case VOICE_MODES.PROMPT:
if (DEBUG_MODE) {
console.log('✅ [DEBUG][VoiceInputOverlay] MODE = PROMPT | Rendering VoicePromptScreen with', searchHistory.length, 'suggestions');
console.log(
'✅ [DEBUG][VoiceInputOverlay] MODE = PROMPT | Rendering VoicePromptScreen with',
searchHistory.length,
'suggestions'
);
}
return (
<VoicePromptScreen
@@ -784,12 +779,17 @@ const VoiceInputOverlay = ({
);
case VOICE_MODES.LISTENING:
if (DEBUG_MODE) {
console.log('🎤 [DEBUG][VoiceInputOverlay] MODE = LISTENING | Rendering VoiceListening (15초 타이머)');
console.log(
'🎤 [DEBUG][VoiceInputOverlay] MODE = LISTENING | Rendering VoiceListening (15초 타이머)'
);
}
return <VoiceListening interimText={interimText} countdown={countdown} />;
case VOICE_MODES.RESPONSE:
if (DEBUG_MODE) {
console.log('💬 [DEBUG][VoiceInputOverlay] MODE = RESPONSE | Rendering VoiceResponse with text:', sttResponseText);
console.log(
'💬 [DEBUG][VoiceInputOverlay] MODE = RESPONSE | Rendering VoiceResponse with text:',
sttResponseText
);
}
return <VoiceResponse responseText={sttResponseText} />;
case VOICE_MODES.NOINIT:
@@ -799,7 +799,9 @@ const VoiceInputOverlay = ({
return <VoiceNotRecognized prompt={NOINIT_ERROR_MESSAGE} />;
case VOICE_MODES.NOTRECOGNIZED:
if (DEBUG_MODE) {
console.log('❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized');
console.log(
'❌ [DEBUG][VoiceInputOverlay] MODE = NOTRECOGNIZED | Rendering VoiceNotRecognized'
);
}
return <VoiceNotRecognized />;
case VOICE_MODES.MODE_3:
@@ -855,10 +857,7 @@ const VoiceInputOverlay = ({
const handleWebSpeechMicClick = useCallback(
(e) => {
if (DEBUG_MODE) {
console.log(
'🎤 [DEBUG] handleWebSpeechMicClick called, currentMode:',
currentMode
);
console.log('🎤 [DEBUG] handleWebSpeechMicClick called, currentMode:', currentMode);
}
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
@@ -873,15 +872,20 @@ const VoiceInputOverlay = ({
// ✨ PROMPT 모드에서만 LISTENING으로 전환 가능
// 1. listening 모드로 전환 (15초 타이머)
// 2. WebSpeech API 시작 (독립 동작)
if (DEBUG_MODE) {
console.log('🎤 [DEBUG] Starting LISTENING mode (15s) + WebSpeech API');
}
console.log('[VoiceInput] 🎙️ 마이크 버튼 클릭 - 음성 입력 시작');
console.log('[VoiceInput] └─ 이전 STT 데이터 초기화');
// ✅ TInput 초기화 (새로운 음성 입력 시작)
// ✅ 이전 STT 데이터 초기화 (새로운 음성 입력 시작)
dispatch(clearSTTText());
// ✅ TInput 초기화
if (onSearchChange) {
onSearchChange({ value: '' });
}
// ✅ Interim text ref 초기화
interimTextRef.current = '';
// 🔊 Beep 소리 재생
playBeep();
@@ -894,16 +898,53 @@ const VoiceInputOverlay = ({
// WebSpeech API 시작
startListening();
// 15초 타이머 설정 (WebSpeech 종료와 무관하게 15초 후 PROMPT 복귀)
// 15초 타이머 설정: 15초 후 누적된 interimText를 최종 결과로 처리
listeningTimerRef.current = setTimeout(() => {
if (DEBUG_MODE) {
console.log('⏰ [VoiceInputOverlay.v2] 15초 타임아웃 - PROMPT 모드로 복귀');
console.log('⏰ [VoiceInputOverlay.v2] 15초 타임아웃 - 최종 결과 처리');
}
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
listeningTimerRef.current = null;
// WebSpeech가 아직 동작 중이면 중지
// 카운트다운 정리
clearIntervalRef(countdownIntervalRef);
// 음성 인식 중지
stopListening();
// ✅ 15초 동안 누적된 interimText를 최종 결과로 사용 (ref로부터 읽기)
const finalText = interimTextRef.current.trim();
console.log('[VoiceInput] 🏁 15초 타이머 종료 - 최종 텍스트:', finalText);
if (finalText && finalText.length >= 3) {
// STT 텍스트 저장
setSttResponseText(finalText);
// RESPONSE 모드로 전환
setCurrentMode(VOICE_MODES.RESPONSE);
setVoiceInputMode(null);
// ✨ 검색 기록에 추가
addToSearchHistory(finalText);
// ✨ ShopperHouse API 자동 호출 (2차 발화 시 searchId 포함)
const query = finalText.trim();
console.log('[VoiceInput] 📤 API 요청 전송');
console.log('[VoiceInput] ├─ query:', query);
console.log(
'[VoiceInput] └─ searchId:',
shopperHouseSearchId || '(없음 - 첫 번째 발화)'
);
dispatch(getShopperHouseSearch(query, shopperHouseSearchId));
} else {
// 입력이 없거나 너무 짧으면 PROMPT 모드로 복귀
console.log('[VoiceInput] ⚠️ 입력 없음 또는 너무 짧음 - PROMPT 모드로 복귀');
setCurrentMode(VOICE_MODES.PROMPT);
setVoiceInputMode(null);
}
// Ref 초기화
interimTextRef.current = '';
listeningTimerRef.current = null;
}, 15000); // 15초
} else {
// listening 모드 또는 기타 모드에서 클릭 시 -> overlay 닫기