sungo-ganpare commited on
Commit
f67cd0b
·
1 Parent(s): aaa4bd6

音声処理の設定を改善し、セグメント分割機能を強化。VTTファイルのサイズ制限を追加し、エラーハンドリングを強化。自然な区切り点を探す関数を追加し、文字起こしの精度を向上。

Browse files
Files changed (1) hide show
  1. transcribe_cli.py +224 -64
transcribe_cli.py CHANGED
@@ -20,11 +20,20 @@ import shutil
20
  MODEL_NAME = "nvidia/parakeet-tdt-0.6b-v2"
21
  TARGET_SAMPLE_RATE = 16000
22
  # 音声の長さに関する閾値 (秒)
23
- LONG_AUDIO_THRESHOLD_SECONDS = 480 # 8分 (この長さを超えると長尺設定を試みる)
24
- VERY_LONG_AUDIO_THRESHOLD_SECONDS = 10800 # 3時間 (この長さを超えるとチャンク分割処理)
25
  # チャンク分割時の設定
26
- CHUNK_LENGTH_SECONDS = 3600 # 1時間
27
- CHUNK_OVERLAP_SECONDS = 30 # 30秒
 
 
 
 
 
 
 
 
 
28
  # ★ 入力ファイルの優先順位付き拡張子リスト
29
  INPUT_PRIORITY_EXTENSIONS: List[str] = ['.wav', '.mp3', '.mp4']
30
  # ★ デフォルトで出力するフォーマットリスト
@@ -168,47 +177,112 @@ def get_audio_duration_with_ffprobe(audio_path_str: str) -> Optional[float]:
168
  return None
169
 
170
  # --- 文字起こしコア関数 ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def transcribe_audio_cli(
172
  transcribe_path_str: str,
173
  model: ASRModel,
174
- duration_sec: float, # この音声セグメントの長さ
175
  device: str
176
  ) -> Tuple[Optional[List], Optional[List], Optional[List]]:
177
- """
178
- 指定されたオーディオファイルをNeMo ASRモデルで文字起こしします。
179
- 成功した場合、(セグメント情報リスト, RAWタイムスタンプリスト, 単語情報リスト) を返します。
180
- 失敗した場合、(None, None, None) を返します。
181
- """
182
- long_audio_settings_applied = False # 長尺設定が適用されたかどうかのフラグ
183
- original_model_dtype = model.dtype # モデルの元のデータ型を保存 (通常は torch.float32)
184
 
185
  try:
186
- # CUDAデバイス使用時はメモリキャッシュをクリア
187
  if device == 'cuda':
188
  torch.cuda.empty_cache()
189
  gc.collect()
190
 
191
- model.to(device) # モデルを推論に使用するデバイスへ移動
192
 
193
- # 音声長に応じてモデル設定を変更 (長尺音声対応)
194
  if duration_sec > LONG_AUDIO_THRESHOLD_SECONDS:
195
  try:
196
  print(f" 情報: 音声長 ({duration_sec:.0f}s) が閾値 ({LONG_AUDIO_THRESHOLD_SECONDS}s) を超えるため、長尺音声向け設定を適用します。")
197
- model.change_attention_model(self_attention_model="rel_pos_local_attn", att_context_size=[256, 256])
198
- model.change_subsampling_conv_chunking_factor(1)
 
 
 
199
  long_audio_settings_applied = True
200
- if device == 'cuda': torch.cuda.empty_cache(); gc.collect()
 
 
201
  except Exception as setting_e:
202
  print(f" 警告: 長尺音声向け設定の適用に失敗しました: {setting_e}。デフォルト設定で続行します。")
203
-
204
  if device == 'cuda' and torch.cuda.is_bf16_supported():
205
  print(" 情報: モデルを bfloat16 に変換して推論を実行します。")
206
  model.to(torch.bfloat16)
207
- elif model.dtype != original_model_dtype:
208
- model.to(original_model_dtype)
209
-
210
  print(f" 文字起こしを実行中 (デバイス: {device}, モデルdtype: {model.dtype})...")
