[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:
2025-10-31 14:41:53 +09:00
parent 8360acb594
commit 42fb104c25
4 changed files with 257 additions and 14 deletions

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;