Jeongsoo1975 commited on
Commit
30ff654
·
1 Parent(s): 5798aca

feat: 주요 개선사항 적용 - 코드 재사용, 다운로드, 사용자 정의 화자명

Browse files
Files changed (4) hide show
  1. app.py +141 -38
  2. audio_summarizer.py +206 -413
  3. config.json +35 -0
  4. 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
- text_result = text_processor.process_text(full_text, progress_callback=progress_callback)
 
 
 
 
 
 
 
 
 
 
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
- speaker1_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get("화자1", []))])
128
- speaker2_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get("화자2", []))])
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- - 화자1 발언 수: {len(conversations.get('화자1', []))}개
139
- - 화자2 발언 수: {len(conversations.get('화자2', []))}개
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
- result = text_processor.process_text(input_text, progress_callback=progress_callback)
 
 
 
 
 
 
 
 
 
 
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
- speaker1_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get("화자1", []))])
196
- speaker2_text = "\n\n".join([f"{i+1}. {utterance}" for i, utterance in enumerate(conversations.get("화자2", []))])
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- - 화자1 발언 수: {len(conversations.get('화자1', []))}개
205
- - 화자2 발언 수: {len(conversations.get('화자2', []))}개
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. **오디오 업로드**: WAV, MP3, MP4 등의 오디오 파일을 업로드하세요
329
- 2. **처리 시작**: '🚀 오디오 처리 시작' 버튼을 클릭하세요
330
- 3. **결과 확인**: 음성 인식 화자 분리 → 맞춤법 교정 순으로 처리됩니다
 
 
331
 
332
  **📝 텍스트 직접 입력:**
333
- 1. **텍스트 입력**: 2인 대화 텍스트를 입력란에 붙여넣기하세요
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 re
 
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
- # logs 폴더 생성
22
- if not os.path.exists("logs"):
23
- os.makedirs("logs")
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.gemini_model = None
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
- wav_files = glob.glob("data/*.wav")
130
- if wav_files:
131
- for file_path in wav_files:
132
- filename = os.path.basename(file_path)
133
- self.file_listbox.insert(tk.END, filename)
134
- self.process_button.config(state=tk.NORMAL)
135
- self.process_all_button.config(state=tk.NORMAL)
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 update_status(self, message):
144
- """UI 상태 메시지를 업데이트합니다."""
145
- self.status_label.config(text=message)
146
- self.root.update_idletasks()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
- def update_progress(self, current, total, message=""):
149
- """진행률을 업데이트합니다."""
150
- if total > 0:
151
- progress = (current / total) * 100
152
- self.progress_bar.config(value=progress)
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 show_result(self, content):
160
- """결과 텍스트 영역에 내용을 표시합니다."""
161
- self.result_text.config(state=tk.NORMAL)
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
- """필요한 AI 모델들을 로드합니다."""
168
  try:
 
 
 
 
169
  if not GOOGLE_API_KEY or GOOGLE_API_KEY == "your_google_api_key_here":
170
- messagebox.showerror("API 오류", ".env 파일에 올바른 Google AI API 키를 입력해주세요.")
171
- logger.error("Google API 키가 설���되지 않았습니다.")
172
- return False
173
-
174
- logger.info("모델 로딩을 시작합니다.")
175
- self.update_status("모델 로딩 중... (최초 실행 시 시간이 걸릴 수 있습니다)")
176
-
177
  # Whisper 모델 로딩
