Spaces:
Runtime error
Runtime error
Jeongsoo1975
commited on
Commit
·
30ff654
1
Parent(s):
5798aca
feat: 주요 개선사항 적용 - 코드 재사용, 다운로드, 사용자 정의 화자명
Browse files- app.py +141 -38
- audio_summarizer.py +206 -413
- config.json +35 -0
- stt_processor.py +374 -149
app.py
CHANGED
|
@@ -67,12 +67,12 @@ def initialize_models():
|
|
| 67 |
logger.error(f"모델 초기화 실패: {e}")
|
| 68 |
return False, f"❌ 초기화 실패: {str(e)}"
|
| 69 |
|
| 70 |
-
def process_audio_file(audio_file, progress=gr.Progress()):
|
| 71 |
"""업로드된 오디오 파일을 처리합니다."""
|
| 72 |
global text_processor, whisper_model
|
| 73 |
|
| 74 |
if audio_file is None:
|
| 75 |
-
return "❌ 오디오 파일을 업로드해주세요.", "", "", "", "", ""
|
| 76 |
|
| 77 |
try:
|
| 78 |
# 모델 초기화 (필요한 경우)
|
|
@@ -80,7 +80,7 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
| 80 |
progress(0.05, desc="모델 초기화 중...")
|
| 81 |
success, message = initialize_models()
|
| 82 |
if not success:
|
| 83 |
-
return message, "", "", "", "", ""
|
| 84 |
|
| 85 |
# 오디오 파일 경로 확인
|
| 86 |
audio_path = audio_file.name if hasattr(audio_file, 'name') else str(audio_file)
|
|
@@ -94,7 +94,7 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
| 94 |
full_text = result['text'].strip()
|
| 95 |
|
| 96 |
if not full_text:
|
| 97 |
-
return "❌ 오디오에서 텍스트를 추출할 수 없습니다.", "", "", "", "", ""
|
| 98 |
|
| 99 |
language = result.get('language', 'unknown')
|
| 100 |
logger.info(f"음성 인식 완료. 언어: {language}, 텍스트 길이: {len(full_text)}")
|
|
@@ -111,10 +111,20 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
| 111 |
|
| 112 |
# 3단계: 텍스트 처리 (화자 분리 + 맞춤법 교정)
|
| 113 |
progress(0.4, desc="AI 화자 분리 및 맞춤법 교정 중...")
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
if not text_result.get("success", False):
|
| 117 |
-
return f"❌ 텍스트 처리 실패: {text_result.get('error', 'Unknown error')}", full_text, "", "", "", ""
|
| 118 |
|
| 119 |
# 결과 추출
|
| 120 |
progress(0.95, desc="결과 정리 중...")
|
|
@@ -124,8 +134,21 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
| 124 |
|
| 125 |
# 화자별 대화 추출
|
| 126 |
conversations = text_result["conversations_by_speaker_corrected"]
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
progress(1.0, desc="처리 완료!")
|
| 131 |
|
|
@@ -135,22 +158,22 @@ def process_audio_file(audio_file, progress=gr.Progress()):
|
|
| 135 |
- 처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
| 136 |
- 감지된 언어: {language}
|
| 137 |
- 텍스트 길이: {len(full_text)}자
|
| 138 |
-
-
|
| 139 |
-
-
|
| 140 |
"""
|
| 141 |
|
| 142 |
-
return status_message, original_text, separated_text, corrected_text, speaker1_text, speaker2_text
|
| 143 |
|
| 144 |
except Exception as e:
|
| 145 |
logger.error(f"오디오 파일 처리 중 오류: {e}")
|
| 146 |
-
return f"❌ 처리 중 오류가 발생했습니다: {str(e)}", "", "", "", "", ""
|
| 147 |
|
| 148 |
-
def process_text_input(input_text, progress=gr.Progress()):
|
| 149 |
"""입력된 텍스트를 처리합니다."""
|
| 150 |
global text_processor
|
| 151 |
|
| 152 |
if not input_text or not input_text.strip():
|
| 153 |
-
return "❌ 처리할 텍스트를 입력해주세요.", "", "", "", "", ""
|
| 154 |
|
| 155 |
try:
|
| 156 |
# 텍스트 프로세서만 초기화
|
|
@@ -158,14 +181,14 @@ def process_text_input(input_text, progress=gr.Progress()):
|
|
| 158 |
progress(0.1, desc="텍스트 프로세서 초기화 중...")
|
| 159 |
|
| 160 |
google_api_key = os.getenv("GOOGLE_API_KEY")
|
| 161 |
-
if not google_api_key:
|
| 162 |
-
return "❌ Google API 키가 설정되지 않았습니다.", "", "", "", "", ""
|
| 163 |
|
| 164 |
TextProcessor, processor_error = safe_import_processor()
|
| 165 |
if TextProcessor is None:
|
| 166 |
-
return f"❌ TextProcessor 로딩 실패: {processor_error}", "", "", "", "", ""
|
| 167 |
|
| 168 |
-
text_processor = TextProcessor(google_api_key)
|
| 169 |
|
| 170 |
# 모델 로딩
|
| 171 |
progress(0.2, desc="AI 모델 로딩 중...")
|
|
@@ -179,10 +202,20 @@ def process_text_input(input_text, progress=gr.Progress()):
|
|
| 179 |
|
| 180 |
# 텍스트 처리
|
| 181 |
progress(0.3, desc="텍스트 처리 시작...")
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
if not result.get("success", False):
|
| 185 |
-
return f"❌ 처리 실패: {result.get('error', 'Unknown error')}", "", "", "", "", ""
|
| 186 |
|
| 187 |
# 결과 추출
|
| 188 |
progress(0.95, desc="결과 정리 중...")
|
|
@@ -192,8 +225,21 @@ def process_text_input(input_text, progress=gr.Progress()):
|
|
| 192 |
|
| 193 |
# 화자별 대화 추출
|
| 194 |
conversations = result["conversations_by_speaker_corrected"]
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
progress(1.0, desc="처리 완료!")
|
| 199 |
|
|
@@ -201,15 +247,15 @@ def process_text_input(input_text, progress=gr.Progress()):
|
|
| 201 |
✅ **텍스트 처리 완료!**
|
| 202 |
- 텍스트명: {result['text_name']}
|
| 203 |
- 처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
| 204 |
-
-
|
| 205 |
-
-
|
| 206 |
"""
|
| 207 |
|
| 208 |
-
return status_message, original_text, separated_text, corrected_text, speaker1_text, speaker2_text
|
| 209 |
|
| 210 |
except Exception as e:
|
| 211 |
logger.error(f"텍스트 처리 중 오류: {e}")
|
| 212 |
-
return f"❌ 처리 중 오류가 발생했습니다: {str(e)}", "", "", "", "", ""
|
| 213 |
|
| 214 |
def create_interface():
|
| 215 |
"""Gradio 인터페이스를 생성합니다."""
|
|
@@ -229,6 +275,12 @@ def create_interface():
|
|
| 229 |
color: #2c3e50;
|
| 230 |
margin-bottom: 20px;
|
| 231 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
"""
|
| 233 |
|
| 234 |
with gr.Blocks(css=css, title="2인 대화 STT 처리기") as interface:
|
|
@@ -243,6 +295,24 @@ def create_interface():
|
|
| 243 |
|
| 244 |
with gr.Row():
|
| 245 |
with gr.Column(scale=1):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
# 입력 섹션
|
| 247 |
with gr.Tabs():
|
| 248 |
with gr.TabItem("🎤 오디오 업로드"):
|
|
@@ -273,9 +343,16 @@ def create_interface():
|
|
| 273 |
|
| 274 |
# 상태 표시
|
| 275 |
status_output = gr.Markdown(
|
| 276 |
-
"### 📊 처리 상태\n준비 완료. 오디오 파일을 업로드하거나 텍스트를
|
| 277 |
elem_classes=["status-box"]
|
| 278 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
with gr.Column(scale=2):
|
| 281 |
# 결과 표시 섹션
|
|
@@ -325,22 +402,32 @@ def create_interface():
|
|
| 325 |
### 📖 사용법
|
| 326 |
|
| 327 |
**🎤 오디오 파일 처리:**
|
| 328 |
-
1.
|
| 329 |
-
2.
|
| 330 |
-
3.
|
|
|
|
|
|
|
| 331 |
|
| 332 |
**📝 텍스트 직접 입력:**
|
| 333 |
-
1.
|
| 334 |
-
2.
|
| 335 |
-
3.
|
|
|
|
| 336 |
|
| 337 |
### ⚙️ 기술 정보
|
| 338 |
- **음성 인식**: OpenAI Whisper (다국어 지원)
|
| 339 |
-
- **화자 분리**: Google Gemini 2.0 Flash
|
| 340 |
- **맞춤법 교정**: 고급 AI 기반 한국어 교정
|
|
|
|
| 341 |
- **지원 형식**: WAV, MP3, MP4, M4A 등
|
| 342 |
- **최적 환경**: 2인 대화, 명확한 음질
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
### ⚠️ 주의사항
|
| 345 |
- 처리 시간은 오디오 길이에 따라 달라집니다 (보통 1-5분)
|
| 346 |
- Google AI API 사용량 제한이 있을 수 있습니다
|
|
@@ -349,28 +436,44 @@ def create_interface():
|
|
| 349 |
- 배경 소음이 적고 화자 구분이 명확한 오디오를 권장합니다
|
| 350 |
""")
|
| 351 |
|
| 352 |
-
# 이벤트 연결
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
outputs = [
|
| 354 |
status_output,
|
| 355 |
original_output,
|
| 356 |
separated_output,
|
| 357 |
corrected_output,
|
| 358 |
speaker1_output,
|
| 359 |
-
speaker2_output
|
|
|
|
| 360 |
]
|
| 361 |
|
| 362 |
# 오디오 처리 이벤트
|
| 363 |
audio_process_btn.click(
|
| 364 |
fn=process_audio_file,
|
| 365 |
-
inputs=[audio_input],
|
| 366 |
outputs=outputs
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
)
|
| 368 |
|
| 369 |
# 텍스트 처리 이벤트
|
| 370 |
text_process_btn.click(
|
| 371 |
fn=process_text_input,
|
| 372 |
-
inputs=[text_input],
|
| 373 |
outputs=outputs
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
)
|
| 375 |
|
| 376 |
return interface
|
|
|
|
| 67 |
logger.error(f"모델 초기화 실패: {e}")
|
| 68 |
return False, f"❌ 초기화 실패: {str(e)}"
|
| 69 |
|
| 70 |
+
def process_audio_file(audio_file, speaker1_name, speaker2_name, progress=gr.Progress()):
|
| 71 |
"""업로드된 오디오 파일을 처리합니다."""
|
| 72 |
global text_processor, whisper_model
|
| 73 |
|
| 74 |
if audio_file is None:
|
| 75 |
+
return "❌ 오디오 파일을 업로드해주세요.", "", "", "", "", "", None
|
| 76 |
|
| 77 |
try:
|
| 78 |
# 모델 초기화 (필요한 경우)
|
|
|
|
| 80 |
progress(0.05, desc="모델 초기화 중...")
|
| 81 |
success, message = initialize_models()
|
| 82 |
if not success:
|
| 83 |
+
return message, "", "", "", "", "", None
|
| 84 |
|
| 85 |
# 오디오 파일 경로 확인
|
| 86 |
audio_path = audio_file.name if hasattr(audio_file, 'name') else str(audio_file)
|
|
|
|
| 94 |
full_text = result['text'].strip()
|
| 95 |
|
| 96 |
if not full_text:
|
| 97 |
+
return "❌ 오디오에서 텍스트를 추출할 수 없습니다.", "", "", "", "", "", None
|
| 98 |
|
| 99 |
language = result.get('language', 'unknown')
|
| 100 |
logger.info(f"음성 인식 완료. 언어: {language}, 텍스트 길이: {len(full_text)}")
|
|
|
|
| 111 |
|
| 112 |
# 3단계: 텍스트 처리 (화자 분리 + 맞춤법 교정)
|
| 113 |
progress(0.4, desc="AI 화자 분리 및 맞춤법 교정 중...")
|
| 114 |
+
|
| 115 |
+
# 사용자 정의 화자 이름 적용
|
| 116 |
+
custom_speaker1 = speaker1_name.strip() if speaker1_name and speaker1_name.strip() else None
|
| 117 |
+
custom_speaker2 = speaker2_name.strip() if speaker2_name and speaker2_name.strip() else None
|
| 118 |
+
|
| 119 |
+
text_result = text_processor.process_text(
|
| 120 |
+
full_text,
|
| 121 |
+
progress_callback=progress_callback,
|
| 122 |
+
speaker1_name=custom_speaker1,
|
| 123 |
+
speaker2_name=custom_speaker2
|
| 124 |
+
)
|
| 125 |
|
| 126 |
if not text_result.get("success", False):
|
| 127 |
+
return f"❌ 텍스트 처리 실패: {text_result.get('error', 'Unknown error')}", full_text, "", "", "", "", None
|
| 128 |
|
| 129 |
# 결과 추출
|
| 130 |
progress(0.95, desc="결과 정리 중...")
|
|
|
|
| 134 |
|
| 135 |
# 화자별 대화 추출
|
| 136 |
conversations = text_result["conversations_by_speaker_corrected"]
|
| 137 |
+
speaker1_key = custom_speaker1 or "화자1"
|
| 138 |
+
speaker2_key = custom_speaker2 or "화자2"
|
| 139 |
+
|
| 140 |
+
speaker1_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get(speaker1_key, []))])
|
| 141 |
+
speaker2_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get(speaker2_key, []))])
|
| 142 |
+
|
| 143 |
+
# 다운로드 파일 생성
|
| 144 |
+
download_file = None
|
| 145 |
+
try:
|
| 146 |
+
text_processor.save_results_to_files(text_result)
|
| 147 |
+
zip_path = text_processor.create_download_zip(text_result)
|
| 148 |
+
if zip_path and os.path.exists(zip_path):
|
| 149 |
+
download_file = zip_path
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.warning(f"다운로드 파일 생성 실패: {e}")
|
| 152 |
|
| 153 |
progress(1.0, desc="처리 완료!")
|
| 154 |
|
|
|
|
| 158 |
- 처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
| 159 |
- 감지된 언어: {language}
|
| 160 |
- 텍스트 길이: {len(full_text)}자
|
| 161 |
+
- {speaker1_key} 발언 수: {len(conversations.get(speaker1_key, []))}개
|
| 162 |
+
- {speaker2_key} 발언 수: {len(conversations.get(speaker2_key, []))}개
|
| 163 |
"""
|
| 164 |
|
| 165 |
+
return status_message, original_text, separated_text, corrected_text, speaker1_text, speaker2_text, download_file
|
| 166 |
|
| 167 |
except Exception as e:
|
| 168 |
logger.error(f"오디오 파일 처리 중 오류: {e}")
|
| 169 |
+
return f"❌ 처리 중 오류가 발생했습니다: {str(e)}", "", "", "", "", "", None
|
| 170 |
|
| 171 |
+
def process_text_input(input_text, speaker1_name, speaker2_name, progress=gr.Progress()):
|
| 172 |
"""입력된 텍스트를 처리합니다."""
|
| 173 |
global text_processor
|
| 174 |
|
| 175 |
if not input_text or not input_text.strip():
|
| 176 |
+
return "❌ 처리할 텍스트를 입력해주세요.", "", "", "", "", "", None
|
| 177 |
|
| 178 |
try:
|
| 179 |
# 텍스트 프로세서만 초기화
|
|
|
|
| 181 |
progress(0.1, desc="텍스트 프로세서 초기화 중...")
|
| 182 |
|
| 183 |
google_api_key = os.getenv("GOOGLE_API_KEY")
|
| 184 |
+
if not google_api_key or not isinstance(google_api_key, str) or len(google_api_key.strip()) == 0:
|
| 185 |
+
return "❌ Google API 키가 설정되지 않았습니다.", "", "", "", "", "", None
|
| 186 |
|
| 187 |
TextProcessor, processor_error = safe_import_processor()
|
| 188 |
if TextProcessor is None:
|
| 189 |
+
return f"❌ TextProcessor 로딩 실패: {processor_error}", "", "", "", "", "", None
|
| 190 |
|
| 191 |
+
text_processor = TextProcessor(google_api_key.strip())
|
| 192 |
|
| 193 |
# 모델 로딩
|
| 194 |
progress(0.2, desc="AI 모델 로딩 중...")
|
|
|
|
| 202 |
|
| 203 |
# 텍스트 처리
|
| 204 |
progress(0.3, desc="텍스트 처리 시작...")
|
| 205 |
+
|
| 206 |
+
# 사용자 정의 화자 이름 적용
|
| 207 |
+
custom_speaker1 = speaker1_name.strip() if speaker1_name and speaker1_name.strip() else None
|
| 208 |
+
custom_speaker2 = speaker2_name.strip() if speaker2_name and speaker2_name.strip() else None
|
| 209 |
+
|
| 210 |
+
result = text_processor.process_text(
|
| 211 |
+
input_text,
|
| 212 |
+
progress_callback=progress_callback,
|
| 213 |
+
speaker1_name=custom_speaker1,
|
| 214 |
+
speaker2_name=custom_speaker2
|
| 215 |
+
)
|
| 216 |
|
| 217 |
if not result.get("success", False):
|
| 218 |
+
return f"❌ 처리 실패: {result.get('error', 'Unknown error')}", "", "", "", "", "", None
|
| 219 |
|
| 220 |
# 결과 추출
|
| 221 |
progress(0.95, desc="결과 정리 중...")
|
|
|
|
| 225 |
|
| 226 |
# 화자별 대화 추출
|
| 227 |
conversations = result["conversations_by_speaker_corrected"]
|
| 228 |
+
speaker1_key = custom_speaker1 or "화자1"
|
| 229 |
+
speaker2_key = custom_speaker2 or "화자2"
|
| 230 |
+
|
| 231 |
+
speaker1_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get(speaker1_key, []))])
|
| 232 |
+
speaker2_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get(speaker2_key, []))])
|
| 233 |
+
|
| 234 |
+
# 다운로드 파일 생성
|
| 235 |
+
download_file = None
|
| 236 |
+
try:
|
| 237 |
+
text_processor.save_results_to_files(result)
|
| 238 |
+
zip_path = text_processor.create_download_zip(result)
|
| 239 |
+
if zip_path and os.path.exists(zip_path):
|
| 240 |
+
download_file = zip_path
|
| 241 |
+
except Exception as e:
|
| 242 |
+
logger.warning(f"다운로드 파일 생성 실패: {e}")
|
| 243 |
|
| 244 |
progress(1.0, desc="처리 완료!")
|
| 245 |
|
|
|
|
| 247 |
✅ **텍스트 처리 완료!**
|
| 248 |
- 텍스트명: {result['text_name']}
|
| 249 |
- 처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
| 250 |
+
- {speaker1_key} 발언 수: {len(conversations.get(speaker1_key, []))}개
|
| 251 |
+
- {speaker2_key} 발언 수: {len(conversations.get(speaker2_key, []))}개
|
| 252 |
"""
|
| 253 |
|
| 254 |
+
return status_message, original_text, separated_text, corrected_text, speaker1_text, speaker2_text, download_file
|
| 255 |
|
| 256 |
except Exception as e:
|
| 257 |
logger.error(f"텍스트 처리 중 오류: {e}")
|
| 258 |
+
return f"❌ 처리 중 오류가 발생했습니다: {str(e)}", "", "", "", "", "", None
|
| 259 |
|
| 260 |
def create_interface():
|
| 261 |
"""Gradio 인터페이스를 생성합니다."""
|
|
|
|
| 275 |
color: #2c3e50;
|
| 276 |
margin-bottom: 20px;
|
| 277 |
}
|
| 278 |
+
.speaker-config {
|
| 279 |
+
background-color: #f8f9fa;
|
| 280 |
+
padding: 15px;
|
| 281 |
+
border-radius: 8px;
|
| 282 |
+
margin: 10px 0;
|
| 283 |
+
}
|
| 284 |
"""
|
| 285 |
|
| 286 |
with gr.Blocks(css=css, title="2인 대화 STT 처리기") as interface:
|
|
|
|
| 295 |
|
| 296 |
with gr.Row():
|
| 297 |
with gr.Column(scale=1):
|
| 298 |
+
# 화자 이름 설정
|
| 299 |
+
gr.HTML('<div class="speaker-config">')
|
| 300 |
+
gr.Markdown("### 👥 화자 이름 설정 (선택사항)")
|
| 301 |
+
with gr.Row():
|
| 302 |
+
speaker1_name = gr.Textbox(
|
| 303 |
+
label="화자1 이름",
|
| 304 |
+
placeholder="예: 김팀장, 홍길동 등 (비워두면 '화자1')",
|
| 305 |
+
value="",
|
| 306 |
+
scale=1
|
| 307 |
+
)
|
| 308 |
+
speaker2_name = gr.Textbox(
|
| 309 |
+
label="화자2 이름",
|
| 310 |
+
placeholder="예: 이대리, 김영희 등 (비워두면 '화자2')",
|
| 311 |
+
value="",
|
| 312 |
+
scale=1
|
| 313 |
+
)
|
| 314 |
+
gr.HTML('</div>')
|
| 315 |
+
|
| 316 |
# 입력 섹션
|
| 317 |
with gr.Tabs():
|
| 318 |
with gr.TabItem("🎤 오디오 업로드"):
|
|
|
|
| 343 |
|
| 344 |
# 상태 표시
|
| 345 |
status_output = gr.Markdown(
|
| 346 |
+
"### 📊 처리 상태\n준비 완료. 화자 이름을 설정하고 오디오 파일을 업로드하거나 텍스트를 입력한 후 처리 버튼을 클릭하세요.",
|
| 347 |
elem_classes=["status-box"]
|
| 348 |
)
|
| 349 |
+
|
| 350 |
+
# 다운로드 섹션
|
| 351 |
+
gr.Markdown("### 📥 결과 다운로드")
|
| 352 |
+
download_file = gr.File(
|
| 353 |
+
label="처리 결과 ZIP 파일",
|
| 354 |
+
visible=False
|
| 355 |
+
)
|
| 356 |
|
| 357 |
with gr.Column(scale=2):
|
| 358 |
# 결과 표시 섹션
|
|
|
|
| 402 |
### 📖 사용법
|
| 403 |
|
| 404 |
**🎤 오디오 파일 처리:**
|
| 405 |
+
1. **화자 이름 설정**: 원하는 화자 이름을 입력하세요 (예: 김팀장, 이대리)
|
| 406 |
+
2. **오디오 업로드**: WAV, MP3, MP4 등의 오디오 파일을 업로드하세요
|
| 407 |
+
3. **처리 시작**: '🚀 오디오 처리 시작' 버튼을 클릭하세요
|
| 408 |
+
4. **결과 확인**: 음성 인식 → 화자 분리 → 맞춤법 교정 순으로 처리됩니다
|
| 409 |
+
5. **다운로드**: 처리 완료 후 ZIP 파일로 모든 결과를 다운로드할 수 있습니다
|
| 410 |
|
| 411 |
**📝 텍스트 직접 입력:**
|
| 412 |
+
1. **화자 이름 설정**: 원하는 화자 이름을 입력하세요
|
| 413 |
+
2. **텍스트 입력**: 2인 대화 텍스트를 입력란에 붙여넣기하세요
|
| 414 |
+
3. **처리 시작**: '🚀 텍스트 처리 시작' 버튼을 클릭하세요
|
| 415 |
+
4. **결과 확인**: 각 탭에서 화자 분리 결과를 확인하세요
|
| 416 |
|
| 417 |
### ⚙️ 기술 정보
|
| 418 |
- **음성 인식**: OpenAI Whisper (다국어 지원)
|
| 419 |
+
- **화자 분리**: Google Gemini 2.0 Flash + AI 응답 검증
|
| 420 |
- **맞춤법 교정**: 고급 AI 기반 한국어 교정
|
| 421 |
+
- **청킹 처리**: 대용량 텍스트 자동 분할 처리
|
| 422 |
- **지원 형식**: WAV, MP3, MP4, M4A 등
|
| 423 |
- **최적 환경**: 2인 대화, 명확한 음질
|
| 424 |
|
| 425 |
+
### 🆕 새로운 기능
|
| 426 |
+
- **사용자 정의 화자 이름**: '화자1', '화자2' 대신 실제 이름 사용
|
| 427 |
+
- **다운로드 기능**: 전체 결과를 ZIP 파일로 다운로드
|
| 428 |
+
- **AI 응답 검증**: 화자 분리 실패 시 자동 감지 및 오류 처리
|
| 429 |
+
- **대용량 파일 지원**: 긴 오디오도 청킹으로 안정적 처리
|
| 430 |
+
|
| 431 |
### ⚠️ 주의사항
|
| 432 |
- 처리 시간은 오디오 길이에 따라 달라집니다 (보통 1-5분)
|
| 433 |
- Google AI API 사용량 제한이 있을 수 있습니다
|
|
|
|
| 436 |
- 배경 소음이 적고 화자 구분이 명확한 오디오를 권장합니다
|
| 437 |
""")
|
| 438 |
|
| 439 |
+
# 이벤트 연결 - 다운로드 파일 포함
|
| 440 |
+
def update_download_visibility(download_path):
|
| 441 |
+
"""다운로드 파일이 생성되면 표시합니다."""
|
| 442 |
+
if download_path and os.path.exists(download_path):
|
| 443 |
+
return gr.File(value=download_path, visible=True)
|
| 444 |
+
else:
|
| 445 |
+
return gr.File(visible=False)
|
| 446 |
+
|
| 447 |
outputs = [
|
| 448 |
status_output,
|
| 449 |
original_output,
|
| 450 |
separated_output,
|
| 451 |
corrected_output,
|
| 452 |
speaker1_output,
|
| 453 |
+
speaker2_output,
|
| 454 |
+
download_file
|
| 455 |
]
|
| 456 |
|
| 457 |
# 오디오 처리 이벤트
|
| 458 |
audio_process_btn.click(
|
| 459 |
fn=process_audio_file,
|
| 460 |
+
inputs=[audio_input, speaker1_name, speaker2_name],
|
| 461 |
outputs=outputs
|
| 462 |
+
).then(
|
| 463 |
+
fn=update_download_visibility,
|
| 464 |
+
inputs=[download_file],
|
| 465 |
+
outputs=[download_file]
|
| 466 |
)
|
| 467 |
|
| 468 |
# 텍스트 처리 이벤트
|
| 469 |
text_process_btn.click(
|
| 470 |
fn=process_text_input,
|
| 471 |
+
inputs=[text_input, speaker1_name, speaker2_name],
|
| 472 |
outputs=outputs
|
| 473 |
+
).then(
|
| 474 |
+
fn=update_download_visibility,
|
| 475 |
+
inputs=[download_file],
|
| 476 |
+
outputs=[download_file]
|
| 477 |
)
|
| 478 |
|
| 479 |
return interface
|
audio_summarizer.py
CHANGED
|
@@ -2,15 +2,12 @@ import tkinter as tk
|
|
| 2 |
from tkinter import scrolledtext, messagebox, ttk
|
| 3 |
import threading
|
| 4 |
import os
|
| 5 |
-
import torch
|
| 6 |
import whisper
|
| 7 |
-
import google.generativeai as genai
|
| 8 |
from dotenv import load_dotenv
|
| 9 |
import logging
|
| 10 |
-
import json
|
| 11 |
-
from datetime import datetime
|
| 12 |
import glob
|
| 13 |
-
import
|
|
|
|
| 14 |
|
| 15 |
# 환경 변수 로드
|
| 16 |
load_dotenv()
|
|
@@ -18,17 +15,10 @@ load_dotenv()
|
|
| 18 |
# --- 설정: .env 파일에서 API 키를 읽어옵니다 ---
|
| 19 |
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
|
| 20 |
|
| 21 |
-
#
|
| 22 |
-
|
| 23 |
-
os.
|
| 24 |
-
|
| 25 |
-
# output 폴더 생성
|
| 26 |
-
if not os.path.exists("output"):
|
| 27 |
-
os.makedirs("output")
|
| 28 |
-
|
| 29 |
-
# data 폴더 생성
|
| 30 |
-
if not os.path.exists("data"):
|
| 31 |
-
os.makedirs("data")
|
| 32 |
|
| 33 |
# 로깅 설정
|
| 34 |
logging.basicConfig(
|
|
@@ -41,451 +31,254 @@ logging.basicConfig(
|
|
| 41 |
)
|
| 42 |
logger = logging.getLogger(__name__)
|
| 43 |
|
| 44 |
-
# -----------------------------------------
|
| 45 |
-
|
| 46 |
class STTProcessorApp:
|
| 47 |
def __init__(self, root):
|
| 48 |
self.root = root
|
| 49 |
self.root.title("2인 대화 STT 처리기 (AI 화자 분리)")
|
| 50 |
self.root.geometry("1000x750")
|
| 51 |
-
|
| 52 |
-
# 모델
|
| 53 |
-
self.models_loaded = False
|
| 54 |
self.whisper_model = None
|
| 55 |
-
self.
|
| 56 |
-
|
| 57 |
-
# UI 요소 생성
|
| 58 |
-
self.main_frame = tk.Frame(root, padx=10, pady=10)
|
| 59 |
-
self.main_frame.pack(fill=tk.BOTH, expand=True)
|
| 60 |
-
|
| 61 |
-
# 제목
|
| 62 |
-
title_label = tk.Label(self.main_frame, text="2인 대화 STT 처리기 (AI 화자 분리)", font=("Arial", 16, "bold"))
|
| 63 |
-
title_label.pack(pady=5)
|
| 64 |
-
|
| 65 |
-
# 설명
|
| 66 |
-
desc_label = tk.Label(self.main_frame, text="Whisper STT + Gemini AI 화자 분리로 2명의 대화를 자동으로 구분합니다", font=("Arial", 10))
|
| 67 |
-
desc_label.pack(pady=2)
|
| 68 |
-
|
| 69 |
-
# WAV 파일 목록 프레임
|
| 70 |
-
files_frame = tk.LabelFrame(self.main_frame, text="data 폴더의 WAV 파일 목록", padx=5, pady=5)
|
| 71 |
-
files_frame.pack(fill=tk.BOTH, expand=True, pady=5)
|
| 72 |
-
|
| 73 |
-
# 파일 목록과 스크롤바
|
| 74 |
-
list_frame = tk.Frame(files_frame)
|
| 75 |
-
list_frame.pack(fill=tk.BOTH, expand=True)
|
| 76 |
-
|
| 77 |
-
scrollbar = tk.Scrollbar(list_frame)
|
| 78 |
-
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
| 79 |
-
|
| 80 |
-
self.file_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set, selectmode=tk.SINGLE)
|
| 81 |
-
self.file_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
| 82 |
-
scrollbar.config(command=self.file_listbox.yview)
|
| 83 |
-
|
| 84 |
-
# 버튼 프레임
|
| 85 |
-
button_frame = tk.Frame(self.main_frame)
|
| 86 |
-
button_frame.pack(fill=tk.X, pady=5)
|
| 87 |
-
|
| 88 |
-
self.refresh_button = tk.Button(button_frame, text="파일 목록 새로고침", command=self.refresh_file_list)
|
| 89 |
-
self.refresh_button.pack(side=tk.LEFT, padx=5)
|
| 90 |
-
|
| 91 |
-
self.process_button = tk.Button(button_frame, text="선택된 파일 처리", command=self.start_processing,
|
| 92 |
-
state=tk.DISABLED)
|
| 93 |
-
self.process_button.pack(side=tk.LEFT, padx=5)
|
| 94 |
-
|
| 95 |
-
self.process_all_button = tk.Button(button_frame, text="모든 파일 처리", command=self.start_processing_all,
|
| 96 |
-
state=tk.DISABLED)
|
| 97 |
-
self.process_all_button.pack(side=tk.LEFT, padx=5)
|
| 98 |
-
|
| 99 |
-
# 진행률 표시
|
| 100 |
-
progress_frame = tk.Frame(self.main_frame)
|
| 101 |
-
progress_frame.pack(fill=tk.X, pady=5)
|
| 102 |
-
|
| 103 |
-
tk.Label(progress_frame, text="진행률:").pack(side=tk.LEFT)
|
| 104 |
-
self.progress_var = tk.StringVar(value="대기 중")
|
| 105 |
-
tk.Label(progress_frame, textvariable=self.progress_var).pack(side=tk.LEFT, padx=10)
|
| 106 |
-
|
| 107 |
-
self.progress_bar = ttk.Progressbar(progress_frame, mode='determinate')
|
| 108 |
-
self.progress_bar.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=10)
|
| 109 |
-
|
| 110 |
-
# 상태 표시줄
|
| 111 |
-
self.status_label = tk.Label(self.main_frame, text="준비 완료. Google API 키를 설정하고 '처리' 버튼을 누르세요.", bd=1,
|
| 112 |
-
relief=tk.SUNKEN, anchor=tk.W)
|
| 113 |
-
self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
|
| 114 |
-
|
| 115 |
-
# 결과 출력 영역
|
| 116 |
-
result_frame = tk.LabelFrame(self.main_frame, text="처리 결과", padx=5, pady=5)
|
| 117 |
-
result_frame.pack(fill=tk.BOTH, expand=True, pady=5)
|
| 118 |
-
|
| 119 |
-
self.result_text = scrolledtext.ScrolledText(result_frame, wrap=tk.WORD, state=tk.DISABLED, height=15)
|
| 120 |
-
self.result_text.pack(fill=tk.BOTH, expand=True)
|
| 121 |
-
|
| 122 |
-
# 초기 파일 목록 로드
|
| 123 |
-
self.refresh_file_list()
|
| 124 |
-
|
| 125 |
-
def refresh_file_list(self):
|
| 126 |
-
"""data 폴더의 WAV 파일 목록을 새로고침합니다."""
|
| 127 |
-
self.file_listbox.delete(0, tk.END)
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
logger.info(f"{len(wav_files)}개의 WAV 파일을 발견했습니다.")
|
| 137 |
-
else:
|
| 138 |
-
self.file_listbox.insert(tk.END, "WAV 파일이 없습니다. data 폴더에 WAV 파일을 넣어주세요.")
|
| 139 |
-
self.process_button.config(state=tk.DISABLED)
|
| 140 |
-
self.process_all_button.config(state=tk.DISABLED)
|
| 141 |
-
logger.warning("data 폴더에 WAV 파일이 없습니다.")
|
| 142 |
|
| 143 |
-
def
|
| 144 |
-
"""UI
|
| 145 |
-
|
| 146 |
-
self.root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
def
|
| 149 |
-
"""
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
if message:
|
| 154 |
-
self.progress_var.set(f"{message} ({current}/{total})")
|
| 155 |
-
else:
|
| 156 |
-
self.progress_var.set(f"{current}/{total}")
|
| 157 |
-
self.root.update_idletasks()
|
| 158 |
|
| 159 |
-
def
|
| 160 |
-
"""
|
| 161 |
-
|
| 162 |
-
self.result_text.insert(tk.END, content + "\n\n")
|
| 163 |
-
self.result_text.see(tk.END)
|
| 164 |
-
self.result_text.config(state=tk.DISABLED)
|
| 165 |
|
| 166 |
def load_models(self):
|
| 167 |
-
"""
|
| 168 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
if not GOOGLE_API_KEY or GOOGLE_API_KEY == "your_google_api_key_here":
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
return False
|
| 173 |
-
|
| 174 |
-
logger.info("모델 로딩을 시작합니다.")
|
| 175 |
-
self.update_status("모델 로딩 중... (최초 실행 시 시간이 걸릴 수 있습니다)")
|
| 176 |
-
|
| 177 |
# Whisper 모델 로딩
|
| 178 |
-
self.
|
| 179 |
-
|
| 180 |
-
self.
|
| 181 |
-
logger.info("Whisper 모델 로딩이 완료되었습니다.")
|
| 182 |
-
|
| 183 |
-
# Gemini 모델 설정
|
| 184 |
-
self.update_status("AI 화자 분리 모델(Gemini) 설정 중...")
|
| 185 |
-
logger.info("Gemini 모델 설정을 시작합니다.")
|
| 186 |
-
genai.configure(api_key=GOOGLE_API_KEY)
|
| 187 |
-
# gemini-2.0-flash: 최신 Gemini 2.0 모델, 빠르고 정확한 처리
|
| 188 |
-
self.gemini_model = genai.GenerativeModel('gemini-2.0-flash')
|
| 189 |
-
logger.info("Gemini 2.0 Flash 모델 설정이 완료되었습니다.")
|
| 190 |
-
|
| 191 |
-
self.models_loaded = True
|
| 192 |
-
self.update_status("모든 모델 로딩 완료. 처리 준비 완료.")
|
| 193 |
-
logger.info("모든 모델 로딩이 완료되었습니다.")
|
| 194 |
-
return True
|
| 195 |
-
except Exception as e:
|
| 196 |
-
error_msg = f"모델을 로딩하는 중 오류가 발생했습니다: {e}"
|
| 197 |
-
messagebox.showerror("모델 로딩 오류", error_msg)
|
| 198 |
-
logger.error(error_msg)
|
| 199 |
-
self.update_status("오류: 모델 로딩 실패")
|
| 200 |
-
return False
|
| 201 |
-
|
| 202 |
-
def start_processing(self):
|
| 203 |
-
"""선택된 파일 처리 시작."""
|
| 204 |
-
selection = self.file_listbox.curselection()
|
| 205 |
-
if not selection:
|
| 206 |
-
messagebox.showwarning("파일 미선택", "처리할 파일을 선택해주세요.")
|
| 207 |
-
return
|
| 208 |
-
|
| 209 |
-
filename = self.file_listbox.get(selection[0])
|
| 210 |
-
if filename == "WAV 파일이 없습니다. data 폴더에 WAV 파일을 넣어주세요.":
|
| 211 |
-
return
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
return
|
| 221 |
|
| 222 |
-
|
| 223 |
-
self.process_files(filenames)
|
| 224 |
-
|
| 225 |
-
def process_files(self, filenames):
|
| 226 |
-
"""파일 처리 시작."""
|
| 227 |
-
# 모델이 로드되지 않았으면 먼저 로드
|
| 228 |
-
if not self.models_loaded:
|
| 229 |
-
if not self.load_models():
|
| 230 |
-
return # 모델 로딩 실패 시 중단
|
| 231 |
-
|
| 232 |
-
# UI 비활성화 및 처리 스레드 시작
|
| 233 |
-
self.refresh_button.config(state=tk.DISABLED)
|
| 234 |
-
self.process_button.config(state=tk.DISABLED)
|
| 235 |
-
self.process_all_button.config(state=tk.DISABLED)
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
def process_audio_files(self, filenames):
|
| 241 |
-
"""백그라운드에서 여러 오디오 파일을 처리하는 메인 로직."""
|
| 242 |
try:
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
self.
|
|
|
|
| 249 |
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
self.show_result(f"✅ {filename} 처리 완료")
|
| 253 |
else:
|
| 254 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
-
self.update_progress(total_files, total_files, "완료")
|
| 257 |
-
self.update_status("모든 파일 처리 완료!")
|
| 258 |
-
logger.info("모든 파일 처리가 완료되었습니다.")
|
| 259 |
-
|
| 260 |
except Exception as e:
|
| 261 |
-
error_msg = f"파일 처리 중
|
| 262 |
-
|
| 263 |
-
self.update_status(
|
|
|
|
| 264 |
finally:
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
| 269 |
|
| 270 |
-
def process_single_audio_file(self, file_path
|
| 271 |
"""단일 오디오 파일을 처리합니다."""
|
| 272 |
try:
|
| 273 |
-
|
| 274 |
base_name = os.path.splitext(filename)[0]
|
| 275 |
|
| 276 |
-
|
| 277 |
-
self.update_status(f"1/4: 음성 인식 진행 중: {filename}")
|
| 278 |
-
logger.info(f"음성 인식 시작: {filename}")
|
| 279 |
|
|
|
|
|
|
|
| 280 |
result = self.whisper_model.transcribe(file_path)
|
| 281 |
full_text = result['text'].strip()
|
| 282 |
|
| 283 |
if not full_text:
|
| 284 |
-
|
| 285 |
return False
|
| 286 |
|
| 287 |
-
|
| 288 |
-
self.
|
| 289 |
-
logger.info(f"AI 화자 분리 시작: {filename}")
|
| 290 |
|
| 291 |
-
|
|
|
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
logger.info(f"맞춤법 교정 시작: {filename}")
|
| 296 |
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
except Exception as e:
|
| 307 |
-
logger.error(f"파일 {filename} 처리 중 오류: {e}")
|
| 308 |
-
return False
|
| 309 |
-
|
| 310 |
-
def separate_speakers_with_gemini(self, text):
|
| 311 |
-
"""Gemini API를 사용하여 텍스트를 화자별로 분리합니다."""
|
| 312 |
-
try:
|
| 313 |
-
prompt = f"""
|
| 314 |
-
당신은 2명의 화자가 나누는 대화를 분석하는 전문가입니다.
|
| 315 |
-
주어진 텍스트를 분석하여 각 발언을 화자별로 구분해주세요.
|
| 316 |
-
|
| 317 |
-
분석 지침:
|
| 318 |
-
1. 대화의 맥락과 내용을 기반으로 화자를 구분하세요
|
| 319 |
-
2. 말투, 주제 전환, 질문과 답변의 패턴을 활용하세요
|
| 320 |
-
3. 화자1과 화자2로 구분하여 표시하세요
|
| 321 |
-
4. 각 발언 앞에 [화자1] 또는 [화자2]를 붙여주세요
|
| 322 |
-
5. 발언이 너무 길 경우 자연스러운 지점에서 나누어주세요
|
| 323 |
-
|
| 324 |
-
출력 형식:
|
| 325 |
-
[화자1] 첫 번째 발언 내용
|
| 326 |
-
[화자2] 두 번째 발언 내용
|
| 327 |
-
[화자1] 세 번째 발언 내용
|
| 328 |
-
...
|
| 329 |
-
|
| 330 |
-
분석할 텍스트:
|
| 331 |
-
{text}
|
| 332 |
-
"""
|
| 333 |
-
|
| 334 |
-
response = self.gemini_model.generate_content(prompt)
|
| 335 |
-
separated_text = response.text.strip()
|
| 336 |
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
try:
|
| 347 |
-
prompt = f"""
|
| 348 |
-
당신은 한국어 맞춤법 교정 전문가입니다.
|
| 349 |
-
주어진 텍스트에서 맞춤법 오류, 띄어쓰기 오류, 오타를 수정해주세요.
|
| 350 |
-
|
| 351 |
-
교정 지침:
|
| 352 |
-
1. 자연스러운 한국어 표현으로 수정하되, 원본의 의미와 말투는 유지하세요
|
| 353 |
-
2. [화자1], [화자2] 태그는 그대로 유지하세요
|
| 354 |
-
3. 전문 용어나 고유명사는 가능한 정확하게 수정하세요
|
| 355 |
-
4. 구어체 특성은 유지하되, 명백한 오타만 수정하세요
|
| 356 |
-
5. 문맥에 맞는 올바른 단어로 교체하세요
|
| 357 |
-
|
| 358 |
-
수정이 필요한 예시:
|
| 359 |
-
- "치특기" → "치트키"
|
| 360 |
-
- "실점픽" → "실전 픽"
|
| 361 |
-
- "복사부천억" → "복사 붙여넣기"
|
| 362 |
-
- "핵심같이가" → "핵심 가치가"
|
| 363 |
-
- "재활" → "재활용"
|
| 364 |
-
- "저정할" → "저장할"
|
| 365 |
-
- "플레일" → "플레어"
|
| 366 |
-
- "서벌 수" → "서버리스"
|
| 367 |
-
- "커리" → "쿼리"
|
| 368 |
-
- "전력" → "전략"
|
| 369 |
-
- "클라클라" → "클라크"
|
| 370 |
-
- "가인만" → "가입만"
|
| 371 |
-
- "M5U" → "MAU"
|
| 372 |
-
- "나온 로도" → "다운로드"
|
| 373 |
-
- "무시무치" → "무시무시"
|
| 374 |
-
- "송신유금" → "송신 요금"
|
| 375 |
-
- "10지가" → "10GB"
|
| 376 |
-
- "유금" → "요금"
|
| 377 |
-
- "전 색을" → "전 세계"
|
| 378 |
-
- "도무원은" → "도구들은"
|
| 379 |
-
- "골차품데" → "골치 아픈데"
|
| 380 |
-
- "변원해" → "변환해"
|
| 381 |
-
- "f 운영" → "서비스 운영"
|
| 382 |
-
- "오류추저개" → "오류 추적기"
|
| 383 |
-
- "f 늘려질" → "서비스가 늘어날"
|
| 384 |
-
- "캐시칭" → "캐싱"
|
| 385 |
-
- "플레이어" → "플레어"
|
| 386 |
-
- "업스테시" → "업스태시"
|
| 387 |
-
- "원시근을" → "웬지슨"
|
| 388 |
-
- "부각이릉도" → "부각들도"
|
| 389 |
-
- "컴포넌트" → "컴포넌트"
|
| 390 |
-
- "본이터링" → "모니터링"
|
| 391 |
-
- "번뜨기는" → "번뜩이는"
|
| 392 |
-
- "사용적 경험" → "사용자 경험"
|
| 393 |
-
|
| 394 |
-
교정할 텍스트:
|
| 395 |
-
{separated_text}
|
| 396 |
-
"""
|
| 397 |
-
|
| 398 |
-
response = self.gemini_model.generate_content(prompt)
|
| 399 |
-
corrected_text = response.text.strip()
|
| 400 |
|
| 401 |
-
|
| 402 |
-
return corrected_text
|
| 403 |
|
| 404 |
except Exception as e:
|
| 405 |
-
|
| 406 |
-
return
|
| 407 |
-
|
| 408 |
-
def parse_separated_text(self, separated_text):
|
| 409 |
-
"""화자별로 분리된 텍스트를 파싱하여 구조화합니다."""
|
| 410 |
-
conversations = {
|
| 411 |
-
"화자1": [],
|
| 412 |
-
"화자2": []
|
| 413 |
-
}
|
| 414 |
-
|
| 415 |
-
# 정규표현식으로 화자별 발언 추출
|
| 416 |
-
pattern = r'\[화자([12])\]\s*(.+?)(?=\[화자[12]\]|$)'
|
| 417 |
-
matches = re.findall(pattern, separated_text, re.DOTALL)
|
| 418 |
-
|
| 419 |
-
for speaker_num, content in matches:
|
| 420 |
-
speaker = f"화자{speaker_num}"
|
| 421 |
-
content = content.strip()
|
| 422 |
-
if content:
|
| 423 |
-
conversations[speaker].append(content)
|
| 424 |
-
|
| 425 |
-
return conversations
|
| 426 |
-
|
| 427 |
-
def save_separated_conversations(self, base_name, original_text, separated_text, corrected_text, whisper_result):
|
| 428 |
-
"""화자별로 분리되고 맞춤법이 교정된 대화 내용을 파일로 저장합니다."""
|
| 429 |
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 430 |
-
|
| 431 |
-
# 교정된 텍스트에서 화자별 대화 파싱
|
| 432 |
-
corrected_conversations = self.parse_separated_text(corrected_text)
|
| 433 |
-
|
| 434 |
-
# 원본 화자별 대화 파싱 (비교용)
|
| 435 |
-
original_conversations = self.parse_separated_text(separated_text)
|
| 436 |
-
|
| 437 |
-
# 1. 전체 대화 저장 (원본, 화자 분리, 맞춤법 교정 포함)
|
| 438 |
-
all_txt_path = f"output/{base_name}_전체대화_{timestamp}.txt"
|
| 439 |
-
with open(all_txt_path, 'w', encoding='utf-8') as f:
|
| 440 |
-
f.write(f"파일명: {base_name}\n")
|
| 441 |
-
f.write(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
| 442 |
-
f.write(f"언어: {whisper_result.get('language', 'unknown')}\n")
|
| 443 |
-
f.write("="*50 + "\n\n")
|
| 444 |
-
f.write("원본 텍스트:\n")
|
| 445 |
-
f.write(original_text + "\n\n")
|
| 446 |
-
f.write("="*50 + "\n\n")
|
| 447 |
-
f.write("화자별 분리 결과 (원본):\n")
|
| 448 |
-
f.write(separated_text + "\n\n")
|
| 449 |
-
f.write("="*50 + "\n\n")
|
| 450 |
-
f.write("화자별 분리 결과 (맞춤법 교정):\n")
|
| 451 |
-
f.write(corrected_text + "\n")
|
| 452 |
-
|
| 453 |
-
# 2. 교정된 화자별 개별 파일 저장
|
| 454 |
-
for speaker, utterances in corrected_conversations.items():
|
| 455 |
-
if utterances:
|
| 456 |
-
speaker_txt_path = f"output/{base_name}_{speaker}_교정본_{timestamp}.txt"
|
| 457 |
-
with open(speaker_txt_path, 'w', encoding='utf-8') as f:
|
| 458 |
-
f.write(f"파일명: {base_name}\n")
|
| 459 |
-
f.write(f"화자: {speaker}\n")
|
| 460 |
-
f.write(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
| 461 |
-
f.write(f"발언 수: {len(utterances)}\n")
|
| 462 |
-
f.write("="*50 + "\n\n")
|
| 463 |
-
|
| 464 |
-
for idx, utterance in enumerate(utterances, 1):
|
| 465 |
-
f.write(f"{idx}. {utterance}\n\n")
|
| 466 |
-
|
| 467 |
-
# 3. JSON 형태로도 저장 (분석용)
|
| 468 |
-
json_path = f"output/{base_name}_data_{timestamp}.json"
|
| 469 |
-
json_data = {
|
| 470 |
-
"filename": base_name,
|
| 471 |
-
"processed_time": datetime.now().isoformat(),
|
| 472 |
-
"language": whisper_result.get("language", "unknown"),
|
| 473 |
-
"original_text": original_text,
|
| 474 |
-
"separated_text": separated_text,
|
| 475 |
-
"corrected_text": corrected_text,
|
| 476 |
-
"conversations_by_speaker_original": original_conversations,
|
| 477 |
-
"conversations_by_speaker_corrected": corrected_conversations,
|
| 478 |
-
"segments": whisper_result.get("segments", [])
|
| 479 |
-
}
|
| 480 |
-
|
| 481 |
-
with open(json_path, 'w', encoding='utf-8') as f:
|
| 482 |
-
json.dump(json_data, f, ensure_ascii=False, indent=2)
|
| 483 |
-
|
| 484 |
-
logger.info(f"결과 저장 완료: {all_txt_path}, {json_path}")
|
| 485 |
-
logger.info(f"교정된 화자별 파일도 저장되었습니다.")
|
| 486 |
-
|
| 487 |
|
| 488 |
-
|
|
|
|
| 489 |
root = tk.Tk()
|
| 490 |
app = STTProcessorApp(root)
|
| 491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from tkinter import scrolledtext, messagebox, ttk
|
| 3 |
import threading
|
| 4 |
import os
|
|
|
|
| 5 |
import whisper
|
|
|
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
import logging
|
|
|
|
|
|
|
| 8 |
import glob
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from stt_processor import TextProcessor
|
| 11 |
|
| 12 |
# 환경 변수 로드
|
| 13 |
load_dotenv()
|
|
|
|
| 15 |
# --- 설정: .env 파일에서 API 키를 읽어옵니다 ---
|
| 16 |
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
|
| 17 |
|
| 18 |
+
# 폴더 생성
|
| 19 |
+
for folder in ["logs", "output", "data"]:
|
| 20 |
+
if not os.path.exists(folder):
|
| 21 |
+
os.makedirs(folder)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# 로깅 설정
|
| 24 |
logging.basicConfig(
|
|
|
|
| 31 |
)
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
|
|
|
|
|
|
| 34 |
class STTProcessorApp:
|
| 35 |
def __init__(self, root):
|
| 36 |
self.root = root
|
| 37 |
self.root.title("2인 대화 STT 처리기 (AI 화자 분리)")
|
| 38 |
self.root.geometry("1000x750")
|
| 39 |
+
|
| 40 |
+
# 모델 초기화
|
|
|
|
| 41 |
self.whisper_model = None
|
| 42 |
+
self.text_processor = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
# UI 구성 요소
|
| 45 |
+
self.setup_ui()
|
| 46 |
+
|
| 47 |
+
# 상태 추적
|
| 48 |
+
self.is_processing = False
|
| 49 |
+
|
| 50 |
+
logger.info("STT 처리기 앱이 시작되었습니다.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
def setup_ui(self):
|
| 53 |
+
"""UI 컴포넌트를 설정합니다."""
|
| 54 |
+
# 상단 프레임 - 상태 정보
|
| 55 |
+
status_frame = ttk.Frame(self.root)
|
| 56 |
+
status_frame.pack(fill=tk.X, padx=10, pady=5)
|
| 57 |
+
|
| 58 |
+
ttk.Label(status_frame, text="상태:").pack(side=tk.LEFT)
|
| 59 |
+
self.status_label = ttk.Label(status_frame, text="준비", foreground="green")
|
| 60 |
+
self.status_label.pack(side=tk.LEFT, padx=(5, 0))
|
| 61 |
+
|
| 62 |
+
# 중앙 프레임 - 로그 출력
|
| 63 |
+
log_frame = ttk.LabelFrame(self.root, text="처리 로그")
|
| 64 |
+
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
| 65 |
+
|
| 66 |
+
self.log_text = scrolledtext.ScrolledText(log_frame, height=25, wrap=tk.WORD)
|
| 67 |
+
self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
| 68 |
+
|
| 69 |
+
# 하단 프레임 - 컨트롤
|
| 70 |
+
control_frame = ttk.Frame(self.root)
|
| 71 |
+
control_frame.pack(fill=tk.X, padx=10, pady=5)
|
| 72 |
+
|
| 73 |
+
# 왼쪽: 모델 로딩 버튼
|
| 74 |
+
self.load_models_btn = ttk.Button(
|
| 75 |
+
control_frame,
|
| 76 |
+
text="모델 로딩",
|
| 77 |
+
command=self.load_models_threaded
|
| 78 |
+
)
|
| 79 |
+
self.load_models_btn.pack(side=tk.LEFT, padx=(0, 10))
|
| 80 |
+
|
| 81 |
+
# 중앙: 처리 버튼
|
| 82 |
+
self.process_btn = ttk.Button(
|
| 83 |
+
control_frame,
|
| 84 |
+
text="오디오 파일 처리 시작",
|
| 85 |
+
command=self.process_files_threaded,
|
| 86 |
+
state=tk.DISABLED
|
| 87 |
+
)
|
| 88 |
+
self.process_btn.pack(side=tk.LEFT, padx=(0, 10))
|
| 89 |
+
|
| 90 |
+
# 오른쪽: 종료 버튼
|
| 91 |
+
ttk.Button(
|
| 92 |
+
control_frame,
|
| 93 |
+
text="종료",
|
| 94 |
+
command=self.root.quit
|
| 95 |
+
).pack(side=tk.RIGHT)
|
| 96 |
+
|
| 97 |
+
# 진행률 표시
|
| 98 |
+
self.progress = ttk.Progressbar(
|
| 99 |
+
control_frame,
|
| 100 |
+
mode='indeterminate'
|
| 101 |
+
)
|
| 102 |
+
self.progress.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
|
| 103 |
+
|
| 104 |
+
def log(self, message):
|
| 105 |
+
"""로그 메시지를 UI에 출력합니다."""
|
| 106 |
+
def append_log():
|
| 107 |
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
| 108 |
+
self.log_text.insert(tk.END, f"[{timestamp}] {message}\n")
|
| 109 |
+
self.log_text.see(tk.END)
|
| 110 |
+
|
| 111 |
+
self.root.after(0, append_log)
|
| 112 |
+
logger.info(message)
|
| 113 |
|
| 114 |
+
def update_status(self, status, color="black"):
|
| 115 |
+
"""상태 라벨을 업데이트합니다."""
|
| 116 |
+
def update():
|
| 117 |
+
self.status_label.config(text=status, foreground=color)
|
| 118 |
+
self.root.after(0, update)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
+
def load_models_threaded(self):
|
| 121 |
+
"""별도 스레드에서 모델을 로딩합니다."""
|
| 122 |
+
threading.Thread(target=self.load_models, daemon=True).start()
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
def load_models(self):
|
| 125 |
+
"""AI 모델들을 로딩합니다."""
|
| 126 |
try:
|
| 127 |
+
self.update_status("모델 로딩 중...", "orange")
|
| 128 |
+
self.log("AI 모델 로딩을 시작합니다...")
|
| 129 |
+
|
| 130 |
+
# API 키 검증
|
| 131 |
if not GOOGLE_API_KEY or GOOGLE_API_KEY == "your_google_api_key_here":
|
| 132 |
+
raise ValueError("Google API 키가 설정되지 않았습니다. .env 파일을 확인하세요.")
|
| 133 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
# Whisper 모델 로딩
|
| 135 |
+
self.log("Whisper 모델을 로딩합니다...")
|
| 136 |
+
self.whisper_model = whisper.load_model("base")
|
| 137 |
+
self.log("Whisper 모델 로딩 완료!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
# TextProcessor 초기화
|
| 140 |
+
self.log("Gemini 텍스트 프로세서를 초기화합니다...")
|
| 141 |
+
self.text_processor = TextProcessor(GOOGLE_API_KEY)
|
| 142 |
+
self.text_processor.load_models()
|
| 143 |
+
self.log("모든 모델이 성공적으로 로딩되었습니다!")
|
| 144 |
+
|
| 145 |
+
self.update_status("준비 완료", "green")
|
| 146 |
+
|
| 147 |
+
# 처리 버튼 활성화
|
| 148 |
+
def enable_button():
|
| 149 |
+
self.process_btn.config(state=tk.NORMAL)
|
| 150 |
+
self.root.after(0, enable_button)
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
error_msg = f"모델 로딩 실패: {str(e)}"
|
| 154 |
+
self.log(error_msg)
|
| 155 |
+
self.update_status("모델 로딩 실패", "red")
|
| 156 |
+
messagebox.showerror("오류", error_msg)
|
| 157 |
+
|
| 158 |
+
def process_files_threaded(self):
|
| 159 |
+
"""별도 스레드에서 파일을 처리합니다."""
|
| 160 |
+
if self.is_processing:
|
| 161 |
+
messagebox.showwarning("경고", "이미 처리 중입니다.")
|
| 162 |
return
|
| 163 |
|
| 164 |
+
threading.Thread(target=self.process_files, daemon=True).start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
+
def process_files(self):
|
| 167 |
+
"""data 폴더의 모든 WAV 파일을 처리합니다."""
|
|
|
|
|
|
|
|
|
|
| 168 |
try:
|
| 169 |
+
self.is_processing = True
|
| 170 |
+
self.update_status("처리 중...", "orange")
|
| 171 |
+
|
| 172 |
+
# 진행률 표시 시작
|
| 173 |
+
def start_progress():
|
| 174 |
+
self.progress.start(10)
|
| 175 |
+
self.root.after(0, start_progress)
|
| 176 |
+
|
| 177 |
+
# WAV 파일 찾기
|
| 178 |
+
wav_files = glob.glob("data/*.wav")
|
| 179 |
+
if not wav_files:
|
| 180 |
+
self.log("data 폴더에 WAV 파일이 없습니다.")
|
| 181 |
+
return
|
| 182 |
+
|
| 183 |
+
self.log(f"{len(wav_files)}개의 WAV 파일을 발견했습니다.")
|
| 184 |
|
| 185 |
+
# 각 파일 처리
|
| 186 |
+
for i, wav_file in enumerate(wav_files):
|
| 187 |
+
self.log(f"\n=== 파일 처리 ({i+1}/{len(wav_files)}) ===")
|
| 188 |
+
success = self.process_single_audio_file(wav_file)
|
| 189 |
|
| 190 |
+
if success:
|
| 191 |
+
self.log(f"✅ {os.path.basename(wav_file)} 처리 완료")
|
|
|
|
| 192 |
else:
|
| 193 |
+
self.log(f"❌ {os.path.basename(wav_file)} 처리 실패")
|
| 194 |
+
|
| 195 |
+
self.log(f"\n모든 파일 처리 완료! 총 {len(wav_files)}개 파일")
|
| 196 |
+
self.update_status("처리 완료", "green")
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
except Exception as e:
|
| 199 |
+
error_msg = f"파일 처리 중 오류: {str(e)}"
|
| 200 |
+
self.log(error_msg)
|
| 201 |
+
self.update_status("처리 실패", "red")
|
| 202 |
+
|
| 203 |
finally:
|
| 204 |
+
self.is_processing = False
|
| 205 |
+
# 진행률 표시 중지
|
| 206 |
+
def stop_progress():
|
| 207 |
+
self.progress.stop()
|
| 208 |
+
self.root.after(0, stop_progress)
|
| 209 |
|
| 210 |
+
def process_single_audio_file(self, file_path):
|
| 211 |
"""단일 오디오 파일을 처리합니다."""
|
| 212 |
try:
|
| 213 |
+
filename = os.path.basename(file_path)
|
| 214 |
base_name = os.path.splitext(filename)[0]
|
| 215 |
|
| 216 |
+
self.log(f"파일 처리 시작: {filename}")
|
|
|
|
|
|
|
| 217 |
|
| 218 |
+
# 1단계: Whisper로 음성 인식
|
| 219 |
+
self.log("1/3: 음성 인식 진행 중...")
|
| 220 |
result = self.whisper_model.transcribe(file_path)
|
| 221 |
full_text = result['text'].strip()
|
| 222 |
|
| 223 |
if not full_text:
|
| 224 |
+
self.log(f"❌ 파일 {filename}에서 텍스트를 추출할 수 없습니다.")
|
| 225 |
return False
|
| 226 |
|
| 227 |
+
language = result.get('language', 'unknown')
|
| 228 |
+
self.log(f"음성 인식 완료 (언어: {language}, 길이: {len(full_text)}자)")
|
|
|
|
| 229 |
|
| 230 |
+
# 2단계: TextProcessor로 화자 분리 및 맞춤법 교정
|
| 231 |
+
self.log("2/3: AI 화자 분리 및 맞춤법 교정 진행 중...")
|
| 232 |
|
| 233 |
+
def progress_callback(status, current, total):
|
| 234 |
+
self.log(f" → {status} ({current}/{total})")
|
|
|
|
| 235 |
|
| 236 |
+
text_result = self.text_processor.process_text(
|
| 237 |
+
full_text,
|
| 238 |
+
text_name=base_name,
|
| 239 |
+
progress_callback=progress_callback
|
| 240 |
+
)
|
| 241 |
|
| 242 |
+
if not text_result.get("success", False):
|
| 243 |
+
self.log(f"❌ 텍스트 처리 실패: {text_result.get('error', 'Unknown error')}")
|
| 244 |
+
return False
|
| 245 |
|
| 246 |
+
# 3단계: 결과 저장
|
| 247 |
+
self.log("3/3: 결과 저장 중...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
+
# 기존 결과에 Whisper 정보 추가
|
| 250 |
+
enhanced_result = text_result.copy()
|
| 251 |
+
enhanced_result.update({
|
| 252 |
+
"base_name": base_name,
|
| 253 |
+
"language": language,
|
| 254 |
+
"whisper_segments": result.get("segments", [])
|
| 255 |
+
})
|
| 256 |
|
| 257 |
+
# 파일 저장
|
| 258 |
+
saved = self.text_processor.save_results_to_files(enhanced_result)
|
| 259 |
+
if saved:
|
| 260 |
+
self.log("결과 파일 저장 완료!")
|
| 261 |
+
else:
|
| 262 |
+
self.log("⚠️ 결과 파일 저장 중 일부 오류 발생")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
+
return True
|
|
|
|
| 265 |
|
| 266 |
except Exception as e:
|
| 267 |
+
self.log(f"❌ 파일 {filename} 처리 중 오류: {str(e)}")
|
| 268 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
+
def main():
|
| 271 |
+
"""메인 함수"""
|
| 272 |
root = tk.Tk()
|
| 273 |
app = STTProcessorApp(root)
|
| 274 |
+
|
| 275 |
+
try:
|
| 276 |
+
root.mainloop()
|
| 277 |
+
except KeyboardInterrupt:
|
| 278 |
+
logger.info("사용자에 의해 프로그램이 종료되었습니다.")
|
| 279 |
+
except Exception as e:
|
| 280 |
+
logger.error(f"예상치 못한 오류: {e}")
|
| 281 |
+
messagebox.showerror("오류", f"예상치 못한 오류가 발생했습니다: {str(e)}")
|
| 282 |
+
|
| 283 |
+
if __name__ == "__main__":
|
| 284 |
+
main()
|
config.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"models": {
|
| 3 |
+
"whisper": {
|
| 4 |
+
"name": "base",
|
| 5 |
+
"options": {
|
| 6 |
+
"language": null,
|
| 7 |
+
"task": "transcribe"
|
| 8 |
+
}
|
| 9 |
+
},
|
| 10 |
+
"gemini": {
|
| 11 |
+
"name": "gemini-2.0-flash",
|
| 12 |
+
"temperature": 0.3,
|
| 13 |
+
"max_tokens": null
|
| 14 |
+
}
|
| 15 |
+
},
|
| 16 |
+
"prompts": {
|
| 17 |
+
"speaker_separation": "당신은 2명의 화자가 나누는 대화를 분석하는 전문가입니다. \n주어진 텍스트를 분석하여 각 발언을 화자별로 구분해주세요.\n\n분석 지침:\n1. 대화의 맥락과 내용을 기반으로 화자를 구분하세요\n2. 말투, 주제 전환, 질문과 답변의 패턴을 활용하세요\n3. 화자1과 화자2로 구분하여 표시하세요\n4. 각 발언 앞에 [화자1] 또는 [화자2]를 붙여주세요\n5. 발언이 너무 길 경우 자연스러운 지점에서 나누어주세요\n\n출력 형식:\n[화자1] 첫 번째 발언 내용\n[화자2] 두 번째 발언 내용\n[화자1] 세 번째 발언 내용\n...\n\n분석할 텍스트:\n{text}",
|
| 18 |
+
|
| 19 |
+
"spell_correction": "당신은 한국어 맞춤법 교정 전문가입니다. \n주어진 텍스트에서 맞춤법 오류, 띄어쓰기 오류, 오타를 수정해주세요.\n\n교정 지침:\n1. 자연스러운 한국어 표현으로 수정하되, 원본의 의미와 말투는 유지하세요\n2. [화자1], [화자2] 태그는 그대로 유지하세요\n3. 전문 용어나 고유명사는 가능한 정확하게 수정하세요\n4. 구어체 특성은 유지하되, 명백한 오타만 수정하세요\n5. 문맥에 맞는 올바른 단어로 교체하세요\n\n수정이 필요한 예시:\n- \"치특기\" → \"치트키\"\n- \"실점픽\" → \"실전 픽\"\n- \"복사부천억\" → \"복사 붙여넣기\"\n- \"핵심같이가\" → \"핵심 가치가\"\n- \"재활\" → \"재활용\"\n- \"저정할\" → \"저장할\"\n- \"플레일\" → \"플레어\"\n- \"서벌 수\" → \"서버리스\"\n- \"커리\" → \"쿼리\"\n- \"전력\" → \"전략\"\n- \"클라클라\" → \"클라크\"\n- \"가인만\" → \"가입만\"\n- \"M5U\" → \"MAU\"\n- \"나온 로도\" → \"다운로드\"\n- \"무시무치\" → \"무시무시\"\n- \"송신유금\" → \"송신 요금\"\n- \"10지가\" → \"10GB\"\n- \"유금\" → \"요금\"\n- \"전 색을\" → \"전 세계\"\n- \"도무원은\" → \"도구들은\"\n- \"골차품데\" → \"골치 아픈데\"\n- \"변원해\" → \"변환해\"\n- \"f 운영\" → \"서비스 운영\"\n- \"오류추저개\" → \"오류 추적기\"\n- \"f 늘려질\" → \"서비스가 늘어날\"\n- \"캐시칭\" → \"캐싱\"\n- \"플레이어\" → \"플레어\"\n- \"업스테시\" → \"업스태시\"\n- \"원시근을\" → \"웬지슨\"\n- \"부각이릉도\" → \"부각들도\"\n- \"컴포넌트\" → \"컴포넌트\"\n- \"본이터링\" → \"모니터링\"\n- \"번뜨기는\" → \"번뜩이는\"\n- \"사용적 경험\" → \"사용자 경험\"\n\n교정할 텍스트:\n{text}"
|
| 20 |
+
},
|
| 21 |
+
"processing": {
|
| 22 |
+
"chunk_size": 20000,
|
| 23 |
+
"enable_chunking": true,
|
| 24 |
+
"validate_ai_response": true,
|
| 25 |
+
"required_speaker_tags": ["[화자1]", "[화자2]"]
|
| 26 |
+
},
|
| 27 |
+
"output": {
|
| 28 |
+
"save_original": true,
|
| 29 |
+
"save_separated": true,
|
| 30 |
+
"save_corrected": true,
|
| 31 |
+
"save_individual_speakers": true,
|
| 32 |
+
"save_json": true,
|
| 33 |
+
"create_download_zip": true
|
| 34 |
+
}
|
| 35 |
+
}
|
stt_processor.py
CHANGED
|
@@ -5,6 +5,8 @@ import logging
|
|
| 5 |
import json
|
| 6 |
from datetime import datetime
|
| 7 |
import re
|
|
|
|
|
|
|
| 8 |
|
| 9 |
# 환경 변수 로드
|
| 10 |
load_dotenv()
|
|
@@ -17,12 +19,13 @@ class TextProcessor:
|
|
| 17 |
텍스트를 AI를 통한 화자 분리 및 맞춤법 교정을 수행하는 클래스
|
| 18 |
"""
|
| 19 |
|
| 20 |
-
def __init__(self, google_api_key=None):
|
| 21 |
"""
|
| 22 |
TextProcessor 초기화
|
| 23 |
|
| 24 |
Args:
|
| 25 |
google_api_key (str): Google AI API 키. None인 경우 환경 변수에서 읽음
|
|
|
|
| 26 |
"""
|
| 27 |
# API 키 안전하게 가져오기
|
| 28 |
if google_api_key:
|
|
@@ -33,6 +36,9 @@ class TextProcessor:
|
|
| 33 |
self.gemini_model = None
|
| 34 |
self.models_loaded = False
|
| 35 |
|
|
|
|
|
|
|
|
|
|
| 36 |
# API 키 검증 - 더 안전한 체크
|
| 37 |
if (self.google_api_key is None or
|
| 38 |
not isinstance(self.google_api_key, str) or
|
|
@@ -40,15 +46,55 @@ class TextProcessor:
|
|
| 40 |
self.google_api_key.strip() == "your_google_api_key_here"):
|
| 41 |
raise ValueError("Google AI API 키가 설정되지 않았습니다. 환경 변수 GOOGLE_API_KEY를 설정하거나 매개변수로 전달하세요.")
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def load_models(self):
|
| 44 |
"""Gemini AI 모델을 로드합니다."""
|
| 45 |
try:
|
| 46 |
logger.info("Gemini 모델 로딩을 시작합니다.")
|
| 47 |
|
|
|
|
|
|
|
|
|
|
| 48 |
# Gemini 모델 설정
|
| 49 |
genai.configure(api_key=self.google_api_key)
|
| 50 |
-
self.gemini_model = genai.GenerativeModel(
|
| 51 |
-
logger.info("
|
| 52 |
|
| 53 |
self.models_loaded = True
|
| 54 |
logger.info("Gemini 모델 로딩이 완료되었습니다.")
|
|
@@ -59,7 +105,74 @@ class TextProcessor:
|
|
| 59 |
logger.error(error_msg)
|
| 60 |
raise Exception(error_msg)
|
| 61 |
|
| 62 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
"""
|
| 64 |
텍스트를 처리하여 화자 분리 및 맞춤법 교정을 수행합니다.
|
| 65 |
|
|
@@ -67,6 +180,8 @@ class TextProcessor:
|
|
| 67 |
input_text (str): 처리할 텍스트
|
| 68 |
text_name (str): 텍스트 이름 (선택사항)
|
| 69 |
progress_callback (function): 진행 상황을 알려주는 콜백 함수
|
|
|
|
|
|
|
| 70 |
|
| 71 |
Returns:
|
| 72 |
dict: 처리 결과 딕셔너리
|
|
@@ -84,42 +199,14 @@ class TextProcessor:
|
|
| 84 |
|
| 85 |
full_text = input_text.strip()
|
| 86 |
|
| 87 |
-
#
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
logger.info(f"AI 화자 분리 시작: {text_name}")
|
| 91 |
-
|
| 92 |
-
speaker_separated_text = self.separate_speakers_with_gemini(full_text)
|
| 93 |
-
|
| 94 |
-
# 2단계: 맞춤법 교정
|
| 95 |
-
if progress_callback:
|
| 96 |
-
progress_callback("맞춤법 교정 중...", 2, 3)
|
| 97 |
-
logger.info(f"맞춤법 교정 시작: {text_name}")
|
| 98 |
-
|
| 99 |
-
corrected_text = self.correct_spelling_with_gemini(speaker_separated_text)
|
| 100 |
-
|
| 101 |
-
# 3단계: 결과 파싱
|
| 102 |
-
if progress_callback:
|
| 103 |
-
progress_callback("결과 정리 중...", 3, 3)
|
| 104 |
-
|
| 105 |
-
# 교정된 텍스트에서 화자별 대화 파싱
|
| 106 |
-
corrected_conversations = self.parse_separated_text(corrected_text)
|
| 107 |
-
original_conversations = self.parse_separated_text(speaker_separated_text)
|
| 108 |
-
|
| 109 |
-
# 결과 딕셔너리 생성
|
| 110 |
-
processing_result = {
|
| 111 |
-
"text_name": text_name,
|
| 112 |
-
"processed_time": datetime.now().isoformat(),
|
| 113 |
-
"original_text": full_text,
|
| 114 |
-
"separated_text": speaker_separated_text,
|
| 115 |
-
"corrected_text": corrected_text,
|
| 116 |
-
"conversations_by_speaker_original": original_conversations,
|
| 117 |
-
"conversations_by_speaker_corrected": corrected_conversations,
|
| 118 |
-
"success": True
|
| 119 |
-
}
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
| 123 |
|
| 124 |
except Exception as e:
|
| 125 |
logger.error(f"텍스트 {text_name} 처리 중 ��류: {e}")
|
|
@@ -129,30 +216,154 @@ class TextProcessor:
|
|
| 129 |
"error": str(e)
|
| 130 |
}
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
def separate_speakers_with_gemini(self, text):
|
| 133 |
"""Gemini API를 사용하여 텍스트를 화자별로 분리합니다."""
|
| 134 |
try:
|
| 135 |
-
prompt =
|
| 136 |
-
|
| 137 |
-
주어진 텍스트를 분석하여 각 발언을 화자별로 구분해주세요.
|
| 138 |
-
|
| 139 |
-
분석 지침:
|
| 140 |
-
1. 대화의 맥락과 내용을 기반으로 화자를 구분하세요
|
| 141 |
-
2. 말투, 주제 전환, 질문과 답변의 패턴을 활용하세요
|
| 142 |
-
3. 화자1과 화자2로 구분하여 표시하세요
|
| 143 |
-
4. 각 발언 앞에 [화자1] 또는 [화자2]를 붙여주세요
|
| 144 |
-
5. 발언이 너무 길 경우 자연스러운 지점에서 나누어주세요
|
| 145 |
-
|
| 146 |
-
출력 형식:
|
| 147 |
-
[화자1] 첫 번째 발언 내용
|
| 148 |
-
[화자2] 두 번째 발언 내용
|
| 149 |
-
[화자1] 세 번째 발언 내용
|
| 150 |
-
...
|
| 151 |
-
|
| 152 |
-
분석할 텍스트:
|
| 153 |
-
{text}
|
| 154 |
-
"""
|
| 155 |
-
|
| 156 |
response = self.gemini_model.generate_content(prompt)
|
| 157 |
separated_text = response.text.strip()
|
| 158 |
|
|
@@ -166,57 +377,8 @@ class TextProcessor:
|
|
| 166 |
def correct_spelling_with_gemini(self, separated_text):
|
| 167 |
"""Gemini API를 사용하여 화자별 분리된 텍스트의 맞춤법을 교정합니다."""
|
| 168 |
try:
|
| 169 |
-
prompt =
|
| 170 |
-
|
| 171 |
-
주어진 텍스트에서 맞춤법 오류, 띄어쓰기 오류, 오타를 수정해주세요.
|
| 172 |
-
|
| 173 |
-
교정 지침:
|
| 174 |
-
1. 자연스러운 한국어 표현으로 수정하되, 원본의 의미와 말투는 유지하세요
|
| 175 |
-
2. [화자1], [화자2] 태그는 그대로 유지하세요
|
| 176 |
-
3. 전문 용어나 고유명사는 가능한 정확하게 수정하세요
|
| 177 |
-
4. 구어체 특성은 유지하되, 명백한 오타만 수정하세요
|
| 178 |
-
5. 문맥에 맞는 올바른 단어로 교체하세요
|
| 179 |
-
|
| 180 |
-
수정이 필요한 예시:
|
| 181 |
-
- "치특기" → "치트키"
|
| 182 |
-
- "실점픽" → "실전 픽"
|
| 183 |
-
- "복사부천억" → "복사 붙여넣기"
|
| 184 |
-
- "핵심같이가" → "핵심 가치가"
|
| 185 |
-
- "재활" → "재활용"
|
| 186 |
-
- "저정할" → "저장할"
|
| 187 |
-
- "플레일" → "플레어"
|
| 188 |
-
- "서벌 수" → "서버리스"
|
| 189 |
-
- "커리" → "쿼리"
|
| 190 |
-
- "전력" → "전략"
|
| 191 |
-
- "클라클라" → "클라크"
|
| 192 |
-
- "가인만" → "가입만"
|
| 193 |
-
- "M5U" → "MAU"
|
| 194 |
-
- "나온 로도" → "다운로드"
|
| 195 |
-
- "무시무치" → "무시무시"
|
| 196 |
-
- "송신유금" → "송신 요금"
|
| 197 |
-
- "10지가" → "10GB"
|
| 198 |
-
- "유금" → "요금"
|
| 199 |
-
- "전 색을" → "전 세계"
|
| 200 |
-
- "도무원은" → "도구들은"
|
| 201 |
-
- "골차품데" → "골치 아픈데"
|
| 202 |
-
- "변원해" → "변환해"
|
| 203 |
-
- "f 운영" → "서비스 운영"
|
| 204 |
-
- "오류추저개" → "오류 추적기"
|
| 205 |
-
- "f 늘려질" → "서비스가 늘어날"
|
| 206 |
-
- "캐시칭" → "캐싱"
|
| 207 |
-
- "플레이어" → "플레어"
|
| 208 |
-
- "업스테시" → "업스태시"
|
| 209 |
-
- "원시근을" → "웬지슨"
|
| 210 |
-
- "부각이릉도" → "부각들도"
|
| 211 |
-
- "컴포넌트" → "컴포넌트"
|
| 212 |
-
- "본이터링" → "모니터링"
|
| 213 |
-
- "번뜨기는" → "번뜩이는"
|
| 214 |
-
- "사용적 경험" → "사용자 경험"
|
| 215 |
-
|
| 216 |
-
교정할 텍스트:
|
| 217 |
-
{separated_text}
|
| 218 |
-
"""
|
| 219 |
-
|
| 220 |
response = self.gemini_model.generate_content(prompt)
|
| 221 |
corrected_text = response.text.strip()
|
| 222 |
|
|
@@ -246,6 +408,72 @@ class TextProcessor:
|
|
| 246 |
|
| 247 |
return conversations
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
def save_results_to_files(self, result, output_dir="output"):
|
| 250 |
"""처리 결과를 파일로 저장합니다."""
|
| 251 |
if not result.get("success", False):
|
|
@@ -257,47 +485,44 @@ class TextProcessor:
|
|
| 257 |
if not os.path.exists(output_dir):
|
| 258 |
os.makedirs(output_dir)
|
| 259 |
|
| 260 |
-
base_name = result["
|
| 261 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
f
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
with open(json_path, 'w', encoding='utf-8') as f:
|
| 296 |
-
json.dump(result, f, ensure_ascii=False, indent=2)
|
| 297 |
-
|
| 298 |
-
logger.info(f"결과 파일 저장 완료: {output_dir}")
|
| 299 |
return True
|
| 300 |
|
| 301 |
except Exception as e:
|
| 302 |
logger.error(f"결과 파일 저장 중 오류: {e}")
|
| 303 |
-
return False
|
|
|
|
| 5 |
import json
|
| 6 |
from datetime import datetime
|
| 7 |
import re
|
| 8 |
+
import tempfile
|
| 9 |
+
import zipfile
|
| 10 |
|
| 11 |
# 환경 변수 로드
|
| 12 |
load_dotenv()
|
|
|
|
| 19 |
텍스트를 AI를 통한 화자 분리 및 맞춤법 교정을 수행하는 클래스
|
| 20 |
"""
|
| 21 |
|
| 22 |
+
def __init__(self, google_api_key=None, config_path="config.json"):
|
| 23 |
"""
|
| 24 |
TextProcessor 초기화
|
| 25 |
|
| 26 |
Args:
|
| 27 |
google_api_key (str): Google AI API 키. None인 경우 환경 변수에서 읽음
|
| 28 |
+
config_path (str): 설정 파일 경로
|
| 29 |
"""
|
| 30 |
# API 키 안전하게 가져오기
|
| 31 |
if google_api_key:
|
|
|
|
| 36 |
self.gemini_model = None
|
| 37 |
self.models_loaded = False
|
| 38 |
|
| 39 |
+
# 설정 파일 로드
|
| 40 |
+
self.config = self.load_config(config_path)
|
| 41 |
+
|
| 42 |
# API 키 검증 - 더 안전한 체크
|
| 43 |
if (self.google_api_key is None or
|
| 44 |
not isinstance(self.google_api_key, str) or
|
|
|
|
| 46 |
self.google_api_key.strip() == "your_google_api_key_here"):
|
| 47 |
raise ValueError("Google AI API 키가 설정되지 않았습니다. 환경 변수 GOOGLE_API_KEY를 설정하거나 매개변수로 전달하세요.")
|
| 48 |
|
| 49 |
+
def load_config(self, config_path):
|
| 50 |
+
"""설정 파일을 로드합니다."""
|
| 51 |
+
try:
|
| 52 |
+
if os.path.exists(config_path):
|
| 53 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
| 54 |
+
config = json.load(f)
|
| 55 |
+
logger.info(f"설정 파일 로드 완료: {config_path}")
|
| 56 |
+
return config
|
| 57 |
+
else:
|
| 58 |
+
logger.warning(f"설정 파일을 찾을 수 없습니다: {config_path}. 기본 설정을 사용합니다.")
|
| 59 |
+
return self.get_default_config()
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"설정 파일 로드 실패: {e}. 기본 설정을 사용합니다.")
|
| 62 |
+
return self.get_default_config()
|
| 63 |
+
|
| 64 |
+
def get_default_config(self):
|
| 65 |
+
"""기본 설정을 반환합니다."""
|
| 66 |
+
return {
|
| 67 |
+
"models": {
|
| 68 |
+
"gemini": {"name": "gemini-2.0-flash", "temperature": 0.3}
|
| 69 |
+
},
|
| 70 |
+
"processing": {
|
| 71 |
+
"chunk_size": 20000,
|
| 72 |
+
"enable_chunking": True,
|
| 73 |
+
"validate_ai_response": True,
|
| 74 |
+
"required_speaker_tags": ["[화자1]", "[화자2]"]
|
| 75 |
+
},
|
| 76 |
+
"output": {
|
| 77 |
+
"save_original": True,
|
| 78 |
+
"save_separated": True,
|
| 79 |
+
"save_corrected": True,
|
| 80 |
+
"save_individual_speakers": True,
|
| 81 |
+
"save_json": True,
|
| 82 |
+
"create_download_zip": True
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
def load_models(self):
|
| 87 |
"""Gemini AI 모델을 로드합니다."""
|
| 88 |
try:
|
| 89 |
logger.info("Gemini 모델 로딩을 시작합니다.")
|
| 90 |
|
| 91 |
+
# 설정에서 모델 이름 가져오기
|
| 92 |
+
model_name = self.config.get("models", {}).get("gemini", {}).get("name", "gemini-2.0-flash")
|
| 93 |
+
|
| 94 |
# Gemini 모델 설정
|
| 95 |
genai.configure(api_key=self.google_api_key)
|
| 96 |
+
self.gemini_model = genai.GenerativeModel(model_name)
|
| 97 |
+
logger.info(f"{model_name} 모델 설정이 완료되었습니다.")
|
| 98 |
|
| 99 |
self.models_loaded = True
|
| 100 |
logger.info("Gemini 모델 로딩이 완료되었습니다.")
|
|
|
|
| 105 |
logger.error(error_msg)
|
| 106 |
raise Exception(error_msg)
|
| 107 |
|
| 108 |
+
def split_text_into_chunks(self, text, chunk_size=None):
|
| 109 |
+
"""텍스트를 청크로 분할합니다."""
|
| 110 |
+
if chunk_size is None:
|
| 111 |
+
chunk_size = self.config.get("processing", {}).get("chunk_size", 20000)
|
| 112 |
+
|
| 113 |
+
if len(text) <= chunk_size:
|
| 114 |
+
return [text]
|
| 115 |
+
|
| 116 |
+
chunks = []
|
| 117 |
+
sentences = re.split(r'[.!?。!?]\s+', text)
|
| 118 |
+
current_chunk = ""
|
| 119 |
+
|
| 120 |
+
for sentence in sentences:
|
| 121 |
+
if len(current_chunk) + len(sentence) <= chunk_size:
|
| 122 |
+
current_chunk += sentence + ". "
|
| 123 |
+
else:
|
| 124 |
+
if current_chunk:
|
| 125 |
+
chunks.append(current_chunk.strip())
|
| 126 |
+
current_chunk = sentence + ". "
|
| 127 |
+
|
| 128 |
+
if current_chunk:
|
| 129 |
+
chunks.append(current_chunk.strip())
|
| 130 |
+
|
| 131 |
+
logger.info(f"텍스트를 {len(chunks)}개 청크로 분할했습니다.")
|
| 132 |
+
return chunks
|
| 133 |
+
|
| 134 |
+
def validate_ai_response(self, response_text, expected_tags=None):
|
| 135 |
+
"""AI 응답의 유효성을 검증합니다."""
|
| 136 |
+
if not self.config.get("processing", {}).get("validate_ai_response", True):
|
| 137 |
+
return True, "검증 비활성화됨"
|
| 138 |
+
|
| 139 |
+
if expected_tags is None:
|
| 140 |
+
expected_tags = self.config.get("processing", {}).get("required_speaker_tags", ["[화자1]", "[화자2]"])
|
| 141 |
+
|
| 142 |
+
# 응답이 비어있는지 확인
|
| 143 |
+
if not response_text or not response_text.strip():
|
| 144 |
+
return False, "AI 응답이 비어 있습니다."
|
| 145 |
+
|
| 146 |
+
# 필요한 태그가 포함되어 있는지 확인
|
| 147 |
+
found_tags = []
|
| 148 |
+
for tag in expected_tags:
|
| 149 |
+
if tag in response_text:
|
| 150 |
+
found_tags.append(tag)
|
| 151 |
+
|
| 152 |
+
if not found_tags:
|
| 153 |
+
return False, f"화자 태그({', '.join(expected_tags)})가 응답에 포함되지 않았습니다."
|
| 154 |
+
|
| 155 |
+
if len(found_tags) < 2:
|
| 156 |
+
return False, f"최소 2개의 화자 태그가 필요하지만 {len(found_tags)}개만 발견되었습니다."
|
| 157 |
+
|
| 158 |
+
return True, f"검증 성공: {', '.join(found_tags)} 태그 발견"
|
| 159 |
+
|
| 160 |
+
def get_prompt(self, prompt_type, **kwargs):
|
| 161 |
+
"""설정에서 프롬프트를 가져와 포맷팅합니다."""
|
| 162 |
+
prompts = self.config.get("prompts", {})
|
| 163 |
+
|
| 164 |
+
if prompt_type == "speaker_separation":
|
| 165 |
+
template = prompts.get("speaker_separation",
|
| 166 |
+
"당신은 2명의 화자가 나누는 대화를 분석하는 전문가입니다. 주어진 텍스트를 화자별로 분리해주세요.\n\n분석할 텍스트:\n{text}")
|
| 167 |
+
elif prompt_type == "spell_correction":
|
| 168 |
+
template = prompts.get("spell_correction",
|
| 169 |
+
"한국어 맞춤법을 교정해주세요. [화자1], [화자2] 태그는 유지하세요.\n\n교정할 텍스트:\n{text}")
|
| 170 |
+
else:
|
| 171 |
+
raise ValueError(f"알 수 없는 프롬프트 타입: {prompt_type}")
|
| 172 |
+
|
| 173 |
+
return template.format(**kwargs)
|
| 174 |
+
|
| 175 |
+
def process_text(self, input_text, text_name=None, progress_callback=None, speaker1_name=None, speaker2_name=None):
|
| 176 |
"""
|
| 177 |
텍스트를 처리하여 화자 분리 및 맞춤법 교정을 수행합니다.
|
| 178 |
|
|
|
|
| 180 |
input_text (str): 처리할 텍스트
|
| 181 |
text_name (str): 텍스트 이름 (선택사항)
|
| 182 |
progress_callback (function): 진행 상황을 알려주는 콜백 함수
|
| 183 |
+
speaker1_name (str): 화자1의 사용자 정의 이름
|
| 184 |
+
speaker2_name (str): 화자2의 사용자 정의 이름
|
| 185 |
|
| 186 |
Returns:
|
| 187 |
dict: 처리 결과 딕셔너리
|
|
|
|
| 199 |
|
| 200 |
full_text = input_text.strip()
|
| 201 |
|
| 202 |
+
# 청킹 여부 결정
|
| 203 |
+
enable_chunking = self.config.get("processing", {}).get("enable_chunking", True)
|
| 204 |
+
chunk_size = self.config.get("processing", {}).get("chunk_size", 20000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
if enable_chunking and len(full_text) > chunk_size:
|
| 207 |
+
return self.process_text_with_chunking(full_text, text_name, progress_callback, speaker1_name, speaker2_name)
|
| 208 |
+
else:
|
| 209 |
+
return self.process_text_single(full_text, text_name, progress_callback, speaker1_name, speaker2_name)
|
| 210 |
|
| 211 |
except Exception as e:
|
| 212 |
logger.error(f"텍스트 {text_name} 처리 중 ��류: {e}")
|
|
|
|
| 216 |
"error": str(e)
|
| 217 |
}
|
| 218 |
|
| 219 |
+
def process_text_single(self, full_text, text_name, progress_callback, speaker1_name, speaker2_name):
|
| 220 |
+
"""단일 텍스트를 처리합니다."""
|
| 221 |
+
# 1단계: Gemini로 화자 분리
|
| 222 |
+
if progress_callback:
|
| 223 |
+
progress_callback("AI 화자 분리 중...", 1, 3)
|
| 224 |
+
logger.info(f"AI 화자 분리 시작: {text_name}")
|
| 225 |
+
|
| 226 |
+
speaker_separated_text = self.separate_speakers_with_gemini(full_text)
|
| 227 |
+
|
| 228 |
+
# AI 응답 검증
|
| 229 |
+
is_valid, validation_msg = self.validate_ai_response(speaker_separated_text)
|
| 230 |
+
if not is_valid:
|
| 231 |
+
raise ValueError(f"화자 분리 실패: {validation_msg}")
|
| 232 |
+
|
| 233 |
+
logger.info(f"화자 분리 검증 완료: {validation_msg}")
|
| 234 |
+
|
| 235 |
+
# 2단계: 맞춤법 교정
|
| 236 |
+
if progress_callback:
|
| 237 |
+
progress_callback("맞춤법 교정 중...", 2, 3)
|
| 238 |
+
logger.info(f"맞춤법 교정 시작: {text_name}")
|
| 239 |
+
|
| 240 |
+
corrected_text = self.correct_spelling_with_gemini(speaker_separated_text)
|
| 241 |
+
|
| 242 |
+
# 3단계: 결과 파싱 및 사용자 정의 이름 적용
|
| 243 |
+
if progress_callback:
|
| 244 |
+
progress_callback("결과 정리 중...", 3, 3)
|
| 245 |
+
|
| 246 |
+
# 교정된 텍스트에서 화자별 대화 파싱
|
| 247 |
+
corrected_conversations = self.parse_separated_text(corrected_text)
|
| 248 |
+
original_conversations = self.parse_separated_text(speaker_separated_text)
|
| 249 |
+
|
| 250 |
+
# 사용자 정의 화자 이름 적용
|
| 251 |
+
if speaker1_name or speaker2_name:
|
| 252 |
+
corrected_conversations, corrected_text = self.apply_custom_speaker_names(
|
| 253 |
+
corrected_conversations, corrected_text, speaker1_name, speaker2_name)
|
| 254 |
+
original_conversations, speaker_separated_text = self.apply_custom_speaker_names(
|
| 255 |
+
original_conversations, speaker_separated_text, speaker1_name, speaker2_name)
|
| 256 |
+
|
| 257 |
+
# 결과 딕셔너리 생성
|
| 258 |
+
processing_result = {
|
| 259 |
+
"text_name": text_name,
|
| 260 |
+
"processed_time": datetime.now().isoformat(),
|
| 261 |
+
"original_text": full_text,
|
| 262 |
+
"separated_text": speaker_separated_text,
|
| 263 |
+
"corrected_text": corrected_text,
|
| 264 |
+
"conversations_by_speaker_original": original_conversations,
|
| 265 |
+
"conversations_by_speaker_corrected": corrected_conversations,
|
| 266 |
+
"speaker1_name": speaker1_name or "화자1",
|
| 267 |
+
"speaker2_name": speaker2_name or "화자2",
|
| 268 |
+
"success": True
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
logger.info(f"텍스트 처리 완료: {text_name}")
|
| 272 |
+
return processing_result
|
| 273 |
+
|
| 274 |
+
def process_text_with_chunking(self, full_text, text_name, progress_callback, speaker1_name, speaker2_name):
|
| 275 |
+
"""청킹을 사용하여 대용량 텍스트를 처리합니다."""
|
| 276 |
+
logger.info(f"대용량 텍스트 청킹 처리 시작: {text_name}")
|
| 277 |
+
|
| 278 |
+
chunks = self.split_text_into_chunks(full_text)
|
| 279 |
+
total_steps = len(chunks) * 2 # 화자 분리 + 맞춤법 교정
|
| 280 |
+
current_step = 0
|
| 281 |
+
|
| 282 |
+
separated_chunks = []
|
| 283 |
+
corrected_chunks = []
|
| 284 |
+
|
| 285 |
+
# 각 청크 처리
|
| 286 |
+
for i, chunk in enumerate(chunks):
|
| 287 |
+
# 화자 분리
|
| 288 |
+
current_step += 1
|
| 289 |
+
if progress_callback:
|
| 290 |
+
progress_callback(f"청크 {i+1}/{len(chunks)} 화자 분리 중...", current_step, total_steps)
|
| 291 |
+
|
| 292 |
+
separated_chunk = self.separate_speakers_with_gemini(chunk)
|
| 293 |
+
|
| 294 |
+
# AI 응답 검증
|
| 295 |
+
is_valid, validation_msg = self.validate_ai_response(separated_chunk)
|
| 296 |
+
if not is_valid:
|
| 297 |
+
logger.warning(f"청크 {i+1} 화자 분리 검증 실패: {validation_msg}")
|
| 298 |
+
# 검증 실패한 청크는 원본을 사용하되 기본 태그 추가
|
| 299 |
+
separated_chunk = f"[화자1] {chunk}"
|
| 300 |
+
|
| 301 |
+
separated_chunks.append(separated_chunk)
|
| 302 |
+
|
| 303 |
+
# 맞춤법 교정
|
| 304 |
+
current_step += 1
|
| 305 |
+
if progress_callback:
|
| 306 |
+
progress_callback(f"청크 {i+1}/{len(chunks)} 맞춤법 교정 중...", current_step, total_steps)
|
| 307 |
+
|
| 308 |
+
corrected_chunk = self.correct_spelling_with_gemini(separated_chunk)
|
| 309 |
+
corrected_chunks.append(corrected_chunk)
|
| 310 |
+
|
| 311 |
+
# 청크들을 다시 합치기
|
| 312 |
+
speaker_separated_text = "\n\n".join(separated_chunks)
|
| 313 |
+
corrected_text = "\n\n".join(corrected_chunks)
|
| 314 |
+
|
| 315 |
+
# 결과 파싱 및 사용자 정의 이름 적용
|
| 316 |
+
corrected_conversations = self.parse_separated_text(corrected_text)
|
| 317 |
+
original_conversations = self.parse_separated_text(speaker_separated_text)
|
| 318 |
+
|
| 319 |
+
if speaker1_name or speaker2_name:
|
| 320 |
+
corrected_conversations, corrected_text = self.apply_custom_speaker_names(
|
| 321 |
+
corrected_conversations, corrected_text, speaker1_name, speaker2_name)
|
| 322 |
+
original_conversations, speaker_separated_text = self.apply_custom_speaker_names(
|
| 323 |
+
original_conversations, speaker_separated_text, speaker1_name, speaker2_name)
|
| 324 |
+
|
| 325 |
+
processing_result = {
|
| 326 |
+
"text_name": text_name,
|
| 327 |
+
"processed_time": datetime.now().isoformat(),
|
| 328 |
+
"original_text": full_text,
|
| 329 |
+
"separated_text": speaker_separated_text,
|
| 330 |
+
"corrected_text": corrected_text,
|
| 331 |
+
"conversations_by_speaker_original": original_conversations,
|
| 332 |
+
"conversations_by_speaker_corrected": corrected_conversations,
|
| 333 |
+
"speaker1_name": speaker1_name or "화자1",
|
| 334 |
+
"speaker2_name": speaker2_name or "화자2",
|
| 335 |
+
"chunks_processed": len(chunks),
|
| 336 |
+
"success": True
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
logger.info(f"청킹 처리 완료: {text_name} ({len(chunks)}개 청크)")
|
| 340 |
+
return processing_result
|
| 341 |
+
|
| 342 |
+
def apply_custom_speaker_names(self, conversations, text, speaker1_name, speaker2_name):
|
| 343 |
+
"""사용자 정의 화자 이름을 적용합니다."""
|
| 344 |
+
updated_conversations = {}
|
| 345 |
+
updated_text = text
|
| 346 |
+
|
| 347 |
+
# 대화 딕셔너리 업데이트
|
| 348 |
+
if speaker1_name:
|
| 349 |
+
updated_conversations[speaker1_name] = conversations.get("화자1", [])
|
| 350 |
+
updated_text = updated_text.replace("[화자1]", f"[{speaker1_name}]")
|
| 351 |
+
else:
|
| 352 |
+
updated_conversations["화자1"] = conversations.get("화자1", [])
|
| 353 |
+
|
| 354 |
+
if speaker2_name:
|
| 355 |
+
updated_conversations[speaker2_name] = conversations.get("화자2", [])
|
| 356 |
+
updated_text = updated_text.replace("[화자2]", f"[{speaker2_name}]")
|
| 357 |
+
else:
|
| 358 |
+
updated_conversations["화자2"] = conversations.get("화자2", [])
|
| 359 |
+
|
| 360 |
+
return updated_conversations, updated_text
|
| 361 |
+
|
| 362 |
def separate_speakers_with_gemini(self, text):
|
| 363 |
"""Gemini API를 사용하여 텍스트를 화자별로 분리합니다."""
|
| 364 |
try:
|
| 365 |
+
prompt = self.get_prompt("speaker_separation", text=text)
|
| 366 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
response = self.gemini_model.generate_content(prompt)
|
| 368 |
separated_text = response.text.strip()
|
| 369 |
|
|
|
|
| 377 |
def correct_spelling_with_gemini(self, separated_text):
|
| 378 |
"""Gemini API를 사용하여 화자별 분리된 텍스트의 맞춤법을 교정합니다."""
|
| 379 |
try:
|
| 380 |
+
prompt = self.get_prompt("spell_correction", text=separated_text)
|
| 381 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
response = self.gemini_model.generate_content(prompt)
|
| 383 |
corrected_text = response.text.strip()
|
| 384 |
|
|
|
|
| 408 |
|
| 409 |
return conversations
|
| 410 |
|
| 411 |
+
def create_download_zip(self, result, output_dir="output"):
|
| 412 |
+
"""처리 결과를 ZIP 파일로 생성합니다."""
|
| 413 |
+
try:
|
| 414 |
+
if not self.config.get("output", {}).get("create_download_zip", True):
|
| 415 |
+
return None
|
| 416 |
+
|
| 417 |
+
base_name = result["text_name"]
|
| 418 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 419 |
+
zip_path = os.path.join(output_dir, f"{base_name}_complete_{timestamp}.zip")
|
| 420 |
+
|
| 421 |
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
| 422 |
+
# 전체 대화 저장
|
| 423 |
+
all_content = self._generate_complete_text_content(result)
|
| 424 |
+
zipf.writestr(f"{base_name}_전체대화_{timestamp}.txt", all_content)
|
| 425 |
+
|
| 426 |
+
# 화자별 개별 파일
|
| 427 |
+
for speaker, utterances in result['conversations_by_speaker_corrected'].items():
|
| 428 |
+
if utterances:
|
| 429 |
+
speaker_content = self._generate_speaker_content(result, speaker, utterances)
|
| 430 |
+
zipf.writestr(f"{base_name}_{speaker}_교정본_{timestamp}.txt", speaker_content)
|
| 431 |
+
|
| 432 |
+
# JSON 데이터
|
| 433 |
+
json_content = json.dumps(result, ensure_ascii=False, indent=2)
|
| 434 |
+
zipf.writestr(f"{base_name}_data_{timestamp}.json", json_content)
|
| 435 |
+
|
| 436 |
+
logger.info(f"ZIP 파일 생성 완료: {zip_path}")
|
| 437 |
+
return zip_path
|
| 438 |
+
|
| 439 |
+
except Exception as e:
|
| 440 |
+
logger.error(f"ZIP 파일 생성 중 오류: {e}")
|
| 441 |
+
return None
|
| 442 |
+
|
| 443 |
+
def _generate_complete_text_content(self, result):
|
| 444 |
+
"""전체 대화 텍스트 내용을 생성합니다."""
|
| 445 |
+
content = []
|
| 446 |
+
content.append(f"파일명: {result['text_name']}")
|
| 447 |
+
content.append(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 448 |
+
content.append(f"화자1: {result.get('speaker1_name', '화자1')}")
|
| 449 |
+
content.append(f"화자2: {result.get('speaker2_name', '화자2')}")
|
| 450 |
+
content.append("="*50)
|
| 451 |
+
content.append("원본 텍스트:")
|
| 452 |
+
content.append(result['original_text'])
|
| 453 |
+
content.append("="*50)
|
| 454 |
+
content.append("화자별 분리 결과 (원본):")
|
| 455 |
+
content.append(result['separated_text'])
|
| 456 |
+
content.append("="*50)
|
| 457 |
+
content.append("화자별 분리 결과 (맞춤법 교정):")
|
| 458 |
+
content.append(result['corrected_text'])
|
| 459 |
+
|
| 460 |
+
return "\n".join(content)
|
| 461 |
+
|
| 462 |
+
def _generate_speaker_content(self, result, speaker, utterances):
|
| 463 |
+
"""화자별 개별 파일 내용을 생성합니다."""
|
| 464 |
+
content = []
|
| 465 |
+
content.append(f"파일명: {result['text_name']}")
|
| 466 |
+
content.append(f"화자: {speaker}")
|
| 467 |
+
content.append(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 468 |
+
content.append(f"발언 수: {len(utterances)}")
|
| 469 |
+
content.append("="*50)
|
| 470 |
+
|
| 471 |
+
for idx, utterance in enumerate(utterances, 1):
|
| 472 |
+
content.append(f"{idx}. {utterance}")
|
| 473 |
+
content.append("")
|
| 474 |
+
|
| 475 |
+
return "\n".join(content)
|
| 476 |
+
|
| 477 |
def save_results_to_files(self, result, output_dir="output"):
|
| 478 |
"""처리 결과를 파일로 저장합니다."""
|
| 479 |
if not result.get("success", False):
|
|
|
|
| 485 |
if not os.path.exists(output_dir):
|
| 486 |
os.makedirs(output_dir)
|
| 487 |
|
| 488 |
+
base_name = result["text_name"]
|
| 489 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 490 |
+
saved_files = []
|
| 491 |
|
| 492 |
+
output_config = self.config.get("output", {})
|
| 493 |
+
|
| 494 |
+
# 1. 전체 대화 저장
|
| 495 |
+
if output_config.get("save_original", True) or output_config.get("save_separated", True) or output_config.get("save_corrected", True):
|
| 496 |
+
all_txt_path = f"{output_dir}/{base_name}_전체대화_{timestamp}.txt"
|
| 497 |
+
with open(all_txt_path, 'w', encoding='utf-8') as f:
|
| 498 |
+
f.write(self._generate_complete_text_content(result))
|
| 499 |
+
saved_files.append(all_txt_path)
|
| 500 |
+
|
| 501 |
+
# 2. 화자별 개별 파일 저장
|
| 502 |
+
if output_config.get("save_individual_speakers", True):
|
| 503 |
+
for speaker, utterances in result['conversations_by_speaker_corrected'].items():
|
| 504 |
+
if utterances:
|
| 505 |
+
speaker_txt_path = f"{output_dir}/{base_name}_{speaker}_교정본_{timestamp}.txt"
|
| 506 |
+
with open(speaker_txt_path, 'w', encoding='utf-8') as f:
|
| 507 |
+
f.write(self._generate_speaker_content(result, speaker, utterances))
|
| 508 |
+
saved_files.append(speaker_txt_path)
|
| 509 |
+
|
| 510 |
+
# 3. JSON 형태로도 저장
|
| 511 |
+
if output_config.get("save_json", True):
|
| 512 |
+
json_path = f"{output_dir}/{base_name}_data_{timestamp}.json"
|
| 513 |
+
with open(json_path, 'w', encoding='utf-8') as f:
|
| 514 |
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
| 515 |
+
saved_files.append(json_path)
|
| 516 |
+
|
| 517 |
+
# 4. ZIP 파일 생성
|
| 518 |
+
zip_path = self.create_download_zip(result, output_dir)
|
| 519 |
+
if zip_path:
|
| 520 |
+
saved_files.append(zip_path)
|
| 521 |
+
|
| 522 |
+
logger.info(f"결과 파일 저장 완료: {len(saved_files)}개 파일")
|
| 523 |
+
result["saved_files"] = saved_files
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
return True
|
| 525 |
|
| 526 |
except Exception as e:
|
| 527 |
logger.error(f"결과 파일 저장 중 오류: {e}")
|
| 528 |
+
return False
|