From 3725ade27d3614035e475f984c88ddb1dd65edca Mon Sep 17 00:00:00 2001 From: optrader Date: Thu, 30 Oct 2025 10:05:17 +0900 Subject: [PATCH] [251030] fix: WebSpeech API Error Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ• 컀밋 μ‹œκ°„: 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 μ„œλΉ„μŠ€ λ ˆμ΄μ–΄ κ°œμ„  β€’ μ†Œκ·œλͺ¨ κΈ°λŠ₯ κ°œμ„  β€’ λͺ¨λ“ˆ ꡬ쑰 κ°œμ„  --- com.twin.app.shoptime/.gitignore | 2 +- .../src/hooks/useWebSpeechManager.js | 124 ++++++ .../services/voice/VoiceRecognitionManager.js | 377 ++++++++++++++++++ .../webSpeech/WebSpeechServiceAsync.js | 255 ++++++++++++ .../VoiceInputOverlay/ApiStatusDisplay.jsx | 104 +++++ .../ApiStatusDisplay.module.less | 207 ++++++++++ .../VoiceInputOverlay/VoiceInputOverlay.jsx | 95 ++++- 7 files changed, 1141 insertions(+), 23 deletions(-) create mode 100644 com.twin.app.shoptime/src/hooks/useWebSpeechManager.js create mode 100644 com.twin.app.shoptime/src/services/voice/VoiceRecognitionManager.js create mode 100644 com.twin.app.shoptime/src/services/webSpeech/WebSpeechServiceAsync.js create mode 100644 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.jsx create mode 100644 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/ApiStatusDisplay.module.less 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 μƒνƒœ ν‘œμ‹œ - 였λ₯Έμͺ½ μ•„λž˜μ— μ‘°μš©ν•˜κ²Œ ν‘œμ‹œ */} + ); };