feat:음성 녹음 GCS 업로드 및 다운로드 기능 추가
- GoogleCloudStorageService 생성 (레거시 방식 JWT 인증) - 10MB 이상 파일은 Google Cloud Storage에 백업 (본사 연구용) - 오디오/파일 다운로드 라우트 추가 - voice-recorder.blade.php 인라인 x-data로 변경 (HTMX 호환) - SalesConsultation 모델에 gcs_uri 필드 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,10 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesConsultation;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -88,8 +90,10 @@ public function destroy(int $consultationId, Request $request): JsonResponse
|
||||
|
||||
/**
|
||||
* 음성 파일 업로드
|
||||
*
|
||||
* 10MB 이상 파일은 Google Cloud Storage에 업로드하여 본사 연구용으로 보관합니다.
|
||||
*/
|
||||
public function uploadAudio(Request $request): JsonResponse
|
||||
public function uploadAudio(Request $request, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
@@ -109,18 +113,30 @@ public function uploadAudio(Request $request): JsonResponse
|
||||
// 파일 저장
|
||||
$file = $request->file('audio');
|
||||
$fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
|
||||
$path = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local');
|
||||
$localPath = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local');
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
// 10MB 이상 파일은 GCS에도 업로드 (본사 연구용)
|
||||
$gcsUri = null;
|
||||
$maxLocalSize = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
if ($fileSize > $maxLocalSize && $gcs->isAvailable()) {
|
||||
$gcsObjectName = "consultations/{$tenantId}/{$scenarioType}/{$fileName}";
|
||||
$localFullPath = Storage::disk('local')->path($localPath);
|
||||
$gcsUri = $gcs->upload($localFullPath, $gcsObjectName);
|
||||
}
|
||||
|
||||
// DB에 저장
|
||||
$consultation = SalesConsultation::createAudio(
|
||||
$tenantId,
|
||||
$scenarioType,
|
||||
$stepId,
|
||||
$path,
|
||||
$localPath,
|
||||
$fileName,
|
||||
$file->getSize(),
|
||||
$fileSize,
|
||||
$transcript,
|
||||
$duration
|
||||
$duration,
|
||||
$gcsUri
|
||||
);
|
||||
|
||||
$consultation->load('creator');
|
||||
@@ -136,6 +152,7 @@ public function uploadAudio(Request $request): JsonResponse
|
||||
'formatted_duration' => $consultation->formatted_duration,
|
||||
'created_by_name' => $consultation->creator->name,
|
||||
'created_at' => $consultation->created_at->format('Y-m-d H:i'),
|
||||
'has_gcs' => !empty($gcsUri),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -196,4 +213,69 @@ public function deleteFile(int $fileId, Request $request): JsonResponse
|
||||
{
|
||||
return $this->destroy($fileId, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오디오 파일 다운로드
|
||||
*/
|
||||
public function downloadAudio(int $consultationId, GoogleCloudStorageService $gcs): Response
|
||||
{
|
||||
$consultation = SalesConsultation::findOrFail($consultationId);
|
||||
|
||||
if ($consultation->consultation_type !== 'audio') {
|
||||
abort(400, '오디오 파일이 아닙니다.');
|
||||
}
|
||||
|
||||
// GCS에 저장된 경우 서명된 URL로 리다이렉트
|
||||
if ($consultation->gcs_uri) {
|
||||
$objectName = str_replace('gs://' . $gcs->getBucketName() . '/', '', $consultation->gcs_uri);
|
||||
$signedUrl = $gcs->getSignedUrl($objectName, 60);
|
||||
|
||||
if ($signedUrl) {
|
||||
return response()->redirectTo($signedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 파일 다운로드
|
||||
$localPath = Storage::disk('local')->path($consultation->file_path);
|
||||
|
||||
if (!file_exists($localPath)) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$extension = pathinfo($consultation->file_name, PATHINFO_EXTENSION) ?: 'webm';
|
||||
$mimeTypes = [
|
||||
'webm' => 'audio/webm',
|
||||
'wav' => 'audio/wav',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'ogg' => 'audio/ogg',
|
||||
'm4a' => 'audio/mp4'
|
||||
];
|
||||
$contentType = $mimeTypes[$extension] ?? 'audio/webm';
|
||||
|
||||
$downloadFileName = '상담녹음_' . $consultation->created_at->format('Ymd_His') . '.' . $extension;
|
||||
|
||||
return response()->download($localPath, $downloadFileName, [
|
||||
'Content-Type' => $contentType,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
public function downloadFile(int $consultationId): Response
|
||||
{
|
||||
$consultation = SalesConsultation::findOrFail($consultationId);
|
||||
|
||||
if ($consultation->consultation_type !== 'file') {
|
||||
abort(400, '첨부파일이 아닙니다.');
|
||||
}
|
||||
|
||||
$localPath = Storage::disk('local')->path($consultation->file_path);
|
||||
|
||||
if (!file_exists($localPath)) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return response()->download($localPath, $consultation->file_name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class SalesConsultation extends Model
|
||||
'file_type',
|
||||
'transcript',
|
||||
'duration',
|
||||
'gcs_uri',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
@@ -93,6 +94,16 @@ public static function createText(int $tenantId, string $scenarioType, ?int $ste
|
||||
|
||||
/**
|
||||
* 음성 상담 기록 생성
|
||||
*
|
||||
* @param int $tenantId 테넌트 ID
|
||||
* @param string $scenarioType 시나리오 타입 (sales/manager)
|
||||
* @param int|null $stepId 단계 ID
|
||||
* @param string $filePath 로컬 파일 경로
|
||||
* @param string $fileName 파일명
|
||||
* @param int $fileSize 파일 크기
|
||||
* @param string|null $transcript 음성 텍스트 변환 결과
|
||||
* @param int|null $duration 녹음 시간 (초)
|
||||
* @param string|null $gcsUri GCS URI (본사 연구용 백업)
|
||||
*/
|
||||
public static function createAudio(
|
||||
int $tenantId,
|
||||
@@ -102,7 +113,8 @@ public static function createAudio(
|
||||
string $fileName,
|
||||
int $fileSize,
|
||||
?string $transcript = null,
|
||||
?int $duration = null
|
||||
?int $duration = null,
|
||||
?string $gcsUri = null
|
||||
): self {
|
||||
return self::create([
|
||||
'tenant_id' => $tenantId,
|
||||
@@ -115,6 +127,7 @@ public static function createAudio(
|
||||
'file_type' => 'audio/webm',
|
||||
'transcript' => $transcript,
|
||||
'duration' => $duration,
|
||||
'gcs_uri' => $gcsUri,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
247
app/Services/GoogleCloudStorageService.php
Normal file
247
app/Services/GoogleCloudStorageService.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Google Cloud Storage 업로드 서비스
|
||||
*
|
||||
* 레거시 PHP 코드와 동일한 방식으로 GCS에 파일을 업로드합니다.
|
||||
* JWT 인증 방식 사용.
|
||||
*/
|
||||
class GoogleCloudStorageService
|
||||
{
|
||||
private ?string $bucketName = null;
|
||||
private ?array $serviceAccount = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS 설정 로드
|
||||
*/
|
||||
private function loadConfig(): void
|
||||
{
|
||||
// GCS 버킷 설정
|
||||
$gcsConfigPath = base_path('../sales/apikey/gcs_config.txt');
|
||||
if (file_exists($gcsConfigPath)) {
|
||||
$config = parse_ini_file($gcsConfigPath);
|
||||
$this->bucketName = $config['bucket_name'] ?? null;
|
||||
}
|
||||
|
||||
// 서비스 계정 로드
|
||||
$serviceAccountPath = base_path('../sales/apikey/google_service_account.json');
|
||||
if (file_exists($serviceAccountPath)) {
|
||||
$this->serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS가 사용 가능한지 확인
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->bucketName !== null && $this->serviceAccount !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS에 파일 업로드
|
||||
*
|
||||
* @param string $filePath 로컬 파일 경로
|
||||
* @param string $objectName GCS에 저장할 객체 이름
|
||||
* @return string|null GCS URI (gs://bucket/object) 또는 실패 시 null
|
||||
*/
|
||||
public function upload(string $filePath, string $objectName): ?string
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
Log::warning('GCS 업로드 실패: 설정되지 않음');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
Log::error('GCS 업로드 실패: 파일 없음 - ' . $filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// OAuth 2.0 토큰 생성
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// GCS에 파일 업로드
|
||||
$fileContent = file_get_contents($filePath);
|
||||
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
|
||||
|
||||
$uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/' .
|
||||
urlencode($this->bucketName) . '/o?uploadType=media&name=' .
|
||||
urlencode($objectName);
|
||||
|
||||
$ch = curl_init($uploadUrl);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
'Content-Type: ' . $mimeType,
|
||||
'Content-Length: ' . strlen($fileContent)
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 300); // 5분 타임아웃
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200) {
|
||||
$gcsUri = 'gs://' . $this->bucketName . '/' . $objectName;
|
||||
Log::info('GCS 업로드 성공: ' . $gcsUri);
|
||||
return $gcsUri;
|
||||
}
|
||||
|
||||
Log::error('GCS 업로드 실패 (HTTP ' . $httpCode . '): ' . ($error ?: $response));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS에서 서명된 다운로드 URL 생성
|
||||
*
|
||||
* @param string $objectName GCS 객체 이름
|
||||
* @param int $expiresInMinutes URL 유효 시간 (분)
|
||||
* @return string|null 서명된 URL 또는 실패 시 null
|
||||
*/
|
||||
public function getSignedUrl(string $objectName, int $expiresInMinutes = 60): ?string
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expiration = time() + ($expiresInMinutes * 60);
|
||||
$stringToSign = "GET\n\n\n{$expiration}\n/{$this->bucketName}/{$objectName}";
|
||||
|
||||
$privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
Log::error('GCS URL 서명 실패: 개인 키 읽기 오류');
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
openssl_free_key($privateKey);
|
||||
}
|
||||
|
||||
$encodedSignature = urlencode(base64_encode($signature));
|
||||
$clientEmail = urlencode($this->serviceAccount['client_email']);
|
||||
|
||||
return "https://storage.googleapis.com/{$this->bucketName}/{$objectName}" .
|
||||
"?GoogleAccessId={$clientEmail}" .
|
||||
"&Expires={$expiration}" .
|
||||
"&Signature={$encodedSignature}";
|
||||
}
|
||||
|
||||
/**
|
||||
* GCS에서 파일 삭제
|
||||
*
|
||||
* @param string $objectName GCS 객체 이름
|
||||
* @return bool 성공 여부
|
||||
*/
|
||||
public function delete(string $objectName): bool
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deleteUrl = 'https://storage.googleapis.com/storage/v1/b/' .
|
||||
urlencode($this->bucketName) . '/o/' .
|
||||
urlencode($objectName);
|
||||
|
||||
$ch = curl_init($deleteUrl);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $httpCode === 204 || $httpCode === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 2.0 액세스 토큰 획득
|
||||
*/
|
||||
private function getAccessToken(): ?string
|
||||
{
|
||||
// JWT 생성
|
||||
$now = time();
|
||||
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
$jwtClaim = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $this->serviceAccount['client_email'],
|
||||
'scope' => 'https://www.googleapis.com/auth/devstorage.full_control',
|
||||
'aud' => 'https://oauth2.googleapis.com/token',
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now
|
||||
]));
|
||||
|
||||
$privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']);
|
||||
if (!$privateKey) {
|
||||
Log::error('GCS 토큰 실패: 개인 키 읽기 오류');
|
||||
return null;
|
||||
}
|
||||
|
||||
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
openssl_free_key($privateKey);
|
||||
}
|
||||
|
||||
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
|
||||
|
||||
// OAuth 토큰 요청
|
||||
$ch = curl_init('https://oauth2.googleapis.com/token');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt
|
||||
]));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
Log::error('GCS 토큰 실패: HTTP ' . $httpCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
return $data['access_token'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL 인코딩
|
||||
*/
|
||||
private function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* 버킷 이름 반환
|
||||
*/
|
||||
public function getBucketName(): ?string
|
||||
{
|
||||
return $this->bucketName;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,254 @@
|
||||
{{-- 음성 녹음 컴포넌트 --}}
|
||||
<div x-data="voiceRecorder()" class="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div x-data="{
|
||||
tenantId: {{ $tenant->id }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
stepId: {{ $stepId ?? 'null' }},
|
||||
|
||||
isRecording: false,
|
||||
audioBlob: null,
|
||||
timer: 0,
|
||||
transcript: '',
|
||||
interimTranscript: '',
|
||||
status: '마이크 버튼을 눌러 녹음을 시작하세요',
|
||||
saving: false,
|
||||
|
||||
mediaRecorder: null,
|
||||
audioChunks: [],
|
||||
timerInterval: null,
|
||||
recognition: null,
|
||||
stream: null,
|
||||
audioContext: null,
|
||||
analyser: null,
|
||||
animationId: null,
|
||||
|
||||
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) {
|
||||
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 = () => {
|
||||
this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||
};
|
||||
|
||||
this.mediaRecorder.start();
|
||||
|
||||
this.timer = 0;
|
||||
this.timerInterval = setInterval(() => {
|
||||
this.timer++;
|
||||
}, 1000);
|
||||
|
||||
this.startSpeechRecognition();
|
||||
this.isRecording = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('녹음 시작 실패:', error);
|
||||
this.status = '마이크 접근 권한이 필요합니다.';
|
||||
}
|
||||
},
|
||||
|
||||
stopRecording() {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
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;
|
||||
this.status = '녹음이 완료되었습니다. 저장하거나 취소하세요.';
|
||||
},
|
||||
|
||||
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.transcript = '';
|
||||
this.interimTranscript = '';
|
||||
|
||||
this.recognition.onresult = (event) => {
|
||||
let finalTranscript = '';
|
||||
let interimTranscript = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalTranscript += transcript;
|
||||
} else {
|
||||
interimTranscript += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
this.transcript += finalTranscript;
|
||||
}
|
||||
this.interimTranscript = interimTranscript;
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.warn('음성 인식 오류:', event.error);
|
||||
};
|
||||
|
||||
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.status = '저장 중... (GCS 업로드 포함)';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('tenant_id', this.tenantId);
|
||||
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 response = await fetch('{{ route('sales.consultations.upload-audio') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.status = '저장되었습니다!' + (result.consultation.has_gcs ? ' (GCS 백업 완료)' : '');
|
||||
this.cancelRecording();
|
||||
|
||||
htmx.ajax('GET',
|
||||
'/sales/consultations/' + this.tenantId + '?scenario_type=' + this.scenarioType + '&step_id=' + (this.stepId || ''),
|
||||
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
||||
);
|
||||
} else {
|
||||
this.status = '저장에 실패했습니다.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('녹음 저장 실패:', error);
|
||||
this.status = '저장 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
cancelRecording() {
|
||||
this.audioBlob = null;
|
||||
this.timer = 0;
|
||||
this.transcript = '';
|
||||
this.interimTranscript = '';
|
||||
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 p-4">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-4 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" />
|
||||
@@ -79,285 +328,7 @@ class="flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg ho
|
||||
|
||||
{{-- 저장된 녹음 목록 안내 --}}
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
녹음 파일은 상담 기록에 자동으로 저장됩니다.
|
||||
녹음 파일은 상담 기록에 저장되며, 10MB 이상은 GCS에 백업됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function voiceRecorder() {
|
||||
return {
|
||||
tenantId: {{ $tenant->id }},
|
||||
scenarioType: '{{ $scenarioType }}',
|
||||
stepId: {{ $stepId ?? 'null' }},
|
||||
|
||||
isRecording: false,
|
||||
audioBlob: null,
|
||||
timer: 0,
|
||||
transcript: '',
|
||||
interimTranscript: '',
|
||||
status: '마이크 버튼을 눌러 녹음을 시작하세요',
|
||||
saving: false,
|
||||
|
||||
// 내부 참조
|
||||
mediaRecorder: null,
|
||||
audioChunks: [],
|
||||
timerInterval: null,
|
||||
recognition: null,
|
||||
stream: null,
|
||||
audioContext: null,
|
||||
analyser: null,
|
||||
animationId: null,
|
||||
|
||||
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) {
|
||||
this.stopRecording();
|
||||
} else {
|
||||
await this.startRecording();
|
||||
}
|
||||
},
|
||||
|
||||
async startRecording() {
|
||||
try {
|
||||
// 마이크 권한 요청
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.status = '녹음 중...';
|
||||
|
||||
// AudioContext 및 Analyser 설정
|
||||
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();
|
||||
|
||||
// MediaRecorder 설정
|
||||
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 = () => {
|
||||
this.audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
||||
};
|
||||
|
||||
this.mediaRecorder.start();
|
||||
|
||||
// 타이머 시작
|
||||
this.timer = 0;
|
||||
this.timerInterval = setInterval(() => {
|
||||
this.timer++;
|
||||
}, 1000);
|
||||
|
||||
// 음성 인식 시작
|
||||
this.startSpeechRecognition();
|
||||
|
||||
this.isRecording = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('녹음 시작 실패:', error);
|
||||
this.status = '마이크 접근 권한이 필요합니다.';
|
||||
}
|
||||
},
|
||||
|
||||
stopRecording() {
|
||||
// MediaRecorder 중지
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
this.mediaRecorder.stop();
|
||||
}
|
||||
|
||||
// 타이머 중지
|
||||
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());
|
||||
}
|
||||
|
||||
// AudioContext 정리
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
}
|
||||
|
||||
this.isRecording = false;
|
||||
this.status = '녹음이 완료되었습니다. 저장하거나 취소하세요.';
|
||||
},
|
||||
|
||||
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.transcript = '';
|
||||
this.interimTranscript = '';
|
||||
|
||||
this.recognition.onresult = (event) => {
|
||||
let finalTranscript = '';
|
||||
let interimTranscript = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalTranscript += transcript;
|
||||
} else {
|
||||
interimTranscript += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
this.transcript += finalTranscript;
|
||||
}
|
||||
this.interimTranscript = interimTranscript;
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.warn('음성 인식 오류:', event.error);
|
||||
};
|
||||
|
||||
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.status = '저장 중...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('tenant_id', this.tenantId);
|
||||
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 response = await fetch('{{ route('sales.consultations.upload-audio') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.status = '저장되었습니다!';
|
||||
this.cancelRecording();
|
||||
|
||||
// 상담 기록 목록 새로고침
|
||||
htmx.ajax('GET',
|
||||
`/sales/consultations/${this.tenantId}?scenario_type=${this.scenarioType}&step_id=${this.stepId || ''}`,
|
||||
{ target: '#consultation-log-container', swap: 'innerHTML' }
|
||||
);
|
||||
} else {
|
||||
this.status = '저장에 실패했습니다.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('녹음 저장 실패:', error);
|
||||
this.status = '저장 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
cancelRecording() {
|
||||
this.audioBlob = null;
|
||||
this.timer = 0;
|
||||
this.transcript = '';
|
||||
this.interimTranscript = '';
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -808,6 +808,8 @@
|
||||
Route::post('/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadAudio'])->name('upload-audio');
|
||||
Route::post('/upload-file', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadFile'])->name('upload-file');
|
||||
Route::delete('/file/{file}', [\App\Http\Controllers\Sales\ConsultationController::class, 'deleteFile'])->name('delete-file');
|
||||
Route::get('/download-audio/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadAudio'])->name('download-audio');
|
||||
Route::get('/download-file/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadFile'])->name('download-file');
|
||||
});
|
||||
|
||||
// 매니저 지정 변경
|
||||
|
||||
Reference in New Issue
Block a user