etax 전자세금계산서 .sam.kr에 맞게 수정
This commit is contained in:
@@ -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