sungo-ganpare commited on
Commit
40e0093
·
1 Parent(s): f2f98a2

kanarisusunda

Browse files
Files changed (3) hide show
  1. app_wsl copy.py +669 -0
  2. app_wsl.py +43 -9
  3. transcribe_cli.py +754 -0
app_wsl copy.py ADDED
@@ -0,0 +1,669 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from nemo.collections.asr.models import ASRModel
2
+ import torch
3
+ import gradio as gr
4
+ import spaces
5
+ import gc
6
+ import shutil
7
+ from pathlib import Path
8
+ from pydub import AudioSegment
9
+ import numpy as np
10
+ import os
11
+ import gradio.themes as gr_themes
12
+ import csv
13
+ import json
14
+ from typing import List, Tuple
15
+
16
+ device = "cuda" if torch.cuda.is_available() else "cpu"
17
+ MODEL_NAME="nvidia/parakeet-tdt-0.6b-v2"
18
+
19
+ model = ASRModel.from_pretrained(model_name=MODEL_NAME)
20
+ model.eval()
21
+
22
+ def start_session(request: gr.Request):
23
+ session_hash = request.session_hash
24
+ # プロジェクトディレクトリ内のoutputsフォルダを使用
25
+ base_dir = Path(__file__).parent
26
+ session_dir = base_dir / "outputs" / session_hash
27
+ session_dir.mkdir(parents=True, exist_ok=True)
28
+ print(f"Session with hash {session_hash} started in {session_dir}")
29
+ return session_dir.as_posix()
30
+
31
+ def end_session(request: gr.Request):
32
+ session_hash = request.session_hash
33
+ base_dir = Path(__file__).parent
34
+ session_dir = base_dir / "outputs" / session_hash
35
+ if session_dir.exists():
36
+ print(f"Session directory {session_dir} will be preserved.")
37
+ # 削除しないように変更
38
+ # shutil.rmtree(session_dir)
39
+ print(f"Session with hash {session_hash} ended.")
40
+
41
+ def get_audio_segment(audio_path, start_second, end_second):
42
+ if not audio_path or not Path(audio_path).exists():
43
+ print(f"Warning: Audio path '{audio_path}' not found or invalid for clipping.")
44
+ return None
45
+ try:
46
+ start_ms = int(start_second * 1000)
47
+ end_ms = int(end_second * 1000)
48
+
49
+ start_ms = max(0, start_ms)
50
+ if end_ms <= start_ms:
51
+ print(f"Warning: End time ({end_second}s) is not after start time ({start_second}s). Adjusting end time.")
52
+ end_ms = start_ms + 100
53
+
54
+ audio = AudioSegment.from_file(audio_path)
55
+ clipped_audio = audio[start_ms:end_ms]
56
+
57
+ samples = np.array(clipped_audio.get_array_of_samples())
58
+ if clipped_audio.channels == 2:
59
+ samples = samples.reshape((-1, 2)).mean(axis=1).astype(samples.dtype)
60
+
61
+ frame_rate = clipped_audio.frame_rate
62
+ if frame_rate <= 0:
63
+ print(f"Warning: Invalid frame rate ({frame_rate}) detected for clipped audio.")
64
+ frame_rate = audio.frame_rate
65
+
66
+ if samples.size == 0:
67
+ print(f"Warning: Clipped audio resulted in empty samples array ({start_second}s to {end_second}s).")
68
+ return None
69
+
70
+ return (frame_rate, samples)
71
+ except FileNotFoundError:
72
+ print(f"Error: Audio file not found at path: {audio_path}")
73
+ return None
74
+ except Exception as e:
75
+ print(f"Error clipping audio {audio_path} from {start_second}s to {end_second}s: {e}")
76
+ return None
77
+
78
+ def preprocess_audio(audio_path, session_dir):
79
+ """
80
+ オーディオファイルの前処理(リサンプリング、モノラル変換)を行う。
81
+
82
+ Args:
83
+ audio_path (str): 入力オーディオファイルのパス。
84
+ session_dir (str): セッションディレクトリのパス。
85
+
86
+ Returns:
87
+ tuple: (processed_path, info_path_name, duration_sec) のタプル、または None(処理に失敗した場合)。
88
+ """
89
+ try:
90
+ original_path_name = Path(audio_path).name
91
+ audio_name = Path(audio_path).stem
92
+
93
+ try:
94
+ gr.Info(f"Loading audio: {original_path_name}", duration=2)
95
+ audio = AudioSegment.from_file(audio_path)
96
+ duration_sec = audio.duration_seconds
97
+ except Exception as load_e:
98
+ gr.Error(f"Failed to load audio file {original_path_name}: {load_e}", duration=None)
99
+ return None, None, None
100
+
101
+ resampled = False
102
+ mono = False
103
+ target_sr = 16000
104
+
105
+ if audio.frame_rate != target_sr:
106
+ try:
107
+ audio = audio.set_frame_rate(target_sr)
108
+ resampled = True
109
+ except Exception as resample_e:
110
+ gr.Error(f"Failed to resample audio: {resample_e}", duration=None)
111
+ return None, None, None
112
+
113
+ if audio.channels == 2:
114
+ try:
115
+ audio = audio.set_channels(1)
116
+ mono = True
117
+ except Exception as mono_e:
118
+ gr.Error(f"Failed to convert audio to mono: {mono_e}", duration=None)
119
+ return None, None, None
120
+ elif audio.channels > 2:
121
+ gr.Error(f"Audio has {audio.channels} channels. Only mono (1) or stereo (2) supported.", duration=None)
122
+ return None, None, None
123
+
124
+ processed_audio_path = None
125
+ if resampled or mono:
126
+ try:
127
+ processed_audio_path = Path(session_dir, f"{audio_name}_resampled.wav")
128
+ audio.export(processed_audio_path, format="wav")
129
+ transcribe_path = processed_audio_path.as_posix()
130
+ info_path_name = f"{original_path_name} (processed)"
131
+ except Exception as export_e:
132
+ gr.Error(f"Failed to export processed audio: {export_e}", duration=None)
133
+ if processed_audio_path and os.path.exists(processed_audio_path):
134
+ os.remove(processed_audio_path)
135
+ return None, None, None
136
+ else:
137
+ transcribe_path = audio_path
138
+ info_path_name = original_path_name
139
+
140
+ return transcribe_path, info_path_name, duration_sec
141
+ except Exception as e:
142
+ gr.Error(f"Audio preprocessing failed: {e}", duration=None)
143
+ return None, None, None
144
+
145
+ def transcribe_audio(transcribe_path, model, duration_sec, device):
146
+ """
147
+ オーディオファイルを文字起こしし、タイムスタンプを取得する。
148
+
149
+ Args:
150
+ transcribe_path (str): 入力オーディオファイルのパス。
151
+ model (ASRModel): 使用するASRモデル。
152
+ duration_sec (float): オーディオファイルの長さ(秒)。
153
+ device (str): 使用するデバイス('cuda' or 'cpu')。
154
+
155
+ Returns:
156
+ tuple: (vis_data, raw_times_data, word_vis_data) のタプル、または None(処理に失敗した場合)。
157
+ """
158
+ long_audio_settings_applied = False
159
+ try:
160
+ # CUDA使用前にメモリをクリア
161
+ if device == 'cuda':
162
+ torch.cuda.empty_cache()
163
+ gc.collect()
164
+
165
+ model.to(device)
166
+ model.to(torch.float32)
167
+ gr.Info(f"Transcribing on {device}...", duration=2)
168
+
169
+ if duration_sec > 480:
170
+ try:
171
+ gr.Info("Audio longer than 8 minutes. Applying optimized settings for long transcription.", duration=3)
172
+ print("Applying long audio settings: Local Attention and Chunking.")
173
+ model.change_attention_model("rel_pos_local_attn", [256,256])
174
+ model.change_subsampling_conv_chunking_factor(1)
175
+ # メモリ効率を改善するための設定
176
+ torch.cuda.empty_cache()
177
+ gc.collect()
178
+ long_audio_settings_applied = True
179
+ except Exception as setting_e:
180
+ gr.Warning(f"Could not apply long audio settings: {setting_e}", duration=5)
181
+ print(f"Warning: Failed to apply long audio settings: {setting_e}")
182
+
183
+ # より効率的なメモリ使用のためにbfloat16を使用
184
+ model.to(torch.bfloat16)
185
+
186
+ # メモリ使用状況をログに出力
187
+ if device == 'cuda':
188
+ print(f"CUDA Memory before transcription: {torch.cuda.memory_allocated() / 1024**2:.2f} MB")
189
+
190
+ output = model.transcribe([transcribe_path], timestamps=True)
191
+
192
+ if not output or not isinstance(output, list) or not output[0] or not hasattr(output[0], 'timestamp') or not output[0].timestamp or 'segment' not in output[0].timestamp:
193
+ gr.Error("Transcription failed or produced unexpected output format.", duration=None)
194
+ return None, None, None
195
+
196
+ # 結果を処理する前にメモリを解放
197
+ if device == 'cuda':
198
+ model.cpu()
199
+ torch.cuda.empty_cache()
200
+ gc.collect()
201
+
202
+ segment_timestamps = output[0].timestamp['segment']
203
+ vis_data = [[f"{ts['start']:.2f}", f"{ts['end']:.2f}", ts['segment']] for ts in segment_timestamps]
204
+ raw_times_data = [[ts['start'], ts['end']] for ts in segment_timestamps]
205
+
206
+ word_timestamps_raw = output[0].timestamp.get("word", [])
207
+ word_vis_data = [
208
+ [f"{w['start']:.2f}", f"{w['end']:.2f}", w["word"]]
209
+ for w in word_timestamps_raw if isinstance(w, dict) and 'start' in w and 'end' in w and 'word' in w
210
+ ]
211
+
212
+ gr.Info("Transcription complete.", duration=2)
213
+ return vis_data, raw_times_data, word_vis_data
214
+
215
+ except torch.cuda.OutOfMemoryError as e:
216
+ error_msg = 'CUDA out of memory. Please try a shorter audio or reduce GPU load.'
217
+ print(f"CUDA OutOfMemoryError: {e}")
218
+ gr.Error(error_msg, duration=None)
219
+ # メモリエラー時に強制的にクリーンアップ
220
+ if device == 'cuda':
221
+ torch.cuda.empty_cache()
222
+ gc.collect()
223
+ return None, None, None
224
+
225
+ except Exception as e:
226
+ error_msg = f"Transcription failed: {e}"
227
+ print(f"Error during transcription processing: {e}")
228
+ gr.Error(error_msg, duration=None)
229
+ return None, None, None
230
+
231
+ finally:
232
+ try:
233
+ if long_audio_settings_applied:
234
+ try:
235
+ print("Reverting long audio settings.")
236
+ model.change_attention_model("rel_pos")
237
+ model.change_subsampling_conv_chunking_factor(-1)
238
+ except Exception as revert_e:
239
+ print(f"Warning: Failed to revert long audio settings: {revert_e}")
240
+ gr.Warning(f"Issue reverting model settings after long transcription: {revert_e}", duration=5)
241
+
242
+ if device == 'cuda':
243
+ model.cpu()
244
+ torch.cuda.empty_cache()
245
+ gc.collect()
246
+ except Exception as cleanup_e:
247
+ print(f"Error during model cleanup: {cleanup_e}")
248
+ gr.Warning(f"Issue during model cleanup: {cleanup_e}", duration=5)
249
+
250
+ def save_transcripts(session_dir, audio_name, vis_data, word_vis_data):
251
+ """
252
+ 文字起こし結果を各種ファイル形式(CSV、SRT、VTT、JSON、LRC)で保存する。
253
+
254
+ Args:
255
+ session_dir (str): セッションディレクトリのパス。
256
+ audio_name (str): オーディオファイルの名前。
257
+ vis_data (list): 表示用の文字起こし結果のリスト。
258
+ word_vis_data (list): 単語レベルのタイムスタンプのリスト。
259
+
260
+ Returns:
261
+ tuple: 各ファイルのダウンロードボタンの更新情報を含むタプル。
262
+ """
263
+ try:
264
+ csv_headers = ["Start (s)", "End (s)", "Segment"]
265
+ csv_file_path = Path(session_dir, f"transcription_{audio_name}.csv")
266
+ with open(csv_file_path, 'w', newline='', encoding='utf-8') as f:
267
+ writer = csv.writer(f)
268
+ writer.writerow(csv_headers)
269
+ writer.writerows(vis_data)
270
+ print(f"CSV transcript saved to temporary file: {csv_file_path}")
271
+
272
+ srt_file_path = Path(session_dir, f"transcription_{audio_name}.srt")
273
+ vtt_file_path = Path(session_dir, f"transcription_{audio_name}.vtt")
274
+ json_file_path = Path(session_dir, f"transcription_{audio_name}.json")
275
+ write_srt(vis_data, srt_file_path)
276
+ write_vtt(vis_data, word_vis_data, vtt_file_path)
277
+ write_json(vis_data, word_vis_data, json_file_path)
278
+ print(f"SRT, VTT, JSON transcript saved to temporary files: {srt_file_path}, {vtt_file_path}, {json_file_path}")
279
+
280
+ lrc_file_path = Path(session_dir, f"transcription_{audio_name}.lrc")
281
+ write_lrc(vis_data, lrc_file_path)
282
+ print(f"LRC transcript saved to temporary file: {lrc_file_path}")
283
+
284
+ return (
285
+ gr.DownloadButton(value=csv_file_path.as_posix(), visible=True),
286
+ gr.DownloadButton(value=srt_file_path.as_posix(), visible=True),
287
+ gr.DownloadButton(value=vtt_file_path.as_posix(), visible=True),
288
+ gr.DownloadButton(value=json_file_path.as_posix(), visible=True),
289
+ gr.DownloadButton(value=lrc_file_path.as_posix(), visible=True)
290
+ )
291
+ except Exception as e:
292
+ gr.Error(f"Failed to create transcript files: {e}", duration=None)
293
+ print(f"Error writing transcript files: {e}")
294
+ return tuple([gr.DownloadButton(visible=False)] * 5)
295
+
296
+ def split_audio_with_overlap(audio_path: str, session_dir: str, chunk_length_sec: int = 3600, overlap_sec: int = 30) -> List[str]:
297
+ """
298
+ 音声ファイルをchunk_length_secごとにoverlap_secのオーバーラップ付きで分割し、
299
+ 分割ファイルのパスリストを返す。
300
+ """
301
+ audio = AudioSegment.from_file(audio_path)
302
+ duration = audio.duration_seconds
303
+ chunk_paths = []
304
+ start = 0
305
+ chunk_idx = 0
306
+ while start < duration:
307
+ end = min(start + chunk_length_sec, duration)
308
+ # オーバーラップを考慮
309
+ chunk_start = max(0, start - (overlap_sec if start > 0 else 0))
310
+ chunk_end = min(end + (overlap_sec if end < duration else 0), duration)
311
+ chunk = audio[chunk_start * 1000:chunk_end * 1000]
312
+ chunk_path = Path(session_dir, f"chunk_{chunk_idx:03d}.wav").as_posix()
313
+ chunk.export(chunk_path, format="wav")
314
+ chunk_paths.append(chunk_path)
315
+ start += chunk_length_sec
316
+ chunk_idx += 1
317
+ return chunk_paths
318
+
319
+ @spaces.GPU
320
+ def get_transcripts_and_raw_times(audio_path, session_dir, progress=gr.Progress(track_tqdm=True)):
321
+ """
322
+ オーディオファイルを処理し、文字起こし結果を生成する。
323
+ 3時間を超える場合は60分ごとに分割し、オーバーラップ付きでASRを実行してマージする。
324
+ """
325
+ if not audio_path:
326
+ gr.Error("No audio file path provided for transcription.", duration=None)
327
+ return [], [], [], None, gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False)
328
+
329
+ audio_name = Path(audio_path).stem
330
+ processed_audio_path = None
331
+ temp_chunk_paths = []
332
+
333
+ try:
334
+ # オーディオの前処理
335
+ transcribe_path, info_path_name, duration_sec = preprocess_audio(audio_path, session_dir)
336
+ if not transcribe_path or not duration_sec:
337
+ return [], [], [], audio_path, gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False)
338
+
339
+ processed_audio_path = transcribe_path if transcribe_path != audio_path else None # 3時間超の場合は分割して逐次ASR
340
+ if duration_sec > 10800:
341
+ gr.Info("Audio is longer than 3 hours. Splitting into 1-hour chunks with overlap for transcription.", duration=5)
342
+ chunk_paths = split_audio_with_overlap(transcribe_path, session_dir, chunk_length_sec=3600, overlap_sec=30)
343
+ temp_chunk_paths = chunk_paths.copy()
344
+ all_vis_data = []
345
+ all_raw_times_data = []
346
+ all_word_vis_data = []
347
+ offset = 0.0
348
+ prev_end = 0.0
349
+ for i, chunk_path in enumerate(progress.tqdm(chunk_paths, desc="Processing audio chunks")):
350
+ chunk_audio = AudioSegment.from_file(chunk_path)
351
+ chunk_duration = chunk_audio.duration_seconds
352
+ # ASR実行
353
+ result = transcribe_audio(chunk_path, model, chunk_duration, device)
354
+ if not result:
355
+ continue
356
+ vis_data, raw_times_data, word_vis_data = result
357
+ # タイムスタンプを全体のオフセットに合わせて補正
358
+ vis_data_offset = []
359
+ raw_times_data_offset = []
360
+ word_vis_data_offset = []
361
+ for row in vis_data:
362
+ s, e, seg = float(row[0]), float(row[1]), row[2]
363
+ vis_data_offset.append([f"{s+offset:.2f}", f"{e+offset:.2f}", seg])
364
+ for row in raw_times_data:
365
+ s, e = float(row[0]), float(row[1])
366
+ raw_times_data_offset.append([s+offset, e+offset])
367
+ for row in word_vis_data:
368
+ s, e, w = float(row[0]), float(row[1]), row[2]
369
+ word_vis_data_offset.append([f"{s+offset:.2f}", f"{e+offset:.2f}", w])
370
+ # オーバーラップ部分の重複除去(単純に前回のend以降のみ追加)
371
+ vis_data_offset = [row for row in vis_data_offset if float(row[0]) >= prev_end]
372
+ raw_times_data_offset = [row for row in raw_times_data_offset if row[0] >= prev_end]
373
+ word_vis_data_offset = [row for row in word_vis_data_offset if float(row[0]) >= prev_end]
374
+ if vis_data_offset:
375
+ prev_end = float(vis_data_offset[-1][1])
376
+ all_vis_data.extend(vis_data_offset)
377
+ all_raw_times_data.extend(raw_times_data_offset)
378
+ all_word_vis_data.extend(word_vis_data_offset)
379
+ offset += chunk_duration - (30 if i < len(chunk_paths)-1 else 0)
380
+ # ファイルの保存
381
+ button_updates = save_transcripts(session_dir, audio_name, all_vis_data, all_word_vis_data)
382
+ # 一時分割ファイル削除
383
+ for p in temp_chunk_paths:
384
+ try:
385
+ os.remove(p)
386
+ except Exception:
387
+ pass
388
+ return (
389
+ all_vis_data,
390
+ all_raw_times_data,
391
+ all_word_vis_data,
392
+ audio_path,
393
+ *button_updates
394
+ )
395
+ else:
396
+ # 3時間以内は従来通り
397
+ result = transcribe_audio(transcribe_path, model, duration_sec, device)
398
+ if not result:
399
+ return [], [], [], audio_path, gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False)
400
+ vis_data, raw_times_data, word_vis_data = result
401
+ button_updates = save_transcripts(session_dir, audio_name, vis_data, word_vis_data)
402
+ return (
403
+ vis_data,
404
+ raw_times_data,
405
+ word_vis_data,
406
+ audio_path,
407
+ *button_updates
408
+ )
409
+ finally:
410
+ if processed_audio_path and os.path.exists(processed_audio_path):
411
+ try:
412
+ os.remove(processed_audio_path)
413
+ print(f"Temporary audio file {processed_audio_path} removed.")
414
+ except Exception as e:
415
+ print(f"Error removing temporary audio file {processed_audio_path}: {e}")
416
+ # 分割ファイルの掃除
417
+ for p in temp_chunk_paths:
418
+ if os.path.exists(p):
419
+ try:
420
+ os.remove(p)
421
+ except Exception:
422
+ pass
423
+
424
+ def play_segment(evt: gr.SelectData, raw_ts_list, current_audio_path):
425
+ if not isinstance(raw_ts_list, list):
426
+ print(f"Warning: raw_ts_list is not a list ({type(raw_ts_list)}). Cannot play segment.")
427
+ return gr.Audio(value=None, label="Selected Segment")
428
+
429
+ if not current_audio_path:
430
+ print("No audio path available to play segment from.")
431
+ return gr.Audio(value=None, label="Selected Segment")
432
+
433
+ selected_index = evt.index[0]
434
+
435
+ if selected_index < 0 or selected_index >= len(raw_ts_list):
436
+ print(f"Invalid index {selected_index} selected for list of length {len(raw_ts_list)}.")
437
+ return gr.Audio(value=None, label="Selected Segment")
438
+
439
+ if not isinstance(raw_ts_list[selected_index], (list, tuple)) or len(raw_ts_list[selected_index]) != 2:
440
+ print(f"Warning: Data at index {selected_index} is not in the expected format [start, end].")
441
+ return gr.Audio(value=None, label="Selected Segment")
442
+
443
+ start_time_s, end_time_s = raw_ts_list[selected_index]
444
+ print(f"Attempting to play segment: {current_audio_path} from {start_time_s:.2f}s to {end_time_s:.2f}s")
445
+ segment_data = get_audio_segment(current_audio_path, start_time_s, end_time_s)
446
+
447
+ if segment_data:
448
+ print("Segment data retrieved successfully.")
449
+ return gr.Audio(value=segment_data, autoplay=True, label=f"Segment: {start_time_s:.2f}s - {end_time_s:.2f}s", interactive=False)
450
+ else:
451
+ print("Failed to get audio segment data.")
452
+ return gr.Audio(value=None, label="Selected Segment")
453
+
454
+ def write_srt(segments, path):
455
+ def sec2srt(t):
456
+ h, rem = divmod(int(float(t)), 3600)
457
+ m, s = divmod(rem, 60)
458
+ ms = int((float(t) - int(float(t))) * 1000)
459
+ return f"{h:02}:{m:02}:{s:02},{ms:03}"
460
+ with open(path, "w", encoding="utf-8") as f:
461
+ for i, seg in enumerate(segments, 1):
462
+ f.write(f"{i}\n{sec2srt(seg[0])} --> {sec2srt(seg[1])}\n{seg[2]}\n\n")
463
+
464
+ def write_vtt(segments, words, path):
465
+ def sec2vtt(t):
466
+ h, rem = divmod(int(float(t)), 3600)
467
+ m, s = divmod(rem, 60)
468
+ ms = int((float(t) - int(float(t))) * 1000)
469
+ return f"{h:02}:{m:02}:{s:02}.{ms:03}"
470
+
471
+ with open(path, "w", encoding="utf-8") as f:
472
+ f.write("WEBVTT\n\n")
473
+
474
+ word_idx = 0
475
+ for seg_idx, seg in enumerate(segments): # segmentにもインデックスが必要な場合に備えてenumerateする
476
+ s_start = float(seg[0])
477
+ s_end = float(seg[1])
478
+ # s_text = seg[2] # s_textはこの関数内では直接VTT出力に使われていない模様
479
+
480
+ segment_words = []
481
+ temp_word_idx = word_idx # 現在のword_idxから探索を開始
482
+ while temp_word_idx < len(words):
483
+ w = words[temp_word_idx]
484
+ w_start_val = float(w[0])
485
+ w_end_val = float(w[1])
486
+ # 単語が現在のセグメントに完全に含まれるか、一部でも重なっていれば含める
487
+ # ここでは元のロジックを踏襲し、セグメント内に開始・終了がある単語を対象とする
488
+ if w_start_val >= s_start and w_end_val <= s_end:
489
+ segment_words.append(w)
490
+ if temp_word_idx == word_idx: # segment_words に追加された最初の単語なら word_idx を進める
491
+ word_idx = temp_word_idx + 1
492
+ temp_word_idx += 1
493
+ elif w_start_val < s_start and w_end_val > s_start: # 単語がセグメント開始をまたぐ場合
494
+ # 必要であれば、このようなケースの単語も segment_words に含める処理を追加
495
+ temp_word_idx += 1
496
+ elif w_start_val > s_end: # 単語の開始がセグメントの終了より後なら、このセグメントの単語は終わり
497
+ break
498
+ else: # 上記以外 (単語がセグメントより完全に前など)
499
+ if temp_word_idx == word_idx: # word_idx が進まない場合を避ける
500
+ word_idx = temp_word_idx + 1
501
+ temp_word_idx += 1
502
+
503
+ # 各単語ごとにタイムスタンプを生成
504
+ for i, word_data in enumerate(segment_words):
505
+ w_start = float(word_data[0])
506
+ w_end = float(word_data[1])
507
+
508
+ # 現在の単語を強調表示し、他の単語は通常表示
509
+ colored_text = ""
510
+ for j, other_word_data in enumerate(segment_words):
511
+ if j == i: # 現在の単語 (i番目) を強調
512
+ colored_text += f"<c.yellow><b>{other_word_data[2]}</b></c> "
513
+ else:
514
+ colored_text += f"{other_word_data[2]} "
515
+
516
+ f.write(f"{sec2vtt(w_start)} --> {sec2vtt(w_end)}\n{colored_text.strip()}\n\n")
517
+
518
+ def write_json(segments, words, path):
519
+ result = {"segments": []}
520
+ word_idx = 0
521
+ for s in segments:
522
+ s_start = float(s[0])
523
+ s_end = float(s[1])
524
+ s_text = s[2]
525
+ word_list = []
526
+ while word_idx < len(words):
527
+ w = words[word_idx]
528
+ w_start = float(w[0])
529
+ w_end = float(w[1])
530
+ if w_start >= s_start and w_end <= s_end:
531
+ word_list.append({"start": w_start, "end": w_end, "word": w[2]})
532
+ word_idx += 1
533
+ elif w_end < s_start:
534
+ word_idx += 1
535
+ else:
536
+ break
537
+ result["segments"].append({
538
+ "start": s_start,
539
+ "end": s_end,
540
+ "text": s_text,
541
+ "words": word_list
542
+ })
543
+ with open(path, "w", encoding="utf-8") as f:
544
+ json.dump(result, f, ensure_ascii=False, indent=2)
545
+
546
+ def write_lrc(segments, path):
547
+ def sec2lrc(t):
548
+ m, s = divmod(float(t), 60)
549
+ return f"[{int(m):02}:{s:05.2f}]"
550
+ with open(path, "w", encoding="utf-8") as f:
551
+ for seg in segments:
552
+ f.write(f"{sec2lrc(seg[0])}{seg[2]}\n")
553
+
554
+ article = (
555
+ "<p style='font-size: 1.1em;'>"
556
+ "このデモは <code><a href='https://huggingface.co/nvidia/parakeet-tdt-0.6b-v2' target='_blank'>parakeet-tdt-0.6b-v2</a></code> "
557
+ "(約6億パラメータ)を用いた高精度な英語音声文字起こしを実演します。"
558
+ "</p>"
559
+ "<p><strong style='color: red; font-size: 1.2em;'>主な特長:</strong></p>"
560
+ "<ul style='font-size: 1.1em;'>" " <li>自動句読点・大文字化</li>"
561
+ " <li>単語レベルのタイムスタンプ(下表クリックで該当区間を再生)</li>"
562
+ " <li>文字レベルのタイムスタンプ表示にも対応</li>"
563
+ " <li>自動チャンク処理による <strong>長時間音声</strong> の効率的な文字起こし(数時間以上の音声にも対応)</li>"
564
+ " <li>数字や歌詞など発話の多様なケースに高いロバスト性</li>"
565
+ "</ul>"
566
+ "<p style='font-size: 1.1em;'>"
567
+ "商用・非商用ともに <strong>ライセンス制限なく利用可能</strong> です。"
568
+ "</p>"
569
+ "<p style='text-align: center;'>"
570
+ "<a href='https://huggingface.co/nvidia/parakeet-tdt-0.6b-v2' target='_blank'>🎙️ モデル詳細</a> | "
571
+ "<a href='https://arxiv.org/abs/2305.05084' target='_blank'>📄 Fast&nbsp;Conformer 論文</a> | "
572
+ "<a href='https://arxiv.org/abs/2304.06795' target='_blank'>📚 TDT 論文</a> | "
573
+ "<a href='https://github.com/NVIDIA/NeMo' target='_blank'>🧑‍💻 NeMo リポジトリ</a>"
574
+ "</p>"
575
+ )
576
+
577
+ examples = [
578
+ ["data/example-yt_saTD1u8PorI.mp3"],
579
+ ]
580
+
581
+ nvidia_theme = gr_themes.Default(
582
+ primary_hue=gr_themes.Color(
583
+ c50="#E6F1D9", c100="#CEE3B3", c200="#B5D58C", c300="#9CC766",
584
+ c400="#84B940", c500="#76B900", c600="#68A600", c700="#5A9200",
585
+ c800="#4C7E00", c900="#3E6A00", c950="#2F5600"
586
+ ),
587
+ neutral_hue="gray",
588
+ font=[gr_themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
589
+ ).set()
590
+
591
+ with gr.Blocks(theme=nvidia_theme) as demo:
592
+ model_display_name = MODEL_NAME.split('/')[-1] if '/' in MODEL_NAME else MODEL_NAME
593
+ gr.Markdown(f"<h1 style='text-align: center; margin: 0 auto;'>長時間対応 音声文字起こし ({model_display_name})</h1>")
594
+ gr.HTML(article)
595
+
596
+ current_audio_path_state = gr.State(None)
597
+ raw_timestamps_list_state = gr.State([])
598
+ session_dir_state = gr.State()
599
+ demo.load(start_session, outputs=[session_dir_state])
600
+
601
+ with gr.Tabs():
602
+ with gr.TabItem("Audio File"):
603
+ file_input = gr.Audio(sources=["upload"], type="filepath", label="Upload Audio File")
604
+ gr.Examples(examples=examples, inputs=[file_input], label="Example Audio Files (Click to Load)")
605
+ file_transcribe_btn = gr.Button("Transcribe Uploaded File", variant="primary")
606
+
607
+ with gr.TabItem("Microphone"):
608
+ mic_input = gr.Audio(sources=["microphone"], type="filepath", label="Record Audio")
609
+ mic_transcribe_btn = gr.Button("Transcribe Microphone Input", variant="primary")
610
+
611
+ gr.Markdown("---")
612
+ gr.Markdown("<p><strong style='color: #FF0000; font-size: 1.2em;'>Transcription Results</strong></p>")
613
+
614
+ download_btn = gr.DownloadButton(label="Download Segment Transcript (CSV)", visible=False)
615
+ srt_btn = gr.DownloadButton(label="Download SRT", visible=False)
616
+ vtt_btn = gr.DownloadButton(label="Download VTT", visible=False)
617
+ json_btn = gr.DownloadButton(label="Download JSON", visible=False)
618
+ lrc_btn = gr.DownloadButton(label="Download LRC", visible=False)
619
+
620
+ with gr.Tabs():
621
+ with gr.TabItem("Segment View (Click row to play segment)"):
622
+ vis_timestamps_df = gr.DataFrame(
623
+ headers=["Start (s)", "End (s)", "Segment"],
624
+ datatype=["number", "number", "str"],
625
+ wrap=True,
626
+ )
627
+ selected_segment_player = gr.Audio(label="Selected Segment", interactive=False)
628
+
629
+ with gr.TabItem("Word View"):
630
+ word_vis_df = gr.DataFrame(
631
+ headers=["Start (s)", "End (s)", "Word"],
632
+ datatype=["number", "number", "str"],
633
+ wrap=False,
634
+ )
635
+
636
+ mic_transcribe_btn.click(
637
+ fn=get_transcripts_and_raw_times,
638
+ inputs=[mic_input, session_dir_state],
639
+ outputs=[vis_timestamps_df, raw_timestamps_list_state, word_vis_df, current_audio_path_state, download_btn, srt_btn, vtt_btn, json_btn, lrc_btn],
640
+ api_name="transcribe_mic"
641
+ )
642
+
643
+ file_transcribe_btn.click(
644
+ fn=get_transcripts_and_raw_times,
645
+ inputs=[file_input, session_dir_state],
646
+ outputs=[vis_timestamps_df, raw_timestamps_list_state, word_vis_df, current_audio_path_state, download_btn, srt_btn, vtt_btn, json_btn, lrc_btn],
647
+ api_name="transcribe_file"
648
+ )
649
+
650
+ vis_timestamps_df.select(
651
+ fn=play_segment,
652
+ inputs=[raw_timestamps_list_state, current_audio_path_state],
653
+ outputs=[selected_segment_player],
654
+ )
655
+
656
+ demo.unload(end_session)
657
+
658
+ if __name__ == "__main__":
659
+ print("Launching Gradio Demo...")
660
+ demo.queue(
661
+ max_size=5,
662
+ default_concurrency_limit=1 # イベントリスナーのデフォルト同時実行数を1に設定
663
+ )
664
+ demo.launch(
665
+ server_name="127.0.0.1",
666
+ server_port=7860,
667
+ share=False,
668
+ max_threads=1 # サーバー全体の同時処理スレッド数を1に設定
669
+ )
app_wsl.py CHANGED
@@ -38,6 +38,33 @@ def end_session(request: gr.Request):
38
  # shutil.rmtree(session_dir)
39
  print(f"Session with hash {session_hash} ended.")
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def get_audio_segment(audio_path, start_second, end_second):
42
  if not audio_path or not Path(audio_path).exists():
43
  print(f"Warning: Audio path '{audio_path}' not found or invalid for clipping.")
@@ -317,7 +344,7 @@ def split_audio_with_overlap(audio_path: str, session_dir: str, chunk_length_sec
317
  return chunk_paths
318
 
319
  @spaces.GPU
320
- def get_transcripts_and_raw_times(audio_path, session_dir):
321
  """
322
  オーディオファイルを処理し、文字起こし結果を生成する。
323
  3時間を超える場合は60分ごとに分割し、オーバーラップ付きでASRを実行してマージする。
@@ -336,10 +363,9 @@ def get_transcripts_and_raw_times(audio_path, session_dir):
336
  if not transcribe_path or not duration_sec:
337
  return [], [], [], audio_path, gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False)
338
 
339
- processed_audio_path = transcribe_path if transcribe_path != audio_path else None
340
-
341
- # 3時間超の場合は分割して逐次ASR
342
  if duration_sec > 10800:
 
343
  chunk_paths = split_audio_with_overlap(transcribe_path, session_dir, chunk_length_sec=3600, overlap_sec=30)
344
  temp_chunk_paths = chunk_paths.copy()
345
  all_vis_data = []
@@ -347,7 +373,7 @@ def get_transcripts_and_raw_times(audio_path, session_dir):
347
  all_word_vis_data = []
348
  offset = 0.0
349
  prev_end = 0.0
350
- for i, chunk_path in enumerate(chunk_paths):
351
  chunk_audio = AudioSegment.from_file(chunk_path)
352
  chunk_duration = chunk_audio.duration_seconds
353
  # ASR実行
@@ -597,14 +623,22 @@ with gr.Blocks(theme=nvidia_theme) as demo:
597
  current_audio_path_state = gr.State(None)
598
  raw_timestamps_list_state = gr.State([])
599
  session_dir_state = gr.State()
600
- demo.load(start_session, outputs=[session_dir_state])
601
-
602
- with gr.Tabs():
603
- with gr.TabItem("Audio File"):
604
  file_input = gr.Audio(sources=["upload"], type="filepath", label="Upload Audio File")
605
  gr.Examples(examples=examples, inputs=[file_input], label="Example Audio Files (Click to Load)")
606
  file_transcribe_btn = gr.Button("Transcribe Uploaded File", variant="primary")
607
 
 
 
 
 
 
 
 
 
 
 
608
  with gr.TabItem("Microphone"):
609
  mic_input = gr.Audio(sources=["microphone"], type="filepath", label="Record Audio")
610
  mic_transcribe_btn = gr.Button("Transcribe Microphone Input", variant="primary")
 
38
  # shutil.rmtree(session_dir)
39
  print(f"Session with hash {session_hash} ended.")
40
 
41
+ def get_server_files(server_dir: str = None) -> List[str]:
42
+ """
43
+ サーバー側の指定ディレクトリ内の音声ファイルの一覧を取得する。
44
+
45
+ Args:
46
+ server_dir (str, optional): 検索するディレクトリ。Noneの場合はデフォルトの場所を使用。
47
+
48
+ Returns:
49
+ List[str]: 音声ファイルのパスのリスト
50
+ """
51
+ if server_dir is None:
52
+ server_dir = str(Path(__file__).parent / "data")
53
+
54
+ audio_extensions = {".mp3", ".wav", ".m4a", ".ogg", ".flac"}
55
+ audio_files = []
56
+
57
+ try:
58
+ for root, _, files in os.walk(server_dir):
59
+ for file in files:
60
+ if Path(file).suffix.lower() in audio_extensions:
61
+ full_path = str(Path(root) / file)
62
+ audio_files.append(full_path)
63
+ return sorted(audio_files)
64
+ except Exception as e:
65
+ print(f"Error scanning directory {server_dir}: {e}")
66
+ return []
67
+
68
  def get_audio_segment(audio_path, start_second, end_second):
69
  if not audio_path or not Path(audio_path).exists():
70
  print(f"Warning: Audio path '{audio_path}' not found or invalid for clipping.")
 
344
  return chunk_paths
345
 
346
  @spaces.GPU
347
+ def get_transcripts_and_raw_times(audio_path, session_dir, progress=gr.Progress(track_tqdm=True)):
348
  """
349
  オーディオファイルを処理し、文字起こし結果を生成する。
350
  3時間を超える場合は60分ごとに分割し、オーバーラップ付きでASRを実行してマージする。
 
363
  if not transcribe_path or not duration_sec:
364
  return [], [], [], audio_path, gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False), gr.DownloadButton(visible=False)
365
 
366
+ processed_audio_path = transcribe_path if transcribe_path != audio_path else None # 3時間超の場合は分割して逐次ASR
 
 
367
  if duration_sec > 10800:
368
+ gr.Info("Audio is longer than 3 hours. Splitting into 1-hour chunks with overlap for transcription.", duration=5)
369
  chunk_paths = split_audio_with_overlap(transcribe_path, session_dir, chunk_length_sec=3600, overlap_sec=30)
370
  temp_chunk_paths = chunk_paths.copy()
371
  all_vis_data = []
 
373
  all_word_vis_data = []
374
  offset = 0.0
375
  prev_end = 0.0
376
+ for i, chunk_path in enumerate(progress.tqdm(chunk_paths, desc="Processing audio chunks")):
377
  chunk_audio = AudioSegment.from_file(chunk_path)
378
  chunk_duration = chunk_audio.duration_seconds
379
  # ASR実行
 
623
  current_audio_path_state = gr.State(None)
624
  raw_timestamps_list_state = gr.State([])
625
  session_dir_state = gr.State()
626
+ demo.load(start_session, outputs=[session_dir_state]) with gr.Tabs():
627
+ with gr.TabItem("Upload Audio"):
 
 
628
  file_input = gr.Audio(sources=["upload"], type="filepath", label="Upload Audio File")
629
  gr.Examples(examples=examples, inputs=[file_input], label="Example Audio Files (Click to Load)")
630
  file_transcribe_btn = gr.Button("Transcribe Uploaded File", variant="primary")
631
 
632
+ with gr.TabItem("Server Files"):
633
+ server_files = get_server_files()
634
+ server_file_dropdown = gr.Dropdown(
635
+ choices=server_files,
636
+ value=server_files[0] if server_files else None,
637
+ label="Select Audio File from Server",
638
+ type="value"
639
+ )
640
+ server_file_transcribe_btn = gr.Button("Transcribe Selected File", variant="primary")
641
+
642
  with gr.TabItem("Microphone"):
643
  mic_input = gr.Audio(sources=["microphone"], type="filepath", label="Record Audio")
644
  mic_transcribe_btn = gr.Button("Transcribe Microphone Input", variant="primary")
transcribe_cli.py ADDED
@@ -0,0 +1,754 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coding: utf-8
2
+ import torch
3
+ import gc
4
+ from pathlib import Path
5
+ from pydub import AudioSegment
6
+ # numpy は直接は使用されていませんが、pydubやNeMoの依存関係で間接的に必要になる可能性があります。
7
+ # import numpy as np
8
+ import os
9
+ import csv
10
+ import json
11
+ from typing import List, Tuple, Optional, Set # Python 3.9+ では Optional, Set は typing から不要な場合あり
12
+ import argparse
13
+ from nemo.collections.asr.models import ASRModel # NeMo ASRモデル
14
+
15
+ # --- グローバル設定 ---
16
+ MODEL_NAME = "nvidia/parakeet-tdt-0.6b-v2"
17
+ TARGET_SAMPLE_RATE = 16000
18
+ # 音声の長さに関する閾値 (秒)
19
+ LONG_AUDIO_THRESHOLD_SECONDS = 480 # 8分 (この長さを超えると長尺設定を試みる)
20
+ VERY_LONG_AUDIO_THRESHOLD_SECONDS = 10800 # 3時間 (この長さを超えるとチャンク分割処理)
21
+ # チャンク分割時の設定
22
+ CHUNK_LENGTH_SECONDS = 3600 # 1時間
23
+ CHUNK_OVERLAP_SECONDS = 30 # 30秒
24
+ # ★ 入力ファイルの優先順位付き拡張子リスト
25
+ INPUT_PRIORITY_EXTENSIONS: List[str] = ['.wav', '.mp3', '.mp4']
26
+ # ★ デフォルトで出力するフォーマットリスト
27
+ DEFAULT_OUTPUT_FORMATS: List[str] = ["csv", "srt", "vtt", "json", "lrc"]
28
+
29
+
30
+ # --- 音声前処理関数 ---
31
+ def preprocess_audio_cli(audio_path_str: str, output_dir_for_temp_files: str) -> Tuple[Optional[str], Optional[str], Optional[float]]:
32
+ """
33
+ オーディオファイルの前処理(リサンプリング、モノラル変換)を行います。
34
+ 成功した場合、(処理済みファイルパス, 表示用名, 音声長) を返します。
35
+ 失敗した場合、(None, None, None) を返します。
36
+ """
37
+ try:
38
+ audio_file_path = Path(audio_path_str)
39
+ original_path_name = audio_file_path.name
40
+ audio_name_stem = audio_file_path.stem
41
+
42
+ print(f" 音声ファイルをロード中: {original_path_name}")
43
+ audio = AudioSegment.from_file(audio_path_str)
44
+ duration_sec = audio.duration_seconds
45
+ print(f" 音声長: {duration_sec:.2f} 秒")
46
+
47
+ except FileNotFoundError:
48
+ print(f"エラー: 音声ファイルが見つかりません: {audio_path_str}")
49
+ return None, None, None
50
+ except Exception as load_e: # pydub.exceptions.CouldntDecodeError などを含む
51
+ print(f"エラー: 音声ファイル '{original_path_name}' のロード/デコードに失敗しました: {load_e}")
52
+ return None, None, None
53
+
54
+ resampled = False
55
+ mono_converted = False
56
+
57
+ # リサンプリング処理
58
+ if audio.frame_rate != TARGET_SAMPLE_RATE:
59
+ try:
60
+ print(f" リサンプリング中: {audio.frame_rate}Hz -> {TARGET_SAMPLE_RATE}Hz")
61
+ audio = audio.set_frame_rate(TARGET_SAMPLE_RATE)
62
+ resampled = True
63
+ except Exception as resample_e:
64
+ print(f"エラー: 音声のリサンプリングに失敗しました: {resample_e}")
65
+ return None, None, None
66
+
67
+ # モノラル変換処理
68
+ if audio.channels == 2:
69
+ try:
70
+ print(" モノラルに変換中 (2ch -> 1ch)")
71
+ audio = audio.set_channels(1)
72
+ mono_converted = True
73
+ except Exception as mono_e:
74
+ print(f"エラー: 音声のモノラル変換に失敗しました: {mono_e}")
75
+ return None, None, None
76
+ elif audio.channels > 2:
77
+ print(f"エラー: 音声チャンネルが {audio.channels} です。1ch(モノラル)または2ch(ステレオ)のみサポートしています。")
78
+ return None, None, None
79
+ elif audio.channels == 1:
80
+ print(" 音声は既にモノラルです。")
81
+
82
+ processed_temp_file_path_obj = None # 一時ファイルのPathオブジェクト
83
+ # 前処理が行われた場合、一時ファイルに保存
84
+ if resampled or mono_converted:
85
+ try:
86
+ temp_suffix = "_preprocessed_temp.wav" # 一時ファイルとわかるような接尾辞
87
+ processed_temp_file_path_obj = Path(output_dir_for_temp_files, f"{audio_name_stem}{temp_suffix}")
88
+
89
+ print(f" 前処理済み音声の一時保存先: {processed_temp_file_path_obj.name}")
90
+ audio.export(processed_temp_file_path_obj, format="wav")
91
+
92
+ path_for_transcription = processed_temp_file_path_obj.as_posix() # 文字起こしに使用するパス
93
+ display_name_for_info = f"{original_path_name} (前処理済み)"
94
+ except Exception as export_e:
95
+ print(f"エラー: 前処理済み音声のエクスポートに失敗しました: {export_e}")
96
+ # エクスポート失敗時、もしファイルが作られていたら削除試行
97
+ if processed_temp_file_path_obj and processed_temp_file_path_obj.exists():
98
+ try: os.remove(processed_temp_file_path_obj)
99
+ except OSError: pass # 削除エラーはここでは致命的ではない
100
+ return None, None, None
101
+ else:
102
+ # 前処理が不要だった場合
103
+ print(" 前処理は不要でした。元のファイルを使用します。")
104
+ path_for_transcription = audio_path_str
105
+ display_name_for_info = original_path_name
106
+
107
+ return path_for_transcription, display_name_for_info, duration_sec
108
+
109
+
110
+ # --- 文字起こしコア関数 ---
111
+ def transcribe_audio_cli(
112
+ transcribe_path_str: str,
113
+ model: ASRModel,
114
+ duration_sec: float, # この音声セグメントの長さ
115
+ device: str
116
+ ) -> Tuple[Optional[List], Optional[List], Optional[List]]:
117
+ """
118
+ 指定されたオーディオファイルをNeMo ASRモデルで文字起こしします。
119
+ 成功した場合、(セグメント情報リスト, RAWタイムスタンプリスト, 単語情報リスト) を返します。
120
+ 失敗した場合、(None, None, None) を返します。
121
+ """
122
+ long_audio_settings_applied = False # 長尺設定が適用されたかどうかのフラグ
123
+ original_model_dtype = model.dtype # モデルの元のデータ型を保存 (通常は torch.float32)
124
+
125
+ try:
126
+ # CUDAデバイス使用時はメモリキャッシュをクリア
127
+ if device == 'cuda':
128
+ torch.cuda.empty_cache()
129
+ gc.collect()
130
+
131
+ model.to(device) # モデルを推論に使用するデバイスへ移動
132
+
133
+ # 音声長に応じてモデル設定を変更 (長尺音声対応)
134
+ if duration_sec > LONG_AUDIO_THRESHOLD_SECONDS:
135
+ try:
136
+ print(f" 情報: 音声長 ({duration_sec:.0f}s) が閾値 ({LONG_AUDIO_THRESHOLD_SECONDS}s) を超えるため、長尺音声向け設定を適用します。")
137
+ # NeMoのドキュメントやモデルカードで推奨される設定を確認すること
138
+ model.change_attention_model(self_attention_model="rel_pos_local_attn", att_context_size=[256, 256])
139
+ model.change_subsampling_conv_chunking_factor(1) # 例: FastConformer系
140
+ long_audio_settings_applied = True
141
+ if device == 'cuda': torch.cuda.empty_cache(); gc.collect() # 設定変更後のメモリ確保のため
142
+ except Exception as setting_e:
143
+ print(f" 警告: 長尺音声向け設定の適用に失敗しました: {setting_e}。デフォルト設定で続行します。")
144
+
145
+ # bfloat16の使用 (GPUが対応し、かつfloat32より効率が良い場合)
146
+ if device == 'cuda' and torch.cuda.is_bf16_supported():
147
+ print(" 情報: モデルを bfloat16 に変換して推論を実行します。")
148
+ model.to(torch.bfloat16)
149
+ elif model.dtype != original_model_dtype: # bf16非対応時やCPU時は元のdtypeに戻す
150
+ model.to(original_model_dtype)
151
+
152
+
153
+ if device == 'cuda':
154
+ # メモリ使用状況のログ(デバッグ用)
155
+ # allocated = torch.cuda.memory_allocated() / 1024**2
156
+ # reserved = torch.cuda.memory_reserved() / 1024**2
157
+ # print(f" CUDAメモリ (文字起こし前): Allocated={allocated:.2f}MB, Reserved={reserved:.2f}MB")
158
+ pass
159
+
160
+ print(f" 文字起こしを実行中 (デバイス: {device}, モデルdtype: {model.dtype})...")
161
+ # `batch_size` はモデルやVRAMに応じて調整。Parakeet系は大きめでも大丈夫な場合がある。
162
+ output = model.transcribe([transcribe_path_str], timestamps=True, batch_size=4) # batch_sizeは環境に応じて調整
163
+
164
+ # 結果の検証
165
+ if not output or not isinstance(output, list) or not output[0] or \
166
+ not hasattr(output[0], 'timestamp') or not output[0].timestamp or \
167
+ 'segment' not in output[0].timestamp:
168
+ print(" エラー: 文字起こしに失敗したか、予期しない出力形式です。")
169
+ return None, None, None
170
+
171
+ # 結果の抽出
172
+ segment_timestamps = output[0].timestamp['segment']
173
+ # vis_data: 表示やCSV保存用のセグメントリスト [[start_str, end_str, segment_text], ...]
174
+ vis_data = [[f"{ts['start']:.2f}", f"{ts['end']:.2f}", ts['segment']] for ts in segment_timestamps]
175
+ # raw_times_data: 内部処理用のfloat型タイムスタンプ [[start_float, end_float], ...]
176
+ raw_times_data = [[ts['start'], ts['end']] for ts in segment_timestamps]
177
+
178
+ word_timestamps_raw = output[0].timestamp.get("word", []) # 単語タイムスタンプがない場合もある
179
+ # word_vis_data: 単語ごとの情報リスト [[start_str, end_str, word_text], ...]
180
+ word_vis_data = [
181
+ [f"{w['start']:.2f}", f"{w['end']:.2f}", w["word"]]
182
+ for w in word_timestamps_raw if isinstance(w, dict) and 'start' in w and 'end' in w and 'word' in w
183
+ ]
184
+ print(" 文字起こし完了。")
185
+ return vis_data, raw_times_data, word_vis_data
186
+
187
+ except torch.cuda.OutOfMemoryError as oom_e:
188
+ print(f" 致命的エラー: CUDAメモリ不足です。 {oom_e}")
189
+ # allocated = torch.cuda.memory_allocated() / 1024**2
190
+ # reserved = torch.cuda.memory_reserved() / 1024**2
191
+ # print(f" 現在のCUDAメモリ: Allocated={allocated:.2f}MB, Reserved={reserved:.2f}MB")
192
+ print(" バッチサイズを小さくする、他のGPU利用アプリを終了するなどの対策を試みてください。")
193
+ return None, None, None
194
+ except Exception as e:
195
+ print(f" エラー: 文字起こし処理中に予期せぬエラーが発生しました: {e}")
196
+ import traceback
197
+ traceback.print_exc() # 詳細なエラー情報を表示
198
+ return None, None, None
199
+ finally:
200
+ # モデルの状態を元に戻す (長尺設定、データ型、デバイス)
201
+ if long_audio_settings_applied:
202
+ try:
203
+ print(" 長尺音声向け設定を元に戻しています。")
204
+ model.change_attention_model(self_attention_model="rel_pos") # デフォルトに戻す例
205
+ model.change_subsampling_conv_chunking_factor(-1) # デフォルトに戻す例
206
+ except Exception as revert_e:
207
+ print(f" 警告: 長尺音声設定の復元に失敗: {revert_e}")
208
+
209
+ model.to(original_model_dtype) # 元のデータ型に戻す
210
+ if model.device.type != 'cpu': # 常にCPUに戻してメモリ解放を試みる
211
+ model.cpu()
212
+
213
+ if device == 'cuda':
214
+ torch.cuda.empty_cache()
215
+ gc.collect()
216
+ # print(" CUDAキャッシュをクリアしました。")
217
+
218
+
219
+ # --- 結果保存関数 ---
220
+ def save_transcripts_cli(output_dir_str: str, audio_file_stem: str,
221
+ vis_data: List, word_vis_data: List, formats: Optional[List[str]] = None):
222
+ """
223
+ 文字起こし結果を指定された形式で保存します。
224
+ """
225
+ if formats is None: # formatsが指定されなかった場合はデフォルトを使用
226
+ formats_to_save = DEFAULT_OUTPUT_FORMATS
227
+ else:
228
+ formats_to_save = formats
229
+
230
+ output_dir_path = Path(output_dir_str)
231
+ output_dir_path.mkdir(parents=True, exist_ok=True) # 保存先ディレクトリがなければ作成
232
+ saved_files_count = 0
233
+
234
+ print(f" 結果を保存中 (対象形式: {', '.join(formats_to_save)})...")
235
+ try:
236
+ if "csv" in formats_to_save:
237
+ csv_file_path = output_dir_path / f"{audio_file_stem}.csv"
238
+ csv_headers = ["Start (s)", "End (s)", "Segment"]
239
+ with open(csv_file_path, 'w', newline='', encoding='utf-8') as f:
240
+ writer = csv.writer(f); writer.writerow(csv_headers); writer.writerows(vis_data)
241
+ print(f" CSVファイルを保存: {csv_file_path.name}"); saved_files_count +=1
242
+ if "srt" in formats_to_save:
243
+ srt_file_path = output_dir_path / f"{audio_file_stem}.srt"
244
+ write_srt(vis_data, srt_file_path) # ヘルパー関数呼び出し
245
+ print(f" SRTファイルを保存: {srt_file_path.name}"); saved_files_count +=1
246
+ if "vtt" in formats_to_save:
247
+ vtt_file_path = output_dir_path / f"{audio_file_stem}.vtt"
248
+ write_vtt(vis_data, word_vis_data, vtt_file_path) # ヘルパー関数呼び出し
249
+ print(f" VTTファイルを保存: {vtt_file_path.name}"); saved_files_count +=1
250
+ if "json" in formats_to_save:
251
+ json_file_path = output_dir_path / f"{audio_file_stem}.json"
252
+ write_json(vis_data, word_vis_data, json_file_path) # ヘルパー関数呼び出し
253
+ print(f" JSONファイルを保存: {json_file_path.name}"); saved_files_count +=1
254
+ if "lrc" in formats_to_save:
255
+ lrc_file_path = output_dir_path / f"{audio_file_stem}.lrc"
256
+ write_lrc(vis_data, lrc_file_path) # ヘルパー関数呼び出し
257
+ print(f" LRCファイルを保存: {lrc_file_path.name}"); saved_files_count +=1
258
+
259
+ if saved_files_count == 0 and formats_to_save: # 何も保存されなかった場合
260
+ print(f" 警告: 指定されたフォーマット {formats_to_save} でのファイルの保存は行われませんでした(対象なし等)。")
261
+ except Exception as e:
262
+ print(f" エラー: 文字起こしファイルの保存中にエラーが発生しました: {e}")
263
+
264
+ # --- 書き出しヘルパー関数群 (SRT, VTT, JSON, LRC) ---
265
+ def write_srt(segments: List, path: Path):
266
+ """SRTファイル形式で書き出します。"""
267
+ def sec2srt(t_float: float) -> str:
268
+ h, rem = divmod(int(t_float), 3600); m, s = divmod(rem, 60)
269
+ ms = int((t_float - int(t_float)) * 1000)
270
+ return f"{h:02}:{m:02}:{s:02},{ms:03}"
271
+ with open(path, "w", encoding="utf-8") as f:
272
+ for i, seg_list in enumerate(segments, 1): # segmentsは [[start_str, end_str, text], ...]
273
+ f.write(f"{i}\n{sec2srt(float(seg_list[0]))} --> {sec2srt(float(seg_list[1]))}\n{seg_list[2]}\n\n")
274
+
275
+ def write_vtt(segments: List, words: List, path: Path):
276
+ """VTTファイル形式で書き出します (単語ごとのタイムスタンプベース)。"""
277
+ def sec2vtt(t_float: float) -> str:
278
+ h, rem = divmod(int(t_float), 3600); m, s = divmod(rem, 60)
279
+ ms = int((t_float - int(t_float)) * 1000)
280
+ return f"{h:02}:{m:02}:{s:02}.{ms:03}"
281
+ with open(path, "w", encoding="utf-8") as f:
282
+ f.write("WEBVTT\n\n")
283
+ current_line: List[str] = []; line_start_time: Optional[float] = None
284
+ MAX_WORDS_PER_LINE = 7 # 1行あたりの最大単語数(調整可能)
285
+
286
+ if not words: # 単語情報がない場合はセグメント情報で代替
287
+ print(" VTT書き出し: 単語情報がないため、セグメント情報を使用します。")
288
+ for i, seg_list in enumerate(segments, 1):
289
+ f.write(f"NOTE Segment {i}\n")
290
+ f.write(f"{sec2vtt(float(seg_list[0]))} --> {sec2vtt(float(seg_list[1]))}\n{seg_list[2]}\n\n")
291
+ return
292
+
293
+ for i, word_data in enumerate(words): # wordsは [[start_str, end_str, word_text], ...]
294
+ w_start = float(word_data[0]); w_text = word_data[2]
295
+ if line_start_time is None: line_start_time = w_start
296
+ current_line.append(w_text)
297
+
298
+ # 次の単語があるか、または現在の行が最大単語数に達したか、最後の単語か
299
+ next_word_start = float(words[i+1][0]) if i + 1 < len(words) else w_start + 999 # 適当な大きな値
300
+ current_word_end = float(word_data[1])
301
+
302
+ # 行区切りの条件
303
+ if len(current_line) >= MAX_WORDS_PER_LINE or \
304
+ (next_word_start - current_word_end > 1.0) or \
305
+ i == len(words) - 1: # 最後の単語
306
+
307
+ line_end_time = current_word_end
308
+ if line_start_time is not None : # line_start_timeが設定されていることを確認
309
+ f.write(f"{sec2vtt(line_start_time)} --> {sec2vtt(line_end_time)}\n")
310
+ f.write(" ".join(current_line) + "\n\n")
311
+ current_line = []; line_start_time = None
312
+
313
+ def write_json(segments: List, words: List, path: Path):
314
+ """JSONファイル形式で書き出します (セグメントとそれに含まれる単語情報)。"""
315
+ result = {"segments": []}; word_idx = 0
316
+ for seg_data in segments: # segmentsは [[start_str, end_str, text], ...]
317
+ s_start_time = float(seg_data[0]); s_end_time = float(seg_data[1]); s_text = seg_data[2]
318
+ segment_words_list: List[dict] = []; temp_current_word_idx = word_idx
319
+
320
+ if words: # 単語情報がある場合のみ処理
321
+ while temp_current_word_idx < len(words):
322
+ w_data = words[temp_current_word_idx]; w_start_time = float(w_data[0]); w_end_time = float(w_data[1])
323
+ # 単語が現在のセグメントに(ほぼ)含まれるか
324
+ if w_start_time >= s_start_time and w_end_time <= s_end_time + 0.1: # 終了時間に少し許容
325
+ segment_words_list.append({"start": w_start_time, "end": w_end_time, "word": w_data[2]})
326
+ temp_current_word_idx += 1
327
+ elif w_start_time < s_start_time : # 単語がセグメントより前に開始 (word_idxを進める)
328
+ temp_current_word_idx += 1
329
+ elif w_start_time > s_end_time: # 単語がセグメントより後に開始 (このセグメントの単語は終わり)
330
+ break
331
+ else: # その他のケース (例: 単語がセグメントをまたぐなど)
332
+ temp_current_word_idx += 1
333
+ word_idx = temp_current_word_idx # word_idxを更新
334
+
335
+ result["segments"].append({"start": s_start_time, "end": s_end_time, "text": s_text, "words": segment_words_list})
336
+ with open(path, "w", encoding="utf-8") as f:
337
+ json.dump(result, f, ensure_ascii=False, indent=2)
338
+
339
+ def write_lrc(segments: List, path: Path):
340
+ """LRCファイル形式で書き出します。"""
341
+ def sec2lrc(t_float: float) -> str:
342
+ m, s = divmod(float(t_float), 60)
343
+ return f"[{int(m):02d}:{s:05.2f}]" # mm:ss.xx (xxは100分の1秒)
344
+ with open(path, "w", encoding="utf-8") as f:
345
+ for seg_list in segments: # segmentsは [[start_str, end_str, text], ...]
346
+ f.write(f"{sec2lrc(float(seg_list[0]))}{seg_list[2]}\n")
347
+
348
+
349
+ # --- 音声分割関数 ---
350
+ def split_audio_with_overlap_cli(
351
+ audio_path_str: str,
352
+ output_dir_for_chunks: str, # チャンクファイルの一時保存先
353
+ chunk_length_sec: int = CHUNK_LENGTH_SECONDS,
354
+ overlap_sec: int = CHUNK_OVERLAP_SECONDS
355
+ ) -> List[str]:
356
+ """
357
+ 音声ファイルを指定された長さのチャンクにオーバーラップ付きで分割し、
358
+ 分割された一時チャンクファイルのパスリストを返します。
359
+ """
360
+ print(f" 音声分割中: 基本チャンク長 {chunk_length_sec}s, オーバーラップ {overlap_sec}s")
361
+ try: audio = AudioSegment.from_file(audio_path_str)
362
+ except Exception as e:
363
+ print(f" エラー: 音声ファイル '{Path(audio_path_str).name}' のロード中にエラー(分割処理): {e}")
364
+ return [] # 空リストを返して処理中断
365
+
366
+ duration_ms = len(audio); chunk_length_ms = chunk_length_sec * 1000; overlap_ms = overlap_sec * 1000
367
+ chunk_paths_list: List[str] = []; start_ms = 0; chunk_idx = 0
368
+ audio_file_stem = Path(audio_path_str).stem # 元のファイル名(拡張子なし)
369
+
370
+ while start_ms < duration_ms:
371
+ # チャンクの実際の開始位置 (オーバーラップを考慮)
372
+ actual_chunk_start_ms = max(0, start_ms - (overlap_ms if start_ms > 0 else 0) )
373
+ # チャンクの基本終了位置
374
+ base_chunk_end_ms = start_ms + chunk_length_ms
375
+ # チャンクの実際の終了位置 (オーバーラップを考慮し、音声長を超えない)
376
+ actual_chunk_end_ms = min(base_chunk_end_ms + (overlap_ms if base_chunk_end_ms < duration_ms else 0), duration_ms)
377
+
378
+ # 実際のチャンクの長さが0以下になる異常ケースを避ける
379
+ if actual_chunk_start_ms >= actual_chunk_end_ms :
380
+ if start_ms >= duration_ms: break # ループ終了条件
381
+ print(f" 警告: チャンク計算で予期せぬ状態。actual_start({actual_chunk_start_ms}) >= actual_end({actual_chunk_end_ms})。スキップします。")
382
+ start_ms += chunk_length_ms; continue # 次の基本開始位置へ
383
+
384
+ chunk_segment = audio[actual_chunk_start_ms:actual_chunk_end_ms]
385
+
386
+ # 一時チャンクファイル名
387
+ chunk_file_name = f"{audio_file_stem}_chunk_{chunk_idx:03d}_temp.wav"
388
+ chunk_file_path_obj = Path(output_dir_for_chunks, chunk_file_name)
389
+
390
+ try:
391
+ chunk_segment.export(chunk_file_path_obj, format="wav")
392
+ chunk_paths_list.append(chunk_file_path_obj.as_posix())
393
+ # print(f" チャンク {chunk_idx:03d} ({actual_chunk_start_ms/1000:.2f}s - {actual_chunk_end_ms/1000:.2f}s) を保存: {chunk_file_name}")
394
+ except Exception as export_chunk_e:
395
+ print(f" エラー: 一時チャンクファイル {chunk_file_name} のエクスポートに失敗: {export_chunk_e}")
396
+ # エラーが発生しても処理を続行し、可能な限り他のチャンクを処理する
397
+
398
+ start_ms += chunk_length_ms # 次の基本開始位置へ
399
+ chunk_idx += 1
400
+
401
+ print(f" 音声を {len(chunk_paths_list)} 個のチャンクに分割しました。")
402
+ return chunk_paths_list
403
+
404
+
405
+ # --- 単一ファイル処理のメインロジック ---
406
+ def process_single_file(
407
+ input_file_path_obj: Path,
408
+ asr_model_instance: ASRModel,
409
+ device_to_use: str,
410
+ output_formats_list: List[str] # 保存するフォーマットのリスト
411
+ ) -> bool: # 成功ならTrue、失敗またはスキップ(CSV既存)ならTrue、致命的エラーならFalse
412
+ """指定された単一の音声/動画ファイルを前処理、文字起こし、結果保存まで行います。"""
413
+ input_file_str = input_file_path_obj.as_posix()
414
+ input_file_stem = input_file_path_obj.stem
415
+ # 出力ファイルと一時ファイルは入力ファイルと同じディレクトリに作成
416
+ output_and_temp_dir_str = input_file_path_obj.parent.as_posix()
417
+
418
+ print(f"\n======== ファイル処理開始: {input_file_path_obj.name} ========")
419
+
420
+ # スキップ処理: CSVファイルの存在確認 (指定された出力形式にcsvが含まれる場合のみ)
421
+ output_csv_path = input_file_path_obj.with_suffix('.csv')
422
+ if "csv" in output_formats_list and output_csv_path.exists():
423
+ print(f"スキップ: 出力CSVファイル '{output_csv_path.name}' は既に存在します。")
424
+ return True # スキップも処理対応済みとみなす
425
+
426
+ temp_preprocessed_audio_path_str: Optional[str] = None # 前処理された一時音声ファイルのパス
427
+ temp_chunk_file_paths_str_list: List[str] = [] # 分割された一時チャンクファイルのパスリスト
428
+
429
+ try:
430
+ # 1. 音声前処理
431
+ print(f"--- ステップ1/3: {input_file_stem} の音声前処理 ---")
432
+ processed_path_for_asr, _, duration_sec_val = preprocess_audio_cli(
433
+ input_file_str, output_and_temp_dir_str
434
+ )
435
+ if not processed_path_for_asr or duration_sec_val is None:
436
+ print(f"エラー: {input_file_path_obj.name} の前処理に失敗しました。このファイルの処理を中断します。")
437
+ return False # 前処理失敗は致命的
438
+
439
+ # 前処理で一時ファイルが作成された場合、そのパスを記録 (後で削除するため)
440
+ if processed_path_for_asr != input_file_str:
441
+ temp_preprocessed_audio_path_str = processed_path_for_asr
442
+
443
+ # 2. 文字起こし (長時間・超長時間対応含む)
444
+ print(f"--- ステッ���2/3: {input_file_stem} の文字起こし (音声長: {duration_sec_val:.2f}秒) ---")
445
+
446
+ final_vis_data: Optional[List] = None
447
+ final_word_vis_data: Optional[List] = None
448
+
449
+ # 超長時間音声 (例: 3時間以上) の場合はチャンク分割して処理
450
+ if duration_sec_val > VERY_LONG_AUDIO_THRESHOLD_SECONDS:
451
+ print(f" 情報: 音声長が{VERY_LONG_AUDIO_THRESHOLD_SECONDS/3600:.1f}時間を超えるため、約{CHUNK_LENGTH_SECONDS/3600:.0f}時間ごとのチャンクに分割して処理します。")
452
+
453
+ chunk_file_paths_str = split_audio_with_overlap_cli(
454
+ processed_path_for_asr, # 前処理済みのパスを使用
455
+ output_and_temp_dir_str, # チャンクも同じディレクトリに一時保存
456
+ chunk_length_sec=CHUNK_LENGTH_SECONDS,
457
+ overlap_sec=CHUNK_OVERLAP_SECONDS
458
+ )
459
+ if not chunk_file_paths_str: # チャンク分割に失敗した場合
460
+ print(f" エラー: {input_file_path_obj.name} のチャンク分割に失敗しました。このファイルの処理を中断します。")
461
+ return False
462
+
463
+ temp_chunk_file_paths_str_list = chunk_file_paths_str[:] # 後で削除するためコピー
464
+
465
+ all_vis_data_merged: List[List[str]] = []
466
+ all_word_vis_data_merged: List[List[str]] = []
467
+ current_global_time_offset_sec = 0.0 # 各チャンクの開始時刻のオフセット
468
+ last_global_segment_end_time_sec = 0.0 # オーバーラップ除去のための前のセグメントの終了時刻
469
+
470
+ for i, chunk_file_path_str in enumerate(temp_chunk_file_paths_str_list):
471
+ print(f" チャンク {i+1}/{len(temp_chunk_file_paths_str_list)} ({Path(chunk_file_path_str).name}) を処理中...")
472
+ try:
473
+ # チャンクの長さは長尺設定の閾値判定に使う。最大で CHUNK_LENGTH + CHUNK_OVERLAP 程度。
474
+ estimated_chunk_duration_for_asr_settings = CHUNK_LENGTH_SECONDS + CHUNK_OVERLAP_SECONDS
475
+
476
+ vis_data_chunk, _, word_vis_data_chunk = transcribe_audio_cli(
477
+ chunk_file_path_str,
478
+ asr_model_instance,
479
+ estimated_chunk_duration_for_asr_settings,
480
+ device_to_use
481
+ )
482
+ if not vis_data_chunk: # チャンクの文字起こし失敗
483
+ print(f" 警告: チャンク {Path(chunk_file_path_str).name} の文字起こしに失敗しました。このチャンクをスキップします。")
484
+ # このチャンクが処理できなかったとしても、次のチャンクの開始時間は進める
485
+ current_global_time_offset_sec += CHUNK_LENGTH_SECONDS - (CHUNK_OVERLAP_SECONDS if i < len(temp_chunk_file_paths_str_list) - 1 else 0)
486
+ continue
487
+
488
+ # タイムスタンプをグローバルに補正し、オーバーラップを除去
489
+ # セグメントデータの処理
490
+ for seg_row_list in vis_data_chunk:
491
+ s_local_sec = float(seg_row_list[0]); e_local_sec = float(seg_row_list[1]); text_seg = seg_row_list[2]
492
+ s_global_sec = s_local_sec + current_global_time_offset_sec
493
+ e_global_sec = e_local_sec + current_global_time_offset_sec
494
+ if s_global_sec >= last_global_segment_end_time_sec - 0.1 : # 0.1秒程度の許容
495
+ all_vis_data_merged.append([f"{s_global_sec:.2f}", f"{e_global_sec:.2f}", text_seg])
496
+ last_global_segment_end_time_sec = max(last_global_segment_end_time_sec, e_global_sec)
497
+
498
+ # 単語データの処理 (同様にオフセットとオーバーラップ考慮)
499
+ temp_last_word_global_end_time_sec = float(all_word_vis_data_merged[-1][1]) if all_word_vis_data_merged else 0.0
500
+ if word_vis_data_chunk: # 単語情報がある場合のみ
501
+ for word_row_list in word_vis_data_chunk:
502
+ w_s_local_sec = float(word_row_list[0]); w_e_local_sec = float(word_row_list[1]); text_word = word_row_list[2]
503
+ w_s_global_sec = w_s_local_sec + current_global_time_offset_sec
504
+ w_e_global_sec = w_e_local_sec + current_global_time_offset_sec
505
+ if w_s_global_sec >= temp_last_word_global_end_time_sec - 0.05: # わずかな重複は許容
506
+ all_word_vis_data_merged.append([f"{w_s_global_sec:.2f}", f"{w_e_global_sec:.2f}", text_word])
507
+ temp_last_word_global_end_time_sec = max(temp_last_word_global_end_time_sec, w_e_global_sec)
508
+
509
+ # 次のチャンクのためのオフセット更新
510
+ if i < len(temp_chunk_file_paths_str_list) - 1: # 最後のチャンク以外
511
+ current_global_time_offset_sec += (CHUNK_LENGTH_SECONDS - CHUNK_OVERLAP_SECONDS)
512
+ # else: # 最後のチャンク。次のオフセットはない。
513
+ except Exception as chunk_proc_e:
514
+ print(f" エラー: チャンク {Path(chunk_file_path_str).name} の処理中に予期せぬエラー: {chunk_proc_e}")
515
+ # エラーでもオフセットは進める
516
+ if i < len(temp_chunk_file_paths_str_list) - 1:
517
+ current_global_time_offset_sec += (CHUNK_LENGTH_SECONDS - CHUNK_OVERLAP_SECONDS)
518
+
519
+ final_vis_data = all_vis_data_merged
520
+ final_word_vis_data = all_word_vis_data_merged
521
+
522
+ else: # 3時間以内 (通常の長時間処理、または短時間処理)
523
+ vis_data_single, _, word_vis_data_single = transcribe_audio_cli(
524
+ processed_path_for_asr,
525
+ asr_model_instance,
526
+ duration_sec_val,
527
+ device_to_use
528
+ )
529
+ if not vis_data_single: # 文字起こし失敗
530
+ print(f"エラー: {input_file_path_obj.name} の文字起こしに失敗しました。このファイルの処理を中断します。")
531
+ return False
532
+
533
+ final_vis_data = vis_data_single
534
+ final_word_vis_data = word_vis_data_single
535
+
536
+ # 3. 結果保存
537
+ if final_vis_data: # 文字起こし結果が空でない場合のみ保存
538
+ print(f"--- ステップ3/3: {input_file_stem} の文字起こし結果保存 ---")
539
+ save_transcripts_cli(output_and_temp_dir_str, input_file_stem,
540
+ final_vis_data, final_word_vis_data if final_word_vis_data else [], # word_vis_dataがNoneの場合空リスト
541
+ formats=output_formats_list)
542
+ else:
543
+ print(f"情報: {input_file_path_obj.name} の文字起こし結果が空のため、ファイルは保存しませんでした。")
544
+
545
+ return True # 処理成功
546
+
547
+ except Exception as e:
548
+ print(f"エラー: ファイル {input_file_path_obj.name} の処理中に予期せぬ大域的エラーが発生しました: {e}")
549
+ import traceback
550
+ traceback.print_exc() # 詳細なエラー情報を表示
551
+ return False # 致命的エラー
552
+ finally:
553
+ # 一時ファイルの削除
554
+ if temp_preprocessed_audio_path_str and Path(temp_preprocessed_audio_path_str).exists():
555
+ try:
556
+ os.remove(temp_preprocessed_audio_path_str)
557
+ print(f" 一時ファイル {Path(temp_preprocessed_audio_path_str).name} を削除しました。")
558
+ except OSError as e_os:
559
+ print(f" 警告: 一時ファイル {Path(temp_preprocessed_audio_path_str).name} の削除に失敗: {e_os}")
560
+
561
+ for chunk_f_str in temp_chunk_file_paths_str_list:
562
+ chunk_f_path_obj = Path(chunk_f_str)
563
+ if chunk_f_path_obj.exists():
564
+ try:
565
+ os.remove(chunk_f_path_obj)
566
+ print(f" 一時チャンクファイル {chunk_f_path_obj.name} を削除しました。")
567
+ except OSError as e_os_chunk:
568
+ print(f" 警告: 一時チャンクファイル {chunk_f_path_obj.name} の削除に失敗: {e_os_chunk}")
569
+ print(f"======== ファイル処理終了: {input_file_path_obj.name} ========\n")
570
+
571
+
572
+ # --- ディレクトリ内ファイルの一括処理関数 ---
573
+ def batch_process_directory(
574
+ target_dir_str: str,
575
+ asr_model_instance: ASRModel,
576
+ device_to_use: str,
577
+ output_formats: Optional[List[str]] = None # 保存するフォーマットのリスト
578
+ ):
579
+ """指定されたディレクトリ内の音声/動画ファイルを優先順位に従って処理し文字起こしを行います。"""
580
+ if output_formats is None: # argparseでデフォルトが設定されるので通常ここは通らない
581
+ output_formats_to_use = DEFAULT_OUTPUT_FORMATS
582
+ else:
583
+ output_formats_to_use = output_formats
584
+
585
+ target_dir_path = Path(target_dir_str)
586
+ if not target_dir_path.is_dir():
587
+ print(f"エラー: 指定されたパス '{target_dir_str}' は有効なディレクトリではありません。処理を中止します。")
588
+ return
589
+
590
+ print(f"処理対象ディレクトリ: {target_dir_path.resolve()}")
591
+ print(f"入力ファイルの探索優先順位: {', '.join(INPUT_PRIORITY_EXTENSIONS)}")
592
+ print(f"出力ファイル形式: {', '.join(output_formats_to_use)}")
593
+
594
+ # 1. ディレクトリ内の全ファイルから、処理対象となりうるファイルの「ステム名」(拡張子なしの名前)を収集
595
+ all_files_in_dir = list(target_dir_path.iterdir()) # ディレクトリ内の全アイテムを取得
596
+ potential_stems: Set[str] = set() # 重複しないステム名を格納
597
+ for f_path_obj in all_files_in_dir:
598
+ # ファイルであり、かつ優先度リストの拡張子に合致する場合
599
+ if f_path_obj.is_file() and f_path_obj.suffix.lower() in INPUT_PRIORITY_EXTENSIONS:
600
+ potential_stems.add(f_path_obj.stem) # ステム名を追加
601
+
602
+ if not potential_stems:
603
+ print(f"情報: ディレクトリ '{target_dir_path.name}' に処理対象の拡張子 ({', '.join(INPUT_PRIORITY_EXTENSIONS)}) を持つファイルは見つかりませんでした。")
604
+ return
605
+
606
+ print(f"{len(potential_stems)} 個のユニークなファイル名(拡張子除く)が見つかりました。優先順位に従って処理対象ファイルを選択します...")
607
+
608
+ files_to_actually_process: List[Path] = [] # 実際に処理するファイルのリスト
609
+ # ステム名をソートして処理順序を一貫させる (任意)
610
+ for stem_name in sorted(list(potential_stems)):
611
+ selected_file_for_this_stem: Optional[Path] = None
612
+ # 優先順位リストに従ってファイルを探す
613
+ for ext_priority in INPUT_PRIORITY_EXTENSIONS: #例: ['.wav', '.mp3', '.mp4']
614
+ potential_file = target_dir_path / f"{stem_name}{ext_priority}"
615
+ if potential_file.exists() and potential_file.is_file():
616
+ selected_file_for_this_stem = potential_file
617
+ print(f" ファイル名 '{stem_name}': '{potential_file.name}' を処理対象として選択 (優先度: {ext_priority})。")
618
+ break # このステム名に対する処理ファイルが見つかったのでループを抜ける
619
+
620
+ if selected_file_for_this_stem:
621
+ files_to_actually_process.append(selected_file_for_this_stem)
622
+ # else: このブロックには通常入らないはず (potential_stems の作り方から)
623
+
624
+ if not files_to_actually_process:
625
+ print("情報: 優先順位を適用した結果、実際に処理するファイルはありませんでした。")
626
+ return
627
+
628
+ print(f"実際に処理するファイル数: {len(files_to_actually_process)} 個")
629
+
630
+ processed_successfully_count = 0
631
+ skipped_due_to_existing_csv_count = 0 # process_single_file内で判定されるスキップ
632
+ failed_count = 0
633
+
634
+ for input_file_to_process_obj in files_to_actually_process:
635
+ # process_single_file は、内部でCSV存在によるスキップ判定も行う
636
+ # 戻り値: Trueなら成功またはスキップ、Falseなら失敗
637
+
638
+ # スキップ判定をここでも行うことで、カウントを正確にする
639
+ is_skipped = False
640
+ if "csv" in output_formats_to_use:
641
+ output_csv_path_check = input_file_to_process_obj.with_suffix('.csv')
642
+ if output_csv_path_check.exists():
643
+ print(f"\n======== ファイル処理開始: {input_file_to_process_obj.name} ========") # process_single_file内のログと合わせる
644
+ print(f"スキップ: 出力CSVファイル '{output_csv_path_check.name}' は既に存在します。")
645
+ print(f"======== ファイル処理終了: {input_file_to_process_obj.name} ========\n")
646
+ skipped_due_to_existing_csv_count += 1
647
+ is_skipped = True
648
+
649
+ if not is_skipped:
650
+ success_flag = process_single_file(
651
+ input_file_to_process_obj,
652
+ asr_model_instance,
653
+ device_to_use,
654
+ output_formats_to_use
655
+ )
656
+ if success_flag:
657
+ processed_successfully_count += 1
658
+ else:
659
+ failed_count += 1
660
+
661
+ print("\n======== 全ファイルのバッチ処理が完了しました ========")
662
+ total_considered = len(files_to_actually_process)
663
+ print(f"総対象ファイル数(優先度選択後): {total_considered}")
664
+ print(f" 処理成功ファイル数: {processed_successfully_count}")
665
+ print(f" CSV既存によりスキップされたファイル数: {skipped_due_to_existing_csv_count}")
666
+ print(f" 処理失敗ファイル数: {failed_count}")
667
+
668
+
669
+ # --- スクリプト実行のエントリポイント ---
670
+ if __name__ == "__main__":
671
+ parser = argparse.ArgumentParser(
672
+ description="指定されたディレクトリ内の音声/動画ファイルをNVIDIA Parakeet ASRモデルで文字起こしします。\n"
673
+ f"同じ名前のファイルが複数ある場合、{' > '.join(INPUT_PRIORITY_EXTENSIONS)} の優先順位で処理します。",
674
+ formatter_class=argparse.RawTextHelpFormatter # ヘルプメッセージの改行を保持
675
+ )
676
+ parser.add_argument(
677
+ "target_directory",
678
+ type=str,
679
+ help="処理対象のファイルが含まれるディレクトリのパス。\n例: /mnt/data/my_videos または C:\\Users\\YourName\\Videos"
680
+ )
681
+ # --extension 引数は削除 (ファイル選択ロジック変更のため)
682
+ parser.add_argument(
683
+ "--formats",
684
+ type=str,
685
+ default=",".join(DEFAULT_OUTPUT_FORMATS), # デフォルトは全形式
686
+ help=(f"出力する文字起こしファイルの形式をカンマ区切りで指定。\n"
687
+ f"例: csv,srt (デフォルト: {','.join(DEFAULT_OUTPUT_FORMATS)})\n"
688
+ f"利用可能な形式: {','.join(DEFAULT_OUTPUT_FORMATS)}")
689
+ )
690
+ parser.add_argument(
691
+ "--device",
692
+ type=str,
693
+ default=None, # 指定がなければ自動判別
694
+ choices=['cuda', 'cpu'],
695
+ help="使用するデバイスを指定 (cuda または cpu)。\n指定がない場合は自動的にCUDAが利用可能か判別します。"
696
+ )
697
+
698
+ args = parser.parse_args()
699
+
700
+ # デバイス選択処理
701
+ if args.device: # ユーザーが明示的にデバイスを指定した場合
702
+ selected_device = args.device
703
+ else: # 自動判別
704
+ selected_device = "cuda" if torch.cuda.is_available() else "cpu"
705
+
706
+ print(f"使用デバイス: {selected_device.upper()}")
707
+ if selected_device == "cuda":
708
+ if not torch.cuda.is_available(): # CUDA指定だが利用不可の場合
709
+ print("警告: CUDAが指定されましたが、システムで利用できません。CPUを使用します。")
710
+ selected_device = "cpu"
711
+ else:
712
+ try:
713
+ print(f"CUDAデバイス名: {torch.cuda.get_device_name(0)}")
714
+ except Exception as e_cuda_name:
715
+ print(f"CUDAデバイス名の取得に失敗しました: {e_cuda_name}")
716
+
717
+
718
+ # ASRモデルのロード (スクリプト開始時に一度だけ)
719
+ print(f"ASRモデル '{MODEL_NAME}' をロードしています...")
720
+ asr_model_main: Optional[ASRModel] = None
721
+ try:
722
+ # `verbose=False` は NeMo のバージョンによってサポートされないため削除済み
723
+ asr_model_main = ASRModel.from_pretrained(model_name=MODEL_NAME)
724
+ asr_model_main.eval() # 評価モードに設定
725
+ print(f"モデル '{MODEL_NAME}' のロードが完了しました。")
726
+ except Exception as model_load_e:
727
+ print(f"致命的エラー: ASRモデル '{MODEL_NAME}' のロードに失敗しました: {model_load_e}")
728
+ print(" 考えられる原因:")
729
+ print(" - NVIDIA NeMo Toolkit (nemo_toolkit[asr]) が正しくインストールされていない。")
730
+ print(" - インターネット接続がないか不安定で、モデルファイルのダウンロードに失敗した。")
731
+ print(" - モデル名が間違っているか、Hugging Face Hub等で利用できなくなっている。")
732
+ exit(1) # モデルロード失敗は致命的なので終了
733
+
734
+ # 出力フォーマットの解析と検証
735
+ # カンマ区切り文字列をリストに変換し、前後の空白を除去、小文字化
736
+ output_formats_requested = [fmt.strip().lower() for fmt in args.formats.split(',') if fmt.strip()]
737
+ # 有効なフォーマットのみを抽出
738
+ final_output_formats_to_use = [fmt for fmt in output_formats_requested if fmt in DEFAULT_OUTPUT_FORMATS]
739
+
740
+ if not output_formats_requested and args.formats: # 何か入力したが全て無効だった場合など
741
+ print(f"警告: 指定された出力フォーマット '{args.formats}' に有効なものが含まれていません。")
742
+
743
+ if not final_output_formats_to_use : # 結果的に有効なフォーマットが一つもなかった場合
744
+ print(f"情報: 有効な出力フォーマットが指定されなかったため、デフォルトの全形式 ({','.join(DEFAULT_OUTPUT_FORMATS)}) で出力します。")
745
+ final_output_formats_to_use = DEFAULT_OUTPUT_FORMATS
746
+
747
+
748
+ # メインのバッチ処理関数を呼び出し
749
+ batch_process_directory(
750
+ args.target_directory,
751
+ asr_model_main,
752
+ selected_device,
753
+ output_formats=final_output_formats_to_use
754
+ )