211
- output = model.transcribe([transcribe_path_str], timestamps=True, batch_size=4)
 
 
 
 
212
 
213
  if not output or not isinstance(output, list) or not output[0] or \
214
  not hasattr(output[0], 'timestamp') or not output[0].timestamp or \
@@ -217,13 +291,65 @@ def transcribe_audio_cli(
217
  return None, None, None
218
 
219
  segment_timestamps = output[0].timestamp['segment']
220
- vis_data = [[f"{ts['start']:.2f}", f"{ts['end']:.2f}", ts['segment']] for ts in segment_timestamps]
221
- raw_times_data = [[ts['start'], ts['end']] for ts in segment_timestamps]
222
- word_timestamps_raw = output[0].timestamp.get("word", [])
223
- word_vis_data = [
224
- [f"{w['start']:.2f}", f"{w['end']:.2f}", w["word"]]
225
- for w in word_timestamps_raw if isinstance(w, dict) and 'start' in w and 'end' in w and 'word' in w
226
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  print(" 文字起こし完了。")
228
  return vis_data, raw_times_data, word_vis_data
229
 
@@ -280,8 +406,16 @@ def save_transcripts_cli(output_dir_str: str, audio_file_stem: str,
280
  print(f" SRTファイルを保存: {srt_file_path.name}"); saved_files_count +=1
281
  if "vtt" in formats_to_save:
282
  vtt_file_path = output_dir_path / f"{audio_file_stem}.vtt"
283
- write_vtt(vis_data, word_vis_data, vtt_file_path)
284
- print(f" VTTファイルを保存: {vtt_file_path.name}"); saved_files_count +=1
 
 
 
 
 
 
 
 
285
  if "json" in formats_to_save:
286
  json_file_path = output_dir_path / f"{audio_file_stem}.json"
287
  write_json(vis_data, word_vis_data, json_file_path)
@@ -295,6 +429,7 @@ def save_transcripts_cli(output_dir_str: str, audio_file_stem: str,
295
  print(f" 警告: 指定されたフォーマット {formats_to_save} でのファイルの保存は行われませんでした。")
296
  except Exception as e:
297
  print(f" エラー: 文字起こしファイルの保存中にエラーが発生しました: {e}")
 
298
 
299
  # --- 書き出しヘルパー関数群 (SRT, VTT, JSON, LRC) ---
300
  def write_srt(segments: List, path: Path):
@@ -308,7 +443,8 @@ def write_srt(segments: List, path: Path):
308
 
309
  def write_vtt(segments: List, words: List, path: Path):
310
  def sec2vtt(t_float: float) -> str:
311
- h, rem = divmod(int(t_float), 3600); m, s = divmod(rem, 60)
 
312
  ms = int((t_float - int(t_float)) * 1000)
313
  return f"{h:02}:{m:02}:{s:02}.{ms:03}"
314
 
@@ -321,13 +457,19 @@ def write_vtt(segments: List, words: List, path: Path):
321
  f.write("::cue(.line) { background: rgba(0,0,0,0.7); padding: 4px; }\n\n")
322
 
323
  if not words:
324
- # フォールバック処理は同じ
325
  for i, seg_list in enumerate(segments, 1):
326
  f.write(f"NOTE Segment {i}\n")
327
  f.write(f"{sec2vtt(float(seg_list[0]))} --> {sec2vtt(float(seg_list[1]))}\n{seg_list[2]}\n\n")
 
 
 
 
 
 
328
  return
329
 
330
- # セグメント単位でグループ化してカラオケ風に
331
  for seg_data in segments:
332
  seg_start = float(seg_data[0])
333
  seg_end = float(seg_data[1])
@@ -342,56 +484,74 @@ def write_vtt(segments: List, words: List, path: Path):
342
 
343
  if not segment_words:
344
  continue
345
-
346
- # セグメント開始時刻から最初の単語開始まで(全て未来色)
 
 
 
347
  first_word_start = float(segment_words[0][1][0])
348
  if seg_start < first_word_start - 0.05:
349
- line_parts = [f'<c.future>{w_data[2]}</c>' for _, w_data in segment_words]
350
  f.write(f"{sec2vtt(seg_start)} --> {sec2vtt(first_word_start)}\n")
351
- f.write(f'<c.line>{" ".join(line_parts)}</c>\n\n')
 
 
 
 
 
 
352
 
353
  # 各単語の処理
354
- for local_idx, (global_word_idx, word_data) in enumerate(segment_words):
355
  w_start = float(word_data[0])
356
  w_end = float(word_data[1])
357
 
358
- # 単語再生中:現在の単語をハイライト
 
 
 
359
  line_parts = []
360
- for i, (_, w_data) in enumerate(segment_words):
361
- w_text = w_data[2]
362
  if i == local_idx:
363
- line_parts.append(f'<c.current>{w_text}</c>')
364
  elif i < local_idx:
365
- line_parts.append(f'<c.past>{w_text}</c>')
366
  else:
367
- line_parts.append(f'<c.future>{w_text}</c>')
368
 
369
- f.write(f"{sec2vtt(w_start)} --> {sec2vtt(w_end)}\n")
370
  f.write(f'<c.line>{" ".join(line_parts)}</c>\n\n')
371
 
372
- # 単語終了から次の単語開始まで(無音期間):過去・未来のみ
373
- if local_idx < len(segment_words) - 1: # 最後の単語でない場合
 
 
 
 
 
 
374
  next_word_start = float(segment_words[local_idx + 1][1][0])
375
  gap_duration = next_word_start - w_end
376
 
377
  if gap_duration > 0.05: # 50ms以上の無音期間がある場合
378
- gap_line_parts = []
379
- for i, (_, w_data) in enumerate(segment_words):
380
- w_text = w_data[2]
381
- if i <= local_idx: # 現在の単語まで(過去)
382
- gap_line_parts.append(f'<c.past>{w_text}</c>')
383
- else: # 未来の単語
384
- gap_line_parts.append(f'<c.future>{w_text}</c>')
385
-
386
  f.write(f"{sec2vtt(w_end)} --> {sec2vtt(next_word_start)}\n")
387
- f.write(f'<c.line>{" ".join(gap_line_parts)}</c>\n\n')
388
- else:
389
- # 最後の単語終了からセグメント終了まで(全て過去色)
390
- if w_end < seg_end - 0.05:
391
- line_parts = [f'<c.past>{w_data[2]}</c>' for _, w_data in segment_words]
392
- f.write(f"{sec2vtt(w_end)} --> {sec2vtt(seg_end)}\n")
393
- f.write(f'<c.line>{" ".join(line_parts)}</c>\n\n')
394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
  def write_json(segments: List, words: List, path: Path):
397
  result = {"segments": []}; word_idx = 0
 
20
  MODEL_NAME = "nvidia/parakeet-tdt-0.6b-v2"
21
  TARGET_SAMPLE_RATE = 16000
22
  # 音声の長さに関する閾値 (秒)
23
+ LONG_AUDIO_THRESHOLD_SECONDS = 480 # 8分
24
+ VERY_LONG_AUDIO_THRESHOLD_SECONDS = 10800 # 3時間
25
  # チャンク分割時の設定
26
+ CHUNK_LENGTH_SECONDS = 1800 # 30分
27
+ CHUNK_OVERLAP_SECONDS = 60 # 1分
28
+ # セグメント処理の設定
29
+ MAX_SEGMENT_LENGTH_SECONDS = 15 # 最大セグメント長(秒)を15秒に短縮
30
+ MAX_SEGMENT_CHARS = 100 # 最大セグメント文字数を100文字に短縮
31
+ MIN_SEGMENT_GAP_SECONDS = 0.3 # 最小セグメント間隔(秒)
32
+ # VTTファイルの最大サイズ(バイト)
33
+ MAX_VTT_SIZE_BYTES = 10 * 1024 * 1024 # 10MB
34
+ # 文の区切り文字
35
+ SENTENCE_ENDINGS = ['.', '!', '?', '。', '!', '?']
36
+ SENTENCE_PAUSES = [',', '、', ';', ';', ':', ':']
37
  # ★ 入力ファイルの優先順位付き拡張子リスト
38
  INPUT_PRIORITY_EXTENSIONS: List[str] = ['.wav', '.mp3', '.mp4']
39
  # ★ デフォルトで出力するフォーマットリスト
 
177
  return None
178
 
179
  # --- 文字起こしコア関数 ---
180
+ def find_natural_break_point(text: str, max_length: int) -> int:
181
+ """テキスト内で自然な区切り点を探す"""
182
+ if len(text) <= max_length:
183
+ return len(text)
184
+
185
+ # 文末で区切る
186
+ for i in range(max_length, 0, -1):
187
+ if i < len(text) and text[i] in SENTENCE_ENDINGS:
188
+ return i + 1
189
+
190
+ # 文の区切りで区切る
191
+ for i in range(max_length, 0, -1):
192
+ if i < len(text) and text[i] in SENTENCE_PAUSES:
193
+ return i + 1
194
+
195
+ # スペースで区切る
196
+ for i in range(max_length, 0, -1):
197
+ if i < len(text) and text[i].isspace():
198
+ return i + 1
199
+
200
+ # それでも見つからない場合は最大長で区切る
201
+ return max_length
202
+
203
+ def split_segment(segment: dict, max_length_seconds: float, max_chars: int) -> List[dict]:
204
+ """セグメントを自然な区切りで分割する"""
205
+ if (segment['end'] - segment['start']) <= max_length_seconds and len(segment['segment']) <= max_chars:
206
+ return [segment]
207
+
208
+ result = []
209
+ current_text = segment['segment']
210
+ current_start = segment['start']
211
+ total_duration = segment['end'] - segment['start']
212
+
213
+ while current_text:
214
+ # 文字数に基づく分割点を探す
215
+ break_point = find_natural_break_point(current_text, max_chars)
216
+
217
+ # 時間に基づく分割点を計算
218
+ text_ratio = break_point / len(segment['segment'])
219
+ segment_duration = total_duration * text_ratio
220
+
221
+ # 分割点が最大長を超えないように調整
222
+ if segment_duration > max_length_seconds:
223
+ time_ratio = max_length_seconds / total_duration
224
+ break_point = int(len(segment['segment']) * time_ratio)
225
+ break_point = find_natural_break_point(current_text, break_point)
226
+ segment_duration = max_length_seconds
227
+
228
+ # 新しいセグメントを作成
229
+ new_segment = {
230
+ 'start': current_start,
231
+ 'end': current_start + segment_duration,
232
+ 'segment': current_text[:break_point].strip()
233
+ }
234
+ result.append(new_segment)
235
+
236
+ # 残りのテキストと開始時間を更新
237
+ current_text = current_text[break_point:].strip()
238
+ current_start = new_segment['end']
239
+
240
+ return result
241
+
242
  def transcribe_audio_cli(
243
  transcribe_path_str: str,
244
  model: ASRModel,
245
+ duration_sec: float,
246
  device: str
247
  ) -> Tuple[Optional[List], Optional[List], Optional[List]]:
248
+ long_audio_settings_applied = False
249
+ original_model_dtype = model.dtype
 
 
 
 
 
250
 
251
  try:
 
252
  if device == 'cuda':
253
  torch.cuda.empty_cache()
254
  gc.collect()
255
 
256
+ model.to(device)
257
 
258
+ # 音声長に応じてモデル設定を変更
259
  if duration_sec > LONG_AUDIO_THRESHOLD_SECONDS:
260
  try:
261
  print(f" 情報: 音声長 ({duration_sec:.0f}s) が閾値 ({LONG_AUDIO_THRESHOLD_SECONDS}s) を超えるため、長尺音声向け設定を適用します。")
262
+ model.change_attention_model(
263
+ self_attention_model="rel_pos_local_attn",
264
+ att_context_size=[128, 128]
265
+ )
266
+ model.change_subsampling_conv_chunking_factor(1)
267
  long_audio_settings_applied = True
268
+ if device == 'cuda':
269
+ torch.cuda.empty_cache()
270
+ gc.collect()
271
  except Exception as setting_e:
272
  print(f" 警告: 長尺音声向け設定の適用に失敗しました: {setting_e}。デフォルト設定で続行します。")
273
+
274
  if device == 'cuda' and torch.cuda.is_bf16_supported():
275
  print(" 情報: モデルを bfloat16 に変換して推論を実行します。")
276
  model.to(torch.bfloat16)
277
+ elif model.dtype != original_model_dtype:
278
+ model.to(original_model_dtype)
279
+
280
  print(f" 文字起こしを実行中 (デバイス: {device}, モデルdtype: {model.dtype})...")
281
+ output = model.transcribe(
282
+ [transcribe_path_str],
283
+ timestamps=True,
284
+ batch_size=2
285
+ )
286
 
287
  if not output or not isinstance(output, list) or not output[0] or \
288
  not hasattr(output[0], 'timestamp') or not output[0].timestamp or \
 
291
  return None, None, None
292
 
293
  segment_timestamps = output[0].timestamp['segment']
294
+
295
+ # セグメントの前処理:より適切なセグメント分割
296
+ processed_segments = []
297
+ current_segment = None
298
+
299
+ for ts in segment_timestamps:
300
+ if current_segment is None:
301
+ current_segment = ts
302
+ else:
303
+ # セグメント結合の条件を厳格化
304
+ time_gap = ts['start'] - current_segment['end']
305
+ current_text = current_segment['segment']
306
+ next_text = ts['segment']
307
+
308
+ # 結合条件のチェック
309
+ should_merge = (
310
+ time_gap < MIN_SEGMENT_GAP_SECONDS and # 時間間隔が短い
311
+ len(current_text) + len(next_text) < MAX_SEGMENT_CHARS and # 文字数制限
312
+ (current_segment['end'] - current_segment['start']) < MAX_SEGMENT_LENGTH_SECONDS and # 現在のセグメントが短い
313
+ (ts['end'] - ts['start']) < MAX_SEGMENT_LENGTH_SECONDS and # 次のセグメントが短い
314
+ not any(current_text.strip().endswith(p) for p in SENTENCE_ENDINGS) # 文の区切りでない
315
+ )
316
+
317
+ if should_merge:
318
+ current_segment['end'] = ts['end']
319
+ current_segment['segment'] += ' ' + ts['segment']
320
+ else:
321
+ # 現在のセグメントを分割
322
+ split_segments = split_segment(current_segment, MAX_SEGMENT_LENGTH_SECONDS, MAX_SEGMENT_CHARS)
323
+ processed_segments.extend(split_segments)
324
+ current_segment = ts
325
+
326
+ if current_segment is not None:
327
+ # 最後のセグメントも分割
328
+ split_segments = split_segment(current_segment, MAX_SEGMENT_LENGTH_SECONDS, MAX_SEGMENT_CHARS)
329
+ processed_segments.extend(split_segments)
330
+
331
+ # 処理済みセグメントからデータを生成
332
+ vis_data = [[f"{ts['start']:.2f}", f"{ts['end']:.2f}", ts['segment']] for ts in processed_segments]
333
+ raw_times_data = [[ts['start'], ts['end']] for ts in processed_segments]
334
+
335
+ # 単語タイムスタンプの処理を改善
336
+ word_timestamps_raw = output[0].timestamp.get("word", [])
337
+ word_vis_data = []
338
+
339
+ for w in word_timestamps_raw:
340
+ if not isinstance(w, dict) or not all(k in w for k in ['start', 'end', 'word']):
341
+ continue
342
+
343
+ # 単語のタイムスタンプを最も近いセグメントに割り当て
344
+ word_start = float(w['start'])
345
+ word_end = float(w['end'])
346
+
347
+ # 単語が完全に含まれるセグメントを探す
348
+ for seg in processed_segments:
349
+ if word_start >= seg['start'] - 0.05 and word_end <= seg['end'] + 0.05:
350
+ word_vis_data.append([f"{word_start:.2f}", f"{word_end:.2f}", w["word"]])
351
+ break
352
+
353
  print(" 文字起こし完了。")
354
  return vis_data, raw_times_data, word_vis_data
355
 
 
406
  print(f" SRTファイルを保存: {srt_file_path.name}"); saved_files_count +=1
407
  if "vtt" in formats_to_save:
408
  vtt_file_path = output_dir_path / f"{audio_file_stem}.vtt"
409
+ try:
410
+ write_vtt(vis_data, word_vis_data, vtt_file_path)
411
+ print(f" VTTファイルを保存: {vtt_file_path.name}"); saved_files_count +=1
412
+ except ValueError as e:
413
+ if "VTTファイルサイズが制限を超えました" in str(e):
414
+ print(f" エラー: {e}")
415
+ # 既に作成されたVTTファイルを削除
416
+ if vtt_file_path.exists():
417
+ vtt_file_path.unlink()
418
+ raise # エラーを上位に伝播
419
  if "json" in formats_to_save:
420
  json_file_path = output_dir_path / f"{audio_file_stem}.json"
421
  write_json(vis_data, word_vis_data, json_file_path)
 
429
  print(f" 警告: 指定されたフォーマット {formats_to_save} でのファイルの保存は行われませんでした。")
430
  except Exception as e:
431
  print(f" エラー: 文字起こしファイルの保存中にエラーが発生しました: {e}")
432
+ raise # エラーを上位に伝播
433
 
434
  # --- 書き出しヘルパー関数群 (SRT, VTT, JSON, LRC) ---
435
  def write_srt(segments: List, path: Path):
 
443
 
444
  def write_vtt(segments: List, words: List, path: Path):
445
  def sec2vtt(t_float: float) -> str:
446
+ h, rem = divmod(int(t_float), 3600)
447
+ m, s = divmod(rem, 60)
448
  ms = int((t_float - int(t_float)) * 1000)
449
  return f"{h:02}:{m:02}:{s:02}.{ms:03}"
450
 
 
457
  f.write("::cue(.line) { background: rgba(0,0,0,0.7); padding: 4px; }\n\n")
458
 
459
  if not words:
460
+ # 単語タイムスタンプがない場合は、セグメント単位で出力
461
  for i, seg_list in enumerate(segments, 1):
462
  f.write(f"NOTE Segment {i}\n")
463
  f.write(f"{sec2vtt(float(seg_list[0]))} --> {sec2vtt(float(seg_list[1]))}\n{seg_list[2]}\n\n")
464
+
465
+ # ファイルサイズをチェック
466
+ current_size = f.tell()
467
+ if current_size > MAX_VTT_SIZE_BYTES:
468
+ print(f"警告: VTTファイルが{MAX_VTT_SIZE_BYTES/1024/1024:.1f}MBを超えました。処理を中止します。")
469
+ raise ValueError("VTTファイルサイズが制限を超えました")
470
  return
471
 
472
+ # セグメント単位で処理
473
  for seg_data in segments:
474
  seg_start = float(seg_data[0])
475
  seg_end = float(seg_data[1])
 
484
 
485
  if not segment_words:
486
  continue
487
+
488
+ # セグメント内の全単語のテキストを一度だけ生成
489
+ all_words = [w_data[2] for _, w_data in segment_words]
490
+
491
+ # セグメント開始から最初の単語まで
492
  first_word_start = float(segment_words[0][1][0])
493
  if seg_start < first_word_start - 0.05:
 
494
  f.write(f"{sec2vtt(seg_start)} --> {sec2vtt(first_word_start)}\n")
495
+ f.write(f'<c.line>{" ".join(f"<c.future>{w}</c>" for w in all_words)}</c>\n\n')
496
+
497
+ # ファイルサイズをチェック
498
+ current_size = f.tell()
499
+ if current_size > MAX_VTT_SIZE_BYTES:
500
+ print(f"警告: VTTファイルが{MAX_VTT_SIZE_BYTES/1024/1024:.1f}MBを超えました。処理を中止します。")
501
+ raise ValueError("VTTファイルサイズが制限を超えました")
502
 
503
  # 各単語の処理
504
+ for local_idx, (_, word_data) in enumerate(segment_words):
505
  w_start = float(word_data[0])
506
  w_end = float(word_data[1])
507
 
508
+ # 単語の表示時間を出力
509
+ f.write(f"{sec2vtt(w_start)} --> {sec2vtt(w_end)}\n")
510
+
511
+ # 現在の単語をハイライトしたテキストを生成
512
  line_parts = []
513
+ for i, w in enumerate(all_words):
 
514
  if i == local_idx:
515
+ line_parts.append(f'<c.current>{w}</c>')
516
  elif i < local_idx:
517
+ line_parts.append(f'<c.past>{w}</c>')
518
  else:
519
+ line_parts.append(f'<c.future>{w}</c>')
520
 
 
521
  f.write(f'<c.line>{" ".join(line_parts)}</c>\n\n')
522
 
523
+ # ファイルサイズをチェック
524
+ current_size = f.tell()
525
+ if current_size > MAX_VTT_SIZE_BYTES:
526
+ print(f"警告: VTTファイルが{MAX_VTT_SIZE_BYTES/1024/1024:.1f}MBを超えました。処理を中止します。")
527
+ raise ValueError("VTTファイルサイズが制限を超えました")
528
+
529
+ # 単語間の無音期間の処理
530
+ if local_idx < len(segment_words) - 1:
531
  next_word_start = float(segment_words[local_idx + 1][1][0])
532
  gap_duration = next_word_start - w_end
533
 
534
  if gap_duration > 0.05: # 50ms以上の無音期間がある場合
 
 
 
 
 
 
 
 
535
  f.write(f"{sec2vtt(w_end)} --> {sec2vtt(next_word_start)}\n")
536
+ f.write(f'<c.line>{" ".join(f"<c.past>{w}</c>" if i <= local_idx else f"<c.future>{w}</c>" for i, w in enumerate(all_words))}</c>\n\n')
 
 
 
 
 
 
537
 
538
+ # ファイルサイズをチェック
539
+ current_size = f.tell()
540
+ if current_size > MAX_VTT_SIZE_BYTES:
541
+ print(f"警告: VTTファイルが{MAX_VTT_SIZE_BYTES/1024/1024:.1f}MBを超えました。処理を中止します。")
542
+ raise ValueError("VTTファイルサイズが制限を超えました")
543
+
544
+ # 最後の単語からセグメント終了まで
545
+ last_word_end = float(segment_words[-1][1][1])
546
+ if last_word_end < seg_end - 0.05:
547
+ f.write(f"{sec2vtt(last_word_end)} --> {sec2vtt(seg_end)}\n")
548
+ f.write(f'<c.line>{" ".join(f"<c.past>{w}</c>" for w in all_words)}</c>\n\n')
549
+
550
+ # ファイルサイズをチェック
551
+ current_size = f.tell()
552
+ if current_size > MAX_VTT_SIZE_BYTES:
553
+ print(f"警告: VTTファ���ルが{MAX_VTT_SIZE_BYTES/1024/1024:.1f}MBを超えました。処理を中止します。")
554
+ raise ValueError("VTTファイルサイズが制限を超えました")
555
 
556
  def write_json(segments: List, words: List, path: Path):
557
  result = {"segments": []}; word_idx = 0