docs:음성입력 STT 가이드 v1.1 - Alpine.js 구현 패턴 추가
- 영업 전략 시나리오 / 매니저 상담 프로세스 STT 개선 내용 반영 - Alpine.js vs React 구현 비교표 - Alpine.js startSpeechRecognition() 코드 + 프리뷰 패널 Blade 코드 - 영업 시나리오 추가 기능 (음성 녹음, 파형 시각화, GCS 백업, 재생) - 데이터 흐름도 (MediaRecorder + STT + 서버 저장) - onend 자동 재시작 패턴 (긴 녹음 대응) - 참조 구현 파일 목록 확장 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
# 음성 입력(STT) 기술 가이드
|
# 음성 입력(STT) 기술 가이드
|
||||||
|
|
||||||
> **문서 버전**: 1.0
|
> **문서 버전**: 1.1
|
||||||
> **작성일**: 2026-02-10
|
> **작성일**: 2026-02-10
|
||||||
> **최초 적용**: 공사현장 사진대지 (`/juil/construction-photos`)
|
> **적용 페이지**: 공사현장 사진대지, 영업 전략 시나리오, 매니저 상담 프로세스
|
||||||
> **대상 프로젝트**: MNG (React 18 + Babel in-browser)
|
> **대상 프로젝트**: MNG (React 18 + Alpine.js)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -637,9 +637,222 @@ const toggle = useCallback((e) => {
|
|||||||
|
|
||||||
한 페이지에 여러 VoiceInputButton을 배치할 수 있다. 각 인스턴스는 독립적인 `recognitionRef`를 가지므로 충돌하지 않는다. 단, **동시에 2개 이상 녹음은 불가**하다 (브라우저 마이크 제한). 한 버튼이 녹음 중일 때 다른 버튼을 누르면 기존 녹음이 중단된다 (브라우저 동작).
|
한 페이지에 여러 VoiceInputButton을 배치할 수 있다. 각 인스턴스는 독립적인 `recognitionRef`를 가지므로 충돌하지 않는다. 단, **동시에 2개 이상 녹음은 불가**하다 (브라우저 마이크 제한). 한 버튼이 녹음 중일 때 다른 버튼을 누르면 기존 녹음이 중단된다 (브라우저 동작).
|
||||||
|
|
||||||
|
### 9.7 onend 자동 재시작 (긴 녹음)
|
||||||
|
|
||||||
|
`continuous: true`여도 브라우저가 무음 감지 시 자동으로 `onend`를 호출한다. 녹음이 계속 진행 중이라면 `onend`에서 재시작해야 한다.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Alpine.js 패턴
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (this.isRecording && this.recognition) {
|
||||||
|
try { this.recognition.start(); } catch (e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// React 패턴 (VoiceInputButton)
|
||||||
|
// onend에서 logUsage + dismiss 타이머 처리
|
||||||
|
recognition.onend = () => {
|
||||||
|
if (startTimeRef.current) {
|
||||||
|
logUsage(startTimeRef.current);
|
||||||
|
startTimeRef.current = null;
|
||||||
|
}
|
||||||
|
setRecording(false);
|
||||||
|
dismissTimerRef.current = setTimeout(() => setFinalizedSegments([]), 2000);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
영업 시나리오는 `onend`에서 재시작하여 긴 상담도 끊김 없이 인식한다. 반면 공사현장 사진대지는 짧은 입력이므로 재시작하지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 향후 확장 가능성
|
## 10. Alpine.js 구현 (영업/매니저 시나리오)
|
||||||
|
|
||||||
|
영업 전략 시나리오와 매니저 상담 프로세스는 **Alpine.js + Blade** 기반이다. React 없이 동일한 STT 규칙을 적용한다.
|
||||||
|
|
||||||
|
### 10.1 적용 파일
|
||||||
|
|
||||||
|
| 파일 | 경로 | 용도 |
|
||||||
|
|------|------|------|
|
||||||
|
| voice-recorder.blade.php | `resources/views/sales/modals/voice-recorder.blade.php` | 음성 녹음 + STT 컴포넌트 |
|
||||||
|
| scenario-modal.blade.php | `resources/views/sales/modals/scenario-modal.blade.php` | 시나리오 모달 (voice-recorder 포함) |
|
||||||
|
| consultation-log.blade.php | `resources/views/sales/modals/consultation-log.blade.php` | 상담 기록 표시/재생 |
|
||||||
|
|
||||||
|
### 10.2 React vs Alpine.js 차이점
|
||||||
|
|
||||||
|
| 항목 | React (공사현장 사진대지) | Alpine.js (영업 시나리오) |
|
||||||
|
|------|--------------------------|--------------------------|
|
||||||
|
| 상태 관리 | `useState`, `useRef` | `x-data` 속성 |
|
||||||
|
| 확정 텍스트 | `finalizedSegments` state | `finalizedSegments` 배열 |
|
||||||
|
| 미확정 텍스트 | `interimText` state | `interimTranscript` |
|
||||||
|
| 자동 스크롤 | `useEffect` + `previewRef` | `$nextTick()` + `$refs` |
|
||||||
|
| 반복 렌더링 | `{arr.map((seg, i) => <span>)}` | `<template x-for="(seg, i) in arr">` |
|
||||||
|
| 조건부 표시 | `{condition && <Component />}` | `x-show="condition"` |
|
||||||
|
| 용도 | input 필드 옆 간단 음성 입력 | 음성 녹음 + 파일 저장 + STT |
|
||||||
|
|
||||||
|
### 10.3 핵심 코드 (Alpine.js)
|
||||||
|
|
||||||
|
#### x-data 상태 정의
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
x-data="{
|
||||||
|
// ... 기존 녹음 상태 ...
|
||||||
|
transcript: '', // 확정 텍스트 합산 (서버 저장용)
|
||||||
|
interimTranscript: '', // 현재 미확정 텍스트
|
||||||
|
finalizedSegments: [], // 확정 텍스트 세그먼트 배열 (프리뷰용)
|
||||||
|
// ...
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### startSpeechRecognition()
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
startSpeechRecognition() {
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) return;
|
||||||
|
|
||||||
|
this.recognition = new SpeechRecognition();
|
||||||
|
this.recognition.lang = 'ko-KR';
|
||||||
|
this.recognition.continuous = true;
|
||||||
|
this.recognition.interimResults = true;
|
||||||
|
this.recognition.maxAlternatives = 1;
|
||||||
|
|
||||||
|
this.transcript = '';
|
||||||
|
this.interimTranscript = '';
|
||||||
|
this.finalizedSegments = [];
|
||||||
|
|
||||||
|
this.recognition.onresult = (event) => {
|
||||||
|
let currentInterim = '';
|
||||||
|
|
||||||
|
// ★ event.resultIndex부터 순회 (중복 방지)
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
const text = event.results[i][0].transcript;
|
||||||
|
|
||||||
|
if (event.results[i].isFinal) {
|
||||||
|
// ★ 확정: finalizedSegments에 영구 저장
|
||||||
|
this.finalizedSegments.push(text);
|
||||||
|
currentInterim = '';
|
||||||
|
} else {
|
||||||
|
// 미확정: 교정만 허용
|
||||||
|
currentInterim = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcript 합산 (서버 저장용)
|
||||||
|
this.transcript = this.finalizedSegments.join(' ');
|
||||||
|
this.interimTranscript = currentInterim;
|
||||||
|
|
||||||
|
// 자동 스크롤
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.transcriptContainer) {
|
||||||
|
this.$refs.transcriptContainer.scrollTop =
|
||||||
|
this.$refs.transcriptContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 긴 녹음 시 자동 재시작
|
||||||
|
this.recognition.onend = () => {
|
||||||
|
if (this.isRecording && this.recognition) {
|
||||||
|
try { this.recognition.start(); } catch (e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.recognition.start();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 프리뷰 패널 UI (Alpine.js Blade)
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{-- 다크 프리뷰 패널 --}}
|
||||||
|
<div x-show="finalizedSegments.length > 0 || interimTranscript"
|
||||||
|
class="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
|
||||||
|
{{-- 헤더: 인식 중/완료 상태 표시 --}}
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-xs font-medium text-gray-400">음성 인식 결과</p>
|
||||||
|
<template x-if="isRecording">
|
||||||
|
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||||
|
<span class="w-1.5 h-1.5 bg-red-400 rounded-full animate-pulse"></span>
|
||||||
|
인식 중
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="!isRecording && finalizedSegments.length > 0">
|
||||||
|
<span class="text-green-400 text-xs">✓ 완료</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500" x-text="transcript.length + ' 자'"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 텍스트 영역 --}}
|
||||||
|
<div class="p-3 max-h-32 overflow-y-auto" x-ref="transcriptContainer"
|
||||||
|
style="line-height: 1.6;">
|
||||||
|
|
||||||
|
{{-- 확정: 흰색 일반체 (삭제 불가) --}}
|
||||||
|
<template x-for="(seg, i) in finalizedSegments" :key="i">
|
||||||
|
<span class="text-white text-sm font-normal
|
||||||
|
transition-colors duration-300" x-text="seg"></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{{-- 미확정: 회색 이탤릭 (교정 가능) --}}
|
||||||
|
<span x-show="interimTranscript"
|
||||||
|
class="text-gray-400 text-sm italic
|
||||||
|
transition-colors duration-200"
|
||||||
|
x-text="interimTranscript"></span>
|
||||||
|
|
||||||
|
{{-- 대기: 녹음 중 + 텍스트 없음 --}}
|
||||||
|
<span x-show="isRecording && finalizedSegments.length === 0 && !interimTranscript"
|
||||||
|
class="text-gray-500 text-sm flex items-center gap-1.5">
|
||||||
|
<span class="w-1.5 h-1.5 bg-red-400 rounded-full animate-pulse"></span>
|
||||||
|
말씀하세요...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.5 영업 시나리오만의 추가 기능
|
||||||
|
|
||||||
|
영업/매니저 시나리오의 voice-recorder는 단순 STT 외에 다음 기능을 포함한다:
|
||||||
|
|
||||||
|
| 기능 | 설명 | API |
|
||||||
|
|------|------|-----|
|
||||||
|
| **음성 파일 녹음** | MediaRecorder로 webm 캡처 | `navigator.mediaDevices.getUserMedia()` |
|
||||||
|
| **파형 시각화** | Canvas + Web Audio API | `AudioContext.createAnalyser()` |
|
||||||
|
| **자동 저장** | 녹음 중지 시 서버로 FormData 전송 | `ConsultationController::uploadAudio()` |
|
||||||
|
| **GCS 백업** | 10MB 이상 파일은 GCS에도 저장 | `GoogleCloudStorageService` |
|
||||||
|
| **Transcript 저장** | STT 결과를 audio 레코드와 함께 DB 저장 | `sales_consultations.transcript` |
|
||||||
|
| **재생/다운로드** | 저장된 음성 파일 재생 및 다운로드 | `ConsultationController::downloadAudio()` |
|
||||||
|
|
||||||
|
### 10.6 데이터 흐름 (영업 시나리오)
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자 마이크
|
||||||
|
│
|
||||||
|
├──→ MediaRecorder (webm 녹음)
|
||||||
|
│ └──→ audioBlob
|
||||||
|
│
|
||||||
|
├──→ Web Audio API (파형 시각화)
|
||||||
|
│ └──→ Canvas 파형 그리기
|
||||||
|
│
|
||||||
|
└──→ SpeechRecognition (STT)
|
||||||
|
│
|
||||||
|
├──→ finalizedSegments[] (확정 세그먼트)
|
||||||
|
│ └──→ transcript (합산, 서버 저장용)
|
||||||
|
│
|
||||||
|
└──→ interimTranscript (미확정)
|
||||||
|
└──→ 프리뷰 패널에만 표시
|
||||||
|
|
||||||
|
[녹음 중지]
|
||||||
|
└──→ FormData { audio, transcript, duration }
|
||||||
|
└──→ POST /sales/consultations/upload-audio
|
||||||
|
└──→ DB + (GCS if > 10MB)
|
||||||
|
└──→ HTMX 상담기록 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 향후 확장 가능성
|
||||||
|
|
||||||
| 기능 | 설명 | 난이도 |
|
| 기능 | 설명 | 난이도 |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
@@ -653,12 +866,22 @@ const toggle = useCallback((e) => {
|
|||||||
|
|
||||||
## 부록 A: 참조 구현 파일
|
## 부록 A: 참조 구현 파일
|
||||||
|
|
||||||
|
### React 구현 (공사현장 사진대지)
|
||||||
|
|
||||||
| 파일 | 설명 |
|
| 파일 | 설명 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `mng/resources/views/juil/construction-photos.blade.php` | 최초 적용 (VoiceInputButton 전체 코드) |
|
| `mng/resources/views/juil/construction-photos.blade.php` | VoiceInputButton 전체 코드 (React) |
|
||||||
| `mng/app/Http/Controllers/Juil/ConstructionSitePhotoController.php` | logSttUsage 엔드포인트 |
|
| `mng/app/Http/Controllers/Juil/ConstructionSitePhotoController.php` | logSttUsage 엔드포인트 |
|
||||||
| `mng/app/Helpers/AiTokenHelper.php` | saveSttUsage 헬퍼 |
|
| `mng/app/Helpers/AiTokenHelper.php` | saveSttUsage / saveGcsStorageUsage 헬퍼 |
|
||||||
| `mng/routes/web.php` | STT 라우트 등록 위치 |
|
|
||||||
|
### Alpine.js 구현 (영업/매니저 시나리오)
|
||||||
|
|
||||||
|
| 파일 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `mng/resources/views/sales/modals/voice-recorder.blade.php` | 음성 녹음 + STT (Alpine.js) |
|
||||||
|
| `mng/resources/views/sales/modals/scenario-modal.blade.php` | 시나리오 모달 (voice-recorder 포함) |
|
||||||
|
| `mng/resources/views/sales/modals/consultation-log.blade.php` | 상담 기록 재생/표시 |
|
||||||
|
| `mng/app/Http/Controllers/Sales/ConsultationController.php` | 음성 업로드/다운로드/삭제 |
|
||||||
|
|
||||||
## 부록 B: CSS 클래스 요약
|
## 부록 B: CSS 클래스 요약
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user