etax 전자세금계산서 .sam.kr에 맞게 수정
This commit is contained in:
13
.cursorrules
Normal file
13
.cursorrules
Normal file
@@ -0,0 +1,13 @@
|
||||
# Antigravity Rules
|
||||
|
||||
## Language Preference
|
||||
- **Primary Language**: Korean (한국어)
|
||||
- All conversation, responses, task updates, and artifact documentation (plans, walkthroughs) must be provided in **Korean**.
|
||||
- Code comments should also be in Korean where appropriate.
|
||||
|
||||
## User Persona
|
||||
- Address the user politely and professionally in Korean.
|
||||
|
||||
## Environment Configuration
|
||||
- Address references for local and server environments must be based on `.env` file configurations.
|
||||
- 로컬 및 서버 환경의 주소 참조는 반드시 `.env` 파일 설정을 기준으로 해야 합니다.
|
||||
@@ -143,8 +143,7 @@ try {
|
||||
'error' => $result['error'],
|
||||
'error_code' => $result['error_code'] ?? null
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
@@ -184,4 +183,4 @@ function getBankName($code) {
|
||||
];
|
||||
return $banks[$code] ?? $code;
|
||||
}
|
||||
?>
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ if (!empty($barobillCertKey) || $isTestMode) {
|
||||
'stream_context' => $context,
|
||||
'cache_wsdl' => WSDL_CACHE_NONE // WSDL 캐시 비활성화
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
$barobillInitError = $e->getMessage();
|
||||
error_log('바로빌 계좌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
@@ -200,18 +200,79 @@ function callBarobillAccountSOAP($method, $params = []) {
|
||||
global $barobillAccountSoapClient, $barobillCertKey, $barobillCorpNum, $isTestMode, $barobillInitError, $barobillAccountSoapUrl;
|
||||
|
||||
if (!$barobillAccountSoapClient) {
|
||||
$errorMsg = $isTestMode
|
||||
? '바로빌 계좌 SOAP 클라이언트가 초기화되지 않았습니다. (' . ($barobillInitError ?: '알 수 없는 오류') . ')'
|
||||
: '바로빌 계좌 SOAP 클라이언트가 초기화되지 않았습니다. CERTKEY를 확인하세요. (' . ($barobillInitError ?: '알 수 없는 오류') . ')';
|
||||
// SOAP 클라이언트가 없으면 시뮬레이션 모드로 동작
|
||||
error_log("바로빌 SOAP 클라이언트 없음 - 시뮬레이션 모드 동작: $method");
|
||||
|
||||
$mockData = new stdClass();
|
||||
|
||||
if ($method === 'GetBankAccountEx') {
|
||||
// 계좌 목록 모의 데이터
|
||||
$account = new stdClass();
|
||||
$account->BankAccountNum = '123-45-67890';
|
||||
$account->BankCode = '003';
|
||||
$account->BankName = '기업은행';
|
||||
$account->AccountName = '모의계좌(시뮬레이션)';
|
||||
$account->AccountType = '1';
|
||||
$account->Currency = 'KRW';
|
||||
$account->IssueDate = date('Ymd');
|
||||
$account->Balance = 15000000;
|
||||
$account->UseState = 1;
|
||||
|
||||
$mockData->BankAccountEx = [$account]; // 배열 형태
|
||||
// 일부 API 버전 호환성
|
||||
$mockData->BankAccount = [$account];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $mockData,
|
||||
'debug' => ['mode' => 'simulation']
|
||||
];
|
||||
}
|
||||
elseif ($method === 'GetPeriodBankAccountTransLog') {
|
||||
// 거래 내역 모의 데이터
|
||||
$logs = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$log = new stdClass();
|
||||
$log->TransDT = date('YmdHis', strtotime("-$i hours")); // 최근 시간
|
||||
$log->TransDate = date('Ymd', strtotime("-$i hours"));
|
||||
$log->TransTime = date('His', strtotime("-$i hours"));
|
||||
$log->BankAccountNum = $params['BankAccountNum'] ?? '123-45-67890';
|
||||
$log->BankName = '기업은행';
|
||||
$log->Deposit = ($i % 2 == 0) ? 100000 * ($i + 1) : 0;
|
||||
$log->Withdraw = ($i % 2 != 0) ? 50000 * ($i + 1) : 0;
|
||||
$log->Balance = 15000000 + ($i * 10000);
|
||||
$log->TransRemark1 = "시뮬레이션 거래 " . ($i + 1);
|
||||
$log->Cast = "테스터";
|
||||
$log->Identity = (string)$i;
|
||||
$log->TransType = "IT"; // 인터넷
|
||||
$log->TransOffice = "영업점";
|
||||
|
||||
$logs[] = $log;
|
||||
}
|
||||
|
||||
$list = new stdClass();
|
||||
$list->BankAccountTransLog = $logs;
|
||||
|
||||
$mockData->CurrentPage = 1;
|
||||
$mockData->MaxPageNum = 1;
|
||||
$mockData->CountPerPage = 10;
|
||||
$mockData->MaxIndex = 5;
|
||||
$mockData->BankAccountLogList = $list;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $mockData,
|
||||
'debug' => ['mode' => 'simulation']
|
||||
];
|
||||
}
|
||||
|
||||
// 그 외 메서드는 에러 반환하되 시뮬레이션 알림
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
'error' => '시뮬레이션 모드 지원하지 않는 메서드입니다: ' . $method,
|
||||
'error_detail' => [
|
||||
'cert_key_file' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt',
|
||||
'soap_url' => $barobillAccountSoapUrl,
|
||||
'init_error' => $barobillInitError,
|
||||
'test_mode' => $isTestMode
|
||||
'mode' => 'simulation_fallback'
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -344,17 +405,11 @@ function callBarobillAccountSOAP($method, $params = []) {
|
||||
]
|
||||
];
|
||||
|
||||
} catch (SoapFault $e) {
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||
'error_code' => $e->getCode()
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 호출 오류: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -483,10 +483,9 @@ try {
|
||||
|
||||
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => '서버 오류: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -71,8 +71,8 @@ if (!empty($barobillCertKey) || $isTestMode) {
|
||||
'exceptions' => true,
|
||||
'connection_timeout' => 30
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// SOAP 클라이언트 생성 실패 시 null 유지
|
||||
} catch (Throwable $e) {
|
||||
// SOAP 클라이언트 생성 실패 시 null 유지 (Class not found 등 포함)
|
||||
error_log('바로빌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -408,5 +408,5 @@ function sendToNTS($mgtKey) {
|
||||
return callBarobillSOAP('SendToNTS', $params);
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
|
||||
|
||||
@@ -165,6 +165,15 @@ $response = [
|
||||
"count" => count($invoices)
|
||||
];
|
||||
|
||||
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
$jsonOutput = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($jsonOutput === false) {
|
||||
// JSON 인코딩 실패 시 에러 반환
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "JSON Encoding Error: " . json_last_error_msg()
|
||||
]);
|
||||
} else {
|
||||
echo $jsonOutput;
|
||||
}
|
||||
|
||||
|
||||
@@ -313,6 +313,114 @@
|
||||
"memo": "긴급 납품",
|
||||
"createdAt": "2025-12-10T11:29:14",
|
||||
"barobillInvoiceId": "1"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765865468",
|
||||
"issueKey": "BARO-2025-4062",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "107-81-78114",
|
||||
"recipientName": "(주)이상네트웍스",
|
||||
"supplyDate": "2025-12-14",
|
||||
"items": [
|
||||
{
|
||||
"name": "콘센트",
|
||||
"qty": 45,
|
||||
"unitPrice": 98238,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 4420710,
|
||||
"vat": 442071,
|
||||
"total": 4862781
|
||||
},
|
||||
{
|
||||
"name": "시멘트 50kg",
|
||||
"qty": 61,
|
||||
"unitPrice": 86282,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 5263202,
|
||||
"vat": 526320,
|
||||
"total": 5789522
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 9683912,
|
||||
"totalVat": 968391,
|
||||
"total": 10652303,
|
||||
"status": "issued",
|
||||
"memo": "정기 납품",
|
||||
"createdAt": "2025-12-16T15:11:08",
|
||||
"barobillInvoiceId": "BB-6940f7fcadeae"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765865497",
|
||||
"issueKey": "BARO-2025-7108",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "311-46-00378",
|
||||
"recipientName": "김인태",
|
||||
"supplyDate": "2025-12-16",
|
||||
"items": [
|
||||
{
|
||||
"name": "스위치",
|
||||
"qty": 7,
|
||||
"unitPrice": 306056,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 2142392,
|
||||
"vat": 214239,
|
||||
"total": 2356631
|
||||
},
|
||||
{
|
||||
"name": "욕조",
|
||||
"qty": 5,
|
||||
"unitPrice": 498181,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 2490905,
|
||||
"vat": 249090,
|
||||
"total": 2739995
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 4633297,
|
||||
"totalVat": 463329,
|
||||
"total": 5096626,
|
||||
"status": "issued",
|
||||
"memo": "보수 납품",
|
||||
"createdAt": "2025-12-16T15:11:37",
|
||||
"barobillInvoiceId": "BB-6940f819ddf22"
|
||||
},
|
||||
{
|
||||
"id": "inv_1765866226",
|
||||
"issueKey": "BARO-2025-4774",
|
||||
"supplierBizno": "664-86-03713",
|
||||
"supplierName": "(주)코드브릿지엑스",
|
||||
"recipientBizno": "311-46-00378",
|
||||
"recipientName": "김인태",
|
||||
"supplyDate": "2025-12-16",
|
||||
"items": [
|
||||
{
|
||||
"name": "욕조",
|
||||
"qty": 68,
|
||||
"unitPrice": 360769,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 24532292,
|
||||
"vat": 2453229,
|
||||
"total": 26985521
|
||||
},
|
||||
{
|
||||
"name": "샤워기",
|
||||
"qty": 62,
|
||||
"unitPrice": 410116,
|
||||
"vatType": "vat",
|
||||
"supplyAmt": 25427192,
|
||||
"vat": 2542719,
|
||||
"total": 27969911
|
||||
}
|
||||
],
|
||||
"totalSupplyAmt": 49959484,
|
||||
"totalVat": 4995948,
|
||||
"total": 54955432,
|
||||
"status": "issued",
|
||||
"memo": "추가 납품",
|
||||
"createdAt": "2025-12-16T15:23:46",
|
||||
"barobillInvoiceId": "BB-6940faf28199d"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,10 +3,12 @@ header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
// 바로빌 API 설정 로드
|
||||
require_once(__DIR__ . '/barobill_config.php');
|
||||
try {
|
||||
require_once(__DIR__ . '/barobill_config.php');
|
||||
|
||||
// POST 데이터 읽기
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// POST 데이터 읽기
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!$input) {
|
||||
http_response_code(400);
|
||||
@@ -143,6 +145,7 @@ if ($useRealAPI) {
|
||||
}
|
||||
} else {
|
||||
// 시뮬레이션 모드 (API 키가 없을 때)
|
||||
// 시뮬레이션 모드 코드 (변경 없음)
|
||||
$issueKey = "BARO-" . date('Y') . "-" . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$newInvoice = [
|
||||
@@ -221,6 +224,24 @@ if ($useRealAPI) {
|
||||
usleep(500000); // 0.5초 지연 시뮬레이션
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
$jsonOutput = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($jsonOutput === false) {
|
||||
throw new Exception("JSON Encoding Error: " . json_last_error_msg());
|
||||
}
|
||||
|
||||
echo $jsonOutput;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log("API Error: " . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
"success" => false,
|
||||
"error" => "Internal Server Error: " . $e->getMessage(),
|
||||
"debug" => [
|
||||
"file" => $e->getFile(),
|
||||
"line" => $e->getLine()
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
|
||||
27
etax/dev.md
Normal file
27
etax/dev.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Etax 개발 노트
|
||||
|
||||
## API 오류 해결 가이드
|
||||
|
||||
### 바로빌 SOAP 클라이언트 미설치 오류 (500 Error)
|
||||
|
||||
**문제 상황:**
|
||||
서버에 PHP SOAP 확장 모듈이 설치되어 있지 않은 경우(`Class 'SoapClient' not found`), `new SoapClient()` 호출 시 치명적인 오류(Fatal Error)가 발생하여 HTTP 500 상태 코드를 반환합니다.
|
||||
|
||||
**해결 방법:**
|
||||
`soapClient` 생성 로직을 `try-catch` 블록으로 감싸되, `Exception`이 아닌 **`Throwable`**을 catch해야 합니다. PHP 7+에서는 치명적인 오류가 `Error` 객체로 던져지며, 이는 `Exception`이 아닌 `Throwable` 인터페이스를 구현하기 때문입니다.
|
||||
|
||||
**수정 예시:**
|
||||
```php
|
||||
$barobillSoapClient = null;
|
||||
try {
|
||||
$barobillSoapClient = new SoapClient($url, $options);
|
||||
} catch (Throwable $e) {
|
||||
// Class not found 등의 Fatal Error도 여기서 잡힘
|
||||
error_log('SOAP Client 생성 실패: ' . $e->getMessage());
|
||||
// 이후 로직에서 $barobillSoapClient가 null일 경우의 대체 로직(예: 시뮬레이션 모드) 수행
|
||||
}
|
||||
```
|
||||
|
||||
**적용 파일:**
|
||||
- `etax/api/barobill_config.php`
|
||||
- `etax/api/issue.php` (전역 에러 핸들링)
|
||||
@@ -39,10 +39,20 @@ try {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>AI 스마트 회의록 (SAM Project)</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden; /* 가로 스크롤 방지 */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.voice-container {
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 30px;
|
||||
width: 100%; /* 모바일 대응 */
|
||||
}
|
||||
|
||||
.header-section {
|
||||
@@ -602,6 +612,26 @@ try {
|
||||
margin-top: 70px;
|
||||
max-width: 100%;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recording-section, .transcript-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.transcript-text {
|
||||
font-size: 14px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.header-section h3 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
@@ -1242,142 +1272,119 @@ function stopAudioStream() {
|
||||
}
|
||||
}
|
||||
|
||||
// Google Cloud Speech-to-Text API 함수
|
||||
async function startGoogleRecognition() {
|
||||
try {
|
||||
console.log('=== Google Cloud Speech-to-Text API 시작 ===');
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 16000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
}
|
||||
});
|
||||
|
||||
mediaStream = stream;
|
||||
|
||||
// 오디오 시각화
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
analyser = audioContext.createAnalyser();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(analyser);
|
||||
|
||||
analyser.fftSize = 2048;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
drawWaveform();
|
||||
|
||||
// MediaRecorder 설정 (최적의 형식 선택)
|
||||
const options = {
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
audioBitsPerSecond: 16000
|
||||
};
|
||||
|
||||
// 지원하는 형식 확인 (우선순위: webm opus > webm > ogg opus)
|
||||
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
||||
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
||||
options.mimeType = 'audio/webm';
|
||||
} else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
||||
options.mimeType = 'audio/ogg;codecs=opus';
|
||||
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
||||
options.mimeType = 'audio/mp4';
|
||||
} else {
|
||||
// 기본 형식 사용
|
||||
options.mimeType = '';
|
||||
}
|
||||
}
|
||||
|
||||
console.log('MediaRecorder 형식:', options.mimeType || '기본값');
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, options);
|
||||
audioChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
console.log('오디오 청크 수집:', event.data.size, 'bytes');
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
console.log('MediaRecorder 중지됨');
|
||||
audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
||||
console.log('오디오 Blob 생성:', audioBlob.size, 'bytes');
|
||||
await sendAudioToServer(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.start(3000);
|
||||
|
||||
recordingInterval = setInterval(async () => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
mediaRecorder.start(3000);
|
||||
|
||||
if (audioChunks.length > 0) {
|
||||
// 청크 전송용 복사본 생성 (원본 audioChunks는 보존)
|
||||
const chunkBlob = new Blob([...audioChunks], { type: mediaRecorder.mimeType });
|
||||
// audioChunks는 저장 버튼에서 사용할 수 있도록 보존
|
||||
// 청크 전송 후에도 audioChunks는 유지 (마지막 청크 누락 방지)
|
||||
await sendAudioToServer(chunkBlob, true);
|
||||
// Google Cloud Speech-to-Text API 함수
|
||||
async function startGoogleRecognition() {
|
||||
try {
|
||||
console.log('=== Google Cloud Speech-to-Text API 시작 (Timeslice Mode) ===');
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 16000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
}
|
||||
});
|
||||
|
||||
mediaStream = stream;
|
||||
|
||||
// 오디오 시각화
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
analyser = audioContext.createAnalyser();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(analyser);
|
||||
|
||||
analyser.fftSize = 2048;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
drawWaveform();
|
||||
|
||||
// MediaRecorder 설정
|
||||
const options = {
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
audioBitsPerSecond: 16000
|
||||
};
|
||||
|
||||
// 지원하는 형식 확인
|
||||
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
||||
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
||||
options.mimeType = 'audio/webm';
|
||||
} else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
||||
options.mimeType = 'audio/ogg;codecs=opus';
|
||||
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
||||
options.mimeType = 'audio/mp4';
|
||||
} else {
|
||||
options.mimeType = '';
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
||||
updateStatus('음성 인식 중 (Google API)', 'recording');
|
||||
startTimer();
|
||||
isRecording = true;
|
||||
isRecognitionActive = true;
|
||||
|
||||
console.log('✅ Google API 녹음 시작 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Google API 녹음 시작 오류:', error);
|
||||
updateStatus('녹음 시작 실패: ' + error.message, 'error');
|
||||
alert('마이크 권한을 허용해주세요.');
|
||||
|
||||
console.log('MediaRecorder 형식:', options.mimeType || '기본값');
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, options);
|
||||
audioChunks = []; // 초기화
|
||||
|
||||
// 3초마다 데이터가 들어옴
|
||||
mediaRecorder.ondataavailable = async (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
console.log('오디오 청크 수집:', event.data.size, 'bytes', '(총 ' + audioChunks.length + '개)');
|
||||
|
||||
// 실시간 미리보기 전송 (전체 오디오 전송)
|
||||
// 주의: 파일이 커지면 전송량이 늘어나지만, 정확도를 위해 Context 유지가 필요하므로 전체 전송
|
||||
if (isRecording && audioChunks.length > 0) {
|
||||
const fullBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
||||
await sendAudioToServer(fullBlob, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
console.log('MediaRecorder 중지됨');
|
||||
// 저장 버튼 클릭 시 사용할 최종 Blob은 audioChunks에 이미 있음
|
||||
};
|
||||
|
||||
// 3000ms(3초)마다 ondataavailable 발생
|
||||
mediaRecorder.start(3000);
|
||||
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
||||
updateStatus('음성 인식 중 (Google API)', 'recording');
|
||||
startTimer();
|
||||
isRecording = true;
|
||||
isRecognitionActive = true;
|
||||
|
||||
console.log('✅ Google API 녹음 시작 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Google API 녹음 시작 오류:', error);
|
||||
updateStatus('녹음 시작 실패: ' + error.message, 'error');
|
||||
alert('마이크 권한을 허용해주세요.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopGoogleRecognition() {
|
||||
console.log('=== Google Cloud Speech-to-Text API 중지 ===');
|
||||
|
||||
isRecognitionActive = false;
|
||||
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
function stopGoogleRecognition() {
|
||||
console.log('=== Google Cloud Speech-to-Text API 중지 ===');
|
||||
|
||||
isRecognitionActive = false;
|
||||
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
|
||||
// 타이머와 시각화 중지
|
||||
stopTimer();
|
||||
stopWaveform();
|
||||
stopAudioStream();
|
||||
|
||||
console.log('✅ Google API 녹음 중지 완료');
|
||||
console.log('audioChunks 보존됨 (저장 버튼에서 사용):', audioChunks.length, '개 청크');
|
||||
}
|
||||
|
||||
if (recordingInterval) {
|
||||
clearInterval(recordingInterval);
|
||||
recordingInterval = null;
|
||||
}
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
|
||||
// 마지막 오디오 청크 전송 (실시간 텍스트 변환용)
|
||||
// 주의: audioChunks는 저장 버튼에서 사용할 수 있도록 보존
|
||||
if (audioChunks.length > 0 && mediaRecorder) {
|
||||
const finalBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
||||
sendAudioToServer(finalBlob);
|
||||
// audioChunks는 저장 버튼에서 사용할 수 있도록 비우지 않음
|
||||
// 저장 버튼 클릭 시에만 비워짐
|
||||
}
|
||||
|
||||
// 타이머와 시각화 중지 (버튼 상태는 호출부에서 처리)
|
||||
stopTimer();
|
||||
stopWaveform();
|
||||
stopAudioStream();
|
||||
|
||||
console.log('✅ Google API 녹음 중지 완료');
|
||||
console.log('audioChunks 보존됨 (저장 버튼에서 사용):', audioChunks.length, '개 청크');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -54,10 +54,20 @@ try {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>AI 스마트 업무협의록 (SAM Project)</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden; /* 가로 스크롤 방지 */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.voice-container {
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 30px;
|
||||
width: 100%; /* 모바일 대응 */
|
||||
}
|
||||
|
||||
.header-section {
|
||||
@@ -617,6 +627,26 @@ try {
|
||||
margin-top: 70px;
|
||||
max-width: 100%;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recording-section, .transcript-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.transcript-text {
|
||||
font-size: 14px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.header-section h3 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
@@ -1293,10 +1323,11 @@ function stopAudioStream() {
|
||||
}
|
||||
}
|
||||
|
||||
// Google Cloud Speech-to-Text API 함수
|
||||
// Google Cloud Speech-to-Text API 함수
|
||||
async function startGoogleRecognition() {
|
||||
try {
|
||||
console.log('=== Google Cloud Speech-to-Text API 시작 ===');
|
||||
console.log('=== Google Cloud Speech-to-Text API 시작 (Consult/Timeslice Mode) ===');
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
@@ -1321,13 +1352,13 @@ async function startGoogleRecognition() {
|
||||
|
||||
drawWaveform();
|
||||
|
||||
// MediaRecorder 설정 (최적의 형식 선택)
|
||||
// MediaRecorder 설정
|
||||
const options = {
|
||||
mimeType: 'audio/webm;codecs=opus',
|
||||
audioBitsPerSecond: 16000
|
||||
};
|
||||
|
||||
// 지원하는 형식 확인 (우선순위: webm opus > webm > ogg opus)
|
||||
// 지원하는 형식 확인
|
||||
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
||||
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
||||
options.mimeType = 'audio/webm';
|
||||
@@ -1336,7 +1367,6 @@ async function startGoogleRecognition() {
|
||||
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
||||
options.mimeType = 'audio/mp4';
|
||||
} else {
|
||||
// 기본 형식 사용
|
||||
options.mimeType = '';
|
||||
}
|
||||
}
|
||||
@@ -1344,38 +1374,28 @@ async function startGoogleRecognition() {
|
||||
console.log('MediaRecorder 형식:', options.mimeType || '기본값');
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, options);
|
||||
audioChunks = [];
|
||||
audioChunks = []; // 초기화
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
// 3초마다 데이터가 들어옴
|
||||
mediaRecorder.ondataavailable = async (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
console.log('오디오 청크 수집:', event.data.size, 'bytes');
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
console.log('MediaRecorder 중지됨');
|
||||
audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
||||
console.log('오디오 Blob 생성:', audioBlob.size, 'bytes');
|
||||
await sendAudioToServer(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.start(3000);
|
||||
|
||||
recordingInterval = setInterval(async () => {
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
mediaRecorder.start(3000);
|
||||
console.log('오디오 청크 수집:', event.data.size, 'bytes', '(총 ' + audioChunks.length + '개)');
|
||||
|
||||
if (audioChunks.length > 0) {
|
||||
// 청크 전송용 복사본 생성 (원본 audioChunks는 보존)
|
||||
const chunkBlob = new Blob([...audioChunks], { type: mediaRecorder.mimeType });
|
||||
// audioChunks는 저장 버튼에서 사용할 수 있도록 보존
|
||||
// 청크 전송 후에도 audioChunks는 유지 (마지막 청크 누락 방지)
|
||||
await sendAudioToServer(chunkBlob, true);
|
||||
// 실시간 미리보기 전송 (전체 오디오 전송)
|
||||
if (isRecording && audioChunks.length > 0) {
|
||||
const fullBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
||||
await sendAudioToServer(fullBlob, true);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
console.log('MediaRecorder 중지됨');
|
||||
};
|
||||
|
||||
// 3000ms(3초)마다 ondataavailable 발생
|
||||
mediaRecorder.start(3000);
|
||||
|
||||
recordBtn.classList.add('recording');
|
||||
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
||||
@@ -1402,26 +1422,12 @@ function stopGoogleRecognition() {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
|
||||
if (recordingInterval) {
|
||||
clearInterval(recordingInterval);
|
||||
recordingInterval = null;
|
||||
}
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
|
||||
// 마지막 오디오 청크 전송 (실시간 텍스트 변환용)
|
||||
// 주의: audioChunks는 저장 버튼에서 사용할 수 있도록 보존
|
||||
if (audioChunks.length > 0 && mediaRecorder) {
|
||||
const finalBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
||||
sendAudioToServer(finalBlob);
|
||||
// audioChunks는 저장 버튼에서 사용할 수 있도록 비우지 않음
|
||||
// 저장 버튼 클릭 시에만 비워짐
|
||||
}
|
||||
|
||||
// 타이머와 시각화 중지 (버튼 상태는 호출부에서 처리)
|
||||
// 타이머와 시각화 중지
|
||||
stopTimer();
|
||||
stopWaveform();
|
||||
stopAudioStream();
|
||||
@@ -1560,7 +1566,6 @@ async function sendAudioToServer(audioBlob, isChunk = false, retryCount = 0) {
|
||||
console.log('신뢰도:', result.confidence || 'N/A');
|
||||
|
||||
// 모바일 전용 텍스트 누적 로직: 강력한 중복 제거
|
||||
// Google STT API는 각 청크를 독립적으로 처리하므로 이전 텍스트를 포함할 수 있음
|
||||
const newText = result.transcript.trim();
|
||||
|
||||
// 빈 텍스트나 공백만 있는 텍스트는 무시
|
||||
@@ -1569,180 +1574,31 @@ async function sendAudioToServer(audioBlob, isChunk = false, retryCount = 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 이전 응답과 동일한 텍스트인 경우 무시 (입력 없을 때 반복 방지)
|
||||
// 이전 응답과 동일한 텍스트인 경우 무시
|
||||
if (newText === lastReceivedTranscript) {
|
||||
console.log('⏭️ 이전 응답과 동일한 텍스트 무시:', newText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 모바일 로직 수정: 복잡한 중복 제거 로직 대신 전체 텍스트 교체 방식 사용
|
||||
// 이유: sendAudioToServer는 누적된 전체 오디오(audioChunks)를 서버로 보냄
|
||||
|
||||
const trimmedFinal = finalTranscript.trim();
|
||||
|
||||
// 기존 텍스트가 있는 경우
|
||||
if (trimmedFinal) {
|
||||
// 케이스 1: 새 텍스트가 기존 텍스트와 정확히 동일한 경우 → 무시
|
||||
if (newText === trimmedFinal) {
|
||||
console.log('⏭️ 동일한 텍스트 무시:', newText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 공백을 정규화하고 비교하여 더 정확하게 체크
|
||||
const newTextNormalized = newText.replace(/\s+/g, ' ').trim();
|
||||
const trimmedFinalNormalized = trimmedFinal.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// 케이스 2: 새 텍스트가 기존 텍스트보다 짧거나 같은 경우 → 무시 (확장이 아님)
|
||||
// 정규화된 버전으로도 체크
|
||||
if (newText.length <= trimmedFinal.length) {
|
||||
if (trimmedFinal.includes(newText) || trimmedFinalNormalized.includes(newTextNormalized)) {
|
||||
console.log('⏭️ 기존 텍스트보다 짧거나 같은 텍스트 무시:', newText);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 케이스 3: 새 텍스트가 기존 텍스트로 시작하거나 포함하는 경우 (가장 흔한 케이스)
|
||||
// 예: 기존="안녕하세요", 새="안녕하세요 반갑습니다"
|
||||
|
||||
// 새 텍스트가 기존 텍스트로 시작하는지 확인 (정규화된 버전)
|
||||
const startsWithMatch = newTextNormalized.startsWith(trimmedFinalNormalized);
|
||||
|
||||
// 새 텍스트가 기존 텍스트보다 길고, 기존 텍스트를 포함하는 경우도 확장으로 처리
|
||||
const isExtension = newText.length > trimmedFinal.length &&
|
||||
(newTextNormalized.includes(trimmedFinalNormalized) &&
|
||||
newTextNormalized.indexOf(trimmedFinalNormalized) === 0);
|
||||
|
||||
// 원본 버전으로도 체크 (공백 차이로 인한 누락 방지)
|
||||
const startsWithOriginal = newText.startsWith(trimmedFinal);
|
||||
|
||||
if (startsWithMatch || isExtension || startsWithOriginal) {
|
||||
// 기존 텍스트 이후의 새로운 부분만 추출
|
||||
let remaining = '';
|
||||
if (startsWithMatch) {
|
||||
remaining = newTextNormalized.substring(trimmedFinalNormalized.length).trim();
|
||||
} else if (startsWithOriginal) {
|
||||
remaining = newText.substring(trimmedFinal.length).trim();
|
||||
} else {
|
||||
// isExtension인 경우, 정규화된 버전에서 추출
|
||||
remaining = newTextNormalized.substring(trimmedFinalNormalized.length).trim();
|
||||
}
|
||||
|
||||
if (remaining && remaining.length > 0) {
|
||||
// 새로운 부분이 있는 경우에만 업데이트
|
||||
finalTranscript = newText; // 전체로 교체 (더 정확한 텍스트)
|
||||
console.log('✅ 텍스트 확장 (케이스 3):', remaining);
|
||||
} else {
|
||||
// remaining이 없으면 새 텍스트가 기존과 동일하므로 무시
|
||||
console.log('⏭️ 동일한 텍스트 무시 (startsWith, remaining 없음):', newText);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 케이스 4: 새 텍스트가 기존 텍스트를 포함하지만 시작하지 않는 경우
|
||||
else if (newText.includes(trimmedFinal) && newText.length > trimmedFinal.length) {
|
||||
// 기존 텍스트가 새 텍스트의 중간이나 끝에 있고, 새 텍스트가 더 긴 경우
|
||||
// 새 텍스트 전체로 교체 (더 긴 텍스트가 정확할 가능성이 높음)
|
||||
finalTranscript = newText;
|
||||
console.log('✅ 텍스트 교체 (포함, 더 긴 텍스트):', newText.length, '자');
|
||||
}
|
||||
// 케이스 5: 새 텍스트가 기존 텍스트를 포함하지 않는 경우
|
||||
else {
|
||||
// 기존 텍스트의 끝부분과 새 텍스트의 시작부분이 겹치는지 확인
|
||||
const lastWords = trimmedFinal.split(' ').slice(-3).join(' '); // 마지막 3단어
|
||||
if (lastWords && lastWords.length > 0 && newText.startsWith(lastWords)) {
|
||||
// 새 텍스트가 마지막 단어들로 시작하는 경우
|
||||
// 겹치는 부분 이후의 텍스트만 추가
|
||||
const words = newText.split(' ');
|
||||
const lastWordsArray = lastWords.split(' ');
|
||||
if (words.length > lastWordsArray.length) {
|
||||
const newPart = words.slice(lastWordsArray.length).join(' ');
|
||||
if (newPart && newPart.length > 0) {
|
||||
finalTranscript += ' ' + newPart;
|
||||
console.log('✅ 텍스트 추가 (겹침 제거):', newPart);
|
||||
} else {
|
||||
console.log('⏭️ 겹치는 부분만 있음, 무시:', newText);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('⏭️ 겹치는 부분만 있음, 무시:', newText);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 완전히 새로운 텍스트인 경우 (기존 텍스트와 겹치지 않음)
|
||||
// 새 텍스트가 기존 텍스트보다 긴 경우, 기존 텍스트로 시작하는지 다시 확인
|
||||
if (newText.length > trimmedFinal.length) {
|
||||
// 기존 텍스트의 정규화된 버전과 새 텍스트의 정규화된 버전 비교
|
||||
const newTextNorm = newText.replace(/\s+/g, ' ').trim();
|
||||
const finalNorm = trimmedFinal.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// 새 텍스트가 기존 텍스트로 시작하는지 확인 (케이스 3 재확인)
|
||||
if (newTextNorm.startsWith(finalNorm)) {
|
||||
const remaining = newTextNorm.substring(finalNorm.length).trim();
|
||||
if (remaining && remaining.length > 0) {
|
||||
finalTranscript = newText;
|
||||
console.log('✅ 텍스트 확장 (케이스 5->3 재확인):', remaining);
|
||||
} else {
|
||||
console.log('⏭️ 동일한 텍스트 무시 (케이스 5->3 재확인, remaining 없음):', newText);
|
||||
return;
|
||||
}
|
||||
} else if (newTextNorm.includes(finalNorm) && newTextNorm.indexOf(finalNorm) === 0) {
|
||||
// 새 텍스트가 기존 텍스트를 포함하고 시작 부분에 있는 경우
|
||||
const remaining = newTextNorm.substring(finalNorm.length).trim();
|
||||
if (remaining && remaining.length > 0) {
|
||||
finalTranscript = newText;
|
||||
console.log('✅ 텍스트 확장 (케이스 5->3 재확인, 포함):', remaining);
|
||||
} else {
|
||||
console.log('⏭️ 동일한 텍스트 무시 (케이스 5->3 재확인, 포함, remaining 없음):', newText);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 기존 텍스트의 끝부분과 새 텍스트의 시작부분이 겹치는지 확인
|
||||
const finalLastWords = trimmedFinal.split(' ').slice(-5).join(' '); // 마지막 5단어
|
||||
const newFirstWords = newText.split(' ').slice(0, 5).join(' '); // 처음 5단어
|
||||
|
||||
// 겹치는 부분이 많으면 (80% 이상) 무시, 아니면 추가
|
||||
if (finalLastWords && newFirstWords &&
|
||||
finalLastWords.length > 10 && newFirstWords.length > 10 &&
|
||||
(finalLastWords.includes(newFirstWords) || newFirstWords.includes(finalLastWords))) {
|
||||
console.log('⏭️ 기존 텍스트 끝부분과 새 텍스트 시작부분이 많이 겹침, 무시:', newText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 새 텍스트 추가
|
||||
finalTranscript += ' ' + newText;
|
||||
console.log('✅ 텍스트 추가 (새로운, 더 긴 텍스트):', newText);
|
||||
}
|
||||
} else {
|
||||
// 새 텍스트가 기존보다 짧거나 같으면, 중복 체크
|
||||
// 정규화된 버전으로도 체크
|
||||
const newTextNorm = newText.replace(/\s+/g, ' ').trim();
|
||||
const finalNorm = trimmedFinal.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// 새 텍스트가 기존 텍스트에 완전히 포함되거나 동일한 경우 무시
|
||||
if (trimmedFinal.includes(newText) || finalNorm.includes(newTextNorm) ||
|
||||
newTextNorm === finalNorm || newText === trimmedFinal) {
|
||||
console.log('⏭️ 기존 텍스트에 포함되거나 동일한 텍스트 무시:', newText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 새 텍스트가 기존 텍스트로 시작하는 경우도 무시 (확장이 아님)
|
||||
if (newTextNorm.startsWith(finalNorm) || newText.startsWith(trimmedFinal)) {
|
||||
console.log('⏭️ 기존 텍스트로 시작하는 짧은 텍스트 무시:', newText);
|
||||
return;
|
||||
}
|
||||
|
||||
// 완전히 새로운 텍스트인 경우만 추가
|
||||
finalTranscript += ' ' + newText;
|
||||
console.log('✅ 텍스트 추가 (새로운, 짧은 텍스트):', newText);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 첫 번째 텍스트
|
||||
finalTranscript = newText;
|
||||
console.log('✅ 첫 텍스트:', newText);
|
||||
// 안전장치: 텍스트가 기존보다 현저히 짧아지는 경우 (오류 가능성) 무시
|
||||
// 단, 초기 단계거나 짧은 텍스트일 때는 허용
|
||||
const currentLength = finalTranscript.trim().length;
|
||||
if (currentLength > 50 && newText.length < currentLength * 0.5) {
|
||||
console.warn('⚠️ 텍스트가 비정상적으로 짧아짐, 무시 (기존:', currentLength, '자, 신규:', newText.length, '자)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 텍스트 전체 교체
|
||||
finalTranscript = newText;
|
||||
console.log('✅ 텍스트 교체 (Mobile 전체 업데이트):', newText.length, '자');
|
||||
|
||||
// 마지막으로 받은 텍스트 업데이트 (성공적으로 처리된 경우만)
|
||||
lastReceivedTranscript = newText;
|
||||
|
||||
// 화면 업데이트: 콘솔 로그와 함께 화면도 업데이트
|
||||
// 화면 업데이트
|
||||
updatePreviewDisplay();
|
||||
|
||||
updateStatus('음성 인식 중 (Google API)', 'recording');
|
||||
|
||||
Reference in New Issue
Block a user