[251030] fix: WebSpeech API Error Update
🕐 커밋 시간: 2025. 10. 30. 10:05:16 📊 변경 통계: • 총 파일: 7개 • 추가: +74줄 • 삭제: -23줄 📁 추가된 파일: + com.twin.app.shoptime/src/hooks/useWebSpeechManager.js + com.twin.app.shoptime/src/services/voice/VoiceRecognitionManager.js + com.twin.app.shoptime/src/services/webSpeech/WebSpeechServiceAsync.js + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.jsx + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.module.less 📝 수정된 파일: ~ com.twin.app.shoptime/.gitignore ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx 🔧 주요 변경 내용: • 핵심 비즈니스 로직 개선 • API 서비스 레이어 개선 • 소규모 기능 개선 • 모듈 구조 개선
This commit is contained in:
2
com.twin.app.shoptime/.gitignore
vendored
2
com.twin.app.shoptime/.gitignore
vendored
@@ -15,7 +15,7 @@ npm-debug.log
|
|||||||
# ipk file
|
# ipk file
|
||||||
srcBackup
|
srcBackup
|
||||||
# com.lgshop.app_*.ipk
|
# com.lgshop.app_*.ipk
|
||||||
.doc
|
.docs
|
||||||
.docs
|
.docs
|
||||||
nul
|
nul
|
||||||
.txt
|
.txt
|
||||||
|
|||||||
124
com.twin.app.shoptime/src/hooks/useWebSpeechManager.js
Normal file
124
com.twin.app.shoptime/src/hooks/useWebSpeechManager.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// src/hooks/useWebSpeechManager.js
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import VoiceRecognitionManager, {
|
||||||
|
VOICE_STATE,
|
||||||
|
WEBSPEECH_VERSION,
|
||||||
|
} from '../services/voice/VoiceRecognitionManager';
|
||||||
|
import WebSpeechServiceAsync from '../services/webSpeech/WebSpeechServiceAsync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSpeechManager를 React Hook으로 통합 (continuous=true 전용)
|
||||||
|
*
|
||||||
|
* 기능:
|
||||||
|
* - Manager 생성 및 라이프사이클 관리
|
||||||
|
* - 상태 변경 리스너 등록
|
||||||
|
* - Redux dispatch
|
||||||
|
* - startListening, stopListening, retry 함수 제공
|
||||||
|
*
|
||||||
|
* @param {Function} onStateChange - 상태 변경 콜백 (newState, oldState) => void
|
||||||
|
* @param {Object} options - Manager 옵션
|
||||||
|
* - webSpeechVersion: WEBSPEECH_VERSION.V1 (기본)
|
||||||
|
* - maxListeningTime: 15000 (기본)
|
||||||
|
* - startTimeoutMs: 5000 (기본)
|
||||||
|
* @returns {Object} { startListening, stopListening, retry, manager }
|
||||||
|
*/
|
||||||
|
export const useWebSpeechManager = (onStateChange, options = {}) => {
|
||||||
|
const managerRef = useRef(null);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Manager 초기화 (한 번만)
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[useWebSpeechManager] Manager 생성');
|
||||||
|
|
||||||
|
// Manager 인스턴스 생성
|
||||||
|
managerRef.current = new VoiceRecognitionManager(
|
||||||
|
new WebSpeechServiceAsync(),
|
||||||
|
{
|
||||||
|
webSpeechVersion: WEBSPEECH_VERSION.V1, // 기본값
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상태 변경 리스너 등록
|
||||||
|
const unsubscribe = managerRef.current.onStateChange((newState, oldState) => {
|
||||||
|
console.log(`[useWebSpeechManager] 상태 변경: ${oldState} → ${newState}`);
|
||||||
|
|
||||||
|
// Redux dispatch
|
||||||
|
dispatch({
|
||||||
|
type: 'VOICE_STATE_CHANGED',
|
||||||
|
payload: {
|
||||||
|
state: newState,
|
||||||
|
oldState: oldState,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 콜백 호출
|
||||||
|
if (onStateChange) {
|
||||||
|
try {
|
||||||
|
onStateChange(newState, oldState);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useWebSpeechManager] onStateChange 콜백 에러:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[useWebSpeechManager] Manager 초기화 완료');
|
||||||
|
|
||||||
|
// cleanup: 언마운트 시
|
||||||
|
return () => {
|
||||||
|
console.log('[useWebSpeechManager] 언마운트 - 정리 중');
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [dispatch, onStateChange, options]);
|
||||||
|
|
||||||
|
// 음성 인식 시작
|
||||||
|
const startListening = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[useWebSpeechManager] startListening() 호출');
|
||||||
|
const result = await managerRef.current.startListeningAsync();
|
||||||
|
console.log('[useWebSpeechManager] ✅ 시작 성공:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useWebSpeechManager] ❌ 시작 실패:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 음성 인식 종료 (수동)
|
||||||
|
const stopListening = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[useWebSpeechManager] stopListening() 호출');
|
||||||
|
const result = await managerRef.current._finishListening('user_stopped');
|
||||||
|
console.log('[useWebSpeechManager] ✅ 종료 성공:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useWebSpeechManager] ❌ 종료 실패:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 재시도
|
||||||
|
const retry = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[useWebSpeechManager] retry() 호출');
|
||||||
|
const result = await managerRef.current.retryAsync();
|
||||||
|
console.log('[useWebSpeechManager] ✅ 재시도 성공:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useWebSpeechManager] ❌ 재시도 실패:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startListening,
|
||||||
|
stopListening,
|
||||||
|
retry,
|
||||||
|
manager: managerRef.current,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useWebSpeechManager;
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
// src/services/voice/VoiceRecognitionManager.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SilenceDetection 버전 정의 (continuous=true에서는 미사용)
|
||||||
|
* ⚠️ continuous=false 환경에서만 사용
|
||||||
|
* 현재는 참고용으로만 유지
|
||||||
|
*/
|
||||||
|
export const SILENCE_DETECTION_VERSIONS = {
|
||||||
|
V1: 'v1', // 미사용
|
||||||
|
V2: 'v2', // 미사용
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSpeech Version 정의
|
||||||
|
*/
|
||||||
|
export const WEBSPEECH_VERSION = {
|
||||||
|
V1: 'v1', // 현재: continuous=true 기본 구현
|
||||||
|
V2: 'v2', // 미래: 개선된 기능 추가
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 음성 인식 상태 머신
|
||||||
|
*/
|
||||||
|
export const VOICE_STATE = {
|
||||||
|
// 초기 상태
|
||||||
|
IDLE: 'idle',
|
||||||
|
// 초기화 단계
|
||||||
|
INITIALIZING: 'initializing',
|
||||||
|
INITIALIZED: 'initialized',
|
||||||
|
// 활성화 단계
|
||||||
|
STARTING: 'starting',
|
||||||
|
STARTED: 'started',
|
||||||
|
// 녹음 단계
|
||||||
|
LISTENING: 'listening',
|
||||||
|
// 처리 단계
|
||||||
|
PROCESSING: 'processing',
|
||||||
|
PROCESSED: 'processed',
|
||||||
|
// 종료 단계
|
||||||
|
STOPPING: 'stopping',
|
||||||
|
STOPPED: 'stopped',
|
||||||
|
// 에러 상태
|
||||||
|
ERROR: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 음성 인식 관리자
|
||||||
|
*
|
||||||
|
* Promise 기반 음성인식 라이프사이클 관리
|
||||||
|
* ⭐ continuous=true 전용
|
||||||
|
*
|
||||||
|
* - 각 단계를 Promise로 래핑하여 순서 보장
|
||||||
|
* - 15초 maxListeningTime 타이머로 자동 종료
|
||||||
|
* - 상태 머신으로 중앙 관리
|
||||||
|
* - 에러 처리 및 복구
|
||||||
|
* - WEBSPEECH_VERSION 조건부 기능 지원
|
||||||
|
*/
|
||||||
|
class VoiceRecognitionManager {
|
||||||
|
constructor(
|
||||||
|
webSpeechService,
|
||||||
|
options = {}
|
||||||
|
) {
|
||||||
|
this.service = webSpeechService;
|
||||||
|
|
||||||
|
// 옵션 기본값
|
||||||
|
this.options = {
|
||||||
|
webSpeechVersion: WEBSPEECH_VERSION.V1, // 기본값
|
||||||
|
maxListeningTime: 15000, // 15초
|
||||||
|
initTimeoutMs: 5000, // 5초
|
||||||
|
startTimeoutMs: 5000, // 5초
|
||||||
|
stopTimeoutMs: 5000, // 5초
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
this.currentState = VOICE_STATE.IDLE;
|
||||||
|
this.stateListeners = [];
|
||||||
|
|
||||||
|
// 타이머 관리
|
||||||
|
this.listeningTimer = null; // maxListeningTime용 (유일한 타이머)
|
||||||
|
|
||||||
|
console.log('[VoiceRecognitionManager] 생성됨', {
|
||||||
|
webSpeechVersion: this.options.webSpeechVersion,
|
||||||
|
maxListeningTime: this.options.maxListeningTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 상태 관리 (옵저버 패턴)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 변경
|
||||||
|
* @param {string} newState - 새 상태 (VOICE_STATE의 값)
|
||||||
|
*/
|
||||||
|
setState(newState) {
|
||||||
|
if (this.currentState === newState) {
|
||||||
|
return; // 동일한 상태로 변경하려면 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldState = this.currentState;
|
||||||
|
this.currentState = newState;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[VoiceRecognitionManager] 상태 변경: ${oldState} → ${newState}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모든 리스너에 알림
|
||||||
|
this.stateListeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener(newState, oldState);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[VoiceRecognitionManager] 리스너 실행 중 에러:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 변경 리스너 등록
|
||||||
|
* @param {Function} callback - (newState, oldState) => void
|
||||||
|
* @returns {Function} unsubscribe 함수
|
||||||
|
*/
|
||||||
|
onStateChange(callback) {
|
||||||
|
this.stateListeners.push(callback);
|
||||||
|
|
||||||
|
// unsubscribe 함수 반환
|
||||||
|
return () => {
|
||||||
|
this.stateListeners = this.stateListeners.filter((l) => l !== callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 핵심: Promise 체인으로 순차 처리
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 음성 인식 시작 (최상위 함수)
|
||||||
|
*
|
||||||
|
* Promise 체인:
|
||||||
|
* cleanup() → initialize() → start() → waitForListening()
|
||||||
|
*
|
||||||
|
* ⭐ continuous=true 기반:
|
||||||
|
* - SilenceDetection 미사용
|
||||||
|
* - 15초 maxListeningTime 타이머만 사용
|
||||||
|
* - 사용자가 수동으로 중단하거나 15초 도달 시 종료
|
||||||
|
*
|
||||||
|
* @returns {Promise<{status: 'success', state: string, timestamp: string}>}
|
||||||
|
* @throws {Error} 어느 단계든 실패하면 throw
|
||||||
|
*/
|
||||||
|
async startListeningAsync() {
|
||||||
|
try {
|
||||||
|
console.log('[VoiceRecognitionManager] startListeningAsync() 시작');
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 1: Cleanup
|
||||||
|
// ========================================
|
||||||
|
console.log('[VoiceRecognitionManager] [Step 1] Cleanup 시작...');
|
||||||
|
this.setState(VOICE_STATE.INITIALIZING);
|
||||||
|
|
||||||
|
await this.service.cleanupAsync();
|
||||||
|
console.log('[VoiceRecognitionManager] ✅ [Step 1] Cleanup 완료');
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 2: Initialize (continuous=true 강제)
|
||||||
|
// ========================================
|
||||||
|
console.log('[VoiceRecognitionManager] [Step 2] Initialize 시작...');
|
||||||
|
|
||||||
|
await this.service.initializeAsync(
|
||||||
|
{
|
||||||
|
lang: 'en-US',
|
||||||
|
continuous: true, // ★ 강제 설정 (continuous=false 지원 안 함)
|
||||||
|
interimResults: true,
|
||||||
|
},
|
||||||
|
this.options.initTimeoutMs
|
||||||
|
);
|
||||||
|
console.log('[VoiceRecognitionManager] ✅ [Step 2] Initialize 완료');
|
||||||
|
this.setState(VOICE_STATE.INITIALIZED);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 3: Start (onstart 이벤트까지 대기)
|
||||||
|
// ========================================
|
||||||
|
console.log('[VoiceRecognitionManager] [Step 3] Start 시작...');
|
||||||
|
this.setState(VOICE_STATE.STARTING);
|
||||||
|
|
||||||
|
await this.service.startAsync(this.options.startTimeoutMs);
|
||||||
|
console.log('[VoiceRecognitionManager] ✅ [Step 3] Start 완료 (onstart 이벤트 수신)');
|
||||||
|
this.setState(VOICE_STATE.STARTED);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 4: Listening (사용자가 말할 수 있는 상태)
|
||||||
|
// ========================================
|
||||||
|
console.log('[VoiceRecognitionManager] [Step 4] Listening 상태 진입');
|
||||||
|
this.setState(VOICE_STATE.LISTENING);
|
||||||
|
console.log('🎉 [완료] 음성 인식 준비 완료! (사용자가 말할 수 있음)');
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 5: 최대 listening 시간 타이머 (15초)
|
||||||
|
// ========================================
|
||||||
|
console.log('[VoiceRecognitionManager] [Step 5] MaxListeningTime 타이머 설정 (15초)');
|
||||||
|
this._setupListeningTimeout();
|
||||||
|
|
||||||
|
console.log('[VoiceRecognitionManager] ✅✅✅ 모든 단계 완료!');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
state: VOICE_STATE.LISTENING,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[VoiceRecognitionManager] ❌ [에러] 음성 인식 시작 실패:', error.message);
|
||||||
|
this.setState(VOICE_STATE.ERROR);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 최대 listening 시간 타이머 (continuous=true 전용)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최대 listening 시간 타이머 설정
|
||||||
|
* 15초를 초과하면 자동으로 종료
|
||||||
|
*
|
||||||
|
* ⭐ continuous=true에서는 이것이 유일한 자동 종료 메커니즘
|
||||||
|
*/
|
||||||
|
_setupListeningTimeout() {
|
||||||
|
this.listeningTimer = setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`[VoiceRecognitionManager] [Timeout] ${this.options.maxListeningTime}ms 초과 → 종료`
|
||||||
|
);
|
||||||
|
this._finishListening('timeout_max_listening_time');
|
||||||
|
}, this.options.maxListeningTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Listening 종료 → Processing → Stopped
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listening 종료
|
||||||
|
*
|
||||||
|
* 순서:
|
||||||
|
* 1. 타이머 정리
|
||||||
|
* 2. PROCESSING 상태로 전환
|
||||||
|
* 3. 최종 텍스트 처리 (외부에서 처리)
|
||||||
|
* 4. Stop 호출 + onend 이벤트까지 대기 ← ★ Promise로 보장
|
||||||
|
* 5. IDLE 상태로 복귀
|
||||||
|
*
|
||||||
|
* @param {string} reason - 종료 이유 (timeout_max_listening_time, user_stopped 등)
|
||||||
|
* @returns {Promise<{status: 'finished', reason: string}>}
|
||||||
|
* @throws {Error} Stop 실패
|
||||||
|
*/
|
||||||
|
async _finishListening(reason) {
|
||||||
|
try {
|
||||||
|
console.log(`[VoiceRecognitionManager] _finishListening() 시작 (이유: ${reason})`);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 1: 타이머 정리
|
||||||
|
// ========================================
|
||||||
|
if (this.listeningTimer) {
|
||||||
|
clearTimeout(this.listeningTimer);
|
||||||
|
this.listeningTimer = null;
|
||||||
|
}
|
||||||
|
console.log('[VoiceRecognitionManager] ✅ 타이머 정리 완료');
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 2: PROCESSING 상태로 전환
|
||||||
|
// ========================================
|
||||||
|
this.setState(VOICE_STATE.PROCESSING);
|
||||||
|
console.log('[VoiceRecognitionManager] [Step 1] PROCESSING 상태로 전환');
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 3: 최종 텍스트 처리
|
||||||
|
// ========================================
|
||||||
|
// 이 부분은 외부(VoiceInputOverlay)에서 처리
|
||||||
|
console.log('[VoiceRecognitionManager] [Step 2] 최종 텍스트 처리 (외부)');
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 4: Stop 호출
|
||||||
|
// ========================================
|
||||||
|
console.log('[VoiceRecognitionManager] [Step 3] Stop 시작...');
|
||||||
|
this.setState(VOICE_STATE.STOPPING);
|
||||||
|
|
||||||
|
await this.service.stopAsync(this.options.stopTimeoutMs);
|
||||||
|
console.log('[VoiceRecognitionManager] ✅ [Step 3] Stop 완료 (onend 이벤트 수신)');
|
||||||
|
this.setState(VOICE_STATE.STOPPED);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Step 5: 최종 상태 복귀
|
||||||
|
// ========================================
|
||||||
|
this.setState(VOICE_STATE.IDLE);
|
||||||
|
console.log('[VoiceRecognitionManager] ✅✅✅ 음성 인식 완전 종료');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'finished',
|
||||||
|
reason: reason,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[VoiceRecognitionManager] ❌ [에러] Listening 종료 실패:', error.message);
|
||||||
|
this.setState(VOICE_STATE.ERROR);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 에러 복구
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 후 재시도
|
||||||
|
*
|
||||||
|
* 순서:
|
||||||
|
* 1. IDLE 상태로 리셋
|
||||||
|
* 2. 100ms 대기 (상태 정리)
|
||||||
|
* 3. startListeningAsync() 재호출
|
||||||
|
*
|
||||||
|
* @returns {Promise<{status: 'success', state: string, timestamp: string}>}
|
||||||
|
*/
|
||||||
|
async retryAsync() {
|
||||||
|
try {
|
||||||
|
console.log('[VoiceRecognitionManager] retryAsync() 시작');
|
||||||
|
|
||||||
|
// Step 1: IDLE 상태로 리셋
|
||||||
|
this.setState(VOICE_STATE.IDLE);
|
||||||
|
|
||||||
|
// Step 2: 100ms 대기
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Step 3: 재시작
|
||||||
|
console.log('[VoiceRecognitionManager] 음성 인식 재시작...');
|
||||||
|
return await this.startListeningAsync();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[VoiceRecognitionManager] ❌ 재시도 실패:', error.message);
|
||||||
|
this.setState(VOICE_STATE.ERROR);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 사용자 정보 제공
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 상태 반환
|
||||||
|
*/
|
||||||
|
getCurrentState() {
|
||||||
|
return this.currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 상태가 LISTENING인가?
|
||||||
|
*/
|
||||||
|
isListening() {
|
||||||
|
return this.currentState === VOICE_STATE.LISTENING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 상태가 ERROR인가?
|
||||||
|
*/
|
||||||
|
isError() {
|
||||||
|
return this.currentState === VOICE_STATE.ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WEBSPEECH_VERSION 조건부 기능 실행
|
||||||
|
* @param {string} version - 버전 (V1, V2)
|
||||||
|
* @returns {boolean} 버전 매칭 여부
|
||||||
|
*/
|
||||||
|
isVersion(version) {
|
||||||
|
return this.options.webSpeechVersion === version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VoiceRecognitionManager;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// src/services/webSpeech/WebSpeechServiceAsync.js
|
||||||
|
|
||||||
|
import WebSpeechService from './WebSpeechService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSpeechService의 비동기 래퍼 (continuous=true 전용)
|
||||||
|
* 각 단계별로 Promise를 반환하여 순서 보장
|
||||||
|
*
|
||||||
|
* ⚠️ 주의: continuous=true 기반으로 설계됨
|
||||||
|
* - onend 이벤트가 자동으로 발생하지 않음
|
||||||
|
* - 명시적으로 stop()을 호출해야 onend 이벤트 발생
|
||||||
|
* - Promise 체인으로 순서 보장 필수
|
||||||
|
*/
|
||||||
|
class WebSpeechServiceAsync extends WebSpeechService {
|
||||||
|
/**
|
||||||
|
* Promise 기반 초기화 (cleanup + initialize)
|
||||||
|
*
|
||||||
|
* 순서:
|
||||||
|
* 1. 기존 인식 세션 완전 정리 (동기)
|
||||||
|
* 2. 새 SpeechRecognition 객체 생성 (동기)
|
||||||
|
* 3. 설정 적용 (동기)
|
||||||
|
* 4. 이벤트 핸들러 등록 (동기)
|
||||||
|
* 5. Promise resolve (동기)
|
||||||
|
*
|
||||||
|
* @param {Object} config - WebSpeech 설정 (continuous 강제 true)
|
||||||
|
* @param {number} timeoutMs - 초기화 타임아웃 (기본 5000ms)
|
||||||
|
* @returns {Promise<{status: 'initialized', timestamp: string}>}
|
||||||
|
*/
|
||||||
|
initializeAsync(config = {}, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
console.log('[WebSpeechServiceAsync] initializeAsync() 시작');
|
||||||
|
|
||||||
|
// Step 1: 기존 인식 세션 정리 (동기)
|
||||||
|
if (this.recognition) {
|
||||||
|
try {
|
||||||
|
this.recognition.abort();
|
||||||
|
console.log('[WebSpeechServiceAsync] 기존 세션 abort() 완료');
|
||||||
|
|
||||||
|
// Step 2: 이벤트 핸들러 명시적 제거
|
||||||
|
this.recognition.onstart = null;
|
||||||
|
this.recognition.onresult = null;
|
||||||
|
this.recognition.onerror = null;
|
||||||
|
this.recognition.onend = null;
|
||||||
|
console.log('[WebSpeechServiceAsync] 이벤트 핸들러 제거 완료');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[WebSpeechServiceAsync] Cleanup 중 에러 (무시):', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: 부모 클래스의 initialize() 호출 (동기)
|
||||||
|
// - new SpeechRecognition() 객체 생성
|
||||||
|
// - 설정 적용 (lang, continuous=true, interimResults, maxAlternatives)
|
||||||
|
// - setupEventHandlers() 호출로 이벤트 리스너 등록
|
||||||
|
//
|
||||||
|
// ⚠️ continuous: true 강제 설정!
|
||||||
|
const mergedConfig = {
|
||||||
|
...config,
|
||||||
|
continuous: true, // ★ continuous=false는 지원하지 않음
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = this.initialize(mergedConfig);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
reject(new Error('WebSpeechService not supported or initialization failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WebSpeechServiceAsync] initialize() 완료');
|
||||||
|
|
||||||
|
// Step 4: Promise resolve (동기)
|
||||||
|
// 이 시점에서 객체 생성과 설정이 모두 완료됨
|
||||||
|
resolve({
|
||||||
|
status: 'initialized',
|
||||||
|
config: {
|
||||||
|
lang: this.recognition.lang,
|
||||||
|
continuous: this.recognition.continuous,
|
||||||
|
interimResults: this.recognition.interimResults,
|
||||||
|
maxAlternatives: this.recognition.maxAlternatives,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebSpeechServiceAsync] initializeAsync() 에러:', error.message);
|
||||||
|
reject(new Error(`Initialize failed: ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise 기반 시작
|
||||||
|
* recognition.start() 호출 후 onstart 이벤트까지 대기
|
||||||
|
*
|
||||||
|
* 순서:
|
||||||
|
* 1. recognition.start() 호출 (동기) → 마이크 접근 권한 요청
|
||||||
|
* 2. onstart 이벤트 대기 (비동기) → 150-300ms 후
|
||||||
|
* 3. 또는 onerror 이벤트 수신 → 즉시~수초
|
||||||
|
* 4. 타임아웃 처리 → 5초
|
||||||
|
*
|
||||||
|
* @param {number} timeoutMs - 타임아웃 시간 (기본 5000ms)
|
||||||
|
* @returns {Promise<{status: 'listening', timestamp: string}>}
|
||||||
|
*
|
||||||
|
* @throws {Error} start() 실패 또는 onstart 타임아웃
|
||||||
|
*/
|
||||||
|
startAsync(timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log('[WebSpeechServiceAsync] startAsync() 시작');
|
||||||
|
|
||||||
|
// Step 1: recognition.start() 호출 (동기)
|
||||||
|
const success = this.start();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
console.error('[WebSpeechServiceAsync] start() 실패 (이미 리스닝 중이거나 초기화 안 됨)');
|
||||||
|
reject(new Error('WebSpeech start() failed - already listening or not initialized'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WebSpeechServiceAsync] start() 호출 완료, onstart 이벤트 대기 중...');
|
||||||
|
|
||||||
|
// Step 2: 타임아웃 설정
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.error(`[WebSpeechServiceAsync] onstart 이벤트 타임아웃 (${timeoutMs}ms)`);
|
||||||
|
reject(new Error(`onstart event timeout (${timeoutMs}ms)`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
// Step 3: onstart 이벤트 대기
|
||||||
|
const originalOnStart = this.callbacks.onStart;
|
||||||
|
this.callbacks.onStart = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
console.log('[WebSpeechServiceAsync] onstart 이벤트 수신 ✅');
|
||||||
|
|
||||||
|
// 원래 onStart 콜백도 호출
|
||||||
|
if (originalOnStart) {
|
||||||
|
originalOnStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise resolve
|
||||||
|
resolve({
|
||||||
|
status: 'listening',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 4: onerror 이벤트도 처리
|
||||||
|
const originalOnError = this.callbacks.onError;
|
||||||
|
this.callbacks.onError = (errorInfo) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
console.error('[WebSpeechServiceAsync] onerror 이벤트 수신:', errorInfo);
|
||||||
|
|
||||||
|
// 원래 onError 콜백도 호출
|
||||||
|
if (originalOnError) {
|
||||||
|
originalOnError(errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise reject
|
||||||
|
reject(new Error(`WebSpeech error: ${errorInfo.error} - ${errorInfo.message}`));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise 기반 중지
|
||||||
|
* recognition.stop() 호출 후 onend 이벤트까지 대기
|
||||||
|
*
|
||||||
|
* ⚠️ continuous=true 특수성:
|
||||||
|
* - onend 이벤트가 자동으로 발생하지 않음
|
||||||
|
* - 명시적으로 stop()을 호출해야 onend 이벤트 발생
|
||||||
|
* - 따라서 이 메서드는 "stop() 호출 후 onend 대기"를 보장
|
||||||
|
*
|
||||||
|
* 순서:
|
||||||
|
* 1. isListening 확인 (동기)
|
||||||
|
* 2. recognition.stop() 호출 (동기)
|
||||||
|
* 3. onend 이벤트 대기 (비동기) ← ★ continuous=true에서는 필수
|
||||||
|
* 4. 또는 onerror 이벤트
|
||||||
|
* 5. 타임아웃 처리 → 5초
|
||||||
|
*
|
||||||
|
* @param {number} timeoutMs - 타임아웃 시간 (기본 5000ms)
|
||||||
|
* @returns {Promise<{status: 'stopped', timestamp: string}>}
|
||||||
|
*
|
||||||
|
* @throws {Error} 현재 리스닝 중이 아니거나 onend 타임아웃
|
||||||
|
*/
|
||||||
|
stopAsync(timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log('[WebSpeechServiceAsync] stopAsync() 시작');
|
||||||
|
|
||||||
|
// Step 1: 현재 리스닝 상태 확인
|
||||||
|
if (!this.isListening) {
|
||||||
|
console.warn('[WebSpeechServiceAsync] 현재 리스닝 중이 아님');
|
||||||
|
reject(new Error('Not currently listening'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: 타임아웃 설정
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.error(`[WebSpeechServiceAsync] onend 이벤트 타임아웃 (${timeoutMs}ms)`);
|
||||||
|
reject(new Error(`onend event timeout (${timeoutMs}ms)`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
// Step 3: onend 이벤트 대기
|
||||||
|
const originalOnEnd = this.callbacks.onEnd;
|
||||||
|
this.callbacks.onEnd = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
console.log('[WebSpeechServiceAsync] onend 이벤트 수신 ✅');
|
||||||
|
|
||||||
|
// 원래 onEnd 콜백도 호출
|
||||||
|
if (originalOnEnd) {
|
||||||
|
originalOnEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise resolve
|
||||||
|
resolve({
|
||||||
|
status: 'stopped',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 4: recognition.stop() 호출 (동기)
|
||||||
|
// ⚠️ continuous=true이므로 명시적으로 호출 필수
|
||||||
|
this.stop();
|
||||||
|
console.log('[WebSpeechServiceAsync] stop() 호출 완료, onend 이벤트 대기 중...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise 기반 정리
|
||||||
|
*
|
||||||
|
* @returns {Promise<{status: 'cleaned', timestamp: string}>}
|
||||||
|
* @throws {Error} cleanup 중 에러
|
||||||
|
*/
|
||||||
|
cleanupAsync() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
console.log('[WebSpeechServiceAsync] cleanupAsync() 시작');
|
||||||
|
|
||||||
|
// 부모 클래스의 cleanup() 호출 (동기)
|
||||||
|
// - abort()
|
||||||
|
// - 콜백 초기화
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
console.log('[WebSpeechServiceAsync] cleanup() 완료 ✅');
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
status: 'cleaned',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebSpeechServiceAsync] cleanupAsync() 에러:', error.message);
|
||||||
|
reject(new Error(`Cleanup failed: ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebSpeechServiceAsync;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import css from './ApiStatusDisplay.module.less';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 상태 표시 컴포넌트
|
||||||
|
*
|
||||||
|
* 오른쪽 아래에 조용한 상태 메시지 표시
|
||||||
|
* - 로딩: "API Status..."
|
||||||
|
* - 성공: "Success"
|
||||||
|
* - 오류: 오류 메시지
|
||||||
|
*
|
||||||
|
* PROMPT/LISTENING 모드 상관없이 항상 표시
|
||||||
|
* query/searchId와 겹치지 않는 위치에 배치
|
||||||
|
*/
|
||||||
|
const ApiStatusDisplay = ({
|
||||||
|
status = 'idle', // 'idle', 'loading', 'success', 'error'
|
||||||
|
message = 'API Status', // 표시할 메시지
|
||||||
|
autoHideDuration = 3000, // ms 단위 (성공시만 적용)
|
||||||
|
onStatusChange = null, // 상태 변경 콜백
|
||||||
|
}) => {
|
||||||
|
const [displayMessage, setDisplayMessage] = useState(message);
|
||||||
|
const [displayStatus, setDisplayStatus] = useState(status);
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const [hideTimer, setHideTimer] = useState(null);
|
||||||
|
|
||||||
|
// status 변경시
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayStatus(status);
|
||||||
|
setDisplayMessage(message);
|
||||||
|
|
||||||
|
// 이전 타이머 정리
|
||||||
|
if (hideTimer) {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
setHideTimer(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공시 자동 숨김
|
||||||
|
if (status === 'success') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
}, autoHideDuration);
|
||||||
|
setHideTimer(timer);
|
||||||
|
} else {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 콜백 호출
|
||||||
|
if (onStatusChange) {
|
||||||
|
onStatusChange({ status, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정리
|
||||||
|
return () => {
|
||||||
|
if (hideTimer) {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [status, message, autoHideDuration, onStatusChange]);
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${css.apiStatusDisplay} ${css[`status_${displayStatus}`]}`}
|
||||||
|
role="status"
|
||||||
|
aria-label={`API Status: ${displayMessage}`}
|
||||||
|
>
|
||||||
|
{/* 상태 아이콘 */}
|
||||||
|
<div className={css.iconContainer}>
|
||||||
|
{displayStatus === 'loading' && (
|
||||||
|
<div className={css.spinner}>
|
||||||
|
<div className={css.spinnerInner}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayStatus === 'success' && (
|
||||||
|
<div className={css.successIcon}>✓</div>
|
||||||
|
)}
|
||||||
|
{displayStatus === 'error' && (
|
||||||
|
<div className={css.errorIcon}>!</div>
|
||||||
|
)}
|
||||||
|
{displayStatus === 'idle' && (
|
||||||
|
<div className={css.idleIcon}>○</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 메시지 */}
|
||||||
|
<div className={css.messageContainer}>
|
||||||
|
<span className={css.message}>{displayMessage}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiStatusDisplay.propTypes = {
|
||||||
|
status: PropTypes.oneOf(['idle', 'loading', 'success', 'error']),
|
||||||
|
message: PropTypes.string,
|
||||||
|
autoHideDuration: PropTypes.number,
|
||||||
|
onStatusChange: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiStatusDisplay;
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
@import '../../../style/CommonStyle.module.less';
|
||||||
|
|
||||||
|
.apiStatusDisplay {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 28px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: "LG Smart UI";
|
||||||
|
z-index: 10000;
|
||||||
|
max-width: 600px;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
line-height: 28px;
|
||||||
|
|
||||||
|
&.status_idle {
|
||||||
|
background: rgba(100, 100, 100, 0.75);
|
||||||
|
color: #cccccc;
|
||||||
|
|
||||||
|
.idleIcon {
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status_loading {
|
||||||
|
background: rgba(0, 100, 200, 0.75);
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status_success {
|
||||||
|
background: rgba(0, 150, 80, 0.75);
|
||||||
|
color: #ffffff;
|
||||||
|
animation: slideInSuccess 0.3s ease;
|
||||||
|
|
||||||
|
.successIcon {
|
||||||
|
display: flex;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status_error {
|
||||||
|
background: rgba(200, 50, 50, 0.75);
|
||||||
|
color: #ffffff;
|
||||||
|
animation: slideInError 0.3s ease;
|
||||||
|
|
||||||
|
.errorIcon {
|
||||||
|
display: flex;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 로딩 스피너 */
|
||||||
|
.spinner {
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
.spinnerInner {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top: 3px solid #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 성공 아이콘 */
|
||||||
|
.successIcon {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에러 아이콘 */
|
||||||
|
.errorIcon {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 대기 아이콘 */
|
||||||
|
.idleIcon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageContainer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: "LG Smart UI";
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 애니메이션 */
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInSuccess {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInError {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 대응 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.apiStatusDisplay {
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
max-width: 380px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconContainer {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
|
||||||
|
.spinnerInner {
|
||||||
|
border-width: 2px;
|
||||||
|
border-top-width: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.successIcon,
|
||||||
|
.errorIcon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idleIcon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import VoiceResponse from './modes/VoiceResponse';
|
|||||||
import VoiceApiError from './modes/VoiceApiError';
|
import VoiceApiError from './modes/VoiceApiError';
|
||||||
import WebSpeechEventDebug from './WebSpeechEventDebug';
|
import WebSpeechEventDebug from './WebSpeechEventDebug';
|
||||||
import VoiceDebugDashboard from './VoiceDebugDashboard';
|
import VoiceDebugDashboard from './VoiceDebugDashboard';
|
||||||
|
import ApiStatusDisplay from './ApiStatusDisplay';
|
||||||
import css from './VoiceInputOverlay.module.less';
|
import css from './VoiceInputOverlay.module.less';
|
||||||
|
|
||||||
const OverlayContainer = SpotlightContainerDecorator(
|
const OverlayContainer = SpotlightContainerDecorator(
|
||||||
@@ -195,6 +196,9 @@ const VoiceInputOverlay = ({
|
|||||||
const [isBubbleClickSearch, setIsBubbleClickSearch] = useState(false);
|
const [isBubbleClickSearch, setIsBubbleClickSearch] = useState(false);
|
||||||
// 디버그 대시보드 표시 여부
|
// 디버그 대시보드 표시 여부
|
||||||
const [showDashboard, setShowDashboard] = useState(false);
|
const [showDashboard, setShowDashboard] = useState(false);
|
||||||
|
// API 상태 표시용 state
|
||||||
|
const [apiStatus, setApiStatus] = useState('idle'); // 'idle', 'loading', 'success', 'error'
|
||||||
|
const [apiStatusMessage, setApiStatusMessage] = useState('API Status...');
|
||||||
// useSearchHistory Hook 적용 (음성검색 기록 관리)
|
// useSearchHistory Hook 적용 (음성검색 기록 관리)
|
||||||
const { addVoiceSearch } = useSearchHistory();
|
const { addVoiceSearch } = useSearchHistory();
|
||||||
|
|
||||||
@@ -581,14 +585,14 @@ const VoiceInputOverlay = ({
|
|||||||
}
|
}
|
||||||
}, [isVisible, isListening, addWebSpeechEventLog]);
|
}, [isVisible, isListening, addWebSpeechEventLog]);
|
||||||
|
|
||||||
// ShopperHouse API 오류 감지 및 APIError 모드로 전환
|
// ShopperHouse API 오류 감지 및 API 상태 표시 업데이트
|
||||||
|
// ⚠️ 최적화: 실시간 상태 표시 제거, 결과만 표시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
console.log('🔍 [DEBUG] shopperHouseError useEffect running:', {
|
console.log('🔍 [DEBUG] shopperHouseError useEffect running:', {
|
||||||
isVisible,
|
isVisible,
|
||||||
hasError: !!shopperHouseError,
|
hasError: !!shopperHouseError,
|
||||||
error: shopperHouseError,
|
error: shopperHouseError,
|
||||||
currentMode,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,29 +604,25 @@ const VoiceInputOverlay = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오류가 발생하고 overlay가 표시 중이며 현재 모드가 RESPONSE일 경우 APIError 모드로 전환
|
// 오류가 발생한 경우 - API 상태 표시 업데이트만 (팝업 대신)
|
||||||
if (isVisible && shopperHouseError && currentMode === VOICE_MODES.RESPONSE) {
|
if (shopperHouseError && isVisible) {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
console.log(
|
console.log('[VoiceInputOverlay] ❌ API error - showing result only:', shopperHouseError);
|
||||||
'[VoiceInputOverlay] ❌ ShopperHouse API error detected, switching to APIERROR mode:',
|
|
||||||
{
|
|
||||||
error: shopperHouseError,
|
|
||||||
currentMode,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIError 모드로 전환
|
// ⚠️ 심각한 오류만 표시 (5xx 서버 오류, 커스텀 메시지)
|
||||||
setCurrentMode(VOICE_MODES.APIERROR);
|
// 인증 관련 오류(401, 402, 501)나 네트워크 오류는 필터링됨 (searchActions.js에서)
|
||||||
setVoiceInputMode(null);
|
const errorMsg = shopperHouseError.retMsg || shopperHouseError.message || '오류 발생';
|
||||||
|
setApiStatus('error');
|
||||||
|
setApiStatusMessage(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Cleanup: 필요 시 정리 로직 추가
|
// Cleanup: 필요 시 정리 로직 추가
|
||||||
};
|
};
|
||||||
}, [shopperHouseError, isVisible, currentMode]);
|
}, [shopperHouseError, isVisible]);
|
||||||
|
|
||||||
// ShopperHouse API 응답 수신 시 overlay 닫기
|
// ShopperHouse API 응답 수신 시 overlay 닫기 및 API 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
console.log('🔍 [DEBUG] shopperHouseData useEffect running:', {
|
console.log('🔍 [DEBUG] shopperHouseData useEffect running:', {
|
||||||
@@ -652,6 +652,14 @@ const VoiceInputOverlay = ({
|
|||||||
}
|
}
|
||||||
shopperHouseDataRef.current = shopperHouseData;
|
shopperHouseDataRef.current = shopperHouseData;
|
||||||
|
|
||||||
|
// ✅ API 성공 상태 표시
|
||||||
|
setApiStatus('success');
|
||||||
|
setApiStatusMessage('Success');
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
console.log('[VoiceInputOverlay] 📍 API Status updated:', { status: 'success', message: 'Success' });
|
||||||
|
}
|
||||||
|
|
||||||
// 직접 닫기 (VoiceResponse 컴포넌트에서 결과를 표시한 후 닫음)
|
// 직접 닫기 (VoiceResponse 컴포넌트에서 결과를 표시한 후 닫음)
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -1538,7 +1546,9 @@ const VoiceInputOverlay = ({
|
|||||||
|
|
||||||
if (currentMode === VOICE_MODES.PROMPT) {
|
if (currentMode === VOICE_MODES.PROMPT) {
|
||||||
// ✨ PROMPT 모드에서만 LISTENING으로 전환 가능
|
// ✨ PROMPT 모드에서만 LISTENING으로 전환 가능
|
||||||
console.log('[VoiceInput] 🎙️ 마이크 버튼 클릭 - WebSpeech 재초기화 시작');
|
console.log('\n🎤 ════════════════════════════════════════════════════════════════');
|
||||||
|
console.log('🎤 [VoiceInput] MIC BUTTON CLICKED - WebSpeech Initialization Flow');
|
||||||
|
console.log('🎤 ════════════════════════════════════════════════════════════════\n');
|
||||||
|
|
||||||
// 📋 단계별 처리:
|
// 📋 단계별 처리:
|
||||||
// 1. WebSpeech 완전 cleanup (이전 상태 제거)
|
// 1. WebSpeech 완전 cleanup (이전 상태 제거)
|
||||||
@@ -1549,34 +1559,44 @@ const VoiceInputOverlay = ({
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 1️⃣ WebSpeech 완전 cleanup (이전 상태 제거)
|
// 1️⃣ WebSpeech 완전 cleanup (이전 상태 제거)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log('[VoiceInput] ├─ [Step 1] WebSpeech cleanup 시작');
|
console.log('[VoiceInput] 📍 [STEP 1] WebSpeech Cleanup');
|
||||||
|
console.log('[VoiceInput] ├─ Dispatching: cleanupWebSpeech()');
|
||||||
dispatch(cleanupWebSpeech());
|
dispatch(cleanupWebSpeech());
|
||||||
|
console.log('[VoiceInput] └─ ✅ Cleanup dispatched\n');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 2️⃣ Redux STT 데이터 초기화 (searchId는 Redux에서 유지됨)
|
// 2️⃣ Redux STT 데이터 초기화 (searchId는 Redux에서 유지됨)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log('[VoiceInput] ├─ [Step 2] STT 데이터 초기화 (searchId 유지)');
|
console.log('[VoiceInput] 📍 [STEP 2] STT Data Clear');
|
||||||
|
console.log('[VoiceInput] ├─ Dispatching: clearSTTText()');
|
||||||
dispatch(clearSTTText());
|
dispatch(clearSTTText());
|
||||||
|
console.log('[VoiceInput] ├─ Clearing interim text buffer');
|
||||||
|
|
||||||
// ✅ TInput 초기화
|
// ✅ TInput 초기화
|
||||||
if (onSearchChange) {
|
if (onSearchChange) {
|
||||||
onSearchChange({ value: '' });
|
onSearchChange({ value: '' });
|
||||||
|
console.log('[VoiceInput] ├─ TInput cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Interim text ref 초기화
|
// ✅ Interim text ref 초기화
|
||||||
interimTextRef.current = '';
|
interimTextRef.current = '';
|
||||||
|
console.log('[VoiceInput] ├─ Interim text ref cleared');
|
||||||
|
|
||||||
// 기존 타이머 정리
|
// 기존 타이머 정리
|
||||||
clearTimerRef(listeningTimerRef);
|
clearTimerRef(listeningTimerRef);
|
||||||
clearTimerRef(silenceDetectionTimerRef);
|
clearTimerRef(silenceDetectionTimerRef);
|
||||||
|
console.log('[VoiceInput] ├─ Previous timers cleared');
|
||||||
|
|
||||||
// UI 모드 업데이트
|
// UI 모드 업데이트
|
||||||
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
|
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
|
||||||
setCurrentMode(VOICE_MODES.LISTENING);
|
setCurrentMode(VOICE_MODES.LISTENING);
|
||||||
|
console.log('[VoiceInput] ├─ UI mode set to WEBSPEECH / LISTENING');
|
||||||
|
|
||||||
// ✅ LISTENING 모드 진입 시 로그 초기화 (새로운 음성 입력 시작)
|
// ✅ LISTENING 모드 진입 시 로그 초기화 (새로운 음성 입력 시작)
|
||||||
setWebSpeechEventLogs([]);
|
setWebSpeechEventLogs([]);
|
||||||
writeLocalStorage(VOICE_EVENT_LOGS_KEY, []);
|
writeLocalStorage(VOICE_EVENT_LOGS_KEY, []);
|
||||||
|
console.log('[VoiceInput] └─ ✅ Event logs cleared\n');
|
||||||
|
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
console.log('🧹 [DEBUG] Cleared webSpeechEventLogs on mic click');
|
console.log('🧹 [DEBUG] Cleared webSpeechEventLogs on mic click');
|
||||||
}
|
}
|
||||||
@@ -1584,8 +1604,12 @@ const VoiceInputOverlay = ({
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 3️⃣ WebSpeech 재초기화 (약간의 지연 후)
|
// 3️⃣ WebSpeech 재초기화 (약간의 지연 후)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log('[VoiceInput] ├─ [Step 3] WebSpeech 재초기화 시작');
|
console.log('[VoiceInput] ⏳ [STEP 3] Waiting 100ms for Redux state sync...');
|
||||||
const reinitializeWebSpeech = setTimeout(() => {
|
const reinitializeWebSpeech = setTimeout(() => {
|
||||||
|
console.log('[VoiceInput] 📍 [STEP 3] WebSpeech Initialization');
|
||||||
|
console.log('[VoiceInput] ├─ Dispatching: initializeWebSpeech()');
|
||||||
|
console.log('[VoiceInput] │ └─ lang: en-US, continuous: true, interimResults: true');
|
||||||
|
|
||||||
// Redux 상태 업데이트를 기다리기 위해 약간의 지연
|
// Redux 상태 업데이트를 기다리기 위해 약간의 지연
|
||||||
dispatch(
|
dispatch(
|
||||||
initializeWebSpeech({
|
initializeWebSpeech({
|
||||||
@@ -1595,17 +1619,37 @@ const VoiceInputOverlay = ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('[VoiceInput] └─ ✅ Initialize dispatched\n');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 4️⃣ WebSpeech 즉시 시작
|
// 4️⃣ WebSpeech 즉시 시작
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log('[VoiceInput] └─ [Step 4] WebSpeech 시작');
|
console.log('[VoiceInput] 📍 [STEP 4] WebSpeech Start');
|
||||||
|
console.log('[VoiceInput] ├─ Dispatching: startWebSpeech()');
|
||||||
dispatch(startWebSpeech());
|
dispatch(startWebSpeech());
|
||||||
|
console.log('[VoiceInput] └─ ✅ Start dispatched\n');
|
||||||
|
|
||||||
// 15초 타이머 설정: 최대 입력 시간 제한
|
// ============================================================
|
||||||
|
// 5️⃣ 15초 타이머 설정 및 준비 완료
|
||||||
|
// ============================================================
|
||||||
|
console.log('[VoiceInput] 📍 [STEP 5] Setup MaxListeningTime Timer');
|
||||||
|
console.log('[VoiceInput] ├─ Setting 15-second listening timeout');
|
||||||
listeningTimerRef.current = setTimeout(() => {
|
listeningTimerRef.current = setTimeout(() => {
|
||||||
|
console.log('[VoiceInput] ⏲️ [TIMEOUT] 15-second max listening time reached');
|
||||||
addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input');
|
addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input');
|
||||||
processFinalVoiceInput('15초 타임아웃');
|
processFinalVoiceInput('15초 타임아웃');
|
||||||
}, 15000); // 15초
|
}, 15000); // 15초
|
||||||
|
|
||||||
|
console.log('[VoiceInput] ├─ ⏲️ Timer started (15000ms)');
|
||||||
|
console.log('[VoiceInput] └─ ✅ Timer configured\n');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ✨ INITIALIZATION COMPLETE
|
||||||
|
// ============================================================
|
||||||
|
console.log('🎤 ════════════════════════════════════════════════════════════════');
|
||||||
|
console.log('✨ [VoiceInput] 음성 인식 준비 완료! (Voice Recognition Ready!)');
|
||||||
|
console.log('✨ [VoiceInput] Waiting for voice input... (max 15 seconds)');
|
||||||
|
console.log('🎤 ════════════════════════════════════════════════════════════════\n');
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Cleanup: 언마운트 시 타이머 정리
|
// Cleanup: 언마운트 시 타이머 정리
|
||||||
@@ -1963,6 +2007,13 @@ const VoiceInputOverlay = ({
|
|||||||
isBubbleClickSearch={isBubbleClickSearch}
|
isBubbleClickSearch={isBubbleClickSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* API 상태 표시 - 오른쪽 아래에 조용하게 표시 */}
|
||||||
|
<ApiStatusDisplay
|
||||||
|
status={apiStatus}
|
||||||
|
message={apiStatusMessage}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
/>
|
||||||
</TFullPopup>
|
</TFullPopup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user