Files
sam-manage/resources/views/sales/modals/voice-recorder.blade.php
김보곤 16309c5f61 refactor:영업/매니저 시나리오 음성 인식 STT 개선
- onresult에서 event.resultIndex부터 순회 (중복 처리 방지)
- finalizedSegments[] 배열로 확정 텍스트 영구 관리
- 다크 프리뷰 패널(bg-gray-900)로 UI 통일
- 확정=흰색 일반체, 미확정=회색 이탤릭 스타일 적용
- 고정 line-height(1.6)으로 텍스트 전환 시 흔들림 방지
- 인식 중/완료 상태 표시 추가
- 공사현장 사진대지 VoiceInputButton과 동일 규칙 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:19:14 +09:00

443 lines
18 KiB
PHP

{{-- 음성 녹음 컴포넌트 (자동 저장) --}}
@php
$isProspectMode = isset($isProspect) && $isProspect;
$entityId = $isProspectMode ? $entity->id : ($entity->id ?? $tenant->id ?? 0);
@endphp
<div x-data="{
entityId: {{ $entityId }},
isProspect: {{ $isProspectMode ? 'true' : 'false' }},
scenarioType: '{{ $scenarioType }}',
stepId: {{ $stepId ?? 'null' }},
isRecording: false,
audioBlob: null,
timer: 0,
transcript: '',
interimTranscript: '',
finalizedSegments: [],
status: '마이크 버튼을 눌러 녹음을 시작하세요',
saving: false,
saveProgress: 0,
mediaRecorder: null,
audioChunks: [],
timerInterval: null,
recognition: null,
stream: null,
audioContext: null,
analyser: null,
animationId: null,
expanded: false,
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
},
async toggleRecording() {
if (this.isRecording) {
await this.stopRecording();
} else {
await this.startRecording();
}
},
async startRecording() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.status = '녹음 중...';
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.audioContext.createAnalyser();
const source = this.audioContext.createMediaStreamSource(this.stream);
source.connect(this.analyser);
this.analyser.fftSize = 2048;
this.drawWaveform();
this.mediaRecorder = new MediaRecorder(this.stream);
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
this.mediaRecorder.onstop = async () => {
this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
// 녹음 중지 후 자동 저장
await this.saveRecording();
};
this.mediaRecorder.start();
this.timer = 0;
this.timerInterval = setInterval(() => {
this.timer++;
}, 1000);
this.startSpeechRecognition();
this.isRecording = true;
} catch (error) {
console.error('녹음 시작 실패:', error);
this.status = '마이크 접근 권한이 필요합니다.';
}
},
async stopRecording() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
if (this.recognition) {
this.recognition.stop();
}
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
if (this.audioContext) {
this.audioContext.close();
}
this.isRecording = false;
// mediaRecorder.stop()을 호출하면 onstop 이벤트에서 자동 저장됨
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
},
startSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('음성 인식이 지원되지 않습니다.');
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 = [];
// 규칙: interim=이탤릭+회색(교정 허용), final=일반체+진한색(삭제 불가)
this.recognition.onresult = (event) => {
let currentInterim = '';
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.onerror = (event) => {
if (event.error === 'no-speech' || event.error === 'aborted') {
return;
}
};
this.recognition.onend = () => {
if (this.isRecording && this.recognition) {
try {
this.recognition.start();
} catch (e) {}
}
};
this.recognition.start();
},
drawWaveform() {
if (!this.analyser || !this.$refs.waveformCanvas) return;
const canvas = this.$refs.waveformCanvas;
const ctx = canvas.getContext('2d');
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const draw = () => {
if (!this.isRecording) return;
this.analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
ctx.strokeStyle = '#9333ea';
ctx.beginPath();
const sliceWidth = canvas.width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0;
const y = v * canvas.height / 2;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
this.animationId = requestAnimationFrame(draw);
};
draw();
},
async saveRecording() {
if (!this.audioBlob || this.saving) return;
this.saving = true;
this.saveProgress = 0;
this.status = '녹음 파일 저장 중...';
// 프로그레스 시뮬레이션 시작
const progressInterval = setInterval(() => {
if (this.saveProgress < 90) {
this.saveProgress += Math.random() * 15;
}
}, 200);
try {
const formData = new FormData();
if (this.isProspect) {
formData.append('prospect_id', this.entityId);
} else {
formData.append('tenant_id', this.entityId);
}
formData.append('scenario_type', this.scenarioType);
if (this.stepId) formData.append('step_id', this.stepId);
formData.append('audio', this.audioBlob, 'recording.webm');
formData.append('transcript', this.transcript);
formData.append('duration', this.timer);
const uploadUrl = this.isProspect
? '{{ route('sales.consultations.prospect.upload-audio') }}'
: '{{ route('sales.consultations.upload-audio') }}';
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'Accept': 'application/json',
},
body: formData,
});
clearInterval(progressInterval);
this.saveProgress = 100;
const result = await response.json();
if (result.success) {
this.status = '저장 완료!' + (result.consultation.has_gcs ? ' (GCS 백업됨)' : '');
// 잠시 후 초기화
setTimeout(() => {
this.resetRecording();
// 상담 기록 컨테이너 갱신
const refreshUrl = this.isProspect
? '/sales/consultations/prospect/' + this.entityId + '?scenario_type=' + this.scenarioType
: '/sales/consultations/' + this.entityId + '?scenario_type=' + this.scenarioType;
htmx.ajax('GET', refreshUrl, { target: '#consultation-log-container', swap: 'innerHTML' });
}, 1000);
} else {
this.status = '저장 실패. 다시 시도해주세요.';
this.saving = false;
}
} catch (error) {
clearInterval(progressInterval);
console.error('녹음 저장 실패:', error);
this.status = '저장 중 오류 발생';
this.saving = false;
}
},
resetRecording() {
this.audioBlob = null;
this.timer = 0;
this.transcript = '';
this.interimTranscript = '';
this.finalizedSegments = [];
this.saving = false;
this.saveProgress = 0;
this.status = '마이크 버튼을 눌러 녹음을 시작하세요';
const canvas = this.$refs.waveformCanvas;
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
}" class="bg-white border border-gray-200 rounded-lg overflow-hidden relative">
{{-- 저장 오버레이 --}}
<div x-show="saving"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="absolute inset-0 z-10 bg-white/95 flex flex-col items-center justify-center">
<div class="w-48 space-y-4">
{{-- 아이콘 --}}
<div class="flex justify-center">
<div class="p-4 bg-purple-100 rounded-full">
<svg class="w-8 h-8 text-purple-600 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
</div>
{{-- 텍스트 --}}
<p class="text-center text-sm font-medium text-gray-700" x-text="status"></p>
{{-- 프로그레스바 --}}
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div class="h-full bg-purple-600 transition-all duration-300 ease-out rounded-full"
:style="'width: ' + Math.min(saveProgress, 100) + '%'"></div>
</div>
<p class="text-center text-xs text-gray-500">잠시만 기다려주세요...</p>
</div>
</div>
{{-- 헤더 (접기/펼치기) --}}
<button type="button"
@click="expanded = !expanded"
:disabled="saving"
class="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors disabled:opacity-50">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
<span class="text-sm font-semibold text-gray-700">음성 녹음</span>
<span x-show="isRecording" class="flex items-center gap-1 px-2 py-0.5 bg-red-100 text-red-600 text-xs font-medium rounded-full">
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
녹음
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400" x-text="expanded ? '접기' : '펼치기'"></span>
<svg class="w-4 h-4 text-gray-400 transition-transform duration-200"
:class="expanded && 'rotate-180'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{{-- 녹음 컨트롤 (접기/펼치기) --}}
<div x-show="expanded"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="px-4 pb-4 space-y-4 border-t border-gray-100">
{{-- 파형 시각화 --}}
<div class="relative mt-4">
<canvas
x-ref="waveformCanvas"
class="w-full h-20 bg-gray-50 rounded-lg border border-gray-200"
></canvas>
{{-- 타이머 오버레이 --}}
<div x-show="isRecording" class="absolute top-2 right-2 flex items-center gap-2 bg-red-500 text-white px-2 py-1 rounded-full text-xs font-medium">
<span class="w-2 h-2 bg-white rounded-full animate-pulse"></span>
<span x-text="formatTime(timer)">00:00</span>
</div>
</div>
{{-- 실시간 텍스트 변환 표시 (interim=이탤릭+회색, final=일반체+진한색) --}}
<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 && !interimTranscript">
<span class="text-green-400 text-xs">&#10003; 완료</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="inline-block w-1.5 h-1.5 bg-red-400 rounded-full animate-pulse"></span>
말씀하세요...
</span>
</div>
</div>
{{-- 녹음 버튼 --}}
<div class="flex flex-col items-center gap-3">
<button
@click="toggleRecording()"
:disabled="saving"
:class="isRecording ? 'bg-red-500 hover:bg-red-600 ring-4 ring-red-200' : 'bg-purple-600 hover:bg-purple-700'"
class="flex items-center justify-center w-16 h-16 rounded-full text-white shadow-lg transition-all transform hover:scale-105 active:scale-95 disabled:opacity-50">
<svg x-show="!isRecording" class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
<svg x-show="isRecording" class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
<p class="text-sm text-gray-600" x-text="isRecording ? '녹음을 중지하려면 버튼을 누르세요' : '버튼을 눌러 녹음 시작'"></p>
</div>
{{-- 안내 문구 --}}
<p class="text-xs text-gray-400 text-center">
녹음 종료 자동 저장됩니다
</p>
</div>
</div>