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' => $result['error'],
|
||||||
'error_code' => $result['error_code'] ?? null
|
'error_code' => $result['error_code'] ?? null
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
}
|
} catch (Throwable $e) {
|
||||||
} catch (Exception $e) {
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => '서버 오류: ' . $e->getMessage()
|
'error' => '서버 오류: ' . $e->getMessage()
|
||||||
@@ -184,4 +183,4 @@ function getBankName($code) {
|
|||||||
];
|
];
|
||||||
return $banks[$code] ?? $code;
|
return $banks[$code] ?? $code;
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ if (!empty($barobillCertKey) || $isTestMode) {
|
|||||||
'stream_context' => $context,
|
'stream_context' => $context,
|
||||||
'cache_wsdl' => WSDL_CACHE_NONE // WSDL 캐시 비활성화
|
'cache_wsdl' => WSDL_CACHE_NONE // WSDL 캐시 비활성화
|
||||||
]);
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Throwable $e) {
|
||||||
$barobillInitError = $e->getMessage();
|
$barobillInitError = $e->getMessage();
|
||||||
error_log('바로빌 계좌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
error_log('바로빌 계좌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -200,18 +200,79 @@ function callBarobillAccountSOAP($method, $params = []) {
|
|||||||
global $barobillAccountSoapClient, $barobillCertKey, $barobillCorpNum, $isTestMode, $barobillInitError, $barobillAccountSoapUrl;
|
global $barobillAccountSoapClient, $barobillCertKey, $barobillCorpNum, $isTestMode, $barobillInitError, $barobillAccountSoapUrl;
|
||||||
|
|
||||||
if (!$barobillAccountSoapClient) {
|
if (!$barobillAccountSoapClient) {
|
||||||
$errorMsg = $isTestMode
|
// SOAP 클라이언트가 없으면 시뮬레이션 모드로 동작
|
||||||
? '바로빌 계좌 SOAP 클라이언트가 초기화되지 않았습니다. (' . ($barobillInitError ?: '알 수 없는 오류') . ')'
|
error_log("바로빌 SOAP 클라이언트 없음 - 시뮬레이션 모드 동작: $method");
|
||||||
: '바로빌 계좌 SOAP 클라이언트가 초기화되지 않았습니다. CERTKEY를 확인하세요. (' . ($barobillInitError ?: '알 수 없는 오류') . ')';
|
|
||||||
|
|
||||||
|
$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 [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $errorMsg,
|
'error' => '시뮬레이션 모드 지원하지 않는 메서드입니다: ' . $method,
|
||||||
'error_detail' => [
|
'error_detail' => [
|
||||||
'cert_key_file' => $_SERVER['DOCUMENT_ROOT'] . '/apikey/barobill_cert_key.txt',
|
|
||||||
'soap_url' => $barobillAccountSoapUrl,
|
|
||||||
'init_error' => $barobillInitError,
|
'init_error' => $barobillInitError,
|
||||||
'test_mode' => $isTestMode
|
'mode' => 'simulation_fallback'
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -344,17 +405,11 @@ function callBarobillAccountSOAP($method, $params = []) {
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
} catch (SoapFault $e) {
|
} catch (Throwable $e) {
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
'error' => 'SOAP 오류: ' . $e->getMessage(),
|
||||||
'error_code' => $e->getCode()
|
'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);
|
echo json_encode($response, JSON_UNESCAPED_UNICODE);
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Throwable $e) {
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => '서버 오류: ' . $e->getMessage()
|
'error' => '서버 오류: ' . $e->getMessage()
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ if (!empty($barobillCertKey) || $isTestMode) {
|
|||||||
'exceptions' => true,
|
'exceptions' => true,
|
||||||
'connection_timeout' => 30
|
'connection_timeout' => 30
|
||||||
]);
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Throwable $e) {
|
||||||
// SOAP 클라이언트 생성 실패 시 null 유지
|
// SOAP 클라이언트 생성 실패 시 null 유지 (Class not found 등 포함)
|
||||||
error_log('바로빌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
error_log('바로빌 SOAP 클라이언트 생성 실패: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,5 +408,5 @@ function sendToNTS($mgtKey) {
|
|||||||
return callBarobillSOAP('SendToNTS', $params);
|
return callBarobillSOAP('SendToNTS', $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,15 @@ $response = [
|
|||||||
"count" => count($invoices)
|
"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": "긴급 납품",
|
"memo": "긴급 납품",
|
||||||
"createdAt": "2025-12-10T11:29:14",
|
"createdAt": "2025-12-10T11:29:14",
|
||||||
"barobillInvoiceId": "1"
|
"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: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
|
||||||
// 바로빌 API 설정 로드
|
// 바로빌 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) {
|
if (!$input) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -143,6 +145,7 @@ if ($useRealAPI) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 시뮬레이션 모드 (API 키가 없을 때)
|
// 시뮬레이션 모드 (API 키가 없을 때)
|
||||||
|
// 시뮬레이션 모드 코드 (변경 없음)
|
||||||
$issueKey = "BARO-" . date('Y') . "-" . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT);
|
$issueKey = "BARO-" . date('Y') . "-" . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
$newInvoice = [
|
$newInvoice = [
|
||||||
@@ -221,6 +224,24 @@ if ($useRealAPI) {
|
|||||||
usleep(500000); // 0.5초 지연 시뮬레이션
|
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>AI 스마트 회의록 (SAM Project)</title>
|
<title>AI 스마트 회의록 (SAM Project)</title>
|
||||||
<style>
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow-x: hidden; /* 가로 스크롤 방지 */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.voice-container {
|
.voice-container {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 40px auto;
|
margin: 40px auto;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
|
width: 100%; /* 모바일 대응 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
@@ -602,6 +612,26 @@ try {
|
|||||||
margin-top: 70px;
|
margin-top: 70px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 15px;
|
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 {
|
.record-button {
|
||||||
@@ -1242,142 +1272,119 @@ function stopAudioStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google Cloud Speech-to-Text API 함수
|
// Google Cloud Speech-to-Text API 함수
|
||||||
async function startGoogleRecognition() {
|
async function startGoogleRecognition() {
|
||||||
try {
|
try {
|
||||||
console.log('=== Google Cloud Speech-to-Text API 시작 ===');
|
console.log('=== Google Cloud Speech-to-Text API 시작 (Timeslice Mode) ===');
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
channelCount: 1,
|
channelCount: 1,
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: true
|
noiseSuppression: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mediaStream = stream;
|
mediaStream = stream;
|
||||||
|
|
||||||
// 오디오 시각화
|
// 오디오 시각화
|
||||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
analyser = audioContext.createAnalyser();
|
analyser = audioContext.createAnalyser();
|
||||||
const source = audioContext.createMediaStreamSource(stream);
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
|
|
||||||
analyser.fftSize = 2048;
|
analyser.fftSize = 2048;
|
||||||
const bufferLength = analyser.frequencyBinCount;
|
const bufferLength = analyser.frequencyBinCount;
|
||||||
dataArray = new Uint8Array(bufferLength);
|
dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
drawWaveform();
|
drawWaveform();
|
||||||
|
|
||||||
// MediaRecorder 설정 (최적의 형식 선택)
|
// MediaRecorder 설정
|
||||||
const options = {
|
const options = {
|
||||||
mimeType: 'audio/webm;codecs=opus',
|
mimeType: 'audio/webm;codecs=opus',
|
||||||
audioBitsPerSecond: 16000
|
audioBitsPerSecond: 16000
|
||||||
};
|
};
|
||||||
|
|
||||||
// 지원하는 형식 확인 (우선순위: webm opus > webm > ogg opus)
|
// 지원하는 형식 확인
|
||||||
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
||||||
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
||||||
options.mimeType = 'audio/webm';
|
options.mimeType = 'audio/webm';
|
||||||
} else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
} else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
||||||
options.mimeType = 'audio/ogg;codecs=opus';
|
options.mimeType = 'audio/ogg;codecs=opus';
|
||||||
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
||||||
options.mimeType = 'audio/mp4';
|
options.mimeType = 'audio/mp4';
|
||||||
} else {
|
} else {
|
||||||
// 기본 형식 사용
|
options.mimeType = '';
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 3000);
|
|
||||||
|
console.log('MediaRecorder 형식:', options.mimeType || '기본값');
|
||||||
recordBtn.classList.add('recording');
|
|
||||||
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
mediaRecorder = new MediaRecorder(stream, options);
|
||||||
updateStatus('음성 인식 중 (Google API)', 'recording');
|
audioChunks = []; // 초기화
|
||||||
startTimer();
|
|
||||||
isRecording = true;
|
// 3초마다 데이터가 들어옴
|
||||||
isRecognitionActive = true;
|
mediaRecorder.ondataavailable = async (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
console.log('✅ Google API 녹음 시작 완료');
|
audioChunks.push(event.data);
|
||||||
|
console.log('오디오 청크 수집:', event.data.size, 'bytes', '(총 ' + audioChunks.length + '개)');
|
||||||
} catch (error) {
|
|
||||||
console.error('Google API 녹음 시작 오류:', error);
|
// 실시간 미리보기 전송 (전체 오디오 전송)
|
||||||
updateStatus('녹음 시작 실패: ' + error.message, 'error');
|
// 주의: 파일이 커지면 전송량이 늘어나지만, 정확도를 위해 Context 유지가 필요하므로 전체 전송
|
||||||
alert('마이크 권한을 허용해주세요.');
|
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() {
|
function stopGoogleRecognition() {
|
||||||
console.log('=== Google Cloud Speech-to-Text API 중지 ===');
|
console.log('=== Google Cloud Speech-to-Text API 중지 ===');
|
||||||
|
|
||||||
isRecognitionActive = false;
|
isRecognitionActive = false;
|
||||||
|
|
||||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||||
mediaRecorder.stop();
|
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>AI 스마트 업무협의록 (SAM Project)</title>
|
<title>AI 스마트 업무협의록 (SAM Project)</title>
|
||||||
<style>
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow-x: hidden; /* 가로 스크롤 방지 */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.voice-container {
|
.voice-container {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 40px auto;
|
margin: 40px auto;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
|
width: 100%; /* 모바일 대응 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
@@ -617,6 +627,26 @@ try {
|
|||||||
margin-top: 70px;
|
margin-top: 70px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 15px;
|
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 {
|
.record-button {
|
||||||
@@ -1293,10 +1323,11 @@ function stopAudioStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Google Cloud Speech-to-Text API 함수
|
||||||
// Google Cloud Speech-to-Text API 함수
|
// Google Cloud Speech-to-Text API 함수
|
||||||
async function startGoogleRecognition() {
|
async function startGoogleRecognition() {
|
||||||
try {
|
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({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
@@ -1321,13 +1352,13 @@ async function startGoogleRecognition() {
|
|||||||
|
|
||||||
drawWaveform();
|
drawWaveform();
|
||||||
|
|
||||||
// MediaRecorder 설정 (최적의 형식 선택)
|
// MediaRecorder 설정
|
||||||
const options = {
|
const options = {
|
||||||
mimeType: 'audio/webm;codecs=opus',
|
mimeType: 'audio/webm;codecs=opus',
|
||||||
audioBitsPerSecond: 16000
|
audioBitsPerSecond: 16000
|
||||||
};
|
};
|
||||||
|
|
||||||
// 지원하는 형식 확인 (우선순위: webm opus > webm > ogg opus)
|
// 지원하는 형식 확인
|
||||||
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
if (!MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
||||||
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
if (MediaRecorder.isTypeSupported('audio/webm')) {
|
||||||
options.mimeType = 'audio/webm';
|
options.mimeType = 'audio/webm';
|
||||||
@@ -1336,7 +1367,6 @@ async function startGoogleRecognition() {
|
|||||||
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
||||||
options.mimeType = 'audio/mp4';
|
options.mimeType = 'audio/mp4';
|
||||||
} else {
|
} else {
|
||||||
// 기본 형식 사용
|
|
||||||
options.mimeType = '';
|
options.mimeType = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1344,38 +1374,28 @@ async function startGoogleRecognition() {
|
|||||||
console.log('MediaRecorder 형식:', options.mimeType || '기본값');
|
console.log('MediaRecorder 형식:', options.mimeType || '기본값');
|
||||||
|
|
||||||
mediaRecorder = new MediaRecorder(stream, options);
|
mediaRecorder = new MediaRecorder(stream, options);
|
||||||
audioChunks = [];
|
audioChunks = []; // 초기화
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
// 3초마다 데이터가 들어옴
|
||||||
|
mediaRecorder.ondataavailable = async (event) => {
|
||||||
if (event.data.size > 0) {
|
if (event.data.size > 0) {
|
||||||
audioChunks.push(event.data);
|
audioChunks.push(event.data);
|
||||||
console.log('오디오 청크 수집:', event.data.size, 'bytes');
|
console.log('오디오 청크 수집:', event.data.size, 'bytes', '(총 ' + audioChunks.length + '개)');
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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는 보존)
|
if (isRecording && audioChunks.length > 0) {
|
||||||
const chunkBlob = new Blob([...audioChunks], { type: mediaRecorder.mimeType });
|
const fullBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
||||||
// audioChunks는 저장 버튼에서 사용할 수 있도록 보존
|
await sendAudioToServer(fullBlob, true);
|
||||||
// 청크 전송 후에도 audioChunks는 유지 (마지막 청크 누락 방지)
|
|
||||||
await sendAudioToServer(chunkBlob, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 3000);
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
console.log('MediaRecorder 중지됨');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3000ms(3초)마다 ondataavailable 발생
|
||||||
|
mediaRecorder.start(3000);
|
||||||
|
|
||||||
recordBtn.classList.add('recording');
|
recordBtn.classList.add('recording');
|
||||||
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
||||||
@@ -1402,26 +1422,12 @@ function stopGoogleRecognition() {
|
|||||||
mediaRecorder.stop();
|
mediaRecorder.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recordingInterval) {
|
|
||||||
clearInterval(recordingInterval);
|
|
||||||
recordingInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaStream) {
|
if (mediaStream) {
|
||||||
mediaStream.getTracks().forEach(track => track.stop());
|
mediaStream.getTracks().forEach(track => track.stop());
|
||||||
mediaStream = null;
|
mediaStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 마지막 오디오 청크 전송 (실시간 텍스트 변환용)
|
// 타이머와 시각화 중지
|
||||||
// 주의: audioChunks는 저장 버튼에서 사용할 수 있도록 보존
|
|
||||||
if (audioChunks.length > 0 && mediaRecorder) {
|
|
||||||
const finalBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
|
||||||
sendAudioToServer(finalBlob);
|
|
||||||
// audioChunks는 저장 버튼에서 사용할 수 있도록 비우지 않음
|
|
||||||
// 저장 버튼 클릭 시에만 비워짐
|
|
||||||
}
|
|
||||||
|
|
||||||
// 타이머와 시각화 중지 (버튼 상태는 호출부에서 처리)
|
|
||||||
stopTimer();
|
stopTimer();
|
||||||
stopWaveform();
|
stopWaveform();
|
||||||
stopAudioStream();
|
stopAudioStream();
|
||||||
@@ -1560,7 +1566,6 @@ async function sendAudioToServer(audioBlob, isChunk = false, retryCount = 0) {
|
|||||||
console.log('신뢰도:', result.confidence || 'N/A');
|
console.log('신뢰도:', result.confidence || 'N/A');
|
||||||
|
|
||||||
// 모바일 전용 텍스트 누적 로직: 강력한 중복 제거
|
// 모바일 전용 텍스트 누적 로직: 강력한 중복 제거
|
||||||
// Google STT API는 각 청크를 독립적으로 처리하므로 이전 텍스트를 포함할 수 있음
|
|
||||||
const newText = result.transcript.trim();
|
const newText = result.transcript.trim();
|
||||||
|
|
||||||
// 빈 텍스트나 공백만 있는 텍스트는 무시
|
// 빈 텍스트나 공백만 있는 텍스트는 무시
|
||||||
@@ -1569,180 +1574,31 @@ async function sendAudioToServer(audioBlob, isChunk = false, retryCount = 0) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이전 응답과 동일한 텍스트인 경우 무시 (입력 없을 때 반복 방지)
|
// 이전 응답과 동일한 텍스트인 경우 무시
|
||||||
if (newText === lastReceivedTranscript) {
|
if (newText === lastReceivedTranscript) {
|
||||||
console.log('⏭️ 이전 응답과 동일한 텍스트 무시:', newText);
|
console.log('⏭️ 이전 응답과 동일한 텍스트 무시:', newText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모바일 로직 수정: 복잡한 중복 제거 로직 대신 전체 텍스트 교체 방식 사용
|
||||||
|
// 이유: sendAudioToServer는 누적된 전체 오디오(audioChunks)를 서버로 보냄
|
||||||
|
|
||||||
const trimmedFinal = finalTranscript.trim();
|
// 안전장치: 텍스트가 기존보다 현저히 짧아지는 경우 (오류 가능성) 무시
|
||||||
|
// 단, 초기 단계거나 짧은 텍스트일 때는 허용
|
||||||
// 기존 텍스트가 있는 경우
|
const currentLength = finalTranscript.trim().length;
|
||||||
if (trimmedFinal) {
|
if (currentLength > 50 && newText.length < currentLength * 0.5) {
|
||||||
// 케이스 1: 새 텍스트가 기존 텍스트와 정확히 동일한 경우 → 무시
|
console.warn('⚠️ 텍스트가 비정상적으로 짧아짐, 무시 (기존:', currentLength, '자, 신규:', newText.length, '자)');
|
||||||
if (newText === trimmedFinal) {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 텍스트 전체 교체
|
||||||
|
finalTranscript = newText;
|
||||||
|
console.log('✅ 텍스트 교체 (Mobile 전체 업데이트):', newText.length, '자');
|
||||||
|
|
||||||
// 마지막으로 받은 텍스트 업데이트 (성공적으로 처리된 경우만)
|
// 마지막으로 받은 텍스트 업데이트 (성공적으로 처리된 경우만)
|
||||||
lastReceivedTranscript = newText;
|
lastReceivedTranscript = newText;
|
||||||
|
|
||||||
// 화면 업데이트: 콘솔 로그와 함께 화면도 업데이트
|
// 화면 업데이트
|
||||||
updatePreviewDisplay();
|
updatePreviewDisplay();
|
||||||
|
|
||||||
updateStatus('음성 인식 중 (Google API)', 'recording');
|
updateStatus('음성 인식 중 (Google API)', 'recording');
|
||||||
|
|||||||
Reference in New Issue
Block a user