sungo-ganpare commited on
Commit
2311e41
·
1 Parent(s): 3ff2783
Files changed (1) hide show
  1. transcribe_cli.py +164 -59
transcribe_cli.py CHANGED
@@ -11,6 +11,7 @@ import json
11
  from typing import List, Tuple, Optional, Set # Python 3.9+ では Optional, Set は typing から不要な場合あり
12
  import argparse
13
  import time # ★処理時間計測のために追加
 
14
  from nemo.collections.asr.models import ASRModel # NeMo ASRモデル
15
 
16
  # --- グローバル設定 ---
@@ -252,32 +253,87 @@ def write_vtt(segments: List, words: List, path: Path):
252
  h, rem = divmod(int(t_float), 3600); m, s = divmod(rem, 60)
253
  ms = int((t_float - int(t_float)) * 1000)
254
  return f"{h:02}:{m:02}:{s:02}.{ms:03}"
 
255
  with open(path, "w", encoding="utf-8") as f:
256
  f.write("WEBVTT\n\n")
257
- current_line: List[str] = []; line_start_time: Optional[float] = None
258
- MAX_WORDS_PER_LINE = 7
259
-
260
- if not words:
261
- print(" VTT書き出し: 単語情報がないため、セグメント情報を使用します。")
 
 
 
262
  for i, seg_list in enumerate(segments, 1):
263
- f.write(f"NOTE Segment {i}\n")
264
- f.write(f"{sec2vtt(float(seg_list[0]))} --> {sec2vtt(float(seg_list[1]))}\n{seg_list[2]}\n\n")
265
  return
266
 
267
- for i, word_data in enumerate(words):
268
- w_start = float(word_data[0]); w_text = word_data[2]
269
- if line_start_time is None: line_start_time = w_start
270
- current_line.append(w_text)
271
- next_word_start = float(words[i+1][0]) if i + 1 < len(words) else w_start + 999
272
- current_word_end = float(word_data[1])
273
- if len(current_line) >= MAX_WORDS_PER_LINE or \
274
- (next_word_start - current_word_end > 1.0) or \
275
- i == len(words) - 1:
276
- line_end_time = current_word_end
277
- if line_start_time is not None :
278
- f.write(f"{sec2vtt(line_start_time)} --> {sec2vtt(line_end_time)}\n")
279
- f.write(" ".join(current_line) + "\n\n")
280
- current_line = []; line_start_time = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
  def write_json(segments: List, words: List, path: Path):
283
  result = {"segments": []}; word_idx = 0
