[251016] feat: VoiceInputOverlay advanced fesatures
🕐 커밋 시간: 2025. 10. 16. 20:42:59 📊 변경 통계: • 총 파일: 1개 • 추가: +149줄 • 삭제: -2줄 📝 수정된 파일: ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx 🔧 주요 변경 내용: • 중간 규모 기능 개선
This commit is contained in:
@@ -51,6 +51,14 @@ const INPUT_SPOTLIGHT_ID = 'voice-overlay-input-box';
|
||||
const MIC_SPOTLIGHT_ID = 'voice-overlay-mic-button';
|
||||
const MIC_WEBSPEECH_SPOTLIGHT_ID = 'voice-overlay-mic-webspeech-button';
|
||||
|
||||
// 🔧 실험적 기능: Wake Word Detection ("Hey Shoptime")
|
||||
// false로 설정하면 이 기능은 완전히 비활성화됩니다
|
||||
const ENABLE_WAKE_WORD = false;
|
||||
|
||||
// 🔧 실험적 기능: Beep Sound on Listening Start
|
||||
// false로 설정하면 Beep 소리가 재생되지 않습니다
|
||||
const ENABLE_BEEP_SOUND = true;
|
||||
|
||||
const VoiceInputOverlay = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
@@ -63,6 +71,7 @@ const VoiceInputOverlay = ({
|
||||
const dispatch = useDispatch();
|
||||
const lastFocusedElement = useRef(null);
|
||||
const listeningTimerRef = useRef(null);
|
||||
const audioContextRef = useRef(null);
|
||||
const [inputFocus, setInputFocus] = useState(false);
|
||||
const [micFocused, setMicFocused] = useState(false);
|
||||
const [micWebSpeechFocused, setMicWebSpeechFocused] = useState(false);
|
||||
@@ -73,6 +82,69 @@ const VoiceInputOverlay = ({
|
||||
// STT 응답 텍스트 저장
|
||||
const [sttResponseText, setSttResponseText] = useState('');
|
||||
|
||||
// 🔊 Beep 소리 재생 함수
|
||||
const playBeep = useCallback(() => {
|
||||
if (!ENABLE_BEEP_SOUND) return;
|
||||
|
||||
try {
|
||||
// AudioContext 지원 여부 확인 (TV 환경에서는 지원되지 않을 수 있음)
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass) {
|
||||
console.warn(
|
||||
'[VoiceInputOverlay] AudioContext not supported in this environment, skipping beep'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// AudioContext 생성 (재사용)
|
||||
if (!audioContextRef.current) {
|
||||
try {
|
||||
audioContextRef.current = new AudioContextClass();
|
||||
} catch (contextErr) {
|
||||
console.warn('[VoiceInputOverlay] Failed to create AudioContext:', contextErr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const audioContext = audioContextRef.current;
|
||||
|
||||
// null 또는 undefined 체크
|
||||
if (!audioContext) {
|
||||
console.warn('[VoiceInputOverlay] AudioContext is null or undefined, skipping beep');
|
||||
return;
|
||||
}
|
||||
|
||||
// AudioContext 메서드 존재 여부 확인
|
||||
if (
|
||||
typeof audioContext.createOscillator !== 'function' ||
|
||||
typeof audioContext.createGain !== 'function'
|
||||
) {
|
||||
console.warn('[VoiceInputOverlay] AudioContext methods not available, skipping beep');
|
||||
return;
|
||||
}
|
||||
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 800; // 800Hz (높은 피치)
|
||||
oscillator.type = 'sine'; // 부드러운 소리
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); // 볼륨 30%
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1); // 페이드아웃
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.1); // 0.1초 재생
|
||||
|
||||
console.log('🔊 [VoiceInputOverlay] Beep sound played successfully');
|
||||
} catch (err) {
|
||||
// 어떤 오류가 발생하더라도 앱이 멈추지 않도록 조용히 처리
|
||||
console.warn('[VoiceInputOverlay] Failed to play beep sound (non-critical):', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Web Speech API Hook (WebSpeech 모드일 때만 활성화)
|
||||
const handleWebSpeechSTT = useCallback((sttText) => {
|
||||
console.log('🎤 [VoiceInputOverlay] WebSpeech STT text received:', sttText);
|
||||
@@ -144,6 +216,24 @@ const VoiceInputOverlay = ({
|
||||
// }
|
||||
// }, [lastSTTText, sttTimestamp, isVisible, onSearchChange, onClose]);
|
||||
|
||||
// 🎉 Wake Word Detection: PROMPT 모드에서 "Hey Shoptime" 감지
|
||||
useEffect(() => {
|
||||
if (!ENABLE_WAKE_WORD) return;
|
||||
if (currentMode !== VOICE_MODES.PROMPT) return;
|
||||
if (!interimText) return;
|
||||
|
||||
const text = interimText.toLowerCase().trim();
|
||||
const wakeWords = ['hey shoptime', 'hey shop time', 'heyshoptime'];
|
||||
|
||||
// Wake Word 감지
|
||||
const detected = wakeWords.some((word) => text.includes(word));
|
||||
|
||||
if (detected) {
|
||||
console.log('🎉 [VoiceInputOverlay] Wake word detected in PROMPT mode:', text);
|
||||
handleWakeWordDetected();
|
||||
}
|
||||
}, [interimText, currentMode, handleWakeWordDetected]);
|
||||
|
||||
// WebSpeech Interim 텍스트 로그 출력
|
||||
useEffect(() => {
|
||||
if (interimText && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
|
||||
@@ -193,6 +283,25 @@ const VoiceInputOverlay = ({
|
||||
|
||||
// 이 useEffect 제거 - renderModeContent()에서 직접 판단하므로 불필요
|
||||
|
||||
// 🎉 Wake Word Detection: PROMPT 모드에서 백그라운드 리스닝 시작
|
||||
useEffect(() => {
|
||||
if (!ENABLE_WAKE_WORD) return;
|
||||
if (!isVisible) return;
|
||||
if (currentMode !== VOICE_MODES.PROMPT) return;
|
||||
|
||||
console.log('🎙️ [VoiceInputOverlay] Starting background listening for wake word detection');
|
||||
|
||||
// PROMPT 모드에서 백그라운드 리스닝 시작
|
||||
startListening();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (ENABLE_WAKE_WORD && currentMode === VOICE_MODES.PROMPT) {
|
||||
stopListening();
|
||||
}
|
||||
};
|
||||
}, [ENABLE_WAKE_WORD, isVisible, currentMode, startListening, stopListening]);
|
||||
|
||||
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
@@ -281,10 +390,45 @@ const VoiceInputOverlay = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 🎉 Wake Word Detection: "Hey Shoptime" 감지 시 자동으로 LISTENING 모드로 전환
|
||||
const handleWakeWordDetected = useCallback(() => {
|
||||
if (!ENABLE_WAKE_WORD) return;
|
||||
|
||||
console.log('🎉 [VoiceInputOverlay] Wake word detected! Switching to LISTENING mode');
|
||||
|
||||
// 기존 타이머 정리
|
||||
if (listeningTimerRef.current) {
|
||||
clearTimeout(listeningTimerRef.current);
|
||||
listeningTimerRef.current = null;
|
||||
}
|
||||
|
||||
// LISTENING 모드로 전환
|
||||
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
|
||||
setCurrentMode(VOICE_MODES.LISTENING);
|
||||
|
||||
// WebSpeech 재시작 (continuous: false로 일반 입력 모드)
|
||||
stopListening();
|
||||
setTimeout(() => {
|
||||
startListening();
|
||||
}, 300);
|
||||
|
||||
// 15초 타이머 설정
|
||||
listeningTimerRef.current = setTimeout(() => {
|
||||
console.log('⏰ [VoiceInputOverlay] 15초 타임아웃 - PROMPT 모드로 복귀');
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setVoiceInputMode(null);
|
||||
listeningTimerRef.current = null;
|
||||
stopListening();
|
||||
}, 15000);
|
||||
}, [startListening, stopListening]);
|
||||
|
||||
// TALK AGAIN 버튼 핸들러
|
||||
const handleTalkAgain = useCallback(() => {
|
||||
console.log('🎤 [VoiceInputOverlay] TALK AGAIN - Restarting LISTENING mode');
|
||||
|
||||
// 🔊 Beep 소리 재생
|
||||
playBeep();
|
||||
|
||||
// 기존 타이머 정리
|
||||
if (listeningTimerRef.current) {
|
||||
clearTimeout(listeningTimerRef.current);
|
||||
@@ -309,7 +453,7 @@ const VoiceInputOverlay = ({
|
||||
listeningTimerRef.current = null;
|
||||
stopListening();
|
||||
}, 15000);
|
||||
}, [startListening, stopListening]);
|
||||
}, [playBeep, startListening, stopListening]);
|
||||
|
||||
// 모드에 따른 컨텐츠 렌더링
|
||||
const renderModeContent = () => {
|
||||
@@ -446,6 +590,9 @@ const VoiceInputOverlay = ({
|
||||
// 2. WebSpeech API 시작 (독립 동작)
|
||||
console.log('🎤 [VoiceInputOverlay] Starting LISTENING mode (15s) + WebSpeech API');
|
||||
|
||||
// 🔊 Beep 소리 재생
|
||||
playBeep();
|
||||
|
||||
// 기존 타이머 정리
|
||||
if (listeningTimerRef.current) {
|
||||
clearTimeout(listeningTimerRef.current);
|
||||
@@ -478,7 +625,7 @@ const VoiceInputOverlay = ({
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[currentMode, handleClose, startListening, stopListening]
|
||||
[currentMode, handleClose, playBeep, startListening, stopListening]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user