diff --git a/com.twin.app.shoptime/.gitignore b/com.twin.app.shoptime/.gitignore index 5f9dd3b1..3151f537 100644 --- a/com.twin.app.shoptime/.gitignore +++ b/com.twin.app.shoptime/.gitignore @@ -15,7 +15,7 @@ npm-debug.log # ipk file srcBackup # com.lgshop.app_*.ipk -.doc +.docs .docs nul .txt diff --git a/com.twin.app.shoptime/src/hooks/useWebSpeechManager.js b/com.twin.app.shoptime/src/hooks/useWebSpeechManager.js new file mode 100644 index 00000000..7ed0d64d --- /dev/null +++ b/com.twin.app.shoptime/src/hooks/useWebSpeechManager.js @@ -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; diff --git a/com.twin.app.shoptime/src/services/voice/VoiceRecognitionManager.js b/com.twin.app.shoptime/src/services/voice/VoiceRecognitionManager.js new file mode 100644 index 00000000..6e6ee780 --- /dev/null +++ b/com.twin.app.shoptime/src/services/voice/VoiceRecognitionManager.js @@ -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; diff --git a/com.twin.app.shoptime/src/services/webSpeech/WebSpeechServiceAsync.js b/com.twin.app.shoptime/src/services/webSpeech/WebSpeechServiceAsync.js new file mode 100644 index 00000000..c29df239 --- /dev/null +++ b/com.twin.app.shoptime/src/services/webSpeech/WebSpeechServiceAsync.js @@ -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; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.jsx new file mode 100644 index 00000000..3bbfe98d --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.jsx @@ -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 ( +
+ {/* 상태 아이콘 */} +
+ {displayStatus === 'loading' && ( +
+
+
+ )} + {displayStatus === 'success' && ( +
+ )} + {displayStatus === 'error' && ( +
!
+ )} + {displayStatus === 'idle' && ( +
+ )} +
+ + {/* 상태 메시지 */} +
+ {displayMessage} +
+
+ ); +}; + +ApiStatusDisplay.propTypes = { + status: PropTypes.oneOf(['idle', 'loading', 'success', 'error']), + message: PropTypes.string, + autoHideDuration: PropTypes.number, + onStatusChange: PropTypes.func, +}; + +export default ApiStatusDisplay; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.module.less new file mode 100644 index 00000000..4e54a449 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.module.less @@ -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; + } +} diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx index 5326a88b..97a8c9b0 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx @@ -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} /> + + {/* API 상태 표시 - 오른쪽 아래에 조용하게 표시 */} + ); };