はじめに
タイムスタンプ付き文字起こしに01:12:30と書かれていれば、多くの場合、その時刻を信じて元動画を開きます。
ところが、動画の切り出し、結合、コンテナ変換、音声だけの前処理などが途中に入ると、文字起こし側の時刻と元動画側の時刻が一致しないことがあります。冒頭から最後まで5秒ずれている場合もあれば、動画の後半ほど誤差が大きくなる場合もあります。
本記事では、この2種類を分けて検証します。
- offset: 全体がほぼ一定量ずれている
- drift: 時間の経過とともに誤差が増減する
SRTの構文検査ではありません。文字起こしの時間軸が、参照すべき元メディアの時間軸と一致しているかを確認する受け入れテストです。
「構文が正しい」と「位置が正しい」は別
次の字幕は形式上は正常です。
42
01:12:30,000 --> 01:12:35,000
次の四半期の予算について説明します。
開始時刻は終了時刻より前で、書式も壊れていません。しかし、実際の発言が元動画の01:12:35にあるなら、5秒のoffsetがあります。
逆に、冒頭では一致していても、録音機器や変換処理の時間基準が異なると、後半でずれが蓄積する場合があります。これがdriftです。
構造検査だけで分かることと、原音照合が必要なことを分けます。
| 検査 | 自動化 | 分かること |
|---|---|---|
| 負の時刻、逆転、範囲外 | 可能 | 時間データとして成立するか |
| 重複、長い空白 | 可能 | 要確認区間の候補 |
| 元発言との位置一致 | 参照点が必要 | offset / drift |
| 発言内容の正しさ | 人による確認が必要 | 誤認識、話者、文脈 |
検証対象を固定する
最初に、「どの動画を基準にするか」を固定します。
文字起こし後に動画の先頭を10秒削除したなら、新しい動画と古い文字起こしは当然一致しません。検証対象は、文字起こしへ投入した元ファイル、または利用者が今後参照する正式版のどちらかに決めます。
大容量動画からタイムスタンプ付き文字起こしを作る場合、最大10GBのファイルを扱えるVideo Transcriberのデスクトップ版のような手段を使えます。本記事の検証スクリプトは製品内蔵機能ではなく、得られた時間情報を元動画と照合する独立した後処理です。
文字起こしは、前編と同じJSONLへ正規化します。
{"video_id":"meeting_07","start_ms":114000,"end_ms":121000,"speaker":"Speaker 1","text":"来月の予算について確認します"}
ここでは動画先頭を0msとする相対時刻を前提にします。
ffprobeでメディア側の時間情報を見る
最初に、動画の長さだけではなくstart_timeとtime_baseも確認します。
ffprobe -v error \
-show_entries "format=start_time,duration:stream=index,codec_type,time_base,start_time,duration" \
-of json source.mp4
出力例は次のようになります。
{
"streams": [
{"index": 0, "codec_type": "video", "time_base": "1/90000", "start_time": "0.000000"},
{"index": 1, "codec_type": "audio", "time_base": "1/48000", "start_time": "0.021333"}
],
"format": {"start_time": "0.000000", "duration": "3600.042000"}
}
time_baseはタイムスタンプ1単位が何秒かを表します。音声と映像で異なること自体は異常ではありません。また、ストリームのstart_timeが完全に同じでないだけで、直ちに音ずれと判断することもできません。
ここで確認したいのは、少なくとも次の3点です。
- 検証対象の動画時間を取得できるか
- コンテナやストリームが0以外から始まっていないか
- 文字起こしの最終時刻が動画範囲内にあるか
PTSを見て不安になっても、いきなりsetpts=PTS-STARTPTSで再エンコードしないでください。元ファイルを変えると、検証したかった基準そのものが変わります。まず読み取り専用で状況を記録します。
構造上あり得ない時刻を検出する
次の関数は、JSONLの発話区間を順番に検査します。
from dataclasses import dataclass
@dataclass
class Issue:
severity: str
code: str
line: int | None
message: str
def validate_segments(segments, media_duration_ms, gap_ms=60_000):
issues = []
previous = None
for segment in segments:
line = segment["line"]
start = segment["start_ms"]
end = segment["end_ms"]
if start < 0:
issues.append(Issue("ERROR", "NEGATIVE_START", line, f"start={start}"))
if end <= start:
issues.append(
Issue("ERROR", "INVALID_RANGE", line, f"start={start}, end={end}")
)
if end > media_duration_ms + 1_000:
issues.append(
Issue(
"ERROR", "OUT_OF_MEDIA", line,
f"end={end}, media={media_duration_ms}"
)
)
if previous is not None:
if start < previous["start_ms"]:
issues.append(
Issue(
"ERROR", "NON_MONOTONIC", line,
f"start={start}, previous={previous['start_ms']}"
)
)
overlap = previous["end_ms"] - start
if overlap > 0:
# 別話者なら同時発話の可能性があるためINFOに落とす
severity = (
"INFO"
if segment["speaker"] != previous["speaker"]
else "WARN"
)
issues.append(
Issue(severity, "OVERLAP", line, f"overlap={overlap}ms")
)
gap = start - previous["end_ms"]
if gap > gap_ms:
issues.append(
Issue("INFO", "LONG_GAP", line, f"gap={gap / 1000:.1f}s")
)
previous = segment
if not segments:
issues.append(Issue("ERROR", "EMPTY", None, "no transcript segments"))
return issues
重複をすべてエラーにしていない点が重要です。複数人が同時に話した場合、別話者の区間が重なることはあり得ます。同一話者の重複は警告、別話者は情報として出し、原音確認の候補にします。
長い空白も同様です。無音の休憩なら正常です。自動検査は「誤り」と断定するのではなく、確認場所を狭めるために使います。
offsetとdriftには参照点が必要
文字起こしの時刻だけを見ても、その時刻が元音声の発言位置と一致するかは分かりません。そこで、前半・中盤・後半から特徴的な発言を一つずつ選び、元動画で実際の位置を確認します。
transcript_sec,media_sec
120.0,125.1
1800.0,1805.4
3540.0,3546.0
-
transcript_sec: 文字起こしに記録された時刻 -
media_sec: 元動画で同じ発言が始まる時刻
最低3点、実運用では5点以上を推奨します。発言開始が曖昧な相槌ではなく、固有名詞や特徴的な文を選びます。
2つの時間軸の関係を、次の一次式として近似します。
media_time = intercept + slope × transcript_time
-
intercept: 固定offsetの推定値 -
slope - 1: 時間に比例するdrift
標準ライブラリだけで回帰する
NumPyを使わなくても、単回帰なら標準ライブラリで計算できます。
import csv
import math
from pathlib import Path
def analyze_anchors(path: Path):
with path.open(encoding="utf-8-sig", newline="") as source:
rows = list(csv.DictReader(source))
if len(rows) < 3:
raise ValueError("at least 3 anchors are required")
x = [float(row["transcript_sec"]) for row in rows]
y = [float(row["media_sec"]) for row in rows]
x_mean = sum(x) / len(x)
y_mean = sum(y) / len(y)
denominator = sum((value - x_mean) ** 2 for value in x)
if denominator == 0:
raise ValueError("transcript_sec values must not all be equal")
slope = sum(
(a - x_mean) * (b - y_mean) for a, b in zip(x, y)
) / denominator
intercept = y_mean - slope * x_mean
residuals = [
media - (intercept + slope * transcript)
for transcript, media in zip(x, y)
]
rmse = math.sqrt(sum(value**2 for value in residuals) / len(residuals))
drift_per_hour = (slope - 1.0) * 3600
return {
"anchor_count": len(rows),
"intercept_sec": round(intercept, 4),
"slope": round(slope, 8),
"drift_sec_per_hour": round(drift_per_hour, 4),
"rmse_sec": round(rmse, 4),
}
上のサンプルでは、次のような結果になります。
{
"anchor_count": 3,
"intercept_sec": 5.0202,
"slope": 1.00026364,
"drift_sec_per_hour": 0.9491,
"rmse_sec": 0.067
}
読み方は次のとおりです。
- 冒頭から約
+5.02秒の固定offsetがある - さらに1時間あたり約
+0.95秒ずつ誤差が増える - 3点は直線にかなり近く、RMSEは約
0.067秒
media_time = 5.0202 + 1.00026364 × transcript_timeとして、文字起こし時刻から元動画時刻を推定できます。
ただし、これは時刻補正を自動適用してよいという意味ではありません。参照点を取り違えていないか、動画の途中に編集点がないかを先に確認します。
判定閾値は「規格」ではなく運用ポリシー
たとえば次のように分類できます。
if abs(drift_per_hour) <= 0.5 and rmse <= 1.0:
classification = "mostly_constant_offset"
elif rmse <= 1.5:
classification = "approximately_linear_drift"
else:
classification = "mixed_error_or_bad_anchors"
ここで使った0.5秒/時やRMSE 1秒は、普遍的な品質規格ではありません。
- 字幕編集なら0.5秒でも大きな違和感になる
- 会議内の発言検索なら数秒の誤差を許容できる場合がある
- 法務、報道、研究用途では、時刻以外の内容確認も必要になる
用途に応じて、ERRORにする境界と、人が確認する境界を決めます。数値を出す目的は、品質を一つの点数にすることではなく、修正判断を再現可能にすることです。
異常地点の前後をクリップ化する
構造検査や参照点分析で怪しい位置を見つけたら、対象時刻の5秒前から20秒だけ切り出します。
ffmpeg -v error -ss 01:12:25 -i source.mp4 -t 20 \
-map "0:v:0?" -map "0:a:0?" \
-c:v libx264 -preset veryfast -c:a aac \
review_01-12-30.mp4
-ssを入力より前に置く方法は高速ですが、キーフレーム位置の影響を受ける場合があります。上の例では再エンコードして確認用クリップを作っています。フレーム単位の厳密な編集ではなく、発言位置と前後文脈を確認する用途です。
元ファイルは上書きせず、確認用クリップを別ディレクトリへ保存します。
レポートをCIで扱える形にする
検証結果を画面表示だけで終わらせず、JSONへ保存します。
{
"media": {
"path": "source.mp4",
"duration_sec": 3600.042,
"start_time": "0.000000"
},
"segment_count": 842,
"issues": [
{
"severity": "WARN",
"code": "OVERLAP",
"line": 318,
"message": "overlap=420ms"
}
],
"anchor_analysis": {
"intercept_sec": 5.0202,
"drift_sec_per_hour": 0.9491,
"rmse_sec": 0.067
}
}
運用上は次のように扱えます。
-
ERRORが1件でもあれば終了コード1 -
WARNは公開前レビューへ送る -
INFOは自動処理を止めない - レポートと元動画のハッシュを同じジョブIDで保存する
これにより、再転録や動画差し替え後に同じ検査を繰り返せます。
自動検査で保証できないこと
最後に、能力境界を明確にします。
この方法で検査できるのは、時間データの構造と、選んだ参照点に対する時間軸のずれです。次の項目は別途確認が必要です。
- 発言内容が一字一句正しいか
- 話者ラベルが実際の人物と一致するか
- 数字、日付、固有名詞、否定表現が正しいか
- 発言を前後から切り離しても意味が変わらないか
- 元動画そのものが編集・差し替えされていないか
また、参照点を3つ選んでも、その3点自体が誤っていれば回帰結果も誤ります。RMSEが小さいことは、選んだ点が直線に乗っている証拠であり、発言内容の正しさを保証する数値ではありません。
まとめ
タイムスタンプ付き文字起こしでは、時刻の書式が正しいだけでは不十分です。
-
ffprobeで元動画の時間情報を記録する - 負の時刻、逆転、範囲外を機械的に検出する
- 前半・中盤・後半から参照点を取る
- 回帰で固定offsetと累積driftを分ける
- 異常地点は短いクリップで元音声を確認する
文字起こしを検索、引用、字幕、議事録へ再利用するほど、「本文がある」だけでなく「元動画の正しい場所へ戻れる」ことが重要になります。タイムスタンプを表示項目ではなく、検証可能なデータとして扱うのが今回のポイントです。