はじめに
iOSのSpeech frameworkを使って動画から英語字幕を自動生成するアプリを開発しています。動画の再生位置に合わせて字幕を表示するため、各セグメントのtimestamp(発話開始時刻)が正確であることが前提の設計です。
当初は「部分結果(partial results)をストリーミング的に出力し、再生と同時に字幕を追いかけ生成する」方式を採用していました。しかし、オンデバイス認識の部分結果ではSFTranscriptionSegment.timestampが常に0を返すという問題に遭遇し、設計の見直しを余儀なくされました。
この記事では、問題の詳細、原因の推測、そして最終的に採用した解決策を実際のコードとともに紹介します。
問題の詳細
期待した動作
SFSpeechRecognitionRequestのshouldReportPartialResults = trueを設定すると、認識が進むたびにコールバックが呼ばれます。各コールバックのresult.bestTranscription.segmentsにはSFTranscriptionSegmentが含まれており、.timestampプロパティで発話開始時刻を取得できるはずです。
これを利用して、認識が完了する前から字幕を画面に表示し、動画の再生位置に追従させたいと考えていました。
実際の動作
オンデバイス認識(requiresOnDeviceRecognition = true)の部分結果では、全セグメントのtimestampが0になります。
# 部分結果のログ(例)
segment[0]: "Hello" timestamp=0.0
segment[1]: "everyone" timestamp=0.0
segment[2]: "welcome" timestamp=0.0
segment[3]: "to" timestamp=0.0
つまり、全ての字幕が動画の0秒地点に紐づいてしまい、再生位置との同期が不可能になります。
中間チャンクの結合で起きるさらなる問題
Speech frameworkは長い音声を内部的にチャンク分割して処理します。チャンクが切り替わるとき、部分結果のセグメント数がリセットされ、新しいチャンクの認識が始まります。
部分結果を蓄積して結合する方式を試みましたが、以下の問題が発生しました。
- 前半チャンクの蓄積分: timestamp=0のセグメントが大量に並ぶ
- 後半チャンクのisFinal結果: 正しいtimestampを持つ
- 結合結果: 動画冒頭にtimestamp=0の重複セグメントが固まり、字幕表示が破綻する
なぜ起きるのか
Apple公式ドキュメントには、部分結果のtimestampが不正確になる旨の明示的な記載はありません。しかし、以下の理由から推測できます。
オンデバイス認識モデルの制約
オンデバイスモデルは、サーバーモデルに比べて軽量化されています。部分結果の段階では音声全体のアライメント(音声波形と単語の対応付け)を完了しておらず、暫定的にtimestamp=0を返していると考えられます。isFinal=trueの時点でアライメントが確定し、正確なtimestampが付与されます。
SFSpeechURLRecognitionRequest特有の事情
SFSpeechURLRecognitionRequestはファイル全体を渡す方式です。リアルタイム入力のSFSpeechAudioBufferRecognitionRequestとは異なり、ファイルの処理進捗と実時間が対応しないため、中間段階でのtimestamp計算がそもそも難しいと推測されます。
検証結果
- オンデバイス認識: 部分結果のtimestampは常に0
- サーバー認識: 部分結果でもある程度のtimestampが返る場合がある(ただし不安定)
- いずれの場合も、
isFinal=true時のtimestampは正確
解決策: isFinal結果だけを使う
結論として、字幕セグメントの出力はisFinal=trueのタイミングに限定しました。
ストリーミング的に字幕を少しずつ表示する体験は諦め、認識完了後に一括で字幕を生成する方式に切り替えています。
ViewModelでの呼び出し側は以下のようになっています。
// 認識はisFinal時に一括で届く(部分結果のtimestampが不正確なため)
let stream = speechService.recognize(url: url)
for try await segment in stream {
guard self.videoURL == url else { return }
self.subtitleSegments.append(segment)
if self.currentSegment == nil {
self.updateCurrentSegment(at: self.currentTime)
}
}
AsyncThrowingStreamのインターフェースは維持していますが、実質的にはisFinal時に全セグメントがまとめてyieldされます。動画の再生自体は認識完了を待たず即座に開始し、字幕は後から追いつく設計です。
実装の詳細
AsyncThrowingStreamによる認識処理
SpeechRecognitionService.recognize(url:)の全体像です。
func recognize(url: URL) -> AsyncThrowingStream<SubtitleSegment, Error> {
AsyncThrowingStream { continuation in
let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
guard let recognizer, recognizer.isAvailable else {
continuation.finish(throwing: RecognitionError.recognizerUnavailable)
return
}
let request = SFSpeechURLRecognitionRequest(url: url)
request.shouldReportPartialResults = true
request.addsPunctuation = true
if recognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
var allTranscriptions: [SFTranscription] = []
var currentBest: SFTranscription?
recognizer.recognitionTask(with: request) { result, error in
// ... コールバック処理
}
}
}
ポイントはshouldReportPartialResults = trueを維持していることです。部分結果のtimestampは使いませんが、チャンク切り替わりの検出に部分結果が必要になります。
チャンク結合のロジック
Speech frameworkは長い音声を複数チャンクに分割して処理します。チャンクが切り替わると、セグメント数がリセットされて少なくなります。この「セグメント数の減少」を検出して、前チャンクの結果を保持します。
let transcription = result.bestTranscription
if !transcription.formattedString.isEmpty {
if let prev = currentBest,
transcription.segments.count < prev.segments.count {
// セグメント数が減った → チャンク切り替わり
// 前のチャンクの結果を保存
allTranscriptions.append(prev)
currentBest = transcription
} else {
currentBest = transcription
}
}
if result.isFinal {
if let current = currentBest {
allTranscriptions.append(current)
}
let segments = Self.buildSegmentsFromMultiple(allTranscriptions)
for segment in segments {
continuation.yield(segment)
}
continuation.finish()
}
allTranscriptionsに各チャンクの最終結果を蓄積し、isFinal時に結合します。
timestamp=0セグメントのフィルタ除去
チャンク結合後も、中間チャンク由来のtimestamp=0セグメントが混入する可能性があります。これを除去するフィルタ処理です。
private static func buildSegmentsFromMultiple(
_ transcriptions: [SFTranscription]
) -> [SubtitleSegment] {
var allSegments: [SubtitleSegment] = []
for transcription in transcriptions {
allSegments.append(contentsOf: buildSegments(from: transcription))
}
allSegments.sort { $0.startTime < $1.startTime }
// 正しいtimestampを持つセグメントがある場合、
// timestamp=0の中間チャンク由来セグメントを除去する
let hasValidTimestamps = allSegments.contains { $0.startTime > 0 }
if hasValidTimestamps {
allSegments = allSegments.filter { $0.endTime > 0 }
}
return allSegments
}
startTime > 0のセグメントが1つでも存在すれば、endTime == 0のセグメントは中間チャンク由来の不正データと判断して除去します。動画冒頭(0秒付近)の正規セグメントはendTime > 0になるため、誤除去されません。
セグメント分割の基準
認識結果のSFTranscriptionSegmentを字幕表示に適した単位にまとめる処理です。以下の3条件で分割しています。
- 次のセグメントとの間に1.5秒以上の空白がある
- ピリオド・疑問符・感嘆符で終わる
- 15単語以上になった
let endsWithPunctuation = seg.substring.hasSuffix(".")
|| seg.substring.hasSuffix("?")
|| seg.substring.hasSuffix("!")
let isTooLong = currentWords.count >= 15
if isLast || hasGap || endsWithPunctuation || isTooLong {
// SubtitleSegmentを生成
}
補足: 部分結果を使いたい場合の代替案
それでも部分結果をリアルタイム表示したい場合、いくつかの代替案があります。
再生時間からの推定
部分結果のtimestampを使わず、「部分結果が届いた時点の再生時間」をtimestampとして代用する方法です。精度は落ちますが、大まかな位置合わせは可能です。ただしSFSpeechURLRecognitionRequestでは認識速度と再生速度が一致しないため、このアプローチは困難です。
SFSpeechAudioBufferRecognitionRequestの使用
ファイル全体を渡すSFSpeechURLRecognitionRequestではなく、AVAudioEngineから音声バッファを逐次渡すSFSpeechAudioBufferRecognitionRequestを使う方法です。リアルタイム入力に近い形になるため、部分結果のtimestampが改善する可能性があります。ただし実装の複雑さが大幅に増します。
Whisperなど外部モデルの利用
AppleのSpeech frameworkに依存せず、WhisperKitなどのオンデバイス音声認識モデルを使う方法です。タイムスタンプの精度が高く、セグメント分割も自前で制御できます。ただしアプリサイズの増加やモデル管理の複雑さがトレードオフになります。
まとめ
SFSpeechRecognizerのオンデバイス認識において、部分結果のtimestampは信頼できません。字幕の再生位置同期のように正確なタイムスタンプが必要な用途では、isFinal=trueの結果のみを使うべきです。
この記事で紹介した対策をまとめると以下の通りです。
- 字幕セグメントの出力は
isFinal=true時に限定する -
shouldReportPartialResults = trueは維持し、チャンク切り替わりの検出に活用する - 複数チャンクの結合時は、timestamp=0のセグメントをフィルタ除去する
- 動画再生は認識完了を待たず即座に開始し、字幕は後から追いつく設計にする
Speech frameworkは手軽にオンデバイス音声認識ができる優れたAPIですが、部分結果のtimestampについてはドキュメントに明記されていない落とし穴があります。同じ問題に遭遇した方の参考になれば幸いです。
宣伝
この記事で紹介した実装は、英語学習アプリ Veloquo で実際に使われています。iPhone/iPad内の英語動画から二重字幕(英語+日本語)を自動生成し、単語タップ辞書や1文ループ再生で学習できるアプリです。完全オンデバイスで動作します。