etax 전자세금계산서 .sam.kr에 맞게 수정

This commit is contained in:
2025-12-16 15:24:44 +09:00
parent 16a54cef2d
commit 5e786c26a1
11 changed files with 465 additions and 371 deletions

View File

@@ -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');