[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
|
||||
srcBackup
|
||||
# com.lgshop.app_*.ipk
|
||||
.doc
|
||||
.docs
|
||||
.docs
|
||||
nul
|
||||
.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 WebSpeechEventDebug from './WebSpeechEventDebug';
|
||||
import VoiceDebugDashboard from './VoiceDebugDashboard';
|
||||
import ApiStatusDisplay from './ApiStatusDisplay';
|
||||
import css from './VoiceInputOverlay.module.less';
|
||||
|
||||
const OverlayContainer = SpotlightContainerDecorator(
|
||||
@@ -195,6 +196,9 @@ const VoiceInputOverlay = ({
|
||||
const [isBubbleClickSearch, setIsBubbleClickSearch] = 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 적용 (음성검색 기록 관리)
|
||||
const { addVoiceSearch } = useSearchHistory();
|
||||
|
||||
@@ -581,14 +585,14 @@ const VoiceInputOverlay = ({
|
||||
}
|
||||
}, [isVisible, isListening, addWebSpeechEventLog]);
|
||||
|
||||
// ShopperHouse API 오류 감지 및 APIError 모드로 전환
|
||||
// ShopperHouse API 오류 감지 및 API 상태 표시 업데이트
|
||||
// ⚠️ 최적화: 실시간 상태 표시 제거, 결과만 표시
|
||||
useEffect(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔍 [DEBUG] shopperHouseError useEffect running:', {
|
||||
isVisible,
|
||||
hasError: !!shopperHouseError,
|
||||
error: shopperHouseError,
|
||||
currentMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -600,29 +604,25 @@ const VoiceInputOverlay = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 오류가 발생하고 overlay가 표시 중이며 현재 모드가 RESPONSE일 경우 APIError 모드로 전환
|
||||
if (isVisible && shopperHouseError && currentMode === VOICE_MODES.RESPONSE) {
|
||||
// 오류가 발생한 경우 - API 상태 표시 업데이트만 (팝업 대신)
|
||||
if (shopperHouseError && isVisible) {
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'[VoiceInputOverlay] ❌ ShopperHouse API error detected, switching to APIERROR mode:',
|
||||
{
|
||||
error: shopperHouseError,
|
||||
currentMode,
|
||||
}
|
||||
);
|
||||
console.log('[VoiceInputOverlay] ❌ API error - showing result only:', shopperHouseError);
|
||||
}
|
||||
|
||||
// APIError 모드로 전환
|
||||
setCurrentMode(VOICE_MODES.APIERROR);
|
||||
setVoiceInputMode(null);
|
||||
// ⚠️ 심각한 오류만 표시 (5xx 서버 오류, 커스텀 메시지)
|
||||
// 인증 관련 오류(401, 402, 501)나 네트워크 오류는 필터링됨 (searchActions.js에서)
|
||||
const errorMsg = shopperHouseError.retMsg || shopperHouseError.message || '오류 발생';
|
||||
setApiStatus('error');
|
||||
setApiStatusMessage(errorMsg);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup: 필요 시 정리 로직 추가
|
||||
};
|
||||
}, [shopperHouseError, isVisible, currentMode]);
|
||||
}, [shopperHouseError, isVisible]);
|
||||
|
||||
// ShopperHouse API 응답 수신 시 overlay 닫기
|
||||
// ShopperHouse API 응답 수신 시 overlay 닫기 및 API 상태 업데이트
|
||||
useEffect(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔍 [DEBUG] shopperHouseData useEffect running:', {
|
||||
@@ -652,6 +652,14 @@ const VoiceInputOverlay = ({
|
||||
}
|
||||
shopperHouseDataRef.current = shopperHouseData;
|
||||
|
||||
// ✅ API 성공 상태 표시
|
||||
setApiStatus('success');
|
||||
setApiStatusMessage('Success');
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInputOverlay] 📍 API Status updated:', { status: 'success', message: 'Success' });
|
||||
}
|
||||
|
||||
// 직접 닫기 (VoiceResponse 컴포넌트에서 결과를 표시한 후 닫음)
|
||||
onClose();
|
||||
}
|
||||
@@ -1538,7 +1546,9 @@ const VoiceInputOverlay = ({
|
||||
|
||||
if (currentMode === VOICE_MODES.PROMPT) {
|
||||
// ✨ PROMPT 모드에서만 LISTENING으로 전환 가능
|
||||
console.log('[VoiceInput] 🎙️ 마이크 버튼 클릭 - WebSpeech 재초기화 시작');
|
||||
console.log('\n🎤 ════════════════════════════════════════════════════════════════');
|
||||
console.log('🎤 [VoiceInput] MIC BUTTON CLICKED - WebSpeech Initialization Flow');
|
||||
console.log('🎤 ════════════════════════════════════════════════════════════════\n');
|
||||
|
||||
// 📋 단계별 처리:
|
||||
// 1. WebSpeech 완전 cleanup (이전 상태 제거)
|
||||
@@ -1549,34 +1559,44 @@ const VoiceInputOverlay = ({
|
||||
// ============================================================
|
||||
// 1️⃣ WebSpeech 완전 cleanup (이전 상태 제거)
|
||||
// ============================================================
|
||||
console.log('[VoiceInput] ├─ [Step 1] WebSpeech cleanup 시작');
|
||||
console.log('[VoiceInput] 📍 [STEP 1] WebSpeech Cleanup');
|
||||
console.log('[VoiceInput] ├─ Dispatching: cleanupWebSpeech()');
|
||||
dispatch(cleanupWebSpeech());
|
||||
console.log('[VoiceInput] └─ ✅ Cleanup dispatched\n');
|
||||
|
||||
// ============================================================
|
||||
// 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());
|
||||
console.log('[VoiceInput] ├─ Clearing interim text buffer');
|
||||
|
||||
// ✅ TInput 초기화
|
||||
if (onSearchChange) {
|
||||
onSearchChange({ value: '' });
|
||||
console.log('[VoiceInput] ├─ TInput cleared');
|
||||
}
|
||||
|
||||
// ✅ Interim text ref 초기화
|
||||
interimTextRef.current = '';
|
||||
console.log('[VoiceInput] ├─ Interim text ref cleared');
|
||||
|
||||
// 기존 타이머 정리
|
||||
clearTimerRef(listeningTimerRef);
|
||||
clearTimerRef(silenceDetectionTimerRef);
|
||||
console.log('[VoiceInput] ├─ Previous timers cleared');
|
||||
|
||||
// UI 모드 업데이트
|
||||
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
|
||||
setCurrentMode(VOICE_MODES.LISTENING);
|
||||
console.log('[VoiceInput] ├─ UI mode set to WEBSPEECH / LISTENING');
|
||||
|
||||
// ✅ LISTENING 모드 진입 시 로그 초기화 (새로운 음성 입력 시작)
|
||||
setWebSpeechEventLogs([]);
|
||||
writeLocalStorage(VOICE_EVENT_LOGS_KEY, []);
|
||||
console.log('[VoiceInput] └─ ✅ Event logs cleared\n');
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🧹 [DEBUG] Cleared webSpeechEventLogs on mic click');
|
||||
}
|
||||
@@ -1584,8 +1604,12 @@ const VoiceInputOverlay = ({
|
||||
// ============================================================
|
||||
// 3️⃣ WebSpeech 재초기화 (약간의 지연 후)
|
||||
// ============================================================
|
||||
console.log('[VoiceInput] ├─ [Step 3] WebSpeech 재초기화 시작');
|
||||
console.log('[VoiceInput] ⏳ [STEP 3] Waiting 100ms for Redux state sync...');
|
||||
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 상태 업데이트를 기다리기 위해 약간의 지연
|
||||
dispatch(
|
||||
initializeWebSpeech({
|
||||
@@ -1595,17 +1619,37 @@ const VoiceInputOverlay = ({
|
||||
})
|
||||
);
|
||||
|
||||
console.log('[VoiceInput] └─ ✅ Initialize dispatched\n');
|
||||
|
||||
// ============================================================
|
||||
// 4️⃣ WebSpeech 즉시 시작
|
||||
// ============================================================
|
||||
console.log('[VoiceInput] └─ [Step 4] WebSpeech 시작');
|
||||
console.log('[VoiceInput] 📍 [STEP 4] WebSpeech Start');
|
||||
console.log('[VoiceInput] ├─ Dispatching: 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(() => {
|
||||
console.log('[VoiceInput] ⏲️ [TIMEOUT] 15-second max listening time reached');
|
||||
addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input');
|
||||
processFinalVoiceInput('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);
|
||||
|
||||
// Cleanup: 언마운트 시 타이머 정리
|
||||
@@ -1963,6 +2007,13 @@ const VoiceInputOverlay = ({
|
||||
isBubbleClickSearch={isBubbleClickSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API 상태 표시 - 오른쪽 아래에 조용하게 표시 */}
|
||||
<ApiStatusDisplay
|
||||
status={apiStatus}
|
||||
message={apiStatusMessage}
|
||||
autoHideDuration={3000}
|
||||
/>
|
||||
</TFullPopup>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user