7. 바람 소리로 데시벨은 체크되었는데, 말은 인식이 안되는 상황에선 고장이 나버린 문제
2025년 10월 10일
꽃다발 방명록
No-speech 오류 후 멈춤 — 자동 재시작 메커니즘 구현
문제 : 바람 소리 후 마이크가 반응하지 않음
전시 환경에서 바람 소리 등으로 음성 인식이 시작됐지만 실제 음성이 없어 no-speech 오류가 발생한 뒤, 이후 음성 인식이 재시작되지 않는 문제가 발생했습니다.
증상
- 바람 소리 등으로 볼륨 임계값 초과 → 음성 인식 시작
- 실제 음성이 없음 →
no-speech오류 발생 - 오류 후 세션 종료
- 이후 어떤 소리에도 반응하지 않음
- 마이크가 "죽은" 상태로 유지됨
예상 동작
바람 소리 → 인식 시작 → no-speech 오류 → 자동 재시작 준비 → 다음 음성 대기
실제 동작
바람 소리 → 인식 시작 → no-speech 오류 → [멈춤] → 이후 반응 없음
원인 분석
1단계: 초기 오류 처리
초기에는 no-speech 오류를 특별히 처리하지 않았습니다.
recognition.onerror = (event) => { if (event.error === "aborted") { recognitionActiveRef.current = false; return; } recognitionActiveRef.current = false; // no-speech 오류를 특별히 처리하지 않음 finalizeRecognition(); // 재시작 로직이 없음 };
문제점:
no-speech오류 시 재시작 플래그를 설정하지 않음onend에서 재시작 로직이 실행되지 않음recognitionActiveRef가 false로 설정되어도 재시작 요청이 큐에 없음
2단계: 실제 동작 흐름 확인
로그를 확인한 결과:
"Volume threshold exceeded 0.1523"
"Starting new recognition session"
"SpeechRecognition.start() invoked"
[바람 소리만 있고 실제 음성 없음]
"Speech recognition error no-speech" // 오류 발생
"Recognition ended" // 세션 종료
[이후 아무 일도 일어나지 않음]
3단계: 상태 확인
오류 발생 후 상태:
recognitionActiveRef.current = false; // ✅ false로 설정됨 pendingRecognitionStartRef.current = false; // ❌ true로 설정되지 않음 status = "idle"; // ✅ idle 상태
문제:
pendingRecognitionStartRef가false로 남아 재시작 요청이 없음onend에서 재시작 로직이 실행되지 않음
4단계: onend 핸들러 확인
초기 onend 핸들러:
recognition.onend = () => { recognitionActiveRef.current = false; if (collectedTranscriptsRef.current.length > 0) { finalizeRecognition(); return; } setStatus((current) => { return current === "listening" ? "idle" : current; }); // ❌ pendingRecognitionStartRef를 확인하지 않음 // ❌ 재시작 로직이 없음 };
문제:
pendingRecognitionStartRef를 확인하지 않음- 재시작 로직이 없음
해결 과정
시도 1: no-speech 오류 시 플래그 설정
no-speech 오류 발생 시 재시작 플래그를 설정했습니다.
if (event.error === "no-speech") { warn("No speech detected during recognition session"); pendingRecognitionStartRef.current = true; } else { errorLog("Speech recognition error", event.error); }
핵심:
no-speech오류 시pendingRecognitionStartRef.current = true로 설정- 재시작 요청을 큐에 추가
시도 2: onend에서 재시작 로직 추가
onend 핸들러에서 플래그를 확인하고 재시작하도록 수정했습니다.
recognition.onend = () => { recognitionActiveRef.current = false; if (collectedTranscriptsRef.current.length > 0) { log("Recognition ended with pending transcripts. Finalizing now."); finalizeRecognition(); return; } setStatus((current) => { log("Recognition ended - previous status", current); return current === "listening" ? "idle" : current; }); if (pendingRecognitionStartRef.current && statusRef.current === "idle") { log("Processing deferred recognition start"); pendingRecognitionStartRef.current = false; setTimeout(() => { startListening(); }, 0); } };
핵심:
recognitionActiveRef.current = false로 상태 초기화pendingRecognitionStartRef.current확인status === "idle"일 때startListening()재호출
시도 3: 상태 초기화 보강
오류 발생 시 상태를 확실히 초기화하도록 수정했습니다.
recognitionActiveRef.current = false;
모든 오류에서 recognitionActiveRef를 false로 설정해 상태를 복원합니다.
해결 후 동작
정상적인 로그 흐름
"Volume threshold exceeded 0.1523"
"Starting new recognition session"
"SpeechRecognition.start() invoked"
[바람 소리만 있고 실제 음성 없음]
"Speech recognition error no-speech"
"No speech detected during recognition session"
"Recognition ended - previous status listening"
"Processing deferred recognition start" // ✅ 재시작 로직 실행
"Starting new recognition session" // ✅ 자동 재시작!
"SpeechRecognition.start() invoked"
[정상적인 음성 인식 재개]
동작 원리
타임라인: 0초 ──────────────────────────────────→ 3초
0.0초: 바람 소리 감지
→ Volume threshold exceeded
→ startListening() 호출
→ recognitionActiveRef = true
0.5초: 실제 음성 없음
→ no-speech 오류 발생
→ onerror 핸들러 실행
→ recognitionActiveRef = false ✅
→ pendingRecognitionStartRef = true ✅
→ finalizeRecognition() 호출
1.0초: onend 이벤트 발생
→ recognitionActiveRef = false (확실히)
→ status = "idle"
→ pendingRecognitionStartRef 확인 ✅
→ startListening() 재호출 ✅
1.5초: 새로운 인식 세션 시작
→ 정상적으로 음성 대기 상태로 복귀 ✅
구현 세부사항
1. no-speech 오류 감지 및 플래그 설정
recognition.onerror = (event) => { if (event.error === "aborted") { log("Recognition aborted by stop()"); recognitionActiveRef.current = false; setStatus((current) => (current === "listening" ? "idle" : current)); return; } recognitionActiveRef.current = false; const fatalErrors = new Set([ "network", "not-allowed", "service-not-allowed", "audio-capture", ]); if (event.error === "no-speech") { warn("No speech detected during recognition session"); pendingRecognitionStartRef.current = true; } else { errorLog("Speech recognition error", event.error); } if (fatalErrors.has(event.error)) { warn("Fatal recognition error detected. Clearing instance."); recognitionRef.current = null; } finalizeRecognition(); requestRecognitionRestart(event.error); };
핵심:
recognitionActiveRef.current = false로 상태 초기화no-speech오류 시pendingRecognitionStartRef.current = true설정- 다른 오류는
requestRecognitionRestart()로 처리
2. onend에서 재시작 처리
recognition.onend = () => { recognitionActiveRef.current = false; if (collectedTranscriptsRef.current.length > 0) { log("Recognition ended with pending transcripts. Finalizing now."); finalizeRecognition(); return; } setStatus((current) => { log("Recognition ended - previous status", current); return current === "listening" ? "idle" : current; }); if (pendingRecognitionStartRef.current && statusRef.current === "idle") { log("Processing deferred recognition start"); pendingRecognitionStartRef.current = false; setTimeout(() => { startListening(); }, 0); } };
핵심:
recognitionActiveRef.current = false로 상태 확실히 초기화pendingRecognitionStartRef.current확인status === "idle"일 때startListening()재호출setTimeout(..., 0)으로 다음 이벤트 루프에서 실행
3. 추가 안전장치: useEffect로 재시작 확인
useEffect(() => { if ( status === "idle" && pendingRecognitionStartRef.current && !recognitionActiveRef.current ) { pendingRecognitionStartRef.current = false; startListening(); } }, [status, startListening]);
onend에서 처리되지 않은 경우를 대비한 추가 안전장치입니다.
문제 해결 전후 비교
해결 전
타임라인:
0.0초: 바람 소리 감지
→ startListening() 호출
→ recognitionActiveRef = true
0.5초: no-speech 오류
→ onerror 실행
→ recognitionActiveRef = false
→ ❌ pendingRecognitionStartRef = false (그대로)
→ finalizeRecognition()
1.0초: onend 이벤트
→ recognitionActiveRef = false
→ status = "idle"
→ ❌ pendingRecognitionStartRef 확인 안 함
→ ❌ 재시작 안 함
결과: 마이크가 "죽은" 상태로 유지 ❌
해결 후
타임라인:
0.0초: 바람 소리 감지
→ startListening() 호출
→ recognitionActiveRef = true
0.5초: no-speech 오류
→ onerror 실행
→ recognitionActiveRef = false ✅
→ pendingRecognitionStartRef = true ✅
→ finalizeRecognition()
1.0초: onend 이벤트
→ recognitionActiveRef = false (확실히)
→ status = "idle"
→ pendingRecognitionStartRef 확인 ✅
→ startListening() 재호출 ✅
1.5초: 새로운 인식 세션 시작
→ 정상적으로 음성 대기 상태로 복귀 ✅
결과: 자동으로 재시작되어 정상 작동 ✅
추가 개선: 다른 오류 처리
no-speech 외 다른 오류는 requestRecognitionRestart()로 처리합니다.
finalizeRecognition(); requestRecognitionRestart(event.error);
일반 오류는 백오프 재시작 메커니즘을 사용합니다.
교훈
1. 오류별 처리 전략
오류 유형에 따라 다른 처리가 필요합니다.
// ✅ 좋은 패턴: 오류 유형별 처리 if (event.error === "no-speech") { // 즉시 재시작 가능한 오류 pendingRecognitionStartRef.current = true; } else if (fatalErrors.has(event.error)) { // 치명적인 오류 - 인스턴스 폐기 recognitionRef.current = null; } else { // 일반 오류 - 백오프 재시작 requestRecognitionRestart(event.error); }
2. 상태 초기화의 중요성
오류 발생 시 상태를 확실히 초기화해야 합니다.
// ✅ 여러 곳에서 상태 초기화 recognition.onerror = () => { recognitionActiveRef.current = false; // 여기서도 }; recognition.onend = () => { recognitionActiveRef.current = false; // 여기서도 확실히 };
3. 재시작 플래그 패턴
비동기 이벤트 간 통신에 플래그를 사용합니다.
// ✅ 좋은 패턴: 플래그로 재시작 요청 큐잉 onerror: () => { pendingRecognitionStartRef.current = true; // 플래그 설정 }; onend: () => { if (pendingRecognitionStartRef.current) { startListening(); // 플래그 확인 후 재시작 } };
4. setTimeout(..., 0) 사용
다음 이벤트 루프에서 실행해 상태 업데이트를 확실히 합니다.
// ✅ 좋은 패턴: 다음 이벤트 루프에서 실행 setTimeout(() => { startListening(); }, 0);
결론
no-speech 오류 후 자동 재시작을 위해 다음을 적용했습니다:
onerror에서no-speech시pendingRecognitionStartRef = true설정onend에서 플래그 확인 후startListening()재호출recognitionActiveRef를 확실히 초기화
이 개선으로:
no-speech오류 후 자동 재시작- 마이크가 "죽은" 상태로 남지 않음
- 전시 환경에서 안정적인 동작
오류 유형별 처리와 상태 초기화, 그리고 재시작 플래그를 통해 자동 복구가 되도록 하였습니다.