From 2ed9d079016a59c08c0a3f575c8bc5eb30b38269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Tue, 10 Feb 2026 09:09:35 +0900 Subject: [PATCH] =?UTF-8?q?docs:=EC=9D=8C=EC=84=B1=EC=9E=85=EB=A0=A5(STT)?= =?UTF-8?q?=20=EA=B8=B0=EC=88=A0=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web Speech API 기반 VoiceInputButton 컴포넌트 상세 설명 - interim/final 텍스트 렌더링 규칙, 프리뷰 패널 UI 스펙 - SpeechRecognition 설정 옵션, 이벤트 핸들러 상세 - 새 페이지 적용 체크리스트 (프론트/백엔드) - 백엔드 STT 사용량 추적 (AiTokenHelper) 패턴 - 트러블슈팅 가이드 (HTTPS, 권한, 언마운트 등) Co-Authored-By: Claude Opus 4.6 --- features/voice-input-stt-guide.md | 674 ++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 features/voice-input-stt-guide.md diff --git a/features/voice-input-stt-guide.md b/features/voice-input-stt-guide.md new file mode 100644 index 0000000..0b204ca --- /dev/null +++ b/features/voice-input-stt-guide.md @@ -0,0 +1,674 @@ +# 음성 입력(STT) 기술 가이드 + +> **문서 버전**: 1.0 +> **작성일**: 2026-02-10 +> **최초 적용**: 공사현장 사진대지 (`/juil/construction-photos`) +> **대상 프로젝트**: MNG (React 18 + Babel in-browser) + +--- + +## 1. 개요 + +### 1.1 목적 + +텍스트 입력 필드(input, textarea)에 **마이크 버튼**을 배치하여, 사용자가 음성으로 텍스트를 입력할 수 있게 하는 브라우저 내장 STT(Speech-to-Text) 기능. + +### 1.2 기술 선택 + +| 방식 | 비용 | 정확도 | 지연 | 채택 | +|------|------|--------|------|------| +| Web Speech API (브라우저 내장) | **무료** | 높음 (Google STT 엔진) | 실시간 | **채택** | +| Google Cloud STT API | 유료 ($0.006/15초) | 매우 높음 | 서버 왕복 | 미채택 | +| Whisper (OpenAI) | 유료 ($0.006/분) | 매우 높음 | 서버 왕복 | 미채택 | + +**선택 이유**: 브라우저 내장 Web Speech API는 Chrome 기반에서 Google STT 엔진을 무료로 사용하며, 실시간 스트리밍으로 interim/final 결과를 즉시 받을 수 있다. 비용 없이 충분한 한국어 인식률을 제공한다. + +### 1.3 브라우저 지원 + +| 브라우저 | 지원 | 비고 | +|----------|------|------| +| Chrome (Desktop/Android) | ✅ | 최적 지원, Google STT 엔진 사용 | +| Edge | ✅ | Chromium 기반 | +| Safari (iOS/macOS) | ✅ | `webkitSpeechRecognition` | +| Firefox | ❌ | 미지원 (버튼 자동 숨김) | + +--- + +## 2. 핵심 개념: Interim vs Final + +Web Speech API의 핵심은 **미확정(interim)** 텍스트와 **확정(final)** 텍스트의 구분이다. + +### 2.1 텍스트 상태 흐름 + +``` +[음성 입력 시작] + │ + ├─ interim: "안녕하" ← 인식 진행 중 (수정될 수 있음) + ├─ interim: "안녕하세" ← 교정 발생 (이전 interim 덮어씀) + ├─ interim: "안녕하세요" ← 교정 발생 + │ + ├─ ★ FINAL: "안녕하세요" ← 확정! (절대 삭제 불가) + │ + ├─ interim: "반갑습" ← 새로운 인식 시작 + ├─ interim: "반갑습니다" + │ + ├─ ★ FINAL: "반갑습니다" ← 확정! + │ +[음성 입력 종료] +``` + +### 2.2 렌더링 규칙 (필수 준수) + +| 상태 | 스타일 | 동작 | 삭제 가능 | +|------|--------|------|-----------| +| **interim** (미확정) | `italic` + `text-gray-400` | 실시간 교정됨. 이전 interim을 덮어씀 | 교정만 허용 | +| **final** (확정) | `font-normal` + `text-white` | `finalizedSegments[]` 배열에 영구 추가 | **절대 불가** | + +### 2.3 input 반영 규칙 + +- **final 이벤트 발생 시에만** `onResult(transcript)` 호출하여 input에 텍스트 추가 +- interim 텍스트는 **프리뷰 패널에만** 표시하고, input에는 반영하지 않음 +- input에 추가된 텍스트는 사용자가 직접 수정 가능 (일반 텍스트) + +--- + +## 3. 컴포넌트 아키텍처 + +### 3.1 VoiceInputButton 컴포넌트 + +``` +┌─────────────────────────────────┐ +│ VoiceInputButton │ +│ │ +│ Props: │ +│ onResult: (text) => void │ ← final 텍스트만 전달 +│ disabled: boolean │ ← 비활성화 (읽기 모드 등) +│ │ +│ State: │ +│ recording: boolean │ ← 녹음 중 여부 +│ finalizedSegments: string[] │ ← 확정 텍스트 누적 (프리뷰용) +│ interimText: string │ ← 현재 미확정 텍스트 +│ │ +│ Refs: │ +│ recognitionRef │ ← SpeechRecognition 인스턴스 +│ startTimeRef │ ← 녹음 시작 시각 (사용량 추적) +│ dismissTimerRef │ ← 프리뷰 닫기 타이머 +│ previewRef │ ← 프리뷰 DOM (자동 스크롤) +│ │ +│ Output: │ +│ [마이크 버튼] + [프리뷰 패널] │ +└─────────────────────────────────┘ +``` + +### 3.2 전체 코드 + +```jsx +function VoiceInputButton({ onResult, disabled }) { + const [recording, setRecording] = useState(false); + const [finalizedSegments, setFinalizedSegments] = useState([]); + const [interimText, setInterimText] = useState(''); + const recognitionRef = useRef(null); + const startTimeRef = useRef(null); + const dismissTimerRef = useRef(null); + const previewRef = useRef(null); + + // 브라우저 지원 확인 + const isSupported = typeof window !== 'undefined' && + (window.SpeechRecognition || window.webkitSpeechRecognition); + + // STT 사용량 로깅 (AI 토큰 사용량 추적) + const logUsage = useCallback((startTime) => { + const duration = Math.max(1, Math.round((Date.now() - startTime) / 1000)); + apiFetch(API.logSttUsage, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ duration_seconds: duration }), + }).catch(() => {}); + }, []); + + // 프리뷰 패널 자동 스크롤 + useEffect(() => { + if (previewRef.current) { + previewRef.current.scrollTop = previewRef.current.scrollHeight; + } + }, [finalizedSegments, interimText]); + + // 녹음 중지 + const stopRecording = useCallback(() => { + recognitionRef.current?.stop(); + recognitionRef.current = null; + if (startTimeRef.current) { + logUsage(startTimeRef.current); + startTimeRef.current = null; + } + setRecording(false); + setInterimText(''); + // 녹음 종료 후 2초 뒤 프리뷰 닫기 + dismissTimerRef.current = setTimeout(() => { + setFinalizedSegments([]); + }, 2000); + }, [logUsage]); + + // 녹음 시작 + const startRecording = useCallback(() => { + // 이전 타이머 정리 + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + const recognition = new SR(); + recognition.lang = 'ko-KR'; // 한국어 + recognition.continuous = true; // 연속 인식 (자동 종료 안 함) + recognition.interimResults = true; // interim 결과 수신 + recognition.maxAlternatives = 1; // 후보 1개만 + + recognition.onresult = (event) => { + // dismiss 타이머 취소 (아직 인식 중) + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + + let currentInterim = ''; + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript; + if (event.results[i].isFinal) { + // ★ 확정: input에 반영 + 프리뷰에 영구 저장 + onResult(transcript); + setFinalizedSegments(prev => [...prev, transcript]); + currentInterim = ''; + } else { + // 미확정: 교정은 허용하되 이전 확정분은 보존 + currentInterim = transcript; + } + } + setInterimText(currentInterim); + }; + + recognition.onerror = () => stopRecording(); + + recognition.onend = () => { + // 브라우저가 자동 종료한 경우 처리 + if (startTimeRef.current) { + logUsage(startTimeRef.current); + startTimeRef.current = null; + } + setRecording(false); + setInterimText(''); + recognitionRef.current = null; + dismissTimerRef.current = setTimeout(() => { + setFinalizedSegments([]); + }, 2000); + }; + + recognitionRef.current = recognition; + startTimeRef.current = Date.now(); + setFinalizedSegments([]); + setInterimText(''); + recognition.start(); + setRecording(true); + }, [onResult, stopRecording, logUsage]); + + // 토글 (시작/중지) + const toggle = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + if (disabled || !isSupported) return; + recording ? stopRecording() : startRecording(); + }, [disabled, isSupported, recording, stopRecording, startRecording]); + + // 컴포넌트 언마운트 시 정리 + useEffect(() => { + return () => { + recognitionRef.current?.stop(); + if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current); + }; + }, []); + + // 미지원 브라우저에서는 렌더링하지 않음 + if (!isSupported) return null; + + const hasContent = finalizedSegments.length > 0 || interimText; + + return ( +
+ {/* 마이크 버튼 */} + + + {/* 스트리밍 프리뷰 패널 */} + {(recording || hasContent) && ( +
+ {/* 확정 텍스트: 일반체 + 흰색 */} + {finalizedSegments.map((seg, i) => ( + + {seg} + + ))} + + {/* 미확정 텍스트: 이탤릭 + 연한 회색 */} + {interimText && ( + + {interimText} + + )} + + {/* 녹음 중 + 텍스트 없음: 대기 표시 */} + {recording && !hasContent && ( + + + 말씀하세요... + + )} + + {/* 녹음 종료 후 확정 텍스트 완료 표시 */} + {!recording && finalizedSegments.length > 0 && !interimText && ( + + )} +
+ )} +
+ ); +} +``` + +--- + +## 4. 사용 패턴 + +### 4.1 기본 사용법 (input 옆에 배치) + +```jsx +function MyForm() { + const [value, setValue] = useState(''); + + return ( +
+ +
+ setValue(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" + placeholder="입력하세요" + /> + setValue(prev => + prev ? prev + ' ' + text : text + )} + /> +
+
+ ); +} +``` + +### 4.2 textarea와 함께 사용 + +```jsx +
{/* items-start: 상단 정렬 */} +