[251031] fix: VoiceInputOverlay Silence Check enabled
🕐 커밋 시간: 2025. 10. 31. 14:41:53 📊 변경 통계: • 총 파일: 4개 • 추가: +257줄 • 삭제: -14줄 📝 수정된 파일: ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx 🔧 주요 변경 내용: • 대규모 기능 개발
This commit is contained in:
@@ -121,7 +121,7 @@ const ENABLE_WAKE_WORD = false;
|
||||
// 🔧 Silence Detection 활성화 여부
|
||||
// continuous: true에서 노이즈를 interim으로 감지할 수 있어
|
||||
// 사용자가 준비 단계에서 조기 종료될 수 있으므로 false로 설정
|
||||
const IS_SILENCE_CHECK_ENABLED = false;
|
||||
const IS_SILENCE_CHECK_ENABLED = true;
|
||||
|
||||
// Utility function to clear a single timer ref (timeout)
|
||||
const clearTimerRef = (timerRef) => {
|
||||
@@ -222,6 +222,12 @@ const VoiceInputOverlay = ({
|
||||
// API 상태 표시용 state
|
||||
const [apiStatus, setApiStatus] = useState('idle'); // 'idle', 'loading', 'success', 'error'
|
||||
const [apiStatusMessage, setApiStatusMessage] = useState('API Status...');
|
||||
// 🚦 Silence Detection 상태 (신호등 인디케이터용)
|
||||
const [silenceSeconds, setSilenceSeconds] = useState(0); // 0, 1, 2, 3
|
||||
const [isSilenceCheckActive, setIsSilenceCheckActive] = useState(false);
|
||||
const [hasReached5Chars, setHasReached5Chars] = useState(false); // 처음 5글자 도달 추적
|
||||
// 💬 Bubble 버튼 상태 (true: 버튼 O, false: 텍스트만)
|
||||
const [isBubbleButton, setIsBubbleButton] = useState(false); // 첫 발화때는 true (Try Saying)
|
||||
// useSearchHistory Hook 적용 (음성검색 기록 관리)
|
||||
const { addVoiceSearch } = useSearchHistory();
|
||||
|
||||
@@ -892,11 +898,13 @@ const VoiceInputOverlay = ({
|
||||
// Silence Detection은 IS_SILENCE_CHECK_ENABLED에 따라 제어
|
||||
useEffect(() => {
|
||||
if (currentMode !== VOICE_MODES.LISTENING) {
|
||||
// ✅ LISTENING 모드가 아니면 타이머 정리
|
||||
// ✅ LISTENING 모드가 아니면 타이머 정리 및 상태 초기화
|
||||
if (IS_SILENCE_CHECK_ENABLED) {
|
||||
clearTimerRef(silenceDetectionTimerRef);
|
||||
silenceDetectionTimerRef.current = null;
|
||||
}
|
||||
setIsSilenceCheckActive(false);
|
||||
setSilenceSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -911,14 +919,34 @@ const VoiceInputOverlay = ({
|
||||
// ✅ Silence Detection (IS_SILENCE_CHECK_ENABLED === true일 때만)
|
||||
if (!IS_SILENCE_CHECK_ENABLED) {
|
||||
// Silence Detection 비활성화 - 15초 타이머에만 의존
|
||||
setIsSilenceCheckActive(false);
|
||||
setSilenceSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!interimText) {
|
||||
// 입력이 없으면 silence detection 시작하지 않음
|
||||
// 🔍 최소 5글자 감지 체크 (처음 도달할 때만)
|
||||
const trimmedText = interimText?.trim() || '';
|
||||
if (!hasReached5Chars && trimmedText.length >= 5) {
|
||||
// 처음 5글자에 도달했을 때만 활성화
|
||||
console.log('[VoiceInput] 🎯 5글자 도달! SilenceCheck 활성화');
|
||||
setHasReached5Chars(true);
|
||||
setIsSilenceCheckActive(true);
|
||||
} else if (hasReached5Chars && trimmedText.length < 5) {
|
||||
// 만약 입력이 5글자 미만으로 줄어들면 (거의 발생하지 않지만)
|
||||
// SilenceCheck는 유지 (처음 도달했으므로)
|
||||
}
|
||||
|
||||
// ✅ hasReached5Chars가 true면 SilenceCheck 유지
|
||||
if (!hasReached5Chars) {
|
||||
// 아직 5글자 미만이면 타이머 정리
|
||||
clearTimerRef(silenceDetectionTimerRef);
|
||||
silenceDetectionTimerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ 5글자 이상 도달 후 SilenceCheck 활성화 상태 유지
|
||||
setIsSilenceCheckActive(true);
|
||||
|
||||
// 3초 silence detection: 마지막 입력 후 3초 동안 추가 입력이 없으면 자동 종료
|
||||
clearTimerRef(silenceDetectionTimerRef);
|
||||
silenceDetectionTimerRef.current = setTimeout(() => {
|
||||
@@ -932,7 +960,7 @@ const VoiceInputOverlay = ({
|
||||
clearTimerRef(silenceDetectionTimerRef);
|
||||
silenceDetectionTimerRef.current = null;
|
||||
};
|
||||
}, [interimText, currentMode, onSearchChange, processFinalVoiceInput, addWebSpeechEventLog, IS_SILENCE_CHECK_ENABLED]);
|
||||
}, [interimText, currentMode, onSearchChange, processFinalVoiceInput, addWebSpeechEventLog, IS_SILENCE_CHECK_ENABLED, hasReached5Chars]);
|
||||
|
||||
// 🎉 Wake Word Detection: PROMPT 모드에서 백그라운드 리스닝 시작
|
||||
useEffect(() => {
|
||||
@@ -962,6 +990,10 @@ const VoiceInputOverlay = ({
|
||||
if (currentMode === VOICE_MODES.LISTENING && voiceInputMode === VOICE_INPUT_MODE.WEBSPEECH) {
|
||||
// 카운트다운 초기화
|
||||
setCountdown(15);
|
||||
// 🚦 LISTENING 시작할 때 hasReached5Chars 리셋
|
||||
setHasReached5Chars(false);
|
||||
setIsSilenceCheckActive(false);
|
||||
setSilenceSeconds(0);
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('⏱️ [VoiceInputOverlay.v2] Starting countdown from 15');
|
||||
@@ -993,6 +1025,36 @@ const VoiceInputOverlay = ({
|
||||
}
|
||||
}, [currentMode, voiceInputMode]);
|
||||
|
||||
// 🚦 Silence Detection 초 카운팅 (isSilenceCheckActive가 true일 때)
|
||||
useEffect(() => {
|
||||
if (!isSilenceCheckActive) {
|
||||
setSilenceSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 0부터 시작해서 1, 2, 3까지 카운팅
|
||||
setSilenceSeconds(0);
|
||||
|
||||
const silenceIntervalRef = setInterval(() => {
|
||||
setSilenceSeconds((prev) => {
|
||||
const next = prev + 1;
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🚦 [VoiceInput] Silence Detection Progress:', next);
|
||||
}
|
||||
if (next >= 3) {
|
||||
// 3초 도달하면 interval 정리 (타이머가 곧 종료됨)
|
||||
clearInterval(silenceIntervalRef);
|
||||
return 3;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(silenceIntervalRef);
|
||||
};
|
||||
}, [isSilenceCheckActive]);
|
||||
|
||||
// VoiceInputOverlay 언마운트 시에만 초기화
|
||||
useEffect(() => {
|
||||
console.log('[VoiceInput] 🚀 VoiceInputOverlay 마운트됨 (searchId 유지)');
|
||||
@@ -1388,11 +1450,32 @@ const VoiceInputOverlay = ({
|
||||
case VOICE_MODES.PROMPT: {
|
||||
// ✨ relativeQueries가 있으면 'How about these ?' 표시, 없으면 'Try saying' 표시
|
||||
const hasRelativeQueries = !!shopperHouseRelativeQueries;
|
||||
const promptTitle = hasRelativeQueries ? 'How about these ?' : 'Try saying';
|
||||
// relativeQueries가 있으면 사용, 없으면 legacySearchHistory 사용
|
||||
const promptSuggestions = hasRelativeQueries
|
||||
? shopperHouseRelativeQueries
|
||||
: legacySearchHistory;
|
||||
|
||||
// 💬 isBubbleButton에 따라 표시 결정
|
||||
// true: 최근 검색어 + 포커스 받는 버튼 (hasRelativeQueries와 무관)
|
||||
// false: 하드코딩된 문구 + 텍스트만 (relativeQueries는 그대로 표시)
|
||||
let promptTitle;
|
||||
let promptSuggestions;
|
||||
|
||||
if (isBubbleButton) {
|
||||
// isBubbleButton = true: 최근 검색어 + Try Saying
|
||||
promptTitle = 'Try saying';
|
||||
promptSuggestions = legacySearchHistory;
|
||||
} else if (hasRelativeQueries) {
|
||||
// isBubbleButton = false && relativeQueries 있음: relativeQueries + How about these (텍스트만)
|
||||
promptTitle = 'How about these ?';
|
||||
promptSuggestions = shopperHouseRelativeQueries;
|
||||
} else {
|
||||
// isBubbleButton = false && relativeQueries 없음: 하드코딩된 문구 + Try saying (텍스트만)
|
||||
promptTitle = 'Try saying';
|
||||
promptSuggestions = [
|
||||
'" Can you recommend a good budget cordless vacuum? "',
|
||||
'" Show me trending skincare. "',
|
||||
'" Find the newest Nike sneakers. "',
|
||||
'" Show me snail cream that helps with sensitive skin. "',
|
||||
'" Recommend a tasty melatonin gummy. "',
|
||||
];
|
||||
}
|
||||
|
||||
// ✨ [DEBUG] Redux 상태 확인 로그
|
||||
console.log('[VoiceInput]-shopperHouseRelativeQueries');
|
||||
@@ -1443,6 +1526,7 @@ const VoiceInputOverlay = ({
|
||||
title={promptTitle}
|
||||
suggestions={promptSuggestions}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
isBubbleButton={isBubbleButton}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1452,7 +1536,14 @@ const VoiceInputOverlay = ({
|
||||
'🎤 [DEBUG][VoiceInputOverlay] MODE = LISTENING | Rendering VoiceListening (15초 타이머)'
|
||||
);
|
||||
}
|
||||
return <VoiceListening interimText={interimText} countdown={countdown} />;
|
||||
return (
|
||||
<VoiceListening
|
||||
interimText={interimText}
|
||||
countdown={countdown}
|
||||
silenceSeconds={silenceSeconds}
|
||||
isSilenceCheckActive={isSilenceCheckActive}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.RESPONSE:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
|
||||
@@ -9,8 +9,15 @@ import css from './VoiceListening.module.less';
|
||||
* 포커스를 받지 않으며 순수하게 시각적 피드백만 제공
|
||||
* @param {string} interimText - 실시간 중간 음성 인식 텍스트
|
||||
* @param {number} countdown - 카운트다운 숫자 (15 -> 1)
|
||||
* @param {number} silenceSeconds - Silence detection 진행 초 (0, 1, 2, 3)
|
||||
* @param {boolean} isSilenceCheckActive - Silence detection 활성화 여부
|
||||
*/
|
||||
const VoiceListening = ({ interimText, countdown }) => {
|
||||
const VoiceListening = ({
|
||||
interimText,
|
||||
countdown,
|
||||
silenceSeconds = 0,
|
||||
isSilenceCheckActive = false,
|
||||
}) => {
|
||||
// 각 문장의 첫 글자를 대문자로 변환
|
||||
const capitalizeSentences = (text) => {
|
||||
if (!text || text.length === 0) return text;
|
||||
@@ -59,6 +66,23 @@ const VoiceListening = ({ interimText, countdown }) => {
|
||||
<div className={`${css.bar} ${css.bar10}`} />
|
||||
</div>
|
||||
)}
|
||||
{/* 🚦 신호등 인디케이터: 5글자 이상 감지 후 SilenceCheck 시작 시에만 표시 */}
|
||||
{isSilenceCheckActive && (
|
||||
<div className={css.silenceIndicator}>
|
||||
<div
|
||||
className={`${css.silenceLight} ${css[`light0Stage${silenceSeconds}`]}`}
|
||||
aria-label="Silence check light 1"
|
||||
/>
|
||||
<div
|
||||
className={`${css.silenceLight} ${css[`light1Stage${silenceSeconds}`]}`}
|
||||
aria-label="Silence check light 2"
|
||||
/>
|
||||
<div
|
||||
className={`${css.silenceLight} ${css[`light2Stage${silenceSeconds}`]}`}
|
||||
aria-label="Silence check light 3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{interimText && (
|
||||
<div className={css.interimTextContainer}>
|
||||
<p className={css.interimText}>{displayText}</p>
|
||||
@@ -71,11 +95,15 @@ const VoiceListening = ({ interimText, countdown }) => {
|
||||
VoiceListening.propTypes = {
|
||||
interimText: PropTypes.string,
|
||||
countdown: PropTypes.number,
|
||||
silenceSeconds: PropTypes.number,
|
||||
isSilenceCheckActive: PropTypes.bool,
|
||||
};
|
||||
|
||||
VoiceListening.defaultProps = {
|
||||
interimText: '',
|
||||
countdown: 15,
|
||||
silenceSeconds: 0,
|
||||
isSilenceCheckActive: false,
|
||||
};
|
||||
|
||||
export default VoiceListening;
|
||||
|
||||
@@ -211,3 +211,107 @@
|
||||
line-height: 1.4;
|
||||
word-break: break-word; // 긴 단어 줄바꿈
|
||||
}
|
||||
|
||||
// 🚦 신호등 인디케이터 컨테이너 (Silence Detection)
|
||||
.silenceIndicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px; // 원 사이 간격
|
||||
margin-top: 24px; // 애니메이션과의 거리
|
||||
margin-bottom: 24px; // 텍스트와의 거리
|
||||
height: 36px; // 원의 크기와 일치
|
||||
}
|
||||
|
||||
// 신호등 개별 원 스타일 (36px)
|
||||
.silenceLight {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%; // 완전한 원형
|
||||
transition: all 0.3s ease-in-out;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); // 약간의 그림자
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Stage 0: 대기 상태 (SilenceCheck 시작 직후)
|
||||
// ============================================
|
||||
.light0Stage0 {
|
||||
background: #4A90E2; // 파랑 (활성화 대기)
|
||||
}
|
||||
|
||||
.light1Stage0 {
|
||||
background: rgba(100, 100, 100, 0.4); // 회색 (비활성)
|
||||
}
|
||||
|
||||
.light2Stage0 {
|
||||
background: rgba(100, 100, 100, 0.4); // 회색 (비활성)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Stage 1: 1초 경과
|
||||
// ============================================
|
||||
.light0Stage1 {
|
||||
background: #7ED321; // 초록 (완료됨)
|
||||
}
|
||||
|
||||
.light1Stage1 {
|
||||
background: #4A90E2; // 파랑 (현재 진행 중)
|
||||
}
|
||||
|
||||
.light2Stage1 {
|
||||
background: rgba(100, 100, 100, 0.4); // 회색 (대기 중)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Stage 2: 2초 경과
|
||||
// ============================================
|
||||
.light0Stage2 {
|
||||
background: #7ED321; // 초록 (완료됨)
|
||||
}
|
||||
|
||||
.light1Stage2 {
|
||||
background: #7ED321; // 초록 (완료됨)
|
||||
}
|
||||
|
||||
.light2Stage2 {
|
||||
background: #F5A623; // 주황 (현재 진행 중)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Stage 3: 3초 경과 (완료)
|
||||
// ============================================
|
||||
.light0Stage3 {
|
||||
background: #7ED321; // 초록
|
||||
}
|
||||
|
||||
.light1Stage3 {
|
||||
background: #7ED321; // 초록
|
||||
}
|
||||
|
||||
.light2Stage3 {
|
||||
background: #7ED321; // 초록 (모두 완료)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 애니메이션 정의
|
||||
// ============================================
|
||||
@keyframes silenceLightPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 진행 중인 원에 펄스 효과 추가
|
||||
.light0Stage1,
|
||||
.light1Stage1,
|
||||
.light2Stage0,
|
||||
.light2Stage1,
|
||||
.light2Stage2 {
|
||||
animation: silenceLightPulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ const PromptContainer = SpotlightContainerDecorator(
|
||||
'div'
|
||||
);
|
||||
|
||||
const VoicePromptScreen = ({ title = 'Try saying', suggestions = [], onSuggestionClick }) => {
|
||||
const VoicePromptScreen = ({
|
||||
title = 'Try saying',
|
||||
suggestions = [],
|
||||
onSuggestionClick,
|
||||
isBubbleButton = true, // true: 버튼 동작 O, false: 텍스트만 표시
|
||||
}) => {
|
||||
// 커링 패턴: suggestion을 미리 바인딩하는 핸들러 생성
|
||||
const createBubbleClickHandler = (suggestion) => {
|
||||
return () => {
|
||||
@@ -32,11 +37,24 @@ const VoicePromptScreen = ({ title = 'Try saying', suggestions = [], onSuggestio
|
||||
<PromptContainer
|
||||
className={css.container}
|
||||
spotlightId="voice-prompt-container"
|
||||
style={{ pointerEvents: 'all' }}
|
||||
style={{ pointerEvents: isBubbleButton ? 'all' : 'none' }} // 버튼 비활성화 시 포인터 이벤트 차단
|
||||
>
|
||||
<div className={css.title}>{title}</div>
|
||||
<div className={css.suggestionsContainer}>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
// isBubbleButton = false이면 일반 div, true이면 SpottableBubble
|
||||
if (!isBubbleButton) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={css.bubbleMessage}
|
||||
style={{ pointerEvents: 'none', cursor: 'default' }} // 텍스트 전용 스타일
|
||||
>
|
||||
<div className={css.bubbleText}>{suggestion}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SpottableBubble
|
||||
key={index}
|
||||
@@ -57,6 +75,7 @@ VoicePromptScreen.propTypes = {
|
||||
title: PropTypes.string,
|
||||
suggestions: PropTypes.arrayOf(PropTypes.string),
|
||||
onSuggestionClick: PropTypes.func,
|
||||
isBubbleButton: PropTypes.bool,
|
||||
};
|
||||
|
||||
VoicePromptScreen.defaultProps = {
|
||||
@@ -68,6 +87,7 @@ VoicePromptScreen.defaultProps = {
|
||||
'" Show me snail cream that helps with sensitive skin. "',
|
||||
'" Recommend a tasty melatonin gummy. "',
|
||||
],
|
||||
isBubbleButton: true,
|
||||
};
|
||||
|
||||
export default VoicePromptScreen;
|
||||
|
||||
Reference in New Issue
Block a user