@@ -354,7 +410,6 @@ def process_single_file(
354
  input_file_stem = input_file_path_obj.stem
355
  output_and_temp_dir_str = input_file_path_obj.parent.as_posix()
356
 
357
- # ★ファイル処理開始時刻を記録
358
  file_processing_start_time = time.time()
359
  actual_audio_duration_sec: Optional[float] = None
360
  success_status = False
@@ -449,7 +504,6 @@ def process_single_file(
449
  print(f"エラー: ファイル {input_file_path_obj.name} の処理中にエラーが発生しました: {e}")
450
  success_status = False
451
  finally:
452
- # ★ファイル処理時間とサマリーをログに出力
453
  file_processing_end_time = time.time()
454
  time_taken_seconds = file_processing_end_time - file_processing_start_time
455
  proc_m = int(time_taken_seconds // 60)
@@ -473,7 +527,7 @@ def process_single_file(
473
  if Path(chunk_f_str).exists():
474
  try: os.remove(chunk_f_str); print(f" 一時チャンクファイル {Path(chunk_f_str).name} を削除しました。")
475
  except OSError as e_os_chunk: print(f" 警告: 一時チャンクファイル {Path(chunk_f_str).name} の削除に失敗: {e_os_chunk}")
476
- print(f"======== ファイル処理終了: {input_file_path_obj.name} ========\n")
477
  return success_status
478
 
479
  # --- ディレクトリ内ファイルの一括処理関数 ---
@@ -483,9 +537,7 @@ def batch_process_directory(
483
  device_to_use: str,
484
  output_formats: Optional[List[str]] = None
485
  ):
486
- # ★バッチ処理全体の開始時刻
487
  batch_start_time = time.time()
488
-
489
  if output_formats is None:
490
  output_formats_to_use = DEFAULT_OUTPUT_FORMATS
491
  else:
@@ -529,18 +581,17 @@ def batch_process_directory(
529
  failed_count = 0
530
 
531
  for input_file_to_process_obj in files_to_actually_process:
 
532
  is_skipped_at_batch_level = False
533
- if "csv" in output_formats_to_use: # CSVスキップ判定はバッチレベルで行う
534
  output_csv_path_check = input_file_to_process_obj.with_suffix('.csv')
535
  if output_csv_path_check.exists():
536
- print(f"\n======== ファイル処理開始: {input_file_to_process_obj.name} ========")
537
  print(f"スキップ (バッチレベル): CSV '{output_csv_path_check.name}' は既に存在します。")
538
- print(f"======== ファイル処理終了 (スキップ): {input_file_to_process_obj.name} ========\n")
539
  skipped_due_to_existing_csv_count += 1
540
  is_skipped_at_batch_level = True
 
541
 
542
  if not is_skipped_at_batch_level:
543
- print(f"\n======== ファイル処理開始: {input_file_to_process_obj.name} ========") # process_single_file に移譲
544
  success_flag = process_single_file(
545
  input_file_to_process_obj,
546
  asr_model_instance,
@@ -551,6 +602,7 @@ def batch_process_directory(
551
  processed_successfully_count += 1
552
  else:
553
  failed_count += 1
 
554
 
555
  print("\n======== 全ファイルのバッチ処理が完了しました ========")
556
  total_considered = len(files_to_actually_process)
@@ -559,7 +611,6 @@ def batch_process_directory(
559
  print(f" CSV既存によりスキップされたファイル数: {skipped_due_to_existing_csv_count}")
560
  print(f" 処理失敗ファイル数: {failed_count}")
561
 
562
- # ★バッチ処理全体の総所要時間を表示
563
  batch_end_time = time.time()
564
  total_batch_time_seconds = batch_end_time - batch_start_time
565
  batch_m = int(total_batch_time_seconds // 60)
@@ -568,28 +619,73 @@ def batch_process_directory(
568
 
569
  # --- スクリプト実行のエントリポイント ---
570
  if __name__ == "__main__":
571
- parser = argparse.ArgumentParser(
572
- description="指定されたディレクトリ内の音声/動画ファイルをNVIDIA Parakeet ASRモデルで文字起こしします。\n"
573
- f"同じ名前のファイルが複数ある場合、{' > '.join(INPUT_PRIORITY_EXTENSIONS)} の優先順位で処理します。",
574
- formatter_class=argparse.RawTextHelpFormatter
575
- )
576
- parser.add_argument(
577
- "target_directory", type=str,
578
- help="処理対象のファイルが含まれるディレクトリのパス。"
579
- )
580
- parser.add_argument(
581
- "--formats", type=str, default=",".join(DEFAULT_OUTPUT_FORMATS),
582
- help=(f"出力する文字起こしファイルの形式をカンマ区切りで指定。\n"
583
- f"例: csv,srt (デフォルト: {','.join(DEFAULT_OUTPUT_FORMATS)})\n"
584
- f"利用可能な形式: {','.join(DEFAULT_OUTPUT_FORMATS)}")
585
- )
586
- parser.add_argument(
587
- "--device", type=str, default=None, choices=['cuda', 'cpu'],
588
- help="使用するデバイスを指定 (cuda または cpu)。指定がなければ自動判別。"
589
- )
590
- args = parser.parse_args()
591
-
592
- if args.device: selected_device = args.device
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  else: selected_device = "cuda" if torch.cuda.is_available() else "cpu"
594
  print(f"使用デバイス: {selected_device.upper()}")
595
  if selected_device == "cuda":
@@ -606,17 +702,26 @@ if __name__ == "__main__":
606
  asr_model_main.eval()
607
  print(f"モデル '{MODEL_NAME}' のロード完了。")
608
  except Exception as model_load_e:
609
- print(f"致命的エラー: ASRモデル '{MODEL_NAME}' のロードに失敗: {model_load_e}"); exit(1)
610
 
611
- output_formats_requested = [fmt.strip().lower() for fmt in args.formats.split(',') if fmt.strip()]
612
  final_output_formats_to_use = [fmt for fmt in output_formats_requested if fmt in DEFAULT_OUTPUT_FORMATS]
613
- if not output_formats_requested and args.formats:
614
- print(f"警告: 指定された出力フォーマット '{args.formats}' は無効です。")
615
  if not final_output_formats_to_use :
616
  print(f"情報: 有効な出力フォーマットが指定されなかったため、デフォルトの全形式 ({','.join(DEFAULT_OUTPUT_FORMATS)}) で出力します。")
617
  final_output_formats_to_use = DEFAULT_OUTPUT_FORMATS
 
 
 
 
 
 
 
 
 
618
 
619
  batch_process_directory(
620
- args.target_directory, asr_model_main, selected_device,
621
  output_formats=final_output_formats_to_use
622
- )
 
11
  from typing import List, Tuple, Optional, Set # Python 3.9+ では Optional, Set は typing から不要な場合あり
12
  import argparse
13
  import time # ★処理時間計測のために追加
14
+ import sys # ★コマンドライン引数チェックのために追加
15
  from nemo.collections.asr.models import ASRModel # NeMo ASRモデル
16
 
17
  # --- グローバル設定 ---
 
253
  h, rem = divmod(int(t_float), 3600); m, s = divmod(rem, 60)
254
  ms = int((t_float - int(t_float)) * 1000)
255
  return f"{h:02}:{m:02}:{s:02}.{ms:03}"
256
+
257
  with open(path, "w", encoding="utf-8") as f:
258
  f.write("WEBVTT\n\n")
259
+ f.write("STYLE\n")
260
+ f.write("::cue(.current) { color: #ffff00; font-weight: bold; }\n")
261
+ f.write("::cue(.past) { color: #888888; }\n")
262
+ f.write("::cue(.future) { color: #ffffff; }\n")
263
+ f.write("::cue(.line) { background: rgba(0,0,0,0.7); padding: 4px; }\n\n")
264
+
265
+ if not words:
266
+ # フォールバック処理は同じ
267
  for i, seg_list in enumerate(segments, 1):
268
+ f.write(f"NOTE Segment {i}\n")
269
+ f.write(f"{sec2vtt(float(seg_list[0]))} --> {sec2vtt(float(seg_list[1]))}\n{seg_list[2]}\n\n")
270
  return
271
 
272
+ # セグメント単位でグループ化してカラオケ風に
273
+ for seg_data in segments:
274
+ seg_start = float(seg_data[0])
275
+ seg_end = float(seg_data[1])
276
+
277
+ # このセグメントに含まれる単語を特定
278
+ segment_words = []
279
+ for word_idx, word_data in enumerate(words):
280
+ word_start = float(word_data[0])
281
+ word_end = float(word_data[1])
282
+ if word_start >= seg_start - 0.1 and word_end <= seg_end + 0.1:
283
+ segment_words.append((word_idx, word_data))
284
+
285
+ if not segment_words:
286
+ continue
287
+
288
+ # セグメント開始時刻から最初の単語開始まで(全て未来色)
289
+ first_word_start = float(segment_words[0][1][0])
290
+ if seg_start < first_word_start - 0.05:
291
+ line_parts = [f'<c.future>{w_data[2]}</c>' for _, w_data in segment_words]
292
+ f.write(f"{sec2vtt(seg_start)} --> {sec2vtt(first_word_start)}\n")
293
+ f.write(f'<c.line>{" ".join(line_parts)}</c>\n\n')
294
+
295
+ # 各単語の処理
296
+ for local_idx, (global_word_idx, word_data) in enumerate(segment_words):
297
+ w_start = float(word_data[0])
298
+ w_end = float(word_data[1])
299
+
300
+ # 単語再生中:現在の単語をハイライト
301
+ line_parts = []
302
+ for i, (_, w_data) in enumerate(segment_words):
303
+ w_text = w_data[2]
304
+ if i == local_idx:
305
+ line_parts.append(f'<c.current>{w_text}</c>')
306
+ elif i < local_idx:
307
+ line_parts.append(f'<c.past>{w_text}</c>')
308
+ else:
309
+ line_parts.append(f'<c.future>{w_text}</c>')
310
+
311
+ f.write(f"{sec2vtt(w_start)} --> {sec2vtt(w_end)}\n")
312
+ f.write(f'<c.line>{" ".join(line_parts)}</c>\n\n')
313
+
314
+ # 単語終了から次の単語開始まで(無音期間):過去・未来のみ
315
+ if local_idx < len(segment_words) - 1: # 最後の単語でない場合
316
+ next_word_start = float(segment_words[local_idx + 1][1][0])
317
+ gap_duration = next_word_start - w_end
318
+
319
+ if gap_duration > 0.05: # 50ms以上の無音期間がある場合
320
+ gap_line_parts = []
321
+ for i, (_, w_data) in enumerate(segment_words):
322
+ w_text = w_data[2]
323
+ if i <= local_idx: # 現在の単語まで(過去)
324
+ gap_line_parts.append(f'<c.past>{w_text}</c>')
325
+ else: # 未来の単語
326
+ gap_line_parts.append(f'<c.future>{w_text}</c>')
327
+
328
+ f.write(f"{sec2vtt(w_end)} --> {sec2vtt(next_word_start)}\n")
329
+ f.write(f'<c.line>{" ".join(gap_line_parts)}</c>\n\n')
330
+ else:
331
+ # 最後の単語終了からセグメント終了まで(全て過去色)
332
+ if w_end < seg_end - 0.05:
333
+ line_parts = [f'<c.past>{w_data[2]}</c>' for _, w_data in segment_words]
334
+ f.write(f"{sec2vtt(w_end)} --> {sec2vtt(seg_end)}\n")
335
+ f.write(f'<c.line>{" ".join(line_parts)}</c>\n\n')
336
+
337
 
338
  def write_json(segments: List, words: List, path: Path):
339
  result = {"segments": []}; word_idx = 0
 
410
  input_file_stem = input_file_path_obj.stem
411
  output_and_temp_dir_str = input_file_path_obj.parent.as_posix()
412
 
 
413
  file_processing_start_time = time.time()
414
  actual_audio_duration_sec: Optional[float] = None
415
  success_status = False
 
504
  print(f"エラー: ファイル {input_file_path_obj.name} の処理中にエラーが発生しました: {e}")
505
  success_status = False
506
  finally:
 
507
  file_processing_end_time = time.time()
508
  time_taken_seconds = file_processing_end_time - file_processing_start_time
509
  proc_m = int(time_taken_seconds // 60)
 
527
  if Path(chunk_f_str).exists():
528
  try: os.remove(chunk_f_str); print(f" 一時チャンクファイル {Path(chunk_f_str).name} を削除しました。")
529
  except OSError as e_os_chunk: print(f" 警告: 一時チャンクファイル {Path(chunk_f_str).name} の削除に失敗: {e_os_chunk}")
530
+ # process_single_file の最後では "ファイル処理終了" のログは batch_process_directory に任せる
531
  return success_status
532
 
533
  # --- ディレクトリ内ファイルの一括処理関数 ---
 
537
  device_to_use: str,
538
  output_formats: Optional[List[str]] = None
539
  ):
 
540
  batch_start_time = time.time()
 
541
  if output_formats is None:
542
  output_formats_to_use = DEFAULT_OUTPUT_FORMATS
543
  else:
 
581
  failed_count = 0
582
 
583
  for input_file_to_process_obj in files_to_actually_process:
584
+ print(f"\n======== ファイル処理開始: {input_file_to_process_obj.name} ========") # 各ファイルの開始ログ
585
  is_skipped_at_batch_level = False
586
+ if "csv" in output_formats_to_use:
587
  output_csv_path_check = input_file_to_process_obj.with_suffix('.csv')
588
  if output_csv_path_check.exists():
 
589
  print(f"スキップ (バッチレベル): CSV '{output_csv_path_check.name}' は既に存在します。")
 
590
  skipped_due_to_existing_csv_count += 1
591
  is_skipped_at_batch_level = True
592
+ print(f"======== ファイル処理終了 (スキップ): {input_file_to_process_obj.name} ========\n") # スキップ時の終了ログ
593
 
594
  if not is_skipped_at_batch_level:
 
595
  success_flag = process_single_file(
596
  input_file_to_process_obj,
597
  asr_model_instance,
 
602
  processed_successfully_count += 1
603
  else:
604
  failed_count += 1
605
+ # process_single_file内で "ファイル処理終了" ログが出力される
606
 
607
  print("\n======== 全ファイルのバッチ処理が完了しました ========")
608
  total_considered = len(files_to_actually_process)
 
611
  print(f" CSV既存によりスキップされたファイル数: {skipped_due_to_existing_csv_count}")
612
  print(f" 処理失敗ファイル数: {failed_count}")
613
 
 
614
  batch_end_time = time.time()
615
  total_batch_time_seconds = batch_end_time - batch_start_time
616
  batch_m = int(total_batch_time_seconds // 60)
 
619
 
620
  # --- スクリプト実行のエントリポイント ---
621
  if __name__ == "__main__":
622
+ # 引数処理とGUI分岐のための準備
623
+ target_directory_arg: Optional[str] = None
624
+ formats_arg_str: str = ",".join(DEFAULT_OUTPUT_FORMATS) # GUI時のデフォルト
625
+ device_arg_str: Optional[str] = None # GUI時のデフォルト (自動判別)
626
+
627
+ if len(sys.argv) == 1: # コマンドライン引数なしの場合
628
+ print("コマンドライン引数なしで起動されました。GUIでディレクトリを選択します。")
629
+ try:
630
+ import tkinter as tk
631
+ from tkinter import filedialog
632
+
633
+ def get_directory_from_gui_local() -> Optional[str]:
634
+ """GUIでディレクトリ選択ダイアログを表示し、選択されたパスを返す"""
635
+ root = tk.Tk()
636
+ root.withdraw() # メインウィンドウは表示しない
637
+ # ダイアログを最前面に表示する試み (環境による)
638
+ root.attributes('-topmost', True)
639
+ selected_path = filedialog.askdirectory(title="処理対象のディレクトリを選択してください")
640
+ root.attributes('-topmost', False)
641
+ root.destroy() # Tkinterウィンドウを破棄
642
+ return selected_path if selected_path else None
643
+
644
+ target_directory_arg = get_directory_from_gui_local()
645
+ if not target_directory_arg:
646
+ print("ディレクトリが選択されませんでした。処理を中止します。")
647
+ sys.exit(0) # 正常終了
648
+ # formats_arg_str と device_arg_str は初期化されたデフォルト値を使用
649
+ print(f"GUIで選択されたディレクトリ: {target_directory_arg}")
650
+ print(f"出力フォーマット (デフォルト): {formats_arg_str}")
651
+ # device_arg_strがNoneの場合、後続の処理で自動判別される
652
+
653
+ except ImportError:
654
+ print("エラー: GUIモードに必要なTkinterライブラリが見つかりません。")
655
+ print("Tkinterをインストールするか、コマンドライン引数を使用してスクリプトを実行してください。例:")
656
+ print(f" python {Path(sys.argv[0]).name} /path/to/your/audio_directory")
657
+ sys.exit(1) # エラー終了
658
+ except Exception as e_gui:
659
+ print(f"GUIの表示中に予期せぬエラーが発生しました: {e_gui}")
660
+ sys.exit(1) # エラー終了
661
+ else: # コマンドライン引数がある場合
662
+ parser = argparse.ArgumentParser(
663
+ description="指定されたディレクトリ内の音声/動画ファイルをNVIDIA Parakeet ASRモデルで文字起こしします。\n"
664
+ f"同じ名前のファイルが複数ある場合、{' > '.join(INPUT_PRIORITY_EXTENSIONS)} の優先順位で処理します。",
665
+ formatter_class=argparse.RawTextHelpFormatter
666
+ )
667
+ parser.add_argument( # 最初の引数は必須のディレクトリ
668
+ "target_directory", type=str,
669
+ help="処理対象のファイルが含まれるディレクトリのパス。"
670
+ )
671
+ parser.add_argument(
672
+ "--formats", type=str, default=",".join(DEFAULT_OUTPUT_FORMATS),
673
+ help=(f"出力する文字起こしファイルの形式をカンマ区切りで指定。\n"
674
+ f"例: csv,srt (デフォルト: {','.join(DEFAULT_OUTPUT_FORMATS)})\n"
675
+ f"利用可能な形式: {','.join(DEFAULT_OUTPUT_FORMATS)}")
676
+ )
677
+ parser.add_argument(
678
+ "--device", type=str, default=None, choices=['cuda', 'cpu'],
679
+ help="使用するデバイスを指定 (cuda または cpu)。指定がなければ自動判別。"
680
+ )
681
+ args = parser.parse_args() # sys.argv[1:] から解析
682
+
683
+ target_directory_arg = args.target_directory
684
+ formats_arg_str = args.formats
685
+ device_arg_str = args.device
686
+
687
+ # --- 共通のセットアップ処理 ---
688
+ if device_arg_str: selected_device = device_arg_str
689
  else: selected_device = "cuda" if torch.cuda.is_available() else "cpu"
690
  print(f"使用デバイス: {selected_device.upper()}")
691
  if selected_device == "cuda":
 
702
  asr_model_main.eval()
703
  print(f"モデル '{MODEL_NAME}' のロード完了。")
704
  except Exception as model_load_e:
705
+ print(f"致命的エラー: ASRモデル '{MODEL_NAME}' のロードに失敗: {model_load_e}"); sys.exit(1)
706
 
707
+ output_formats_requested = [fmt.strip().lower() for fmt in formats_arg_str.split(',') if fmt.strip()]
708
  final_output_formats_to_use = [fmt for fmt in output_formats_requested if fmt in DEFAULT_OUTPUT_FORMATS]
709
+ if not output_formats_requested and formats_arg_str:
710
+ print(f"警告: 指定された出力フォーマット '{formats_arg_str}' は無効です。")
711
  if not final_output_formats_to_use :
712
  print(f"情報: 有効な出力フォーマットが指定されなかったため、デフォルトの全形式 ({','.join(DEFAULT_OUTPUT_FORMATS)}) で出力します。")
713
  final_output_formats_to_use = DEFAULT_OUTPUT_FORMATS
714
+
715
+ # target_directory_arg が None でないことを確認 (GUIキャンセル時など)
716
+ if not target_directory_arg:
717
+ print("エラー: 処理対象のディレクトリが指定されていません。処理を中止します。")
718
+ sys.exit(1)
719
+
720
+ if not asr_model_main: # 通常、モデルロード失敗で既にexitしているはずだが念のため
721
+ print("致命的エラー: ASRモデルがロードされていません。処理を中止します。")
722
+ sys.exit(1)
723
 
724
  batch_process_directory(
725
+ target_directory_arg, asr_model_main, selected_device,
726
  output_formats=final_output_formats_to_use
727
+ )