178
- self.update_status("음성 인식 모델(Whisper) 로딩 중...")
179
- logger.info("Whisper 모델 로딩을 시작합니다.")
180
- self.whisper_model = whisper.load_model("base") # "small", "medium", "large" 등으로 변경 가능
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
- self.process_files([filename])
214
-
215
- def start_processing_all(self):
216
- """모든 파일 처리 시작."""
217
- wav_files = glob.glob("data/*.wav")
218
- if not wav_files:
219
- messagebox.showwarning("파일 없음", "data 폴더에 처리할 WAV 파일이 없습니다.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  return
221
 
222
- filenames = [os.path.basename(f) for f in wav_files]
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
- processing_thread = threading.Thread(target=self.process_audio_files, args=(filenames,))
238
- processing_thread.start()
239
-
240
- def process_audio_files(self, filenames):
241
- """백그라운드에서 여러 오디오 파일을 처리하는 메인 로직."""
242
  try:
243
- total_files = len(filenames)
244
- logger.info(f"{total_files}개의 파일 처리를 시작합니다.")
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
- for idx, filename in enumerate(filenames):
247
- file_path = os.path.join("data", filename)
248
- self.update_progress(idx, total_files, f"처리 중: {filename}")
 
249
 
250
- result = self.process_single_audio_file(file_path, filename)
251
- if result:
252
- self.show_result(f"✅ {filename} 처리 완료")
253
  else:
254
- self.show_result(f"❌ {filename} 처리 실패")
 
 
 
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"파일 처리 중 오류가 발생했습니다: {e}"
262
- logger.error(error_msg)
263
- self.update_status(f"오류: {e}")
 
264
  finally:
265
- # UI 다시 활성화
266
- self.refresh_button.config(state=tk.NORMAL)
267
- self.process_button.config(state=tk.NORMAL)
268
- self.process_all_button.config(state=tk.NORMAL)
 
269
 
270
- def process_single_audio_file(self, file_path, filename):
271
  """단일 오디오 파일을 처리합니다."""
272
  try:
273
- logger.info(f"파일 처리 시작: {file_path}")
274
  base_name = os.path.splitext(filename)[0]
275
 
276
- # 1단계: Whisper로 음성 인식
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
- logger.warning(f"파일 {filename}에서 텍스트를 추출할 수 없습니다.")
285
  return False
286
 
287
- # 2단계: Gemini로 화자 분리
288
- self.update_status(f"2/4: AI 화자 분리 진행 중: {filename}")
289
- logger.info(f"AI 화자 분리 시작: {filename}")
290
 
291
- speaker_separated_text = self.separate_speakers_with_gemini(full_text)
 
292
 
293
- # 3단계: 맞춤법 교정
294
- self.update_status(f"3/4: 맞춤법 교정 진행 중: {filename}")
295
- logger.info(f"맞춤법 교정 시작: {filename}")
296
 
297
- corrected_text = self.correct_spelling_with_gemini(speaker_separated_text)
 
 
 
 
298
 
299
- # 4단계: 결과 저장
300
- self.update_status(f"4/4: 결과 저장 중: {filename}")
301
- self.save_separated_conversations(base_name, full_text, speaker_separated_text, corrected_text, result)
302
 
303
- logger.info(f"파일 처리 완료: {filename}")
304
- return True
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
- logger.info("Gemini를 통한 화자 분리가 완료되었습니다.")
338
- return separated_text
 
 
 
 
 
339
 
340
- except Exception as e:
341
- logger.error(f"Gemini 화자 분리 중 오류: {e}")
342
- return f"[오류] 화자 분리 실패: {str(e)}"
343
-
344
- def correct_spelling_with_gemini(self, separated_text):
345
- """Gemini API를 사용하여 화자별 분리된 텍스트의 맞춤법을 교정합니다."""
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
- logger.info("Gemini를 통한 맞춤법 교정이 완료되었습니다.")
402
- return corrected_text
403
 
404
  except Exception as e:
405
- logger.error(f"Gemini 맞춤법 교정 중 오류: {e}")
406
- return separated_text # 오류 발생 시 원본 반환
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
- if __name__ == "__main__":
 
489
  root = tk.Tk()
490
  app = STTProcessorApp(root)
491
- root.mainloop()
 
 
 
 
 
 
 
 
 
 
 
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('gemini-2.0-flash')
51
- logger.info("Gemini 2.0 Flash 모델 설정이 완료되었습니다.")
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 process_text(self, input_text, text_name=None, progress_callback=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 1단계: Gemini로 화자 분리
88
- if progress_callback:
89
- progress_callback("AI 화자 분리 중...", 1, 3)
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
- logger.info(f"텍스트 처리 완료: {text_name}")
122
- return processing_result
 
 
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 = f"""
136
- 당신은 2명의 화자가 나누는 대화를 분석하는 전문가입니다.
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 = f"""
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["base_name"]
261
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
 
262
 
263
- # 1. 전체 대화 저장 (원본, 화자 분리, 맞춤법 교정 포함)
264
- all_txt_path = f"{output_dir}/{base_name}_전체대화_{timestamp}.txt"
265
- with open(all_txt_path, 'w', encoding='utf-8') as f:
266
- f.write(f"파일명: {base_name}\n")
267
- f.write(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
268
- f.write(f"언어: {result['language']}\n")
269
- f.write("="*50 + "\n\n")
270
- f.write("원본 텍스트:\n")
271
- f.write(result['original_text'] + "\n\n")
272
- f.write("="*50 + "\n\n")
273
- f.write("화자별 분리 결과 (원본):\n")
274
- f.write(result['separated_text'] + "\n\n")
275
- f.write("="*50 + "\n\n")
276
- f.write("화자별 분리 결과 (맞춤법 교정):\n")
277
- f.write(result['corrected_text'] + "\n")
278
-
279
- # 2. 교정된 화자별 개별 파일 저장
280
- for speaker, utterances in result['conversations_by_speaker_corrected'].items():
281
- if utterances:
282
- speaker_txt_path = f"{output_dir}/{base_name}_{speaker}_교정본_{timestamp}.txt"
283
- with open(speaker_txt_path, 'w', encoding='utf-8') as f:
284
- f.write(f"파일명: {base_name}\n")
285
- f.write(f"화자: {speaker}\n")
286
- f.write(f"처리 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
287
- f.write(f"발언 수: {len(utterances)}\n")
288
- f.write("="*50 + "\n\n")
289
-
290
- for idx, utterance in enumerate(utterances, 1):
291
- f.write(f"{idx}. {utterance}\n\n")
292
-
293
- # 3. JSON 형태로도 저장 (분석용)
294
- json_path = f"{output_dir}/{base_name}_data_{timestamp}.json